| | |
| | | !/ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql |
| | | !/ruoyi-admin/src/main/resources/db/migration/V19__collector_disconnect_open_dedupe.sql |
| | | !/ruoyi-admin/src/main/resources/db/migration/V21__natural_end_interval_backfill.sql |
| | | !/ruoyi-admin/src/main/resources/db/migration/V22__interval_integrity_and_completion.sql |
| | | /.mvn-review-repository/ |
| | | /ruoyi-admin/logs/ |
| | |
| | | import com.ruoyi.common.core.controller.BaseController; |
| | | import com.ruoyi.common.core.domain.AjaxResult; |
| | | import com.ruoyi.system.service.ITestLiveService; |
| | | import com.ruoyi.system.service.ITestIntegrityService; |
| | | |
| | | /** 测试控制台实时 REST/SSE 入口,仅负责鉴权和协议转发。 */ |
| | | @RestController |
| | |
| | | public class TTestLiveController extends BaseController |
| | | { |
| | | private final ITestLiveService liveService; |
| | | public TTestLiveController(ITestLiveService liveService) { this.liveService = liveService; } |
| | | private final ITestIntegrityService integrityService; |
| | | public TTestLiveController(ITestLiveService liveService, ITestIntegrityService integrityService) |
| | | { this.liveService = liveService; this.integrityService = integrityService; } |
| | | |
| | | @PreAuthorize("@ss.hasPermi('system:test:query')") |
| | | @GetMapping("/{testId}/live-state") |
| | | public AjaxResult liveState(@PathVariable Long testId) { return success(liveService.current(testId)); } |
| | | |
| | | @PreAuthorize("@ss.hasPermi('system:test:query')") |
| | | @GetMapping("/{testId}/integrity") |
| | | public AjaxResult integrity(@PathVariable Long testId) { return success(integrityService.selectIssues(testId)); } |
| | | |
| | | @PreAuthorize("@ss.hasPermi('system:test:query')") |
| | | @GetMapping(value = "/{testId}/live", produces = MediaType.TEXT_EVENT_STREAM_VALUE) |
| | | public SseEmitter live(@PathVariable Long testId) { return liveService.stream(testId); } |
| | | } |
| New file |
| | |
| | | -- Ticket14:按接收区间保存序号完整性问题,并为采集器历史记录保留区间归属。 |
| | | ALTER TABLE t_test_collector_sample |
| | | ADD COLUMN reception_interval_id BIGINT NULL AFTER binding_id, |
| | | ADD KEY idx_collector_sample_interval (reception_interval_id), |
| | | ADD CONSTRAINT fk_collector_sample_reception_interval FOREIGN KEY (reception_interval_id) |
| | | REFERENCES t_test_reception_interval (interval_id); |
| | | |
| | | CREATE TABLE t_test_integrity_issue ( |
| | | issue_id BIGINT NOT NULL AUTO_INCREMENT, |
| | | test_id BIGINT NOT NULL, |
| | | interval_id BIGINT NOT NULL, |
| | | binding_id BIGINT NOT NULL, |
| | | source_type VARCHAR(32) NOT NULL, |
| | | source_id BIGINT NOT NULL, |
| | | source_channel_code VARCHAR(64) NULL, |
| | | issue_type VARCHAR(64) NOT NULL, |
| | | issue_status VARCHAR(16) NOT NULL DEFAULT 'OPEN', |
| | | missing_from_sequence BIGINT NULL, |
| | | missing_to_sequence BIGINT NULL, |
| | | retry_count INT NOT NULL DEFAULT 0, |
| | | detail VARCHAR(2000) NULL, |
| | | first_detected_at_utc DATETIME(3) NOT NULL, |
| | | last_detected_at_utc DATETIME(3) NOT NULL, |
| | | resolved_at_utc DATETIME(3) NULL, |
| | | PRIMARY KEY (issue_id), |
| | | KEY idx_integrity_issue_test (test_id, issue_status, last_detected_at_utc), |
| | | KEY idx_integrity_issue_interval (interval_id, source_type, source_id, source_channel_code), |
| | | CONSTRAINT fk_integrity_issue_test FOREIGN KEY (test_id) REFERENCES t_test (test_id), |
| | | CONSTRAINT fk_integrity_issue_interval FOREIGN KEY (interval_id) REFERENCES t_test_reception_interval (interval_id), |
| | | CONSTRAINT fk_integrity_issue_binding FOREIGN KEY (binding_id) REFERENCES t_test_channel_binding (binding_id), |
| | | CONSTRAINT chk_integrity_issue_source CHECK (source_type IN ('CABINET_CHANNEL', 'COLLECTOR_CHANNEL')), |
| | | CONSTRAINT chk_integrity_issue_status CHECK (issue_status IN ('OPEN', 'RESOLVED')), |
| | | CONSTRAINT chk_integrity_issue_range CHECK (missing_to_sequence IS NULL OR missing_to_sequence >= missing_from_sequence) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试接收区间完整性问题'; |
| New file |
| | |
| | | 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; |
| | | |
| | | /** Ticket14 迁移契约:完整性事实追加存储且不改写历史迁移。 */ |
| | | class Ticket14MigrationContractTest |
| | | { |
| | | @Test |
| | | void addsIntervalIntegrityFactsAndCollectorIntervalOwnership() |
| | | { |
| | | String sql; |
| | | try (InputStream stream = getClass().getClassLoader().getResourceAsStream( |
| | | "db/migration/V22__interval_integrity_and_completion.sql")) |
| | | { |
| | | if (stream == null) throw new AssertionError("缺少 Ticket14 迁移"); |
| | | sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); |
| | | } |
| | | catch (Exception exception) { throw new AssertionError(exception); } |
| | | assertTrue(sql.contains("t_test_integrity_issue")); |
| | | assertTrue(sql.contains("reception_interval_id")); |
| | | assertTrue(sql.contains("SEQUENCE_GAP") || sql.contains("issue_type")); |
| | | assertTrue(sql.contains("issue_status")); |
| | | assertFalse(sql.matches("(?s).*\\bDROP\\s+(TABLE|COLUMN)\\b.*")); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.system.service.impl; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | |
| | | import java.util.Arrays; |
| | | import java.util.List; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** 区间完整性序号检查的外部行为测试。 */ |
| | | class IntervalIntegrityCheckerTest |
| | | { |
| | | @Test |
| | | void ignoresDuplicatesAndOutOfOrderSamples() |
| | | { |
| | | assertEquals(List.of(new IntervalIntegrityChecker.Gap(3, 3), new IntervalIntegrityChecker.Gap(5, 5)), |
| | | IntervalIntegrityChecker.gaps(List.of(4L, 1L, 2L, 4L, 6L))); |
| | | } |
| | | |
| | | @Test |
| | | void treatsEachReceptionIntervalIndependently() |
| | | { |
| | | assertEquals(List.of(), IntervalIntegrityChecker.gaps(List.of(1L, 2L))); |
| | | assertEquals(List.of(), IntervalIntegrityChecker.gaps(List.of(8L, 9L))); |
| | | } |
| | | |
| | | @Test |
| | | void reportsMultipleOpenGaps() |
| | | { |
| | | assertEquals(List.of(new IntervalIntegrityChecker.Gap(3, 4), new IntervalIntegrityChecker.Gap(7, 8)), |
| | | IntervalIntegrityChecker.gaps(List.of(1L, 2L, 5L, 6L, 9L))); |
| | | } |
| | | |
| | | @Test |
| | | void doesNotTreatPauseOrHandoverAsAnIntraIntervalGap() |
| | | { |
| | | assertEquals(List.of(), IntervalIntegrityChecker.gaps(List.of(10L, 11L))); |
| | | assertEquals(List.of(), IntervalIntegrityChecker.gaps(List.of(20L, 21L))); |
| | | } |
| | | |
| | | @Test |
| | | void emptyIntervalHasNoSyntheticSequenceGap() |
| | | { |
| | | assertEquals(List.of(), IntervalIntegrityChecker.gaps(List.of())); |
| | | } |
| | | |
| | | @Test |
| | | void filtersInvalidSequenceValuesWithoutInventingGaps() |
| | | { |
| | | assertEquals(List.of(), IntervalIntegrityChecker.gaps(Arrays.asList(null, 0L, -1L, 5L))); |
| | | } |
| | | |
| | | @Test |
| | | void remainsStableAcrossReplayPermutationsAndDuplicates() |
| | | { |
| | | List<IntervalIntegrityChecker.Gap> expected = List.of( |
| | | new IntervalIntegrityChecker.Gap(3, 3), new IntervalIntegrityChecker.Gap(6, 7)); |
| | | List<List<Long>> replays = List.of( |
| | | List.of(8L, 1L, 2L, 4L, 5L, 8L), |
| | | List.of(1L, 8L, 5L, 4L, 2L, 5L), |
| | | List.of(2L, 4L, 1L, 8L, 4L, 5L)); |
| | | for (List<Long> replay : replays) assertEquals(expected, IntervalIntegrityChecker.gaps(replay)); |
| | | assertEquals(expected, IntervalIntegrityChecker.gaps(replays.get(0))); |
| | | } |
| | | } |
| | |
| | | .contains("system:test:query")); |
| | | } |
| | | |
| | | @Test |
| | | void integrityMappingIsAuthenticatedSurface() |
| | | { |
| | | Method integrity = method("integrity", Long.class); |
| | | assertArrayEquals(new String[] { "/{testId}/integrity" }, integrity.getAnnotation(GetMapping.class).value()); |
| | | assertTrue(integrity.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value() |
| | | .contains("system:test:query")); |
| | | } |
| | | |
| | | private Method method(String name, Class<?>... types) |
| | | { |
| | | try { return TTestLiveController.class.getDeclaredMethod(name, types); } |
| New file |
| | |
| | | package com.ruoyi.system.domain; |
| | | |
| | | import java.util.Date; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | |
| | | /** 测试来源区间完整性问题事实。 */ |
| | | public class TTestIntegrityIssue |
| | | { |
| | | private Long issueId; |
| | | private Long testId; |
| | | private Long intervalId; |
| | | private Long bindingId; |
| | | private String sourceType; |
| | | private Long sourceId; |
| | | private String sourceChannelCode; |
| | | private String issueType; |
| | | private String issueStatus; |
| | | private Long missingFromSequence; |
| | | private Long missingToSequence; |
| | | private Integer retryCount; |
| | | private String detail; |
| | | @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC") |
| | | private Date firstDetectedAtUtc; |
| | | @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC") |
| | | private Date lastDetectedAtUtc; |
| | | @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC") |
| | | private Date resolvedAtUtc; |
| | | |
| | | public Long getIssueId() { return issueId; } |
| | | public void setIssueId(Long value) { issueId = value; } |
| | | public Long getTestId() { return testId; } |
| | | public void setTestId(Long value) { testId = value; } |
| | | public Long getIntervalId() { return intervalId; } |
| | | public void setIntervalId(Long value) { intervalId = 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 String getIssueType() { return issueType; } |
| | | public void setIssueType(String value) { issueType = value; } |
| | | public String getIssueStatus() { return issueStatus; } |
| | | public void setIssueStatus(String value) { issueStatus = value; } |
| | | public Long getMissingFromSequence() { return missingFromSequence; } |
| | | public void setMissingFromSequence(Long value) { missingFromSequence = value; } |
| | | public Long getMissingToSequence() { return missingToSequence; } |
| | | public void setMissingToSequence(Long value) { missingToSequence = value; } |
| | | public Integer getRetryCount() { return retryCount; } |
| | | public void setRetryCount(Integer value) { retryCount = value; } |
| | | public String getDetail() { return detail; } |
| | | public void setDetail(String value) { detail = value; } |
| | | public Date getFirstDetectedAtUtc() { return firstDetectedAtUtc; } |
| | | public void setFirstDetectedAtUtc(Date value) { firstDetectedAtUtc = value; } |
| | | public Date getLastDetectedAtUtc() { return lastDetectedAtUtc; } |
| | | public void setLastDetectedAtUtc(Date value) { lastDetectedAtUtc = value; } |
| | | public Date getResolvedAtUtc() { return resolvedAtUtc; } |
| | | public void setResolvedAtUtc(Date value) { resolvedAtUtc = value; } |
| | | } |
| | |
| | | import com.ruoyi.system.domain.TTestCollectorExceptionInterval; |
| | | import com.ruoyi.system.domain.TTestReceptionInterval; |
| | | import com.ruoyi.system.domain.TTestBackfillCursor; |
| | | import com.ruoyi.system.domain.TTestIntegrityIssue; |
| | | import com.ruoyi.system.domain.TDetectionChannel; |
| | | import com.ruoyi.system.domain.vo.TestBatteryOptionVo; |
| | | import com.ruoyi.system.domain.vo.TestChannelOptionVo; |
| | |
| | | int updateTestBackfillWaiting(@Param("testId") Long testId, @Param("updatedAtUtc") Date updatedAtUtc, |
| | | @Param("error") String error); |
| | | int updateTestBackfillCompleted(@Param("testId") Long testId, @Param("updatedAtUtc") Date updatedAtUtc); |
| | | int updateTestCompleted(@Param("testId") Long testId, @Param("updatedAtUtc") Date updatedAtUtc); |
| | | int updateTestNaturallyEnded(@Param("testId") Long testId, @Param("endedAtUtc") Date endedAtUtc); |
| | | String selectSampleContentHash(@Param("channelId") Long channelId, @Param("btsTestId") String btsTestId, |
| | | @Param("sequenceId") Long sequenceId); |
| | |
| | | Long selectBtsSampleId(@Param("channelId") Long channelId, @Param("btsTestId") String btsTestId, |
| | | @Param("sequenceId") Long sequenceId); |
| | | int insertCollectorSample(@Param("testId") Long testId, @Param("bindingId") Long bindingId, |
| | | @Param("intervalId") Long intervalId, |
| | | @Param("collectorId") Long collectorId, @Param("channelCode") String channelCode, |
| | | @Param("sequenceId") Long sequenceId, @Param("deviceTimeUtc") Date deviceTimeUtc, |
| | | @Param("deviceTimeRaw") String deviceTimeRaw, @Param("receivedAtUtc") Date receivedAtUtc, |
| | |
| | | @Param("metricCode") String metricCode); |
| | | int insertBackfillConflict(@Param("testId") Long testId, @Param("channelId") Long channelId, |
| | | @Param("detail") String detail); |
| | | List<TTestIntegrityIssue> selectIntegrityIssues(Long testId); |
| | | List<TTestIntegrityIssue> selectTestExceptionFacts(Long testId); |
| | | List<Long> selectBtsSequencesByInterval(Long intervalId); |
| | | List<Long> selectCollectorSequencesByInterval(Long intervalId); |
| | | TTestIntegrityIssue selectOpenIntegrityIssue(@Param("intervalId") Long intervalId, |
| | | @Param("sourceType") String sourceType, @Param("sourceId") Long sourceId, |
| | | @Param("sourceChannelCode") String sourceChannelCode, @Param("missingFrom") Long missingFrom, |
| | | @Param("missingTo") Long missingTo); |
| | | List<TTestIntegrityIssue> selectOpenIntegrityIssues(@Param("intervalId") Long intervalId, |
| | | @Param("sourceType") String sourceType, @Param("sourceId") Long sourceId, |
| | | @Param("sourceChannelCode") String sourceChannelCode); |
| | | int insertIntegrityIssue(TTestIntegrityIssue issue); |
| | | int touchIntegrityIssue(@Param("issueId") Long issueId, @Param("retryCount") Integer retryCount, |
| | | @Param("detail") String detail, @Param("detectedAtUtc") Date detectedAtUtc); |
| | | int rewindBackfillCursor(@Param("testId") Long testId, @Param("bindingId") Long bindingId, |
| | | @Param("sourceType") String sourceType, @Param("sourceId") Long sourceId, |
| | | @Param("sourceChannelCode") String sourceChannelCode, @Param("nextPosition") Long nextPosition); |
| | | int resolveIntegrityIssues(@Param("testId") Long testId, @Param("intervalId") Long intervalId, |
| | | @Param("sourceType") String sourceType, @Param("sourceId") Long sourceId, |
| | | @Param("sourceChannelCode") String sourceChannelCode, @Param("resolvedAtUtc") Date resolvedAtUtc); |
| | | int resolveIntegrityIssue(@Param("issueId") Long issueId, @Param("resolvedAtUtc") Date resolvedAtUtc); |
| | | int countOpenIntegrityIssues(Long testId); |
| | | int countOpenExceptions(Long testId); |
| | | } |
| New file |
| | |
| | | package com.ruoyi.system.service; |
| | | |
| | | import java.util.List; |
| | | import com.ruoyi.system.domain.TTestIntegrityIssue; |
| | | |
| | | /** 查询测试区间完整性事实。 */ |
| | | public interface ITestIntegrityService |
| | | { |
| | | List<TTestIntegrityIssue> selectIssues(Long testId); |
| | | } |
| | |
| | | import java.time.Instant; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import com.ruoyi.system.domain.TTestIntegrityIssue; |
| | | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| | | |
| | | /** 提供测试控制台的双状态实时事实与鉴权 SSE。 */ |
| | |
| | | /** 打开实时 SSE;连接初次发送当前事实,后续只推送最新快照。 */ |
| | | SseEmitter stream(Long testId); |
| | | record LiveState(Long testId, String testStatus, String receptionStatus, Instant updatedAt, |
| | | List<LiveSource> sources, List<Map<String, Object>> exceptions) { } |
| | | List<LiveSource> sources, List<Map<String, Object>> exceptions, |
| | | List<TTestIntegrityIssue> integrityIssues) |
| | | { |
| | | public LiveState(Long testId, String testStatus, String receptionStatus, Instant updatedAt, |
| | | List<LiveSource> sources, List<Map<String, Object>> exceptions) |
| | | { this(testId, testStatus, receptionStatus, updatedAt, sources, exceptions, List.of()); } |
| | | } |
| | | record LiveSource(String sourceType, Long sourceId, String batteryNo, String deviceStatus, |
| | | Map<String, Object> latestSnapshot) { } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.system.service.impl; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.Collection; |
| | | import java.util.Comparator; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Set; |
| | | |
| | | /** 按单个接收区间检查历史序号;暂停和区间边界由调用方分组后自然排除。 */ |
| | | final class IntervalIntegrityChecker |
| | | { |
| | | record Gap(long from, long to) { } |
| | | |
| | | private IntervalIntegrityChecker() { } |
| | | |
| | | static List<Gap> gaps(Collection<Long> sequenceIds) |
| | | { |
| | | if (sequenceIds == null || sequenceIds.isEmpty()) return List.of(); |
| | | Set<Long> unique = new LinkedHashSet<>(); |
| | | sequenceIds.stream().filter(value -> value != null && value > 0).forEach(unique::add); |
| | | List<Long> ordered = new ArrayList<>(unique); |
| | | ordered.sort(Comparator.naturalOrder()); |
| | | List<Gap> result = new ArrayList<>(); |
| | | for (int index = 1; index < ordered.size(); index++) |
| | | { |
| | | long previous = ordered.get(index - 1); |
| | | long current = ordered.get(index); |
| | | if (current > previous + 1) result.add(new Gap(previous + 1, current - 1)); |
| | | } |
| | | return List.copyOf(result); |
| | | } |
| | | } |
| | |
| | | .toList()); |
| | | boolean abnormal = persisted.stream().anyMatch(item -> "OPEN".equals(item.getIntervalStatus())); |
| | | String testStatus = abnormal && "RUNNING".equals(test.getTestStatus()) ? "RUNNING_ABNORMAL" : test.getTestStatus(); |
| | | return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now(), sources, exceptions); |
| | | List<com.ruoyi.system.domain.TTestIntegrityIssue> integrity = new ArrayList<>(); |
| | | List<com.ruoyi.system.domain.TTestIntegrityIssue> persistedIntegrity = testMapper.selectIntegrityIssues(testId); |
| | | if (persistedIntegrity != null) integrity.addAll(persistedIntegrity); |
| | | List<com.ruoyi.system.domain.TTestIntegrityIssue> persistedExceptions = testMapper.selectTestExceptionFacts(testId); |
| | | if (persistedExceptions != null) integrity.addAll(persistedExceptions); |
| | | return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now(), sources, exceptions, |
| | | integrity == null ? List.of() : integrity); |
| | | } |
| | | |
| | | private LiveSource snapshotFor(Long testId, LiveSource fresh, boolean freeze) |
| | |
| | | emitter.send(SseEmitter.event().name("reception-status").data(Map.of("testId", testId, "receptionStatus", state.receptionStatus()))); |
| | | emitter.send(SseEmitter.event().name("test-status").data(Map.of("testId", testId, "testStatus", state.testStatus()))); |
| | | emitter.send(SseEmitter.event().name("exception").data(state.exceptions())); |
| | | emitter.send(SseEmitter.event().name("integrity").data(state.integrityIssues())); |
| | | emitter.send(SseEmitter.event().name("heartbeat").data(Map.of("at", state.updatedAt()))); |
| | | } |
| | | |
| | |
| | | testMapper.deleteChannelLeasesByTestId(testId); |
| | | testMapper.deleteCollectorChannelLeasesByTestId(testId); |
| | | testMapper.insertTestAudit(testId, "TEST_STOPPED", operatorId, updateBy, null, |
| | | JSON.toJSONString(Map.of("testStatus", "COMPLETED", "receptionStatus", "ENDED", |
| | | JSON.toJSONString(Map.of("testStatus", "COMPLETION_PENDING", "receptionStatus", "ENDED", |
| | | "polling", "STOPPED", "closedIntervals", closedIntervals, "release", "IMMEDIATE"))); |
| | | return 1; |
| | | } |
| | |
| | | import com.ruoyi.system.domain.TTestChannelBinding; |
| | | import com.ruoyi.system.domain.TTestCollectorChannel; |
| | | import com.ruoyi.system.domain.TTestReceptionInterval; |
| | | import com.ruoyi.system.domain.TTestIntegrityIssue; |
| | | import com.ruoyi.system.mapper.TTestMapper; |
| | | import com.ruoyi.system.service.ITestBackfillService; |
| | | import com.ruoyi.system.service.ITTestService; |
| | |
| | | public class TestBackfillServiceImpl implements ITestBackfillService |
| | | { |
| | | private static final int PAGE_SIZE = 1000; |
| | | private static final int MAX_INTEGRITY_RETRIES = 3; |
| | | private final TTestMapper mapper; |
| | | private final DeviceAdapterRegistry registry; |
| | | private final BtsSimulatorStateStore collectorHistoryStore; |
| | |
| | | ? List.<TTestCollectorChannel>of() : battery.getCollectorChannels()) |
| | | backfillCollector(test, battery, mapping); |
| | | } |
| | | mapper.updateTestBackfillCompleted(test.getTestId(), new Date()); |
| | | Date checkedAt = new Date(); |
| | | boolean complete = checkIntegrity(test, checkedAt); |
| | | if (complete) |
| | | { |
| | | mapper.updateTestCompleted(test.getTestId(), checkedAt); |
| | | mapper.insertTestAudit(test.getTestId(), "TEST_COMPLETED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("backfillStatus", "COMPLETED", "integrity", "PASSED"))); |
| | | } |
| | | else |
| | | { |
| | | mapper.updateTestBackfillWaiting(test.getTestId(), checkedAt, "区间完整性检查未通过"); |
| | | mapper.insertTestAudit(test.getTestId(), "INTEGRITY_CHECK_BLOCKED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("backfillStatus", "WAITING", "integrity", "OPEN"))); |
| | | } |
| | | } |
| | | |
| | | private void backfillCabinet(TTest test, TTestBattery battery, TTestChannelBinding binding) |
| | |
| | | for (CollectorMeasurementRecord record : page.records()) |
| | | { |
| | | if (!mapping.getMeasurementCode().equalsIgnoreCase(record.metricCode()) || !within(record.deviceTime(), intervals)) continue; |
| | | saveCollectorSample(test, battery, mapping, record); |
| | | saveCollectorSample(test, battery, mapping, intervals, record); |
| | | } |
| | | offset = page.nextOffset(); |
| | | cursor.setNextPosition(offset + 1L); |
| | |
| | | } |
| | | |
| | | private void saveCollectorSample(TTest test, TTestBattery battery, TTestCollectorChannel mapping, |
| | | CollectorMeasurementRecord record) |
| | | List<TTestReceptionInterval> intervals, CollectorMeasurementRecord record) |
| | | { |
| | | byte[] hash = hash(record.sequenceId() + "|" + record.deviceTimeRaw() + "|" + record.metricCode() + "|" |
| | | + record.value() + "|" + record.unit()); |
| | |
| | | "采集器=" + record.collectorCode() + ", seqid=" + record.sequenceId()); |
| | | return; |
| | | } |
| | | mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), battery.getCollectorId(), |
| | | mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), |
| | | intervalId(intervals, record.deviceTime()), battery.getCollectorId(), |
| | | record.channelCode(), record.sequenceId(), Date.from(record.deviceTime()), record.deviceTimeRaw(), |
| | | Date.from(record.serverReceivedAt()), record.metricCode(), record.value(), record.unit(), hash); |
| | | } |
| | | |
| | | private boolean checkIntegrity(TTest test, Date checkedAt) |
| | | { |
| | | boolean complete = true; |
| | | for (TTestReceptionInterval interval : mapper.selectTestReceptionIntervals(test.getTestId())) |
| | | { |
| | | String sourceType = interval.getSourceType(); |
| | | List<Long> sequenceIds = "CABINET_CHANNEL".equals(sourceType) |
| | | ? mapper.selectBtsSequencesByInterval(interval.getIntervalId()) |
| | | : mapper.selectCollectorSequencesByInterval(interval.getIntervalId()); |
| | | List<IntervalIntegrityChecker.Gap> gaps = IntervalIntegrityChecker.gaps(sequenceIds); |
| | | if (sequenceIds == null || sequenceIds.isEmpty()) |
| | | { |
| | | complete = false; |
| | | TTestIntegrityIssue old = mapper.selectOpenIntegrityIssue(interval.getIntervalId(), sourceType, |
| | | interval.getSourceId(), interval.getSourceChannelCode(), null, null); |
| | | if (old == null) |
| | | { |
| | | TTestIntegrityIssue issue = new TTestIntegrityIssue(); |
| | | issue.setTestId(test.getTestId()); issue.setIntervalId(interval.getIntervalId()); |
| | | issue.setBindingId(interval.getBindingId()); issue.setSourceType(sourceType); |
| | | issue.setSourceId(interval.getSourceId()); issue.setSourceChannelCode(interval.getSourceChannelCode()); |
| | | issue.setIssueType("NO_SAMPLES"); issue.setRetryCount(1); |
| | | issue.setDetail("接收区间没有可确认的历史采样"); issue.setFirstDetectedAtUtc(checkedAt); |
| | | issue.setLastDetectedAtUtc(checkedAt); mapper.insertIntegrityIssue(issue); |
| | | mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_DETECTED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("intervalId", interval.getIntervalId(), "sourceType", sourceType, |
| | | "issueType", "NO_SAMPLES"))); |
| | | } |
| | | else |
| | | { |
| | | int retry = old.getRetryCount() == null ? 1 : old.getRetryCount() + 1; |
| | | int cappedRetry = Math.min(retry, MAX_INTEGRITY_RETRIES); |
| | | mapper.touchIntegrityIssue(old.getIssueId(), cappedRetry, |
| | | old.getDetail(), checkedAt); |
| | | mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RETRIED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("issueId", old.getIssueId(), "retryCount", cappedRetry))); |
| | | if (cappedRetry < MAX_INTEGRITY_RETRIES) |
| | | mapper.rewindBackfillCursor(test.getTestId(), interval.getBindingId(), sourceType, |
| | | interval.getSourceId(), interval.getSourceChannelCode(), 1L); |
| | | } |
| | | continue; |
| | | } |
| | | if (gaps.isEmpty()) |
| | | { |
| | | int resolved = mapper.resolveIntegrityIssues(test.getTestId(), interval.getIntervalId(), sourceType, |
| | | interval.getSourceId(), interval.getSourceChannelCode(), checkedAt); |
| | | if (resolved > 0) mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RESOLVED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("intervalId", interval.getIntervalId()))); |
| | | continue; |
| | | } |
| | | complete = false; |
| | | for (TTestIntegrityIssue open : mapper.selectOpenIntegrityIssues(interval.getIntervalId(), sourceType, |
| | | interval.getSourceId(), interval.getSourceChannelCode())) |
| | | { |
| | | boolean stillOpen = gaps.stream().anyMatch(gap -> Objects.equals(gap.from(), open.getMissingFromSequence()) |
| | | && Objects.equals(gap.to(), open.getMissingToSequence())); |
| | | if (!stillOpen && mapper.resolveIntegrityIssue(open.getIssueId(), checkedAt) > 0) |
| | | mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RESOLVED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("issueId", open.getIssueId()))); |
| | | } |
| | | for (IntervalIntegrityChecker.Gap gap : gaps) |
| | | { |
| | | TTestIntegrityIssue old = mapper.selectOpenIntegrityIssue(interval.getIntervalId(), sourceType, |
| | | interval.getSourceId(), interval.getSourceChannelCode(), gap.from(), gap.to()); |
| | | if (old == null) |
| | | { |
| | | TTestIntegrityIssue issue = new TTestIntegrityIssue(); |
| | | issue.setTestId(test.getTestId()); issue.setIntervalId(interval.getIntervalId()); |
| | | issue.setBindingId(interval.getBindingId()); issue.setSourceType(sourceType); |
| | | issue.setSourceId(interval.getSourceId()); issue.setSourceChannelCode(interval.getSourceChannelCode()); |
| | | issue.setIssueType("SEQUENCE_GAP"); issue.setMissingFromSequence(gap.from()); |
| | | issue.setMissingToSequence(gap.to()); issue.setRetryCount(1); |
| | | issue.setDetail("接收区间内存在未解释序号缺口"); issue.setFirstDetectedAtUtc(checkedAt); |
| | | issue.setLastDetectedAtUtc(checkedAt); mapper.insertIntegrityIssue(issue); |
| | | mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_DETECTED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("intervalId", interval.getIntervalId(), "sourceType", sourceType, |
| | | "missingFrom", gap.from(), "missingTo", gap.to()))); |
| | | } |
| | | else |
| | | { |
| | | int retry = old.getRetryCount() == null ? 1 : old.getRetryCount() + 1; |
| | | int cappedRetry = Math.min(retry, MAX_INTEGRITY_RETRIES); |
| | | mapper.touchIntegrityIssue(old.getIssueId(), cappedRetry, |
| | | old.getDetail(), checkedAt); |
| | | mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RETRIED", null, "system", null, |
| | | JSON.toJSONString(java.util.Map.of("issueId", old.getIssueId(), "retryCount", cappedRetry))); |
| | | if (cappedRetry >= MAX_INTEGRITY_RETRIES) continue; |
| | | } |
| | | // next_position 是分页偏移而非 seqid;从安全起点重放,依靠幂等键避免覆盖原始事实。 |
| | | mapper.rewindBackfillCursor(test.getTestId(), interval.getBindingId(), sourceType, |
| | | interval.getSourceId(), interval.getSourceChannelCode(), 1L); |
| | | } |
| | | } |
| | | return complete && mapper.countOpenIntegrityIssues(test.getTestId()) == 0 |
| | | && mapper.countOpenExceptions(test.getTestId()) == 0; |
| | | } |
| | | |
| | | private TTestBackfillCursor cursor(TTest test, Long bindingId, String sourceType, Long sourceId, |
| | | String channelCode, String protocolTestId) |
| | | { |
| New file |
| | |
| | | package com.ruoyi.system.service.impl; |
| | | |
| | | import java.util.List; |
| | | import org.springframework.stereotype.Service; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import com.ruoyi.system.domain.TTestIntegrityIssue; |
| | | import com.ruoyi.system.mapper.TTestMapper; |
| | | import com.ruoyi.system.service.ITTestService; |
| | | import com.ruoyi.system.service.ITestIntegrityService; |
| | | |
| | | /** 完整性查询只返回持久化问题事实,不把实时快照当作历史结果。 */ |
| | | @Service |
| | | public class TestIntegrityServiceImpl implements ITestIntegrityService |
| | | { |
| | | private final TTestMapper mapper; |
| | | private final ITTestService tests; |
| | | |
| | | public TestIntegrityServiceImpl(TTestMapper mapper, ITTestService tests) |
| | | { this.mapper = mapper; this.tests = tests; } |
| | | |
| | | @Override |
| | | public List<TTestIntegrityIssue> selectIssues(Long testId) |
| | | { |
| | | if (testId == null) throw new ServiceException("测试ID不能为空"); |
| | | tests.selectTestById(testId); |
| | | List<TTestIntegrityIssue> issues = new java.util.ArrayList<>(); |
| | | List<TTestIntegrityIssue> integrity = mapper.selectIntegrityIssues(testId); |
| | | if (integrity != null) issues.addAll(integrity); |
| | | List<TTestIntegrityIssue> exceptions = mapper.selectTestExceptionFacts(testId); |
| | | if (exceptions != null) issues.addAll(exceptions); |
| | | return issues; |
| | | } |
| | | } |
| | |
| | | update t_test set backfill_status = 'COMPLETED', backfill_completed_at_utc = #{updatedAtUtc}, |
| | | update_time = #{updatedAtUtc} where test_id = #{testId} and reception_status = 'ENDED' |
| | | </update> |
| | | <update id="updateTestCompleted"> |
| | | update t_test set test_status = 'COMPLETED', backfill_status = 'COMPLETED', |
| | | backfill_completed_at_utc = #{updatedAtUtc}, update_time = #{updatedAtUtc} |
| | | where test_id = #{testId} and reception_status = 'ENDED' and test_status = 'COMPLETION_PENDING' |
| | | </update> |
| | | <update id="updateTestNaturallyEnded"> |
| | | update t_test set test_status = 'COMPLETION_PENDING', reception_status = 'ENDED', |
| | | ended_at_utc = #{endedAtUtc}, backfill_status = 'NOT_STARTED', update_time = #{endedAtUtc} |
| | |
| | | </select> |
| | | <insert id="insertCollectorSample"> |
| | | insert ignore into t_test_collector_sample |
| | | (test_id, binding_id, collector_id, collector_channel_code, sequence_id, device_time_utc, |
| | | (test_id, binding_id, reception_interval_id, collector_id, collector_channel_code, sequence_id, device_time_utc, |
| | | device_time_raw, server_received_at_utc, metric_code, metric_value, unit, content_sha256) |
| | | values (#{testId}, #{bindingId}, #{collectorId}, #{channelCode}, #{sequenceId}, #{deviceTimeUtc}, |
| | | values (#{testId}, #{bindingId}, #{intervalId}, #{collectorId}, #{channelCode}, #{sequenceId}, #{deviceTimeUtc}, |
| | | #{deviceTimeRaw}, #{receivedAtUtc}, #{metricCode}, #{metricValue}, #{unit}, #{contentSha256}) |
| | | </insert> |
| | | <resultMap id="TTestIntegrityIssueResultMap" type="com.ruoyi.system.domain.TTestIntegrityIssue"> |
| | | <id property="issueId" column="issue_id"/><result property="testId" column="test_id"/> |
| | | <result property="intervalId" column="interval_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="issueType" column="issue_type"/> |
| | | <result property="issueStatus" column="issue_status"/><result property="missingFromSequence" column="missing_from_sequence"/> |
| | | <result property="missingToSequence" column="missing_to_sequence"/><result property="retryCount" column="retry_count"/> |
| | | <result property="detail" column="detail"/><result property="firstDetectedAtUtc" column="first_detected_at_utc"/> |
| | | <result property="lastDetectedAtUtc" column="last_detected_at_utc"/><result property="resolvedAtUtc" column="resolved_at_utc"/> |
| | | </resultMap> |
| | | <select id="selectIntegrityIssues" resultMap="TTestIntegrityIssueResultMap"> |
| | | select * from t_test_integrity_issue where test_id = #{testId} |
| | | order by issue_status, last_detected_at_utc desc, issue_id desc |
| | | </select> |
| | | <select id="selectTestExceptionFacts" resultMap="TTestIntegrityIssueResultMap"> |
| | | select exception_id issue_id, test_id, null interval_id, null binding_id, |
| | | case when channel_id is null then 'UNKNOWN_SOURCE' else 'CABINET_CHANNEL' end source_type, |
| | | channel_id source_id, null source_channel_code, exception_type issue_type, |
| | | exception_status issue_status, null missing_from_sequence, null missing_to_sequence, |
| | | occurrence_count retry_count, detail, first_occurred_at_utc first_detected_at_utc, |
| | | last_occurred_at_utc last_detected_at_utc, handled_at_utc resolved_at_utc |
| | | from t_test_exception where test_id = #{testId} |
| | | order by exception_status, last_occurred_at_utc desc, exception_id desc |
| | | </select> |
| | | <select id="selectBtsSequencesByInterval" resultType="long"> |
| | | select seq_id from t_test_sample where reception_interval_id = #{intervalId} order by seq_id |
| | | </select> |
| | | <select id="selectCollectorSequencesByInterval" resultType="long"> |
| | | select distinct sequence_id from t_test_collector_sample where reception_interval_id = #{intervalId} order by sequence_id |
| | | </select> |
| | | <select id="selectOpenIntegrityIssue" resultMap="TTestIntegrityIssueResultMap"> |
| | | select * from t_test_integrity_issue where interval_id = #{intervalId} and issue_status = 'OPEN' |
| | | and source_type = #{sourceType} and source_id = #{sourceId} |
| | | and (source_channel_code = #{sourceChannelCode} or (source_channel_code is null and #{sourceChannelCode} is null)) |
| | | and (missing_from_sequence = #{missingFrom} or (missing_from_sequence is null and #{missingFrom} is null)) |
| | | and (missing_to_sequence = #{missingTo} or (missing_to_sequence is null and #{missingTo} is null)) limit 1 |
| | | </select> |
| | | <select id="selectOpenIntegrityIssues" resultMap="TTestIntegrityIssueResultMap"> |
| | | select * from t_test_integrity_issue where interval_id = #{intervalId} and issue_status = 'OPEN' |
| | | and source_type = #{sourceType} and source_id = #{sourceId} |
| | | and (source_channel_code = #{sourceChannelCode} or (source_channel_code is null and #{sourceChannelCode} is null)) |
| | | order by issue_id |
| | | </select> |
| | | <insert id="insertIntegrityIssue" parameterType="com.ruoyi.system.domain.TTestIntegrityIssue" useGeneratedKeys="true" keyProperty="issueId"> |
| | | insert into t_test_integrity_issue |
| | | (test_id, interval_id, binding_id, source_type, source_id, source_channel_code, issue_type, issue_status, |
| | | missing_from_sequence, missing_to_sequence, retry_count, detail, first_detected_at_utc, last_detected_at_utc) |
| | | values (#{testId}, #{intervalId}, #{bindingId}, #{sourceType}, #{sourceId}, #{sourceChannelCode}, #{issueType}, 'OPEN', |
| | | #{missingFromSequence}, #{missingToSequence}, #{retryCount}, #{detail}, #{firstDetectedAtUtc}, #{lastDetectedAtUtc}) |
| | | </insert> |
| | | <update id="touchIntegrityIssue"> |
| | | update t_test_integrity_issue set retry_count = #{retryCount}, detail = #{detail}, last_detected_at_utc = #{detectedAtUtc} |
| | | where issue_id = #{issueId} and issue_status = 'OPEN' |
| | | </update> |
| | | <update id="rewindBackfillCursor"> |
| | | update t_test_backfill_cursor set next_position = least(next_position, #{nextPosition}), |
| | | source_status = 'PENDING', last_error = '区间序号缺口待重试', updated_at_utc = utc_timestamp(3) |
| | | where test_id = #{testId} and binding_id = #{bindingId} and source_type = #{sourceType} |
| | | and source_id = #{sourceId} |
| | | and (source_channel_code = #{sourceChannelCode} or (source_channel_code is null and #{sourceChannelCode} is null)) |
| | | </update> |
| | | <update id="resolveIntegrityIssues"> |
| | | update t_test_integrity_issue set issue_status = 'RESOLVED', resolved_at_utc = #{resolvedAtUtc} |
| | | where test_id = #{testId} and interval_id = #{intervalId} and issue_status = 'OPEN' |
| | | and source_type = #{sourceType} and source_id = #{sourceId} |
| | | and (source_channel_code = #{sourceChannelCode} or (source_channel_code is null and #{sourceChannelCode} is null)) |
| | | </update> |
| | | <update id="resolveIntegrityIssue"> |
| | | update t_test_integrity_issue set issue_status = 'RESOLVED', resolved_at_utc = #{resolvedAtUtc} |
| | | where issue_id = #{issueId} and issue_status = 'OPEN' |
| | | </update> |
| | | <select id="countOpenIntegrityIssues" resultType="int"> |
| | | select count(1) from t_test_integrity_issue where test_id = #{testId} and issue_status = 'OPEN' |
| | | </select> |
| | | <select id="countOpenExceptions" resultType="int"> |
| | | select count(1) from t_test_exception where test_id = #{testId} and exception_status = 'OPEN' |
| | | </select> |
| | | <select id="selectCollectorSampleId" resultType="long"> |
| | | select sample_id from t_test_collector_sample |
| | | where collector_id = #{collectorId} and collector_channel_code = #{channelCode} |
| | |
| | | </update> |
| | | <update id="updateReceptionStopped"> |
| | | update t_test |
| | | set test_status = 'COMPLETED', reception_status = 'ENDED', ended_at_utc = #{endedAtUtc}, |
| | | set test_status = 'COMPLETION_PENDING', reception_status = 'ENDED', ended_at_utc = #{endedAtUtc}, |
| | | backfill_status = 'NOT_STARTED', |
| | | update_by = #{updateBy}, update_time = #{endedAtUtc} |
| | | where test_id = #{testId} and reception_status in ('RECEIVING', 'PAUSED') and del_flag = 0 |