feat: add jt808 raw frame history query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
This commit is contained in:
@@ -313,8 +313,11 @@ SELECT ts, frame_id, phone, message_id, metadata_json, raw_uri
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS 'http://127.0.0.1:20200/api/event-history/jt808/locations?phone=13079963301&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&order=DESC&limit=3'
|
curl -sS 'http://127.0.0.1:20200/api/event-history/jt808/locations?phone=13079963301&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&order=DESC&limit=3'
|
||||||
|
curl -sS 'http://127.0.0.1:20200/api/event-history/jt808/raw-frames?phone=13079963320&messageId=256&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&order=DESC&limit=3'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`/api/event-history/jt808/raw-frames` 用于排查注册、鉴权、心跳、位置等原始帧索引。返回项会保留 `messageId`、`messageIdHex`、`rawUri`、`parseStatus`、`peer`、`vin`、`phone` 和完整 `metadataJson`;注册帧的 `metadataJson` 包含 `jt808.register.province`、`jt808.register.city`、`jt808.register.maker`、`jt808.register.deviceType`、`jt808.register.deviceId`、`jt808.register.plateColor`、`jt808.register.plate`、`identitySource`、`identityResolved` 等字段。
|
||||||
|
|
||||||
GB32960 链路已用正式平台登录和合成实时帧验证:
|
GB32960 链路已用正式平台登录和合成实时帧验证:
|
||||||
|
|
||||||
- 正式平台 `115.29.187.205` 已连接 TCP `32960`,服务收到 `0x05 PLATFORM_LOGIN` 并返回 ACK;日志显示账号 `Hyundai`、策略 `ALLOW`。
|
- 正式平台 `115.29.187.205` 已连接 TCP `32960`,服务收到 `0x05 PLATFORM_LOGIN` 并返回 ACK;日志显示账号 `Hyundai`、策略 `ALLOW`。
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.lingniu.ingest.eventhistory;
|
||||||
|
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineHistoryReader;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdenginePage;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdenginePageCursor;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineQueryOrder;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineRawFrameQuery;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineRawFrameRow;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||||
|
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||||
|
@RequestMapping("/api/event-history/jt808")
|
||||||
|
@Tag(name = "jt-808-raw-frame-controller", description = "JT808 原始帧索引分页查询接口。")
|
||||||
|
public final class Jt808RawFrameHistoryController {
|
||||||
|
|
||||||
|
private final TdengineHistoryReader reader;
|
||||||
|
|
||||||
|
public Jt808RawFrameHistoryController(TdengineHistoryReader reader) {
|
||||||
|
if (reader == null) {
|
||||||
|
throw new IllegalArgumentException("reader must not be null");
|
||||||
|
}
|
||||||
|
this.reader = reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/raw-frames")
|
||||||
|
@Operation(
|
||||||
|
summary = "查询 JT808 原始帧索引",
|
||||||
|
description = "按 phone、VIN、vehicleKey、messageId、parseStatus 和时间范围查询 TDengine raw_frames。"
|
||||||
|
+ "返回完整 metadataJson,包含注册帧字段、身份解析来源、rawArchiveUri 等排障信息。"
|
||||||
|
+ "分页使用 cursorTs + cursorId,不使用 offset。")
|
||||||
|
public RawFramePageResponse rawFrames(
|
||||||
|
@Parameter(description = "JT808 终端手机号或终端标识。", example = "13079963320")
|
||||||
|
@RequestParam(required = false) String phone,
|
||||||
|
@Parameter(description = "VIN。VIN 已反写后可用该字段查询。", example = "LNVIN000000000001")
|
||||||
|
@RequestParam(required = false) String vin,
|
||||||
|
@Parameter(description = "内部车辆键;传入后精确过滤 vehicle_key。", example = "jt808:13079963320")
|
||||||
|
@RequestParam(required = false) String vehicleKey,
|
||||||
|
@Parameter(description = "JT808 消息 ID,支持十进制,例如注册帧 256、位置帧 512。", example = "256")
|
||||||
|
@RequestParam(required = false) Integer messageId,
|
||||||
|
@Parameter(description = "解析状态,例如 SUCCEEDED、FAILED。", example = "SUCCEEDED")
|
||||||
|
@RequestParam(required = false) String parseStatus,
|
||||||
|
@Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-29T13:00:00")
|
||||||
|
@RequestParam String dateFrom,
|
||||||
|
@Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-29T13:10:00")
|
||||||
|
@RequestParam String dateTo,
|
||||||
|
@Parameter(description = "排序方向。", example = "DESC")
|
||||||
|
@RequestParam(defaultValue = "DESC") TdengineQueryOrder order,
|
||||||
|
@Parameter(description = "返回原始帧数量上限,最大 1000。", example = "100")
|
||||||
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
|
@Parameter(description = "上一页返回的 nextCursor.ts。", example = "2026-06-29T05:00:01Z")
|
||||||
|
@RequestParam(required = false) String cursorTs,
|
||||||
|
@Parameter(description = "上一页返回的 nextCursor.id。", example = "jt808-frame-xxx")
|
||||||
|
@RequestParam(required = false) String cursorId) throws IOException {
|
||||||
|
if (allBlank(phone, vin, vehicleKey) && messageId == null && isBlank(parseStatus)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"phone, vin, vehicleKey, messageId or parseStatus is required for jt808 raw frame query");
|
||||||
|
}
|
||||||
|
QueryTimeRange range = QueryTimeRange.parse(dateFrom, dateTo);
|
||||||
|
TdenginePage<TdengineRawFrameRow> page = reader.queryRawFrames(new TdengineRawFrameQuery(
|
||||||
|
"JT808",
|
||||||
|
normalize(vehicleKey),
|
||||||
|
normalize(vin),
|
||||||
|
normalize(phone),
|
||||||
|
messageId,
|
||||||
|
normalize(parseStatus).toUpperCase(Locale.ROOT),
|
||||||
|
range.eventTimeFrom(),
|
||||||
|
range.eventTimeTo().plusMillis(1),
|
||||||
|
order,
|
||||||
|
limit,
|
||||||
|
cursor(cursorTs, cursorId)));
|
||||||
|
return RawFramePageResponse.from(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TdenginePageCursor cursor(String cursorTs, String cursorId) {
|
||||||
|
boolean hasTs = cursorTs != null && !cursorTs.isBlank();
|
||||||
|
boolean hasId = cursorId != null && !cursorId.isBlank();
|
||||||
|
if (!hasTs && !hasId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasTs || !hasId) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "cursorTs and cursorId must be provided together");
|
||||||
|
}
|
||||||
|
return new TdenginePageCursor(Instant.parse(cursorTs.trim()), cursorId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean allBlank(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (!isBlank(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RawFramePageResponse(
|
||||||
|
List<RawFrameResponse> items,
|
||||||
|
CursorResponse nextCursor) {
|
||||||
|
|
||||||
|
private static RawFramePageResponse from(TdenginePage<TdengineRawFrameRow> page) {
|
||||||
|
CursorResponse next = page.nextCursor()
|
||||||
|
.map(cursor -> new CursorResponse(cursor.ts().toString(), cursor.tieBreaker()))
|
||||||
|
.orElse(null);
|
||||||
|
return new RawFramePageResponse(
|
||||||
|
page.items().stream().map(RawFrameResponse::from).toList(),
|
||||||
|
next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CursorResponse(String ts, String id) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RawFrameResponse(
|
||||||
|
String eventTime,
|
||||||
|
String receivedAt,
|
||||||
|
String frameId,
|
||||||
|
int messageId,
|
||||||
|
String messageIdHex,
|
||||||
|
int subType,
|
||||||
|
String rawUri,
|
||||||
|
String checksum,
|
||||||
|
long rawSizeBytes,
|
||||||
|
String parseStatus,
|
||||||
|
String parseError,
|
||||||
|
String peer,
|
||||||
|
String metadataJson,
|
||||||
|
String protocol,
|
||||||
|
String vehicleKey,
|
||||||
|
String vin,
|
||||||
|
String phone) {
|
||||||
|
|
||||||
|
private static RawFrameResponse from(TdengineRawFrameRow row) {
|
||||||
|
return new RawFrameResponse(
|
||||||
|
row.ts().toString(),
|
||||||
|
row.receivedAt().toString(),
|
||||||
|
row.frameId(),
|
||||||
|
row.messageId(),
|
||||||
|
"0x%04X".formatted(row.messageId()),
|
||||||
|
row.subType(),
|
||||||
|
row.rawUri(),
|
||||||
|
row.checksum(),
|
||||||
|
row.rawSizeBytes(),
|
||||||
|
row.parseStatus(),
|
||||||
|
row.parseError(),
|
||||||
|
row.peer(),
|
||||||
|
row.metadataJson(),
|
||||||
|
row.protocol(),
|
||||||
|
row.vehicleKey(),
|
||||||
|
row.vin(),
|
||||||
|
row.phone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.lingniu.ingest.eventhistory.EventHistoryEnvelopeIngestor;
|
|||||||
import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
|
import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
|
||||||
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
|
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
|
||||||
import com.lingniu.ingest.eventhistory.Jt808LocationHistoryController;
|
import com.lingniu.ingest.eventhistory.Jt808LocationHistoryController;
|
||||||
|
import com.lingniu.ingest.eventhistory.Jt808RawFrameHistoryController;
|
||||||
import com.lingniu.ingest.eventhistory.TelemetryFieldHistoryController;
|
import com.lingniu.ingest.eventhistory.TelemetryFieldHistoryController;
|
||||||
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
|
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
|
||||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
||||||
@@ -106,6 +107,13 @@ public class EventHistoryAutoConfiguration {
|
|||||||
return new Jt808LocationHistoryController(reader);
|
return new Jt808LocationHistoryController(reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||||
|
@ConditionalOnMissingBean
|
||||||
|
public Jt808RawFrameHistoryController jt808RawFrameHistoryController(TdengineHistoryReader reader) {
|
||||||
|
return new Jt808RawFrameHistoryController(reader);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnBean(TdengineHistoryReader.class)
|
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.lingniu.ingest.eventhistory;
|
||||||
|
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineHistoryReader;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineLocationQuery;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineLocationRow;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdenginePage;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdenginePageCursor;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineQueryOrder;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineRawFrameQuery;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineRawFrameRow;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineTelemetryFieldQuery;
|
||||||
|
import com.lingniu.ingest.tdenginehistory.TdengineTelemetryFieldRow;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class Jt808RawFrameHistoryControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queriesRawFramesByPhoneMessageIdAndCursor() throws Exception {
|
||||||
|
CapturingReader reader = new CapturingReader(new TdenginePage<>(
|
||||||
|
List.of(row("frame-1", "2026-06-29T12:00:01Z")),
|
||||||
|
Optional.of(new TdenginePageCursor(Instant.parse("2026-06-29T12:00:01Z"), "frame-1"))));
|
||||||
|
Jt808RawFrameHistoryController controller = new Jt808RawFrameHistoryController(reader);
|
||||||
|
|
||||||
|
Jt808RawFrameHistoryController.RawFramePageResponse response = controller.rawFrames(
|
||||||
|
"13079963320",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0x0100,
|
||||||
|
null,
|
||||||
|
"2026-06-29T00:00:00+08:00",
|
||||||
|
"2026-06-29T23:59:59+08:00",
|
||||||
|
TdengineQueryOrder.DESC,
|
||||||
|
20,
|
||||||
|
"2026-06-29T12:00:00Z",
|
||||||
|
"frame-0");
|
||||||
|
|
||||||
|
assertThat(reader.query.protocol()).isEqualTo("JT808");
|
||||||
|
assertThat(reader.query.vehicleKey()).isBlank();
|
||||||
|
assertThat(reader.query.phone()).isEqualTo("13079963320");
|
||||||
|
assertThat(reader.query.messageId()).isEqualTo(0x0100);
|
||||||
|
assertThat(reader.query.cursor()).isEqualTo(new TdenginePageCursor(
|
||||||
|
Instant.parse("2026-06-29T12:00:00Z"), "frame-0"));
|
||||||
|
assertThat(response.items()).hasSize(1);
|
||||||
|
Jt808RawFrameHistoryController.RawFrameResponse item = response.items().getFirst();
|
||||||
|
assertThat(item.messageId()).isEqualTo(0x0100);
|
||||||
|
assertThat(item.messageIdHex()).isEqualTo("0x0100");
|
||||||
|
assertThat(item.metadataJson()).contains("jt808.register.deviceId");
|
||||||
|
assertThat(response.nextCursor()).isEqualTo(new Jt808RawFrameHistoryController.CursorResponse(
|
||||||
|
"2026-06-29T12:00:01Z", "frame-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnboundedRawFrameQuery() {
|
||||||
|
Jt808RawFrameHistoryController controller = new Jt808RawFrameHistoryController(
|
||||||
|
new CapturingReader(new TdenginePage<>(List.of(), Optional.empty())));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.rawFrames(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"2026-06-29",
|
||||||
|
"2026-06-29",
|
||||||
|
TdengineQueryOrder.DESC,
|
||||||
|
20,
|
||||||
|
null,
|
||||||
|
null))
|
||||||
|
.isInstanceOfSatisfying(ResponseStatusException.class, ex ->
|
||||||
|
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TdengineRawFrameRow row(String frameId, String ts) {
|
||||||
|
Instant instant = Instant.parse(ts);
|
||||||
|
return new TdengineRawFrameRow(
|
||||||
|
instant,
|
||||||
|
frameId,
|
||||||
|
instant.plusMillis(500),
|
||||||
|
0x0100,
|
||||||
|
0,
|
||||||
|
instant,
|
||||||
|
"archive://2026/06/29/JT808/unknown/" + frameId + ".bin",
|
||||||
|
"sha256:" + frameId,
|
||||||
|
72,
|
||||||
|
"SUCCEEDED",
|
||||||
|
"",
|
||||||
|
"222.66.200.68:41000",
|
||||||
|
"{\"jt808.register.deviceId\":\"9963320\",\"jt808.register.plate\":\"沪A61559F\"}",
|
||||||
|
"JT808",
|
||||||
|
"jt808:13079963320",
|
||||||
|
"unknown",
|
||||||
|
"13079963320");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CapturingReader implements TdengineHistoryReader {
|
||||||
|
private final TdenginePage<TdengineRawFrameRow> response;
|
||||||
|
private TdengineRawFrameQuery query;
|
||||||
|
|
||||||
|
private CapturingReader(TdenginePage<TdengineRawFrameRow> response) {
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TdenginePage<TdengineRawFrameRow> queryRawFrames(TdengineRawFrameQuery query) throws IOException {
|
||||||
|
this.query = query;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TdenginePage<TdengineLocationRow> queryLocations(TdengineLocationQuery query) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TdenginePage<TdengineTelemetryFieldRow> queryTelemetryFields(TdengineTelemetryFieldQuery query) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.lingniu.ingest.eventhistory.EventHistoryEnvelopeIngestor;
|
|||||||
import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
|
import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
|
||||||
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
|
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
|
||||||
import com.lingniu.ingest.eventhistory.Jt808LocationHistoryController;
|
import com.lingniu.ingest.eventhistory.Jt808LocationHistoryController;
|
||||||
|
import com.lingniu.ingest.eventhistory.Jt808RawFrameHistoryController;
|
||||||
import com.lingniu.ingest.eventhistory.TelemetryFieldHistoryController;
|
import com.lingniu.ingest.eventhistory.TelemetryFieldHistoryController;
|
||||||
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
|
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
|
||||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
||||||
@@ -91,6 +92,14 @@ class EventHistoryAutoConfigurationTest {
|
|||||||
.run(context -> assertThat(context).hasSingleBean(Jt808LocationHistoryController.class));
|
.run(context -> assertThat(context).hasSingleBean(Jt808LocationHistoryController.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsJt808RawFrameHistoryControllerWhenTdengineReaderExists() {
|
||||||
|
contextRunner
|
||||||
|
.withPropertyValues("lingniu.ingest.event-history.enabled=true")
|
||||||
|
.withBean(TdengineHistoryReader.class, () -> mock(TdengineHistoryReader.class))
|
||||||
|
.run(context -> assertThat(context).hasSingleBean(Jt808RawFrameHistoryController.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createsTelemetryFieldHistoryControllerWhenTdengineReaderExists() {
|
void createsTelemetryFieldHistoryControllerWhenTdengineReaderExists() {
|
||||||
contextRunner
|
contextRunner
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ public final class TdengineHistoryQueries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public TdengineQueryStatement rawFrames(TdengineRawFrameQuery query) {
|
public TdengineQueryStatement rawFrames(TdengineRawFrameQuery query) {
|
||||||
|
if (query.childTableQuery()) {
|
||||||
String table = schema.rawFrameTable(query.protocol(), query.vehicleKey());
|
String table = schema.rawFrameTable(query.protocol(), query.vehicleKey());
|
||||||
return query(table, RAW_COLUMNS, "frame_id", query.from(), query.to(),
|
return query(table, RAW_COLUMNS, "frame_id", query.from(), query.to(),
|
||||||
query.order(), query.limit(), query.cursor());
|
query.order(), query.limit(), query.cursor());
|
||||||
}
|
}
|
||||||
|
return rawFramesStable(query);
|
||||||
|
}
|
||||||
|
|
||||||
public TdengineQueryStatement locations(TdengineLocationQuery query) {
|
public TdengineQueryStatement locations(TdengineLocationQuery query) {
|
||||||
String table = schema.locationTable(query.protocol(), query.vehicleKey());
|
String table = schema.locationTable(query.protocol(), query.vehicleKey());
|
||||||
@@ -71,4 +74,43 @@ public final class TdengineHistoryQueries {
|
|||||||
values.add(limit);
|
values.add(limit);
|
||||||
return new TdengineQueryStatement(sql.toString(), values);
|
return new TdengineQueryStatement(sql.toString(), values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TdengineQueryStatement rawFramesStable(TdengineRawFrameQuery query) {
|
||||||
|
List<Object> values = new ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder(384)
|
||||||
|
.append("SELECT ").append(RAW_COLUMNS)
|
||||||
|
.append(" FROM ").append(schema.rawFramesStableTable())
|
||||||
|
.append(" WHERE ts >= ? AND ts < ?");
|
||||||
|
values.add(query.from());
|
||||||
|
values.add(query.to());
|
||||||
|
addFilter(sql, values, "protocol", query.protocol());
|
||||||
|
addFilter(sql, values, "vehicle_key", query.vehicleKey());
|
||||||
|
addFilter(sql, values, "vin", query.vin());
|
||||||
|
addFilter(sql, values, "phone", query.phone());
|
||||||
|
if (query.messageId() != null) {
|
||||||
|
sql.append(" AND message_id = ?");
|
||||||
|
values.add(query.messageId());
|
||||||
|
}
|
||||||
|
addFilter(sql, values, "parse_status", query.parseStatus());
|
||||||
|
if (query.cursor() != null) {
|
||||||
|
String op = query.order() == TdengineQueryOrder.ASC ? ">" : "<";
|
||||||
|
sql.append(" AND (ts ").append(op).append(" ? OR (ts = ? AND frame_id ")
|
||||||
|
.append(op).append(" ?))");
|
||||||
|
values.add(query.cursor().ts());
|
||||||
|
values.add(query.cursor().ts());
|
||||||
|
values.add(query.cursor().tieBreaker());
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY ts ").append(query.order().name())
|
||||||
|
.append(", frame_id ").append(query.order().name())
|
||||||
|
.append(" LIMIT ?");
|
||||||
|
values.add(query.limit());
|
||||||
|
return new TdengineQueryStatement(sql.toString(), values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addFilter(StringBuilder sql, List<Object> values, String column, String value) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
sql.append(" AND ").append(column).append(" = ?");
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ public final class TdengineHistorySchema {
|
|||||||
return "raw_" + TdengineIdentifier.fragment(protocol) + "_" + TdengineIdentifier.hash16(vehicleKey);
|
return "raw_" + TdengineIdentifier.fragment(protocol) + "_" + TdengineIdentifier.hash16(vehicleKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String rawFramesStableTable() {
|
||||||
|
return "raw_frames";
|
||||||
|
}
|
||||||
|
|
||||||
public String locationTable(String protocol, String vehicleKey) {
|
public String locationTable(String protocol, String vehicleKey) {
|
||||||
return "loc_" + TdengineIdentifier.fragment(protocol) + "_" + TdengineIdentifier.hash16(vehicleKey);
|
return "loc_" + TdengineIdentifier.fragment(protocol) + "_" + TdengineIdentifier.hash16(vehicleKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,56 @@ import java.time.Instant;
|
|||||||
public record TdengineRawFrameQuery(
|
public record TdengineRawFrameQuery(
|
||||||
String protocol,
|
String protocol,
|
||||||
String vehicleKey,
|
String vehicleKey,
|
||||||
|
String vin,
|
||||||
|
String phone,
|
||||||
|
Integer messageId,
|
||||||
|
String parseStatus,
|
||||||
Instant from,
|
Instant from,
|
||||||
Instant to,
|
Instant to,
|
||||||
TdengineQueryOrder order,
|
TdengineQueryOrder order,
|
||||||
int limit,
|
int limit,
|
||||||
TdenginePageCursor cursor
|
TdenginePageCursor cursor
|
||||||
) {
|
) {
|
||||||
|
public TdengineRawFrameQuery(String protocol,
|
||||||
|
String vehicleKey,
|
||||||
|
Instant from,
|
||||||
|
Instant to,
|
||||||
|
TdengineQueryOrder order,
|
||||||
|
int limit,
|
||||||
|
TdenginePageCursor cursor) {
|
||||||
|
this(protocol, vehicleKey, "", "", null, "", from, to, order, limit, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
public TdengineRawFrameQuery {
|
public TdengineRawFrameQuery {
|
||||||
if (protocol == null || protocol.isBlank()) {
|
if (protocol == null || protocol.isBlank()) {
|
||||||
throw new IllegalArgumentException("protocol must not be blank");
|
throw new IllegalArgumentException("protocol must not be blank");
|
||||||
}
|
}
|
||||||
if (vehicleKey == null || vehicleKey.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("vehicleKey must not be blank");
|
|
||||||
}
|
|
||||||
if (from == null || to == null || !from.isBefore(to)) {
|
if (from == null || to == null || !from.isBefore(to)) {
|
||||||
throw new IllegalArgumentException("query time range must be valid");
|
throw new IllegalArgumentException("query time range must be valid");
|
||||||
}
|
}
|
||||||
|
protocol = protocol.trim().toUpperCase();
|
||||||
|
vehicleKey = trim(vehicleKey);
|
||||||
|
vin = trim(vin);
|
||||||
|
phone = trim(phone);
|
||||||
|
parseStatus = trim(parseStatus);
|
||||||
order = order == null ? TdengineQueryOrder.DESC : order;
|
order = order == null ? TdengineQueryOrder.DESC : order;
|
||||||
limit = Math.max(1, Math.min(limit, 1001));
|
limit = Math.max(1, Math.min(limit, 1001));
|
||||||
}
|
}
|
||||||
|
|
||||||
TdengineRawFrameQuery withLimit(int newLimit) {
|
TdengineRawFrameQuery withLimit(int newLimit) {
|
||||||
return new TdengineRawFrameQuery(protocol, vehicleKey, from, to, order, newLimit, cursor);
|
return new TdengineRawFrameQuery(protocol, vehicleKey, vin, phone, messageId, parseStatus,
|
||||||
|
from, to, order, newLimit, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean childTableQuery() {
|
||||||
|
return !vehicleKey.isBlank()
|
||||||
|
&& vin.isBlank()
|
||||||
|
&& phone.isBlank()
|
||||||
|
&& messageId == null
|
||||||
|
&& parseStatus.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,41 @@ class TdengineHistoryQueriesTest {
|
|||||||
101);
|
101);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rawFrameQueryUsesStableWhenPhoneMessageIdOrParseStatusFiltersArePresent() {
|
||||||
|
TdengineRawFrameQuery query = new TdengineRawFrameQuery(
|
||||||
|
"JT808",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"13079963320",
|
||||||
|
0x0100,
|
||||||
|
"SUCCEEDED",
|
||||||
|
Instant.parse("2026-06-29T00:00:00Z"),
|
||||||
|
Instant.parse("2026-06-30T00:00:00Z"),
|
||||||
|
TdengineQueryOrder.DESC,
|
||||||
|
50,
|
||||||
|
new TdenginePageCursor(Instant.parse("2026-06-29T12:00:00Z"), "frame-100"));
|
||||||
|
|
||||||
|
TdengineQueryStatement statement = queries.rawFrames(query);
|
||||||
|
|
||||||
|
assertThat(statement.sql())
|
||||||
|
.startsWith("SELECT ts, frame_id, received_at, message_id")
|
||||||
|
.contains(" FROM raw_frames")
|
||||||
|
.contains(" WHERE ts >= ? AND ts < ?")
|
||||||
|
.contains(" AND protocol = ?")
|
||||||
|
.contains(" AND phone = ?")
|
||||||
|
.contains(" AND message_id = ?")
|
||||||
|
.contains(" AND parse_status = ?")
|
||||||
|
.contains(" AND (ts < ? OR (ts = ? AND frame_id < ?))")
|
||||||
|
.contains(" ORDER BY ts DESC, frame_id DESC LIMIT ?");
|
||||||
|
assertThat(statement.values())
|
||||||
|
.containsExactly(
|
||||||
|
query.from(), query.to(),
|
||||||
|
"JT808", "13079963320", 0x0100, "SUCCEEDED",
|
||||||
|
query.cursor().ts(), query.cursor().ts(), query.cursor().tieBreaker(),
|
||||||
|
50);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void locationQueryCapsLimitAndUsesDescendingCursor() {
|
void locationQueryCapsLimitAndUsesDescendingCursor() {
|
||||||
TdengineLocationQuery query = new TdengineLocationQuery(
|
TdengineLocationQuery query = new TdengineLocationQuery(
|
||||||
|
|||||||
Reference in New Issue
Block a user