feat: add jt808 raw frame history query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

This commit is contained in:
lingniu
2026-06-29 20:44:21 +08:00
parent f56de96cec
commit 12ac1dab3b
9 changed files with 443 additions and 7 deletions

View File

@@ -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`

View File

@@ -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());
}
}
}

View File

@@ -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

View File

@@ -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();
}
}
}

View File

@@ -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

View File

@@ -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);
}
}
} }

View File

@@ -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);
} }

View File

@@ -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();
} }
} }

View File

@@ -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(