feat: productionize mqtt ingress and trim history APIs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

This commit is contained in:
lingniu
2026-06-30 01:28:51 +08:00
parent 1e2e59bddc
commit bf6728041b
15 changed files with 554 additions and 158 deletions

View File

@@ -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 专用历史帧、业务快照、字段查询和字段字典接口。")

View File

@@ -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 位置历史分页查询接口。")

View File

@@ -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 原始帧索引分页查询接口。")

View File

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

View File

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

View File

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

View File

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