docs: add detailed 32960 pipeline comments
This commit is contained in:
@@ -13,6 +13,13 @@ import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 统一 API 错误响应。
|
||||
*
|
||||
* <p>业务接口抛出的参数错误、时间格式错误和 Spring 参数绑定错误都会转成包含具体原因的
|
||||
* JSON,而不是让前端只能看到默认 400/500。这里的 timestamp 仅表示错误发生时间,
|
||||
* 按东八区展示;业务数据里的 eventTime/ingestTime 不在这里改时区。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public final class ApiExceptionHandler {
|
||||
|
||||
@@ -65,6 +72,7 @@ public final class ApiExceptionHandler {
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiError> generic(Exception ex,
|
||||
HttpServletRequest request) {
|
||||
// 保留异常 message 方便联调定位;底层如果没有 message,则退回通用文案。
|
||||
return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通用历史事件查询接口。
|
||||
*
|
||||
* <p>这是跨协议的低层记录查询,返回的是 EventFileStore 中的 envelope/snapshot 记录。
|
||||
* GB32960 业务查询应优先使用 {@link Gb32960FrameController},因为它会回读 RAW 并按协议展开全部字段。
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(EventFileStore.class)
|
||||
@@ -158,6 +164,7 @@ public class EventHistoryController {
|
||||
if (!fields.isArray()) {
|
||||
return Map.of();
|
||||
}
|
||||
// 通用导出只识别 telemetry snapshot 的扁平 fields;复杂 GB32960 block 字段用专用 CSV 接口。
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for (JsonNode field : fields) {
|
||||
String key = field.path("key").asText("");
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.io.IOException;
|
||||
/**
|
||||
* Consumer-side entry point that stores one Kafka envelope value into the
|
||||
* historical detail file store.
|
||||
*
|
||||
* <p>当前 32960 本机运行配置没有启用 Kafka consumer;这个类保留给“其他服务把 envelope
|
||||
* 写回历史库”的解耦部署方式。失败时返回结构化结果,由 Kafka worker 决定是否进 DLQ。
|
||||
*/
|
||||
public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
|
||||
|
||||
@@ -44,10 +47,12 @@ public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
|
||||
store.append(record);
|
||||
return EnvelopeIngestResult.stored(record.eventId(), record.vin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// 协议不可解析或缺少必要字段属于不可重试问题,避免 Kafka consumer 无限重放。
|
||||
return envelope == null
|
||||
? EnvelopeIngestResult.invalid(ex.getMessage())
|
||||
: EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage());
|
||||
} catch (IOException ex) {
|
||||
// 文件库写入失败可能是临时 I/O 问题,交给上层 worker 按失败处理。
|
||||
return EnvelopeIngestResult.failed(
|
||||
envelope == null ? "" : envelope.getEventId(),
|
||||
envelope == null ? "" : envelope.getVin(),
|
||||
|
||||
@@ -27,6 +27,16 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* GB32960 RAW 历史帧查询和业务快照组装服务。
|
||||
*
|
||||
* <p>历史库里保存的是 RAW_ARCHIVE 索引,不保存完整解码后的宽表。查询时先从
|
||||
* {@link EventFileStore} 找到 archive:// URI,再读取本地 RAW .bin,用当前协议解析器即时解码。
|
||||
* 这种设计让写入路径保持轻量,也允许后续修复解析器后直接重放历史 RAW 得到新字段。
|
||||
*
|
||||
* <p>snapshot 的聚合单位是 {@code vin + eventTime}。同一时刻可能有多个 32960 子包,
|
||||
* 比如广东燃料电池堆电压分帧;这里会把这些子包合并成前端可消费的单个逻辑快照。
|
||||
*/
|
||||
public final class Gb32960DecodedFrameService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Gb32960DecodedFrameService.class);
|
||||
@@ -76,6 +86,7 @@ public final class Gb32960DecodedFrameService {
|
||||
String vin,
|
||||
String platformAccount) throws IOException {
|
||||
int frameLimit = Math.max(1, Math.min(limit, 1000));
|
||||
// 只查 RAW_ARCHIVE:REALTIME/LOCATION 等派生事件不再作为 32960 历史查询的数据源。
|
||||
List<EventFileRecord> records = store.query(new EventFileQuery(
|
||||
ProtocolId.GB32960,
|
||||
dateFrom,
|
||||
@@ -91,6 +102,7 @@ public final class Gb32960DecodedFrameService {
|
||||
LinkedHashSet<String> seenUris = new LinkedHashSet<>();
|
||||
for (EventFileRecord record : records) {
|
||||
String rawArchiveUri = rawArchiveUri(record);
|
||||
// 同一个 RAW 可能因为索引重建或事件补偿被看到多次,按 URI 去重保证返回帧唯一。
|
||||
if (rawArchiveUri == null || rawArchiveUri.isBlank() || !seenUris.add(rawArchiveUri)) {
|
||||
continue;
|
||||
}
|
||||
@@ -201,6 +213,7 @@ public final class Gb32960DecodedFrameService {
|
||||
throw new IllegalArgumentException("vin is required for gb32960 snapshots");
|
||||
}
|
||||
int snapshotLimit = Math.max(1, Math.min(limit, 1000));
|
||||
// 经验上一个完整 snapshot 可能由多个 RAW 子包组成,查询帧数需要放大后再按 snapshot 截断。
|
||||
List<DecodedFrame> frames = query(dateFrom, dateTo, eventTimeFrom, eventTimeTo,
|
||||
order, snapshotLimit * 30, vin, platformAccount);
|
||||
LinkedHashMap<String, SnapshotBuilder> builders = new LinkedHashMap<>();
|
||||
@@ -227,6 +240,7 @@ public final class Gb32960DecodedFrameService {
|
||||
try {
|
||||
return decode(record, rawArchiveUri, platformAccount);
|
||||
} catch (NoSuchFileException e) {
|
||||
// 索引存在但 RAW 文件被人工清理时不中断整次查询,返回仍可用的其他帧。
|
||||
log.warn("skip gb32960 frame because raw archive is missing eventId={} rawArchiveUri={}",
|
||||
record.eventId(), rawArchiveUri);
|
||||
return null;
|
||||
@@ -402,6 +416,7 @@ public final class Gb32960DecodedFrameService {
|
||||
if (!Double.isFinite(value)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
// 协议缩放后的 double 容易出现 12.300000000000002;对外统一保留 6 位并去掉尾零。
|
||||
BigDecimal normalized = BigDecimal.valueOf(value)
|
||||
.setScale(OUTPUT_DOUBLE_SCALE, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros();
|
||||
@@ -418,6 +433,7 @@ public final class Gb32960DecodedFrameService {
|
||||
? rawArchiveUri.substring("archive://".length())
|
||||
: rawArchiveUri;
|
||||
Path path = archiveRoot.resolve(key.replace("..", "_").replaceAll("^/+", "")).normalize();
|
||||
// archive:// 是外部输入,必须限制在 archiveRoot 内,避免通过 ../ 读取任意文件。
|
||||
if (!path.startsWith(archiveRoot)) {
|
||||
throw new IOException("raw archive path escapes root: " + rawArchiveUri);
|
||||
}
|
||||
@@ -562,6 +578,7 @@ public final class Gb32960DecodedFrameService {
|
||||
int index = Integer.parseInt(part);
|
||||
return index >= 0 && index < list.size() ? list.get(index) : null;
|
||||
}
|
||||
// 对数组字段支持 "stacks.cellVoltages" 这类投影,返回每个元素上对应属性的列表。
|
||||
List<Object> out = new ArrayList<>(list.size());
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> itemMap) {
|
||||
@@ -593,8 +610,10 @@ public final class Gb32960DecodedFrameService {
|
||||
for (Map<String, Object> block : frame.blocks()) {
|
||||
String type = String.valueOf(block.get("type"));
|
||||
if ("GD_FC_STACK".equals(type)) {
|
||||
// 广东燃料电池堆电压可能按帧号拆成多个 RAW,这里按 cell 起始序号合并。
|
||||
mergeStack(block);
|
||||
} else {
|
||||
// 非分帧块同一 snapshot 内只保留第一次出现的块,避免后到空值覆盖前面完整值。
|
||||
blocks.putIfAbsent(type, deepCopy(block));
|
||||
}
|
||||
}
|
||||
@@ -625,6 +644,7 @@ public final class Gb32960DecodedFrameService {
|
||||
int incomingStart = intValue(incoming.get("frameCellStart"), 1);
|
||||
List<Double> targetVoltages = doubleList(target.get("frameCellVoltagesV"));
|
||||
List<Double> incomingVoltages = doubleList(incoming.get("frameCellVoltagesV"));
|
||||
// GB/T 分帧的起始序号是 1-based;内部 List 是 0-based,所以写入时统一减一。
|
||||
int cellCount = Math.max(intValue(target.get("cellCount"), 0), intValue(incoming.get("cellCount"), 0));
|
||||
int needed = Math.max(cellCount, Math.max(
|
||||
targetStart - 1 + targetVoltages.size(),
|
||||
@@ -693,6 +713,7 @@ public final class Gb32960DecodedFrameService {
|
||||
normalized.removeLast();
|
||||
}
|
||||
long missing = normalized.stream().filter(item -> item == null).count();
|
||||
// 给前端明确的完整性标记,避免只能靠数组长度猜测是否缺帧。
|
||||
target.put("frameCellStart", 1);
|
||||
target.put("frameCellCount", normalized.size());
|
||||
target.put("frameCellVoltagesV", normalized);
|
||||
|
||||
@@ -2,6 +2,13 @@ package com.lingniu.ingest.eventhistory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* GB32960 字段字典。
|
||||
*
|
||||
* <p>字典是前端字段选择器、中文表头、Tooltip、枚举/位图展示的单一来源。
|
||||
* 字段 key 必须和 {@link Gb32960DecodedFrameService} 输出的 block data 路径一致;
|
||||
* 支持点号路径和数组投影路径,例如 {@code GD_FC_STACK.stacks.frameCellVoltagesV}。
|
||||
*/
|
||||
public final class Gb32960FieldDictionary {
|
||||
|
||||
private Gb32960FieldDictionary() {
|
||||
@@ -12,6 +19,7 @@ public final class Gb32960FieldDictionary {
|
||||
}
|
||||
|
||||
private static final List<Packet> PACKETS = List.of(
|
||||
// 标准 32960 字段优先放前面;广东燃料电池扩展字段放后面,方便前端按常用程度展示。
|
||||
packet("VEHICLE", "整车数据", "GB/T 32960 整车运行状态、车速、里程、电压、电流、SOC 等核心字段。",
|
||||
field("vehicleState", "车辆状态", "", "车辆运行、停止等状态编码。",
|
||||
mappings(
|
||||
@@ -178,13 +186,22 @@ public final class Gb32960FieldDictionary {
|
||||
return new ValueMapping(code, value, nameZh, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个数据包/信息体类型的字段集合,例如 VEHICLE 或 GD_FC_DCDC。
|
||||
*/
|
||||
public record Packet(String code, String nameZh, String description, List<Field> fields) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 可查询字段定义。key 是接口查询参数使用的英文路径,nameZh/unit/description 用于前端展示。
|
||||
*/
|
||||
public record Field(String key, String nameZh, String unit, String description,
|
||||
List<ValueMapping> valueMappings) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议枚举值或位图值的展示映射。code 保留原始协议表示,value 是解析后的业务值。
|
||||
*/
|
||||
public record ValueMapping(String code, String value, String nameZh, String description) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* GB32960 历史查询 HTTP 边界。
|
||||
*
|
||||
* <p>这个 Controller 只做参数校验、时间范围解析和 Swagger 说明,真正的 RAW 读取、
|
||||
* 协议解码、snapshot 合并、字段投影都放在 {@link Gb32960DecodedFrameService}。
|
||||
* 这样可以保证接口层足够薄,后续如果要把查询服务拆出去,只需要迁移 service 和 store。
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(Gb32960DecodedFrameService.class)
|
||||
@@ -90,6 +97,7 @@ public final class Gb32960FrameController {
|
||||
@RequestParam String vin,
|
||||
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
|
||||
@RequestParam(required = false) String platformAccount) throws IOException {
|
||||
// snapshot 是按同一 VIN + eventTime 合并多个 RAW 子包,跨车查询会造成错误合并和低效扫描。
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshots");
|
||||
}
|
||||
@@ -115,6 +123,7 @@ public final class Gb32960FrameController {
|
||||
@RequestParam String fields,
|
||||
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
|
||||
@RequestParam(required = false) String platformAccount) throws IOException {
|
||||
// 字段投影接口是前端高频接口,强制单车查询可以命中 VIN 分区,避免全库扫描后再 join。
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field query");
|
||||
}
|
||||
@@ -140,6 +149,7 @@ public final class Gb32960FrameController {
|
||||
@RequestParam String fields,
|
||||
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
|
||||
@RequestParam(required = false) String platformAccount) throws IOException {
|
||||
// CSV 复用字段投影逻辑,表头中文化由 dictionary 提供,避免导出和页面展示出现两套字段定义。
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field csv export");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* 查询时间范围解析器。
|
||||
*
|
||||
* <p>HTTP 查询允许传日期、带时区时间或不带时区的本地时间:
|
||||
* 日期用于分区裁剪,不带时区的时间统一按 Asia/Shanghai 转成 UTC Instant。
|
||||
* 返回值同时包含 LocalDate 分区边界和精确到秒/毫秒的 eventTime 边界。
|
||||
*/
|
||||
final class QueryTimeRange {
|
||||
|
||||
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
@@ -56,6 +63,7 @@ final class QueryTimeRange {
|
||||
}
|
||||
String value = raw.trim();
|
||||
if (value.length() == 10) {
|
||||
// 纯日期查询覆盖东八区自然日;结束日期取当天最后一毫秒,兼容 dateTo=2026-06-23。
|
||||
LocalDate date = LocalDate.parse(value);
|
||||
Instant instant = endOfRange
|
||||
? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1)
|
||||
@@ -63,9 +71,11 @@ final class QueryTimeRange {
|
||||
return new Boundary(date, instant);
|
||||
}
|
||||
try {
|
||||
// 带 Z 或 +08:00 的时间按调用方显式时区解析。
|
||||
Instant instant = OffsetDateTime.parse(value).toInstant();
|
||||
return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant);
|
||||
} catch (RuntimeException ignored) {
|
||||
// 不带时区的前端输入按东八区解释,接口返回的数据时间本身仍保持原始 UTC 字符串。
|
||||
LocalDateTime local = LocalDateTime.parse(value);
|
||||
Instant instant = local.atZone(DEFAULT_ZONE).toInstant();
|
||||
return new Boundary(local.toLocalDate(), instant);
|
||||
|
||||
@@ -13,6 +13,10 @@ import java.util.Map;
|
||||
/**
|
||||
* Maps Kafka protobuf envelopes into records stored by the historical detail
|
||||
* file store.
|
||||
*
|
||||
* <p>这是跨协议 Kafka envelope 回灌历史库的通用适配器。它保存的是 protobuf
|
||||
* telemetry_snapshot 的 JSON 视图,不负责 GB32960 原始包解码;32960 全字段 snapshot
|
||||
* 查询仍以 rawArchiveUri 指向的 .bin 为准。
|
||||
*/
|
||||
public final class TelemetryEnvelopeRecordMapper {
|
||||
|
||||
@@ -28,6 +32,7 @@ public final class TelemetryEnvelopeRecordMapper {
|
||||
ProtocolId protocol = protocol(envelope.getSource());
|
||||
Map<String, String> metadata = new LinkedHashMap<>(envelope.getMetadataMap());
|
||||
if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) {
|
||||
// 保留未知 source 原文,避免历史库标准化成 UNKNOWN 后丢失排障线索。
|
||||
metadata.putIfAbsent("originalSource", envelope.getSource());
|
||||
}
|
||||
return new EventFileRecord(
|
||||
|
||||
@@ -22,6 +22,18 @@ import org.springframework.context.annotation.Bean;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Event History 查询/消费服务自动装配。
|
||||
*
|
||||
* <p>该模块有两种入口:
|
||||
* <ul>
|
||||
* <li>HTTP 查询入口:直接查询 {@link EventFileStore},GB32960 专用接口还会回读 RAW archive
|
||||
* <li>Kafka consumer 入口:把其他服务投递的 envelope 再写入 {@link EventFileStore}
|
||||
* </ul>
|
||||
*
|
||||
* <p>当前 32960 单体运行模式通常只启用 HTTP 查询和本地 event-file-store;
|
||||
* Kafka consumer 是否启动还取决于 {@code lingniu.ingest.sink.mq.consumer.enabled=true}。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class})
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@@ -46,6 +58,7 @@ public class EventHistoryAutoConfiguration {
|
||||
@ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
// 只注册 processor;真正拉 Kafka 的 runner 在 sink-mq 模块按 consumer.enabled 决定是否创建。
|
||||
return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink);
|
||||
}
|
||||
|
||||
@@ -62,6 +75,7 @@ public class EventHistoryAutoConfiguration {
|
||||
public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store,
|
||||
Gb32960MessageDecoder decoder,
|
||||
SinkArchiveProperties archiveProperties) {
|
||||
// snapshot/frame 查询通过 EventFileStore 找到 rawArchiveUri,再从 archiveRoot 读取 .bin 即时解码。
|
||||
return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null);
|
||||
}
|
||||
|
||||
@@ -76,6 +90,7 @@ public class EventHistoryAutoConfiguration {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive");
|
||||
}
|
||||
// 支持 file:// URI,便于部署配置里统一用 URI 风格表达 archive 根路径。
|
||||
if (value.startsWith("file://")) {
|
||||
return Path.of(URI.create(value));
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public final class DailyMileageCalculator {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
|
||||
// 两种策略都只基于同一 VIN 的里程点计算,不能跨车辆 join;
|
||||
// 上层 repository 负责按 VIN 过滤,避免大车队查询时扩大扫描面。
|
||||
double value = switch (strategy) {
|
||||
case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points);
|
||||
case DAY_MAX_MINUS_DAY_MIN -> dayMaxMinusDayMin(statDate, points);
|
||||
@@ -39,6 +41,7 @@ public final class DailyMileageCalculator {
|
||||
}
|
||||
|
||||
private double currentLastMinusPreviousLast(LocalDate statDate, List<MileagePoint> points) {
|
||||
// 适用于累计里程单调递增的设备:当天最后一帧减前一日最后一帧。
|
||||
Optional<MileagePoint> previousLast = points.stream()
|
||||
.filter(point -> localDate(point).isBefore(statDate))
|
||||
.max(Comparator.comparing(MileagePoint::eventTime));
|
||||
@@ -53,6 +56,7 @@ public final class DailyMileageCalculator {
|
||||
}
|
||||
|
||||
private double dayMaxMinusDayMin(LocalDate statDate, List<MileagePoint> points) {
|
||||
// 兜底策略:仅使用当天数据,适合缺少前一日最后点但当天点足够的场景。
|
||||
List<MileagePoint> currentDay = points.stream()
|
||||
.filter(point -> localDate(point).isEqual(statDate))
|
||||
.toList();
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class DailyVehicleStatService {
|
||||
|
||||
public Optional<VehicleDailyStatResult> calculateAndSave(String vin, LocalDate statDate) {
|
||||
VehicleStatRule rule = ruleRepository.ruleFor(vin);
|
||||
// 统计是从里程点二次计算出来的派生数据,不作为 32960 全字段历史查询的数据源。
|
||||
OptionalDouble dailyMileage = mileageCalculator.calculate(
|
||||
statDate,
|
||||
rule.dailyMileageStrategy(),
|
||||
|
||||
@@ -22,6 +22,7 @@ public final class FileVehicleStatRepository implements VehicleStatRepository {
|
||||
throw new IllegalArgumentException("root must not be null");
|
||||
}
|
||||
Path absoluteRoot = root.toAbsolutePath();
|
||||
// 当前实现是轻量本地 TSV,适合开发和小规模派生统计;生产历史全字段查询走 DuckDB RAW 索引。
|
||||
this.pointsFile = absoluteRoot.resolve("mileage-points.tsv");
|
||||
this.dailyStatsFile = absoluteRoot.resolve("daily-stats.tsv");
|
||||
}
|
||||
@@ -44,6 +45,7 @@ public final class FileVehicleStatRepository implements VehicleStatRepository {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// statDate 由计算器按策略判断;这里返回该 VIN 的点,避免仓储层混入业务口径。
|
||||
out.add(new MileagePoint(Instant.parse(parts[1]), Double.parseDouble(parts[2])));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Ignore corrupt rows instead of making the whole statistics API unavailable.
|
||||
@@ -118,6 +120,7 @@ public final class FileVehicleStatRepository implements VehicleStatRepository {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
// TSV 文件没有转义层,VIN 中的控制字符统一替换,防止破坏行结构。
|
||||
return value.trim().replace('\t', '_').replace('\n', '_').replace('\r', '_');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class VehicleStatEventProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 统计消费的是已经标准化后的 telemetry_snapshot,不重新解析 RAW .bin。
|
||||
OptionalDouble totalMileage = totalMileage(envelope);
|
||||
if (totalMileage.isEmpty()) {
|
||||
return;
|
||||
|
||||
@@ -34,6 +34,7 @@ public final class VehicleStatEventSink implements EventSink {
|
||||
|
||||
@Override
|
||||
public boolean accepts(VehicleEvent event) {
|
||||
// 直接 EventSink 路径只服务老的 Realtime 派生统计;32960 RAW 历史链路不依赖它。
|
||||
return event instanceof VehicleEvent.Realtime realtime
|
||||
&& realtime.payload() != null
|
||||
&& realtime.payload().totalMileageKm() != null;
|
||||
|
||||
@@ -31,6 +31,7 @@ public class VehicleStatAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatRepository vehicleStatRepository(VehicleStatProperties props) {
|
||||
// 派生统计默认落本地文件,便于独立开关;主历史库仍由 event-file-store 管理。
|
||||
return new FileVehicleStatRepository(Path.of(props.getFilePath()));
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ public class VehicleStatAutoConfiguration {
|
||||
@ConditionalOnMissingBean(name = "vehicleStatEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor vehicleStatEnvelopeConsumerProcessor(VehicleStatEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
// Bean 名必须和 sink-mq 默认 binding 对齐,KafkaEnvelopeConsumerFactory 才能自动创建 worker。
|
||||
return new EnvelopeConsumerProcessor("vehicle-stat", ingestor, deadLetterSink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.vehicle-stat")
|
||||
public class VehicleStatProperties {
|
||||
|
||||
/** 派生统计本地文件根目录;不是 32960 RAW archive 或 DuckDB 历史库目录。 */
|
||||
private String filePath = "./target/vehicle-stat/";
|
||||
|
||||
/** 统计自然日口径,默认按国内业务使用东八区。 */
|
||||
private String zoneId = "Asia/Shanghai";
|
||||
|
||||
public String getFilePath() {
|
||||
|
||||
@@ -17,6 +17,7 @@ public final class RedisVehicleStateRepository implements VehicleStateRepository
|
||||
|
||||
@Override
|
||||
public void putState(String vin, String json) {
|
||||
// 每辆车一个 Redis key,读最新状态时不需要扫描或 join 历史表。
|
||||
put(key("vehicle:state:", vin), json);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ public final class VehicleStateController {
|
||||
|
||||
@GetMapping("/{vin}")
|
||||
public ResponseEntity<String> state(@PathVariable String vin) {
|
||||
// 查询的是 Redis 最新状态快照,不是 event-file-store 的历史 snapshot。
|
||||
return json(repository.getState(vin));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public final class VehicleStateEnvelopeIngestor implements EnvelopeIngestor {
|
||||
VehicleEnvelope envelope = null;
|
||||
try {
|
||||
envelope = parse(kafkaValue);
|
||||
// Kafka 消费路径只接受标准 VehicleEnvelope;坏消息返回明确结果给处理器写 DLQ。
|
||||
updater.update(envelope);
|
||||
return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class VehicleStateUpdater {
|
||||
Map<String, String> fields = fields(envelope);
|
||||
String vin = envelope.getVin();
|
||||
|
||||
// vehicle-state 只维护“最新状态”缓存,覆盖写 Redis,不承担历史查询或 RAW 回放职责。
|
||||
repository.putState(vin, json(base(envelope, fields)));
|
||||
repository.putLastEvent(vin, json(lastEvent(envelope)));
|
||||
|
||||
@@ -41,6 +42,7 @@ public final class VehicleStateUpdater {
|
||||
private static Map<String, String> fields(VehicleEnvelope envelope) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) {
|
||||
// 同名字段以后到者为准,和最新状态语义一致。
|
||||
fields.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return fields;
|
||||
@@ -89,6 +91,7 @@ public final class VehicleStateUpdater {
|
||||
}
|
||||
|
||||
private static boolean hasSafetyFields(Map<String, String> fields) {
|
||||
// safety 是从标准字段中筛出来的窄视图,字段缺失时不写对应 Redis key。
|
||||
return fields.containsKey("safety_category")
|
||||
|| fields.containsKey("hydrogen_leak_detected")
|
||||
|| fields.containsKey("hydrogen_leak_level")
|
||||
|
||||
@@ -22,6 +22,7 @@ public class VehicleStateAutoConfiguration {
|
||||
@ConditionalOnBean(StringRedisTemplate.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateRepository vehicleStateRepository(StringRedisTemplate redis) {
|
||||
// 车辆状态模块依赖 Redis;没有 Redis Bean 时不自动启用,避免误以为它是历史库。
|
||||
return new RedisVehicleStateRepository(redis);
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ public class VehicleStateAutoConfiguration {
|
||||
@ConditionalOnMissingBean(name = "vehicleStateEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor vehicleStateEnvelopeConsumerProcessor(VehicleStateEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
// Bean 名必须和 sink-mq 默认 binding 对齐,KafkaEnvelopeConsumerFactory 才能自动创建 worker。
|
||||
return new EnvelopeConsumerProcessor("vehicle-state", ingestor, deadLetterSink);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user