feat: productionize mqtt ingress and trim history APIs
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:
@@ -27,6 +27,7 @@ import java.util.List;
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history.api", name = "specialized-enabled", havingValue = "true")
|
||||
@ConditionalOnBean(Gb32960DecodedFrameService.class)
|
||||
@RequestMapping("/api/event-history/gb32960")
|
||||
@Tag(name = "gb-32960-frame-controller", description = "GB32960 专用历史帧、业务快照、字段查询和字段字典接口。")
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.List;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history.api", name = "specialized-enabled", havingValue = "true")
|
||||
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||
@RequestMapping("/api/event-history/jt808")
|
||||
@Tag(name = "jt-808-location-controller", description = "JT808 位置历史分页查询接口。")
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.Locale;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history.api", name = "specialized-enabled", havingValue = "true")
|
||||
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||
@RequestMapping("/api/event-history/jt808")
|
||||
@Tag(name = "jt-808-raw-frame-controller", description = "JT808 原始帧索引分页查询接口。")
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.lingniu.ingest.eventhistory;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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 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")
|
||||
@Tag(name = "location-history-controller", description = "通用位置历史分页查询接口。")
|
||||
public final class LocationHistoryController {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final TdengineHistoryReader reader;
|
||||
|
||||
public LocationHistoryController(TdengineHistoryReader reader) {
|
||||
if (reader == null) {
|
||||
throw new IllegalArgumentException("reader must not be null");
|
||||
}
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
@GetMapping("/locations")
|
||||
@Operation(
|
||||
summary = "查询车辆位置历史",
|
||||
description = "按协议、车辆标识和时间范围查询 TDengine 位置点。分页使用 cursorTs + cursorId,不使用 offset。")
|
||||
public LocationPageResponse locations(
|
||||
@Parameter(description = "协议名,例如 GB32960、JT808、MQTT_YUTONG、XINDA_PUSH。", required = true, example = "MQTT_YUTONG")
|
||||
@RequestParam String protocol,
|
||||
@Parameter(description = "内部车辆键。传入后优先使用。", example = "LMRKH9AC2R1004087")
|
||||
@RequestParam(required = false) String vehicleKey,
|
||||
@Parameter(description = "VIN。GB32960、宇通、信达通常可直接用 VIN。", example = "LMRKH9AC2R1004087")
|
||||
@RequestParam(required = false) String vin,
|
||||
@Parameter(description = "JT808 终端手机号或终端标识;JT808 会自动映射为 jt808:<phone>。", example = "g7gps")
|
||||
@RequestParam(required = false) String phone,
|
||||
@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 = "location-xxx")
|
||||
@RequestParam(required = false) String cursorId) throws IOException {
|
||||
String normalizedProtocol = require(protocol, "protocol is required for location query").toUpperCase(Locale.ROOT);
|
||||
QueryTimeRange range = QueryTimeRange.parse(dateFrom, dateTo);
|
||||
TdenginePage<TdengineLocationRow> page = reader.queryLocations(new TdengineLocationQuery(
|
||||
normalizedProtocol,
|
||||
resolveVehicleKey(normalizedProtocol, vehicleKey, vin, phone),
|
||||
range.eventTimeFrom(),
|
||||
range.eventTimeTo().plusMillis(1),
|
||||
order,
|
||||
limit,
|
||||
cursor(cursorTs, cursorId)));
|
||||
return LocationPageResponse.from(page);
|
||||
}
|
||||
|
||||
private static String resolveVehicleKey(String protocol, String vehicleKey, String vin, String phone) {
|
||||
String explicitVehicleKey = trimToNull(vehicleKey);
|
||||
if (explicitVehicleKey != null) {
|
||||
return explicitVehicleKey;
|
||||
}
|
||||
String vinValue = trimToNull(vin);
|
||||
if (vinValue != null) {
|
||||
return vinValue;
|
||||
}
|
||||
String phoneValue = trimToNull(phone);
|
||||
if (phoneValue != null) {
|
||||
if ("JT808".equals(protocol) && !phoneValue.startsWith("jt808:")) {
|
||||
return "jt808:" + phoneValue;
|
||||
}
|
||||
return phoneValue;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vehicleKey, vin or phone is required");
|
||||
}
|
||||
|
||||
private static String require(String value, String message) {
|
||||
String trimmed = trimToNull(value);
|
||||
if (trimmed == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
public record LocationPageResponse(
|
||||
List<LocationResponse> items,
|
||||
CursorResponse nextCursor) {
|
||||
|
||||
private static LocationPageResponse from(TdenginePage<TdengineLocationRow> page) {
|
||||
CursorResponse next = page.nextCursor()
|
||||
.map(cursor -> new CursorResponse(cursor.ts().toString(), cursor.tieBreaker()))
|
||||
.orElse(null);
|
||||
return new LocationPageResponse(
|
||||
page.items().stream().map(LocationResponse::from).toList(),
|
||||
next);
|
||||
}
|
||||
}
|
||||
|
||||
public record CursorResponse(String ts, String id) {
|
||||
}
|
||||
|
||||
public record LocationResponse(
|
||||
String eventTime,
|
||||
String receivedAt,
|
||||
String factId,
|
||||
String frameId,
|
||||
String phone,
|
||||
String vin,
|
||||
String vehicleKey,
|
||||
double longitude,
|
||||
double latitude,
|
||||
double altitudeM,
|
||||
double speedKmh,
|
||||
double directionDeg,
|
||||
long alarmFlag,
|
||||
long statusFlag,
|
||||
Double totalMileageKm,
|
||||
String rawUri,
|
||||
String metadataJson,
|
||||
String protocol) {
|
||||
|
||||
private static LocationResponse from(TdengineLocationRow row) {
|
||||
return new LocationResponse(
|
||||
row.ts().toString(),
|
||||
row.receivedAt().toString(),
|
||||
row.factId(),
|
||||
row.frameId(),
|
||||
row.phone(),
|
||||
row.vin(),
|
||||
row.vehicleKey(),
|
||||
row.longitude(),
|
||||
row.latitude(),
|
||||
row.altitudeM(),
|
||||
row.speedKmh(),
|
||||
row.directionDeg(),
|
||||
row.alarmFlag(),
|
||||
row.statusFlag(),
|
||||
row.totalMileageKm(),
|
||||
rawUri(row),
|
||||
row.metadataJson(),
|
||||
row.protocol());
|
||||
}
|
||||
|
||||
private static String rawUri(TdengineLocationRow row) {
|
||||
if (row.rawUri() != null && !row.rawUri().isBlank()) {
|
||||
return row.rawUri();
|
||||
}
|
||||
String metadataJson = row.metadataJson();
|
||||
if (metadataJson == null || metadataJson.isBlank()) {
|
||||
return row.rawUri();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readTree(metadataJson).path("rawArchiveUri").asText(row.rawUri());
|
||||
} catch (JsonProcessingException ignored) {
|
||||
return row.rawUri();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
|
||||
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
|
||||
import com.lingniu.ingest.eventhistory.Jt808LocationHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.Jt808RawFrameHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.LocationHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.TelemetryFieldHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
||||
@@ -95,6 +96,7 @@ public class EventHistoryAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(Gb32960DecodedFrameService.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history.api", name = "specialized-enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean
|
||||
public Gb32960FrameController gb32960FrameController(Gb32960DecodedFrameService service) {
|
||||
return new Gb32960FrameController(service);
|
||||
@@ -103,12 +105,21 @@ public class EventHistoryAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||
@ConditionalOnMissingBean
|
||||
public LocationHistoryController locationHistoryController(TdengineHistoryReader reader) {
|
||||
return new LocationHistoryController(reader);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history.api", name = "specialized-enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808LocationHistoryController jt808LocationHistoryController(TdengineHistoryReader reader) {
|
||||
return new Jt808LocationHistoryController(reader);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(TdengineHistoryReader.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history.api", name = "specialized-enabled", havingValue = "true")
|
||||
@ConditionalOnMissingBean
|
||||
public Jt808RawFrameHistoryController jt808RawFrameHistoryController(TdengineHistoryReader reader) {
|
||||
return new Jt808RawFrameHistoryController(reader);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LocationHistoryControllerTest {
|
||||
|
||||
@Test
|
||||
void queriesGenericLocationHistoryWithCursor() throws Exception {
|
||||
AtomicReference<TdengineLocationQuery> captured = new AtomicReference<>();
|
||||
TdengineHistoryReader reader = new StubReader(captured, new TdenginePage<>(
|
||||
List.of(row("fact-1")),
|
||||
Optional.of(new TdenginePageCursor(Instant.parse("2026-06-29T05:00:01Z"), "fact-1"))));
|
||||
LocationHistoryController controller = new LocationHistoryController(reader);
|
||||
|
||||
LocationHistoryController.LocationPageResponse response = controller.locations(
|
||||
"mqtt_yutong",
|
||||
null,
|
||||
"LMRKH9AC2R1004087",
|
||||
null,
|
||||
"2026-06-29T13:00:00",
|
||||
"2026-06-29T13:10:00",
|
||||
TdengineQueryOrder.DESC,
|
||||
100,
|
||||
null,
|
||||
null);
|
||||
|
||||
assertThat(captured.get().protocol()).isEqualTo("MQTT_YUTONG");
|
||||
assertThat(captured.get().vehicleKey()).isEqualTo("LMRKH9AC2R1004087");
|
||||
assertThat(response.items()).extracting(LocationHistoryController.LocationResponse::factId)
|
||||
.containsExactly("fact-1");
|
||||
assertThat(response.nextCursor()).isEqualTo(new LocationHistoryController.CursorResponse(
|
||||
"2026-06-29T05:00:01Z", "fact-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsJt808PhoneToVehicleKey() throws Exception {
|
||||
AtomicReference<TdengineLocationQuery> captured = new AtomicReference<>();
|
||||
LocationHistoryController controller = new LocationHistoryController(
|
||||
new StubReader(captured, new TdenginePage<>(List.of(), null)));
|
||||
|
||||
controller.locations(
|
||||
"JT808",
|
||||
null,
|
||||
null,
|
||||
"g7gps",
|
||||
"2026-06-29T13:00:00",
|
||||
"2026-06-29T13:10:00",
|
||||
TdengineQueryOrder.DESC,
|
||||
100,
|
||||
null,
|
||||
null);
|
||||
|
||||
assertThat(captured.get().vehicleKey()).isEqualTo("jt808:g7gps");
|
||||
}
|
||||
|
||||
private static TdengineLocationRow row(String factId) {
|
||||
Instant ts = Instant.parse("2026-06-29T05:00:00Z");
|
||||
return new TdengineLocationRow(
|
||||
ts,
|
||||
factId,
|
||||
"frame-1",
|
||||
ts,
|
||||
121.1,
|
||||
30.5,
|
||||
0.0,
|
||||
32.0,
|
||||
90.0,
|
||||
0,
|
||||
1,
|
||||
null,
|
||||
"",
|
||||
"{\"rawArchiveUri\":\"archive://raw.bin\"}",
|
||||
"MQTT_YUTONG",
|
||||
"LMRKH9AC2R1004087",
|
||||
"LMRKH9AC2R1004087",
|
||||
"");
|
||||
}
|
||||
|
||||
private record StubReader(AtomicReference<TdengineLocationQuery> captured,
|
||||
TdenginePage<TdengineLocationRow> page) implements TdengineHistoryReader {
|
||||
|
||||
@Override
|
||||
public com.lingniu.ingest.tdenginehistory.TdenginePage<com.lingniu.ingest.tdenginehistory.TdengineRawFrameRow> queryRawFrames(
|
||||
com.lingniu.ingest.tdenginehistory.TdengineRawFrameQuery query) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TdenginePage<TdengineLocationRow> queryLocations(TdengineLocationQuery query) {
|
||||
captured.set(query);
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.lingniu.ingest.tdenginehistory.TdenginePage<com.lingniu.ingest.tdenginehistory.TdengineTelemetryFieldRow> queryTelemetryFields(
|
||||
com.lingniu.ingest.tdenginehistory.TdengineTelemetryFieldQuery query) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
|
||||
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
|
||||
import com.lingniu.ingest.eventhistory.Jt808LocationHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.Jt808RawFrameHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.LocationHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.TelemetryFieldHistoryController;
|
||||
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
||||
@@ -58,6 +59,7 @@ class EventHistoryAutoConfigurationTest {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.event-history.enabled=true",
|
||||
"lingniu.ingest.event-history.api.specialized-enabled=true",
|
||||
"lingniu.ingest.sink.archive.path=/tmp/lingniu-test-archive")
|
||||
.withBean(Gb32960MessageDecoder.class, () -> mock(Gb32960MessageDecoder.class))
|
||||
.withBean(SinkArchiveProperties.class, SinkArchiveProperties::new)
|
||||
@@ -73,6 +75,7 @@ class EventHistoryAutoConfigurationTest {
|
||||
.withConfiguration(AutoConfigurations.of(EventHistoryAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.event-history.enabled=true",
|
||||
"lingniu.ingest.event-history.api.specialized-enabled=true",
|
||||
"lingniu.ingest.sink.archive.path=/tmp/lingniu-test-archive")
|
||||
.withBean(Gb32960MessageDecoder.class, () -> mock(Gb32960MessageDecoder.class))
|
||||
.withBean(TdengineHistoryReader.class, () -> mock(TdengineHistoryReader.class))
|
||||
@@ -87,16 +90,33 @@ class EventHistoryAutoConfigurationTest {
|
||||
@Test
|
||||
void createsJt808LocationHistoryControllerWhenTdengineReaderExists() {
|
||||
contextRunner
|
||||
.withPropertyValues("lingniu.ingest.event-history.enabled=true")
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.event-history.enabled=true",
|
||||
"lingniu.ingest.event-history.api.specialized-enabled=true")
|
||||
.withBean(TdengineHistoryReader.class, () -> mock(TdengineHistoryReader.class))
|
||||
.run(context -> assertThat(context).hasSingleBean(Jt808LocationHistoryController.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsJt808RawFrameHistoryControllerWhenTdengineReaderExists() {
|
||||
void createsGenericLocationAndTelemetryControllersWhenTdengineReaderExists() {
|
||||
contextRunner
|
||||
.withPropertyValues("lingniu.ingest.event-history.enabled=true")
|
||||
.withBean(TdengineHistoryReader.class, () -> mock(TdengineHistoryReader.class))
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(LocationHistoryController.class);
|
||||
assertThat(context).hasSingleBean(TelemetryFieldHistoryController.class);
|
||||
assertThat(context).doesNotHaveBean(Jt808LocationHistoryController.class);
|
||||
assertThat(context).doesNotHaveBean(Jt808RawFrameHistoryController.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsJt808RawFrameHistoryControllerWhenTdengineReaderExists() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.event-history.enabled=true",
|
||||
"lingniu.ingest.event-history.api.specialized-enabled=true")
|
||||
.withBean(TdengineHistoryReader.class, () -> mock(TdengineHistoryReader.class))
|
||||
.run(context -> assertThat(context).hasSingleBean(Jt808RawFrameHistoryController.class));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user