yeg
2026-09-01 3d781111a5289987fdda13ce45bcfcabad207644
实现控制台数据修订导入删除

新增三类控制台数据的修订行为、覆盖状态和导入事实记录,补充字段白名单、测试完成校验及实时采样物理删除。
8个文件已添加
3个文件已修改
516 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V24__console_data_revision_actions.sql 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V25__console_import_records.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/Ticket25MigrationContractTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestConsoleImportRecord.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataOverride.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ITestConsoleDataService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
@@ -21,7 +21,10 @@
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestResult;
import com.ruoyi.system.domain.TTestLogicStepMapping;
import com.ruoyi.system.domain.TTestDataRevisionAction;
import com.ruoyi.system.domain.TTestDataOverride;
import com.ruoyi.system.service.ITTestService;
import com.ruoyi.system.service.ITestConsoleDataService;
/** 测试管理 Controller。 */
@RestController
@@ -30,6 +33,9 @@
{
    @Autowired
    private ITTestService testService;
    @Autowired
    private ITestConsoleDataService consoleDataService;
    @PreAuthorize("@ss.hasAnyPermi('system:test:add,system:test:edit')")
    @GetMapping("/options")
@@ -147,6 +153,36 @@
        return success(testService.selectTestById(testId));
    }
    /** 返回当前测试某类数据的人工覆盖和删除状态,供刷新后恢复展示。 */
    @PreAuthorize("@ss.hasPermi('system:test:query')")
    @GetMapping("/{testId}/console-data/{dataType}/overrides")
    public AjaxResult dataOverrides(@PathVariable Long testId, @PathVariable String dataType)
    {
        return success(consoleDataService.selectOverrides(testId, dataType));
    }
    /** 一次提交编辑、导入或删除;服务端只追加一条修订行为。 */
    @PreAuthorize("@ss.hasPermi('system:test:edit')")
    @Log(title = "测试控制台数据修订", businessType = BusinessType.UPDATE)
    @PostMapping("/{testId}/console-data/actions")
    public AjaxResult dataAction(@PathVariable Long testId, @RequestBody ConsoleDataActionRequest request)
    {
        if (request == null) return error("数据操作不能为空");
        TTestDataRevisionAction action = new TTestDataRevisionAction();
        action.setTestId(testId); action.setDataType(request.dataType()); action.setOperationType(request.operationType());
        action.setScopeSummary(request.scopeSummary()); action.setIdentitySummary(request.identitySummary());
        action.setChangesSummary(request.changesSummary()); action.setReason(request.reason());
        action.setOperatorId(getUserId()); action.setOperatorName(getUsername());
        Long actionId = consoleDataService.submitAction(action,
                request.overrides() == null ? java.util.Collections.emptyList() : request.overrides(),
                request.btsSampleIds(), request.collectorSampleIds());
        return success().put("actionId", actionId);
    }
    public record ConsoleDataActionRequest(String dataType, String operationType, String scopeSummary,
            String identitySummary, String changesSummary, String reason, List<TTestDataOverride> overrides,
            List<Long> btsSampleIds, List<Long> collectorSampleIds) { }
    @PreAuthorize("@ss.hasPermi('system:test:remove')")
    @Log(title = "测试管理", businessType = BusinessType.DELETE)
    @DeleteMapping("/{testIds}")
ruoyi-admin/src/main/resources/db/migration/V24__console_data_revision_actions.sql
New file
@@ -0,0 +1,37 @@
-- Ticket25:控制台三类数据的一次用户行为修订、覆盖状态和物理删除支持。
CREATE TABLE t_test_data_revision_action (
    action_id BIGINT NOT NULL AUTO_INCREMENT,
    test_id BIGINT NOT NULL,
    data_type VARCHAR(16) NOT NULL,
    operation_type VARCHAR(16) NOT NULL,
    scope_summary VARCHAR(1000) NOT NULL,
    identity_summary JSON NOT NULL,
    changes_summary JSON NULL,
    reason VARCHAR(500) NOT NULL,
    operator_id BIGINT NULL,
    operator_name VARCHAR(64) NOT NULL,
    created_at_utc DATETIME(3) NOT NULL,
    PRIMARY KEY (action_id),
    KEY idx_data_revision_test_time (test_id, data_type, created_at_utc),
    CONSTRAINT fk_data_revision_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
    CONSTRAINT chk_data_revision_type CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE')),
    CONSTRAINT chk_data_revision_operation CHECK (operation_type IN ('EDIT', 'IMPORT', 'DELETE')),
    CONSTRAINT chk_data_revision_reason CHECK (CHAR_LENGTH(TRIM(reason)) > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试控制台一次数据修订行为';
CREATE TABLE t_test_data_override (
    override_id BIGINT NOT NULL AUTO_INCREMENT,
    test_id BIGINT NOT NULL,
    data_type VARCHAR(16) NOT NULL,
    stable_identity VARCHAR(512) NOT NULL,
    field_values JSON NULL,
    deleted TINYINT(1) NOT NULL DEFAULT 0,
    action_id BIGINT NOT NULL,
    created_at_utc DATETIME(3) NOT NULL,
    PRIMARY KEY (override_id),
    UNIQUE KEY uk_data_override_identity (test_id, data_type, stable_identity),
    KEY idx_data_override_action (action_id),
    CONSTRAINT fk_data_override_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
    CONSTRAINT fk_data_override_action FOREIGN KEY (action_id) REFERENCES t_test_data_revision_action (action_id),
    CONSTRAINT chk_data_override_type CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='控制台数据最新人工覆盖或删除状态';
ruoyi-admin/src/main/resources/db/migration/V25__console_import_records.sql
New file
@@ -0,0 +1,16 @@
-- Ticket25:导入数据保留独立的后端事实记录,覆盖状态只负责展示修订。
CREATE TABLE t_test_console_import_record (
    import_record_id BIGINT NOT NULL AUTO_INCREMENT,
    test_id BIGINT NOT NULL,
    data_type VARCHAR(16) NOT NULL,
    stable_identity VARCHAR(512) NOT NULL,
    row_values JSON NOT NULL,
    action_id BIGINT NOT NULL,
    created_at_utc DATETIME(3) NOT NULL,
    PRIMARY KEY (import_record_id),
    UNIQUE KEY uk_console_import_identity (test_id, data_type, stable_identity),
    KEY idx_console_import_action (action_id),
    CONSTRAINT fk_console_import_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
    CONSTRAINT fk_console_import_action FOREIGN KEY (action_id) REFERENCES t_test_data_revision_action (action_id),
    CONSTRAINT chk_console_import_type CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='控制台导入原始记录';
ruoyi-admin/src/test/java/com/ruoyi/config/Ticket25MigrationContractTest.java
New file
@@ -0,0 +1,30 @@
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;
/** Ticket25 迁移契约:一次行为审计、覆盖状态和物理删除所需的持久化结构。 */
class Ticket25MigrationContractTest
{
    @Test
    void storesSingleActionAndStableIdentityOverrides()
    {
        String sql;
        try (InputStream stream = getClass().getClassLoader().getResourceAsStream(
                "db/migration/V24__console_data_revision_actions.sql"))
        {
            if (stream == null) throw new AssertionError("缺少 Ticket25 迁移");
            sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
        }
        catch (Exception exception) { throw new AssertionError(exception); }
        assertTrue(sql.contains("t_test_data_revision_action"));
        assertTrue(sql.contains("t_test_data_override"));
        assertTrue(sql.contains("identity_summary JSON"));
        assertTrue(sql.contains("operation_type"));
        assertTrue(sql.contains("uk_data_override_identity"));
        assertFalse(sql.matches("(?s).*\\bDROP\\s+(TABLE|COLUMN)\\b.*"));
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestConsoleImportRecord.java
New file
@@ -0,0 +1,31 @@
package com.ruoyi.system.domain;
import java.io.Serializable;
import java.util.Date;
/** 控制台导入后端事实记录。 */
public class TTestConsoleImportRecord implements Serializable
{
    private static final long serialVersionUID = 1L;
    private Long importRecordId;
    private Long testId;
    private String dataType;
    private String stableIdentity;
    private String rowValues;
    private Long actionId;
    private Date createdAtUtc;
    public Long getImportRecordId() { return importRecordId; }
    public void setImportRecordId(Long value) { importRecordId = value; }
    public Long getTestId() { return testId; }
    public void setTestId(Long value) { testId = value; }
    public String getDataType() { return dataType; }
    public void setDataType(String value) { dataType = value; }
    public String getStableIdentity() { return stableIdentity; }
    public void setStableIdentity(String value) { stableIdentity = value; }
    public String getRowValues() { return rowValues; }
    public void setRowValues(String value) { rowValues = value; }
    public Long getActionId() { return actionId; }
    public void setActionId(Long value) { actionId = value; }
    public Date getCreatedAtUtc() { return createdAtUtc; }
    public void setCreatedAtUtc(Date value) { createdAtUtc = value; }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataOverride.java
New file
@@ -0,0 +1,35 @@
package com.ruoyi.system.domain;
import java.io.Serializable;
import java.util.Date;
/** 某个稳定数据身份的最新人工覆盖状态。 */
public class TTestDataOverride implements Serializable
{
    private static final long serialVersionUID = 1L;
    private Long overrideId;
    private Long testId;
    private String dataType;
    private String stableIdentity;
    private String fieldValues;
    private Boolean deleted;
    private Long actionId;
    private Date createdAtUtc;
    public Long getOverrideId() { return overrideId; }
    public void setOverrideId(Long value) { overrideId = value; }
    public Long getTestId() { return testId; }
    public void setTestId(Long value) { testId = value; }
    public String getDataType() { return dataType; }
    public void setDataType(String value) { dataType = value; }
    public String getStableIdentity() { return stableIdentity; }
    public void setStableIdentity(String value) { stableIdentity = value; }
    public String getFieldValues() { return fieldValues; }
    public void setFieldValues(String value) { fieldValues = value; }
    public Boolean getDeleted() { return deleted; }
    public void setDeleted(Boolean value) { deleted = value; }
    public Long getActionId() { return actionId; }
    public void setActionId(Long value) { actionId = value; }
    public Date getCreatedAtUtc() { return createdAtUtc; }
    public void setCreatedAtUtc(Date value) { createdAtUtc = value; }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java
New file
@@ -0,0 +1,44 @@
package com.ruoyi.system.domain;
import java.io.Serializable;
import java.util.Date;
/** 一次控制台数据编辑、导入或删除行为。 */
public class TTestDataRevisionAction implements Serializable
{
    private static final long serialVersionUID = 1L;
    private Long actionId;
    private Long testId;
    private String dataType;
    private String operationType;
    private String scopeSummary;
    private String identitySummary;
    private String changesSummary;
    private String reason;
    private Long operatorId;
    private String operatorName;
    private Date createdAtUtc;
    public Long getActionId() { return actionId; }
    public void setActionId(Long value) { actionId = value; }
    public Long getTestId() { return testId; }
    public void setTestId(Long value) { testId = value; }
    public String getDataType() { return dataType; }
    public void setDataType(String value) { dataType = value; }
    public String getOperationType() { return operationType; }
    public void setOperationType(String value) { operationType = value; }
    public String getScopeSummary() { return scopeSummary; }
    public void setScopeSummary(String value) { scopeSummary = value; }
    public String getIdentitySummary() { return identitySummary; }
    public void setIdentitySummary(String value) { identitySummary = value; }
    public String getChangesSummary() { return changesSummary; }
    public void setChangesSummary(String value) { changesSummary = value; }
    public String getReason() { return reason; }
    public void setReason(String value) { reason = value; }
    public Long getOperatorId() { return operatorId; }
    public void setOperatorId(Long value) { operatorId = value; }
    public String getOperatorName() { return operatorName; }
    public void setOperatorName(String value) { operatorName = value; }
    public Date getCreatedAtUtc() { return createdAtUtc; }
    public void setCreatedAtUtc(Date value) { createdAtUtc = value; }
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -18,6 +18,9 @@
import com.ruoyi.system.domain.TTestBackfillCursor;
import com.ruoyi.system.domain.TTestIntegrityIssue;
import com.ruoyi.system.domain.TDetectionChannel;
import com.ruoyi.system.domain.TTestDataRevisionAction;
import com.ruoyi.system.domain.TTestConsoleImportRecord;
import com.ruoyi.system.domain.TTestDataOverride;
import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
import com.ruoyi.system.domain.vo.TestChannelOptionVo;
import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
@@ -187,4 +190,16 @@
    int resolveIntegrityIssue(@Param("issueId") Long issueId, @Param("resolvedAtUtc") Date resolvedAtUtc);
    int countOpenIntegrityIssues(Long testId);
    int countOpenExceptions(Long testId);
    int insertDataRevisionAction(TTestDataRevisionAction action);
    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);
    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
New file
@@ -0,0 +1,15 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.system.domain.TTestDataOverride;
import com.ruoyi.system.domain.TTestDataRevisionAction;
/** 控制台三类数据的修订、导入和物理删除服务。 */
public interface ITestConsoleDataService
{
    List<TTestDataOverride> selectOverrides(Long testId, String dataType);
    /** 在一个事务中写入一次行为、覆盖状态并执行请求的物理删除。 */
    Long submitAction(TTestDataRevisionAction action, List<TTestDataOverride> overrides,
            List<Long> btsSampleIds, List<Long> collectorSampleIds);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
New file
@@ -0,0 +1,204 @@
package com.ruoyi.system.service.impl;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.ArrayList;
import java.util.HashSet;
import com.alibaba.fastjson2.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.system.domain.TTestDataOverride;
import com.ruoyi.system.domain.TTestDataRevisionAction;
import com.ruoyi.system.domain.TTestConsoleImportRecord;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.service.ITestConsoleDataService;
/** 控制台数据操作的边界校验和事务协调。 */
@Service
public class TestConsoleDataServiceImpl implements ITestConsoleDataService
{
    private static final Set<String> DATA_TYPES = Set.of("REALTIME", "STEP", "CYCLE");
    private static final Set<String> OPERATIONS = Set.of("EDIT", "IMPORT", "DELETE");
    @Autowired
    private TTestMapper mapper;
    @Override
    public List<TTestDataOverride> selectOverrides(Long testId, String dataType)
    {
        validateType(testId, dataType);
        return mapper.selectDataOverrides(testId, dataType);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long submitAction(TTestDataRevisionAction action, List<TTestDataOverride> overrides,
            List<Long> btsSampleIds, List<Long> collectorSampleIds)
    {
        if (action == null || action.getTestId() == null) throw new ServiceException("测试ID不能为空");
        validateType(action.getTestId(), action.getDataType());
        if (!OPERATIONS.contains(action.getOperationType())) throw new ServiceException("数据操作类型无效");
        if ("IMPORT".equals(action.getOperationType()))
        {
            com.ruoyi.system.domain.TTest test = mapper.selectTestById(action.getTestId());
            if (test == null || !"ENDED".equals(test.getReceptionStatus()) || !"COMPLETED".equals(test.getTestStatus()))
                throw new ServiceException("测试完成后才可以导入数据");
        }
        if ("EDIT".equals(action.getOperationType())) validateEditableFields(action);
        if (action.getReason() == null || action.getReason().trim().isEmpty()) throw new ServiceException("数据操作原因不能为空");
        if (action.getOperatorName() == null || action.getOperatorName().trim().isEmpty()) action.setOperatorName("未知用户");
        action.setReason(action.getReason().trim());
        action.setCreatedAtUtc(new Date());
        mapper.insertDataRevisionAction(action);
        if ("IMPORT".equals(action.getOperationType()) && action.getChangesSummary() != null)
        {
            try
            {
                @SuppressWarnings("unchecked")
                List<Map<String, Object>> rows = (List<Map<String, Object>>) (List<?>) JSON.parseArray(action.getChangesSummary(), Map.class);
                Set<String> identities = new HashSet<>();
                for (Map<String, Object> row : rows)
                {
                    Object identity = row.get("recordNo");
                    if (identity == null || !identities.add(String.valueOf(identity)))
                        throw new ServiceException("导入数据存在重复稳定身份");
                    Object batteryNo = row.get("batteryNo");
                    if (batteryNo != null && !String.valueOf(batteryNo).isBlank()
                            && mapper.countTestBatteryNo(action.getTestId(), String.valueOf(batteryNo)) == 0)
                        throw new ServiceException("导入数据包含不属于当前测试的电池号: " + batteryNo);
                    TTestConsoleImportRecord record = new TTestConsoleImportRecord();
                    record.setTestId(action.getTestId()); record.setDataType(action.getDataType());
                    record.setStableIdentity(String.valueOf(identity)); record.setRowValues(JSON.toJSONString(row));
                    record.setActionId(action.getActionId()); record.setCreatedAtUtc(action.getCreatedAtUtc());
                    mapper.insertConsoleImportRecord(record);
                }
            }
            catch (ServiceException exception) { throw exception; }
            catch (Exception exception) { throw new ServiceException("导入数据格式无效"); }
        }
        if (overrides != null)
        {
            Set<String> identities = new HashSet<>();
            for (TTestDataOverride item : overrides)
            {
                if (item == null || item.getStableIdentity() == null || item.getStableIdentity().trim().isEmpty())
                    throw new ServiceException("数据稳定身份不能为空");
                item.setStableIdentity(item.getStableIdentity().trim());
                if (!identities.add(item.getStableIdentity())) throw new ServiceException("同一批数据存在重复稳定身份");
                validateOverride(item, action.getOperationType(), action.getDataType());
                item.setTestId(action.getTestId()); item.setDataType(action.getDataType());
                item.setActionId(action.getActionId()); item.setCreatedAtUtc(action.getCreatedAtUtc());
                mapper.upsertDataOverride(item);
            }
        }
        if ("DELETE".equals(action.getOperationType()) && "REALTIME".equals(action.getDataType()) && 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]);
                if (sampleId != null) resolvedIds.add(sampleId);
            }
            if (!resolvedIds.isEmpty())
            {
                mapper.deleteBtsMeasurementsBySampleIds(resolvedIds);
                mapper.deleteBtsSamplesByIds(action.getTestId(), resolvedIds);
            }
        }
        if (btsSampleIds != null && !btsSampleIds.isEmpty())
        {
            // 采样明细受外键保护,必须在主采样物理删除前清理。
            mapper.deleteBtsMeasurementsBySampleIds(btsSampleIds);
            mapper.deleteBtsSamplesByIds(action.getTestId(), btsSampleIds);
        }
        if (collectorSampleIds != null && !collectorSampleIds.isEmpty()) mapper.deleteCollectorSamplesByIds(action.getTestId(), collectorSampleIds);
        return action.getActionId();
    }
    private void validateType(Long testId, String dataType)
    {
        if (testId == null || testId < 1) throw new ServiceException("测试ID无效");
        if (!DATA_TYPES.contains(dataType)) throw new ServiceException("控制台数据类型无效");
    }
    private void validateEditableFields(TTestDataRevisionAction action)
    {
        if (action.getChangesSummary() == null || action.getChangesSummary().isBlank())
            throw new ServiceException("修改内容不能为空");
        try
        {
            @SuppressWarnings("unchecked")
            List<Map<String, Object>> changes = (List<Map<String, Object>>) (List<?>) JSON.parseArray(action.getChangesSummary(), Map.class);
            Set<String> editable = switch (action.getDataType())
            {
                case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy");
                case "STEP" -> Set.of("stepDuration", "capacity", "energy", "startVoltage", "endVoltage",
                        "chargePeakVoltage", "dischargeValleyVoltage");
                case "CYCLE" -> Set.of("chargeCapacity", "dischargeCapacity", "efficiency", "chargeEnergy",
                        "dischargeEnergy");
                default -> Set.of();
            };
            Set<String> immutable = Set.of("recordNo", "batteryNo", "cycleNo", "stepNo", "stepName",
                    "sequenceId", "recordedAt", "absoluteTime");
            for (Map<String, Object> item : changes)
            {
                Map<String, Object> before = objectMap(item.get("before"));
                Map<String, Object> after = objectMap(item.get("after"));
                for (String key : immutable)
                    if (!java.util.Objects.equals(before.get(key), after.get(key)))
                        throw new ServiceException("记录身份字段不可修改: " + key);
                for (String key : after.keySet())
                    if (!java.util.Objects.equals(before.get(key), after.get(key)) && !editable.contains(key))
                        throw new ServiceException("字段不可修改: " + key);
            }
        }
        catch (ServiceException exception) { throw exception; }
        catch (Exception exception) { throw new ServiceException("修改内容格式无效"); }
    }
    private void validateOverride(TTestDataOverride item, String operation, String dataType)
    {
        if (!"EDIT".equals(operation) || item.getFieldValues() == null || item.getFieldValues().isBlank()) return;
        try
        {
            Map<String, Object> values = JSON.parseObject(item.getFieldValues(), Map.class);
            for (String key : values.keySet())
                if (!editableFields(dataType).contains(key)) throw new ServiceException("字段不可修改: " + key);
        }
        catch (ServiceException exception) { throw exception; }
        catch (Exception exception) { throw new ServiceException("修改字段格式无效"); }
    }
    private Set<String> editableFields(String dataType)
    {
        return switch (dataType)
        {
            case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy");
            case "STEP" -> Set.of("stepDuration", "capacity", "energy", "startVoltage", "endVoltage",
                    "chargePeakVoltage", "dischargeValleyVoltage");
            case "CYCLE" -> Set.of("chargeCapacity", "dischargeCapacity", "efficiency", "chargeEnergy",
                    "dischargeEnergy");
            default -> Set.of();
        };
    }
    private Long[] parseRealtimeIdentity(String value)
    {
        String[] parts = value.split(":", -1);
        if (parts.length != 3) return null;
        try { return new Long[] { Long.valueOf(parts[0]), Long.valueOf(parts[2]) }; }
        catch (NumberFormatException exception) { return null; }
    }
    @SuppressWarnings("unchecked")
    private Map<String, Object> objectMap(Object value)
    {
        return value instanceof Map ? (Map<String, Object>) value : Map.of();
    }
}
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -784,4 +784,57 @@
        where exception_fact.test_id = #{testId} and exception_fact.interval_status = 'OPEN'
        order by exception_fact.opened_at_utc, exception_fact.interval_id
    </select>
    <resultMap id="TTestDataOverrideResultMap" type="com.ruoyi.system.domain.TTestDataOverride">
        <id property="overrideId" column="override_id"/><result property="testId" column="test_id"/>
        <result property="dataType" column="data_type"/><result property="stableIdentity" column="stable_identity"/>
        <result property="fieldValues" column="field_values"/><result property="deleted" column="deleted"/>
        <result property="actionId" column="action_id"/><result property="createdAtUtc" column="created_at_utc"/>
    </resultMap>
    <insert id="insertDataRevisionAction" parameterType="com.ruoyi.system.domain.TTestDataRevisionAction"
            useGeneratedKeys="true" keyProperty="actionId">
        insert into t_test_data_revision_action
        (test_id, data_type, operation_type, scope_summary, identity_summary, changes_summary,
         reason, operator_id, operator_name, created_at_utc)
        values (#{testId}, #{dataType}, #{operationType}, #{scopeSummary}, cast(#{identitySummary} as json),
                cast(#{changesSummary} as json), #{reason}, #{operatorId}, #{operatorName}, #{createdAtUtc})
    </insert>
    <insert id="upsertDataOverride" parameterType="com.ruoyi.system.domain.TTestDataOverride">
        insert into t_test_data_override
        (test_id, data_type, stable_identity, field_values, deleted, action_id, created_at_utc)
        values (#{testId}, #{dataType}, #{stableIdentity}, cast(#{fieldValues} as json), #{deleted}, #{actionId}, #{createdAtUtc})
        on duplicate key update field_values = values(field_values), deleted = values(deleted),
            action_id = values(action_id), created_at_utc = values(created_at_utc)
    </insert>
    <select id="selectDataOverrides" resultMap="TTestDataOverrideResultMap">
        select override_id, test_id, data_type, stable_identity, field_values, deleted, action_id, created_at_utc
        from t_test_data_override where test_id = #{testId} and data_type = #{dataType}
        order by override_id
    </select>
    <delete id="deleteBtsSamplesByIds">
        delete from t_test_sample where test_id = #{testId} and sample_id in
        <foreach collection="sampleIds" item="id" open="(" separator="," close=")">#{id}</foreach>
    </delete>
    <delete id="deleteBtsMeasurementsBySampleIds">
        delete from t_test_sample_measurement where sample_id in
        <foreach collection="sampleIds" item="id" open="(" separator="," close=")">#{id}</foreach>
    </delete>
    <delete id="deleteCollectorSamplesByIds">
        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}
        limit 1
    </select>
    <insert id="insertConsoleImportRecord" parameterType="com.ruoyi.system.domain.TTestConsoleImportRecord"
            useGeneratedKeys="true" keyProperty="importRecordId">
        insert into t_test_console_import_record
        (test_id, data_type, stable_identity, row_values, action_id, created_at_utc)
        values (#{testId}, #{dataType}, #{stableIdentity}, cast(#{rowValues} as json), #{actionId}, #{createdAtUtc})
    </insert>
    <select id="countTestBatteryNo" resultType="int">
        select count(1) from t_test_battery
        where test_id = #{testId} and battery_no = #{batteryNo} and del_flag = 0
    </select>
</mapper>