From ba68ffe061fd7504aa27d21092b8e1d4536ba8ce Mon Sep 17 00:00:00 2001 From: kkfluous Date: Tue, 23 Jun 2026 11:55:44 +0800 Subject: [PATCH] feat: make gb32960 archive history query production ready --- .gitignore | 1 + README.md | 57 +- .../src/main/resources/application.yml | 153 ---- .../gateway/TerminalCommandController.java | 145 ---- docs/module-data-flow.html | 733 +++++++++++++++++ ...-20-gb32960-body-parser-block-isolation.md | 600 ++++++++++++++ docs/target-architecture.md | 270 +++++++ docs/vehicle-telemetry-internal-fields.md | 146 ++++ .../mqtt/client/MqttEndpointManager.java | 152 ---- .../mqtt/mapper/YutongEventMapper.java | 99 --- .../inbound/mqtt/YutongEventMapperTest.java | 67 -- .../ingest/inbound/xinda/XindaPushClient.java | 267 ------ .../ingest/api/event/LocationPayload.java | 12 - .../ingest/core/dispatcher/Dispatcher.java | 114 --- .../apps/bootstrap-all}/pom.xml | 21 + .../ingest/bootstrap/IngestApplication.java | 0 .../src/main/resources/application.yml | 235 ++++++ .../BootstrapApplicationDefaultsTest.java | 134 +++ .../ingest/bootstrap/IngestEndToEndTest.java | 0 ...tocolAutoConfigurationCompositionTest.java | 73 ++ .../bootstrap/RawArchiveEndToEndTest.java | 0 .../apps/command-gateway}/pom.xml | 15 + .../CommandGatewayAutoConfiguration.java | 0 .../lingniu/ingest/gateway/CommandResult.java | 0 .../gateway/TerminalCommandController.java | 278 +++++++ ...ot.autoconfigure.AutoConfiguration.imports | 0 .../TerminalCommandControllerTest.java | 147 ++++ .../core/ingest-api}/pom.xml | 1 + .../com/lingniu/ingest/api/ProtocolId.java | 1 + .../ingest/api/annotation/AsyncBatch.java | 0 .../ingest/api/annotation/EventEmit.java | 0 .../ingest/api/annotation/IdempotentKey.java | 0 .../ingest/api/annotation/MessageMapping.java | 0 .../api/annotation/ProtocolHandler.java | 0 .../ingest/api/annotation/RateLimited.java | 0 .../ingest/api/annotation/TraceSpan.java | 0 .../consumer/EnvelopeConsumerProcessor.java | 56 ++ .../api/consumer/EnvelopeConsumerRecord.java | 20 + .../consumer/EnvelopeDeadLetterRecord.java | 36 + .../api/consumer/EnvelopeDeadLetterSink.java | 6 + .../api/consumer/EnvelopeIngestResult.java | 45 ++ .../ingest/api/consumer/EnvelopeIngestor.java | 6 + .../ingest/api/event/AlarmPayload.java | 30 +- .../ingest/api/event/LocationPayload.java | 23 + .../ingest/api/event/RawArchiveKeys.java | 53 ++ .../ingest/api/event/RealtimePayload.java | 0 .../ingest/api/event/TelemetryFieldValue.java | 74 ++ .../ingest/api/event/TelemetrySnapshot.java | 144 ++++ .../ingest/api/event/VehicleEvent.java | 0 .../VehicleEventTelemetrySnapshotMapper.java | 206 +++++ .../com/lingniu/ingest/api/package-info.java | 0 .../ingest/api/pipeline/IngestContext.java | 0 .../api/pipeline/IngestInterceptor.java | 0 .../lingniu/ingest/api/pipeline/RawFrame.java | 0 .../lingniu/ingest/api/sink/EventSink.java | 0 .../ingest/api/spi/DecodeException.java | 0 .../lingniu/ingest/api/spi/EventMapper.java | 0 .../lingniu/ingest/api/spi/FrameDecoder.java | 0 .../lingniu/ingest/api/spi/FrameEncoder.java | 0 .../ingest/api/spi/MessageHandler.java | 0 .../ingest/api/spi/ProtocolPlugin.java | 0 .../EnvelopeConsumerProcessorTest.java | 55 ++ .../ingest/api/event/RawArchiveKeysTest.java | 40 + .../api/event/TelemetryFieldValueTest.java | 39 + .../api/event/TelemetrySnapshotTest.java | 58 ++ ...hicleEventTelemetrySnapshotMapperTest.java | 175 ++++ .../core/ingest-codec-common}/pom.xml | 1 + .../com/lingniu/ingest/codec/BccChecksum.java | 0 .../com/lingniu/ingest/codec/BcdCodec.java | 0 .../com/lingniu/ingest/codec/BitUtils.java | 0 .../lingniu/ingest/codec/BcdCodecTest.java | 0 .../core/ingest-core}/pom.xml | 1 + .../core/concurrency/AsyncBatchExecutor.java | 61 +- .../core/concurrency/DisruptorEventBus.java | 0 .../core/concurrency/VehicleEventSlot.java | 0 .../config/IngestCoreAutoConfiguration.java | 0 .../core/config/IngestCoreProperties.java | 0 .../AnnotationHandlerBeanPostProcessor.java | 0 .../ingest/core/dispatcher/Dispatcher.java | 183 +++++ .../core/dispatcher/HandlerDefinition.java | 0 .../core/dispatcher/HandlerInvoker.java | 0 .../core/dispatcher/HandlerRegistry.java | 3 + .../core/pipeline/InterceptorChain.java | 0 .../pipeline/builtin/DedupInterceptor.java | 14 +- .../builtin/RateLimitInterceptor.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../DispatcherRawArchiveMetadataTest.java | 218 +++++ .../core/dispatcher/HandlerRegistryTest.java | 51 ++ .../builtin/DedupInterceptorTest.java | 40 + .../core/observability}/pom.xml | 1 + .../ingest/observability/IngestMetrics.java | 0 .../ObservabilityAutoConfiguration.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../core/session-core}/pom.xml | 15 + .../ingest/session/CommandDispatcher.java | 0 .../lingniu/ingest/session/DeviceSession.java | 0 .../ingest/session/InMemorySessionStore.java | 0 .../ingest/session/NoopCommandDispatcher.java | 0 .../ingest/session/RedisSessionStore.java | 187 +++++ .../ingest/session/SessionProperties.java | 34 + .../lingniu/ingest/session/SessionStore.java | 0 .../config/SessionCoreAutoConfiguration.java | 40 + ...ot.autoconfigure.AutoConfiguration.imports | 0 .../ingest/session/RedisSessionStoreTest.java | 137 ++++ .../SessionCoreAutoConfigurationTest.java | 39 + modules/core/vehicle-identity/pom.xml | 47 ++ .../identity/FileVehicleIdentityService.java | 101 +++ .../InMemoryVehicleIdentityService.java | 75 ++ .../ingest/identity/VehicleIdentity.java | 16 + .../identity/VehicleIdentityBinding.java | 25 + .../identity/VehicleIdentityLookup.java | 22 + .../identity/VehicleIdentityRegistry.java | 6 + .../identity/VehicleIdentityResolver.java | 6 + .../identity/VehicleIdentitySource.java | 12 + .../VehicleIdentityAutoConfiguration.java | 41 + .../config/VehicleIdentityProperties.java | 22 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../FileVehicleIdentityServiceTest.java | 41 + .../InMemoryVehicleIdentityServiceTest.java | 47 ++ .../VehicleIdentityAutoConfigurationTest.java | 44 + .../inbound/inbound-mqtt}/pom.xml | 5 + .../mqtt/client/MqttEndpointManager.java | 330 ++++++++ .../inbound/mqtt/client/MqttTlsSupport.java | 113 +++ .../config/MqttInboundAutoConfiguration.java | 31 +- .../mqtt/config/MqttInboundProperties.java | 9 + .../mqtt/handler/MqttRealtimeHandler.java | 16 +- .../mqtt/mapper/YutongEventMapper.java | 247 ++++++ .../inbound/mqtt/model/MqttPayload.java | 21 +- .../mqtt/parser/MqttPayloadParser.java | 13 +- .../inbound/mqtt/profile/MqttProfile.java | 20 + .../mqtt/profile/MqttProfileRegistry.java | 164 ++++ .../mqtt/profile/YutongMqttProfile.java | 36 + ...ot.autoconfigure.AutoConfiguration.imports | 0 .../inbound/mqtt/MqttEndpointManagerTest.java | 377 +++++++++ .../inbound/mqtt/MqttProfileRegistryTest.java | 137 ++++ .../inbound/mqtt/MqttTlsSupportTest.java | 103 +++ .../inbound/mqtt/YutongEventMapperTest.java | 226 ++++++ .../inbound/inbound-xinda-push}/pom.xml | 20 + .../ingest/inbound/xinda/XindaPushClient.java | 336 ++++++++ .../inbound/xinda/XindaPushEventMapper.java | 353 ++++++++ .../inbound/xinda/XindaPushMessage.java | 32 + .../xinda/XindaPushRealtimeHandler.java | 37 + .../config/XindaPushAutoConfiguration.java | 22 +- .../xinda/config/XindaPushProperties.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../inbound/xinda/XindaPushClientTest.java | 242 ++++++ .../xinda/XindaPushEventMapperTest.java | 215 +++++ .../xinda/XindaPushRealtimeHandlerTest.java | 30 + .../protocols/protocol-gb32960}/pom.xml | 1 + .../auth/Gb32960PlatformAuthorizer.java | 2 +- .../gb32960/auth/Gb32960VinAuthorizer.java | 0 .../gb32960/codec/Gb32960BodyParser.java | 0 .../gb32960/codec/Gb32960CommandParser.java | 3 +- .../gb32960/codec/Gb32960FrameDecoder.java | 21 +- .../gb32960/codec/Gb32960FrameEncoder.java | 0 .../gb32960/codec/Gb32960MessageDecoder.java | 13 +- .../gb32960/codec/Gb32960ParserContext.java | 0 .../gb32960/codec/InfoBlockParser.java | 0 .../codec/InfoBlockParserRegistry.java | 0 .../protocol/gb32960/codec/ValueDecoder.java | 0 .../parser/v2016/AlarmV2016BlockParser.java | 0 .../v2016/DriveMotorV2016BlockParser.java | 0 .../parser/v2016/EngineV2016BlockParser.java | 0 .../v2016/ExtremeValueV2016BlockParser.java | 0 .../v2016/FuelCellV2016BlockParser.java | 0 .../v2016/PositionV2016BlockParser.java | 0 .../v2016/TemperatureV2016BlockParser.java | 0 .../parser/v2016/VehicleV2016BlockParser.java | 0 .../parser/v2016/VoltageV2016BlockParser.java | 0 .../parser/v2025/AlarmV2025BlockParser.java | 0 .../BatteryTemperatureV2025BlockParser.java | 0 .../v2025/DriveMotorV2025BlockParser.java | 0 .../parser/v2025/EngineV2025BlockParser.java | 0 .../v2025/FuelCellStackV2025BlockParser.java | 0 .../v2025/FuelCellV2025BlockParser.java | 0 .../MinParallelVoltageV2025BlockParser.java | 0 .../v2025/PositionV2025BlockParser.java | 0 ...SuperCapacitorExtremeV2025BlockParser.java | 0 .../v2025/SuperCapacitorV2025BlockParser.java | 0 .../parser/v2025/VehicleV2025BlockParser.java | 0 .../GdFcAirConditionerBlockParser.java | 0 .../guangdong/GdFcAuxiliaryBlockParser.java | 0 .../vendor/guangdong/GdFcDcDcBlockParser.java | 0 .../GdFcDemoExtensionBlockParser.java | 0 .../guangdong/GdFcStackBlockParser.java | 0 .../guangdong/GdFcVehicleInfoBlockParser.java | 0 .../guangdong/GdFcVendorTlvBlockParser.java | 0 .../codec/profile/Gb32960ProfileRegistry.java | 0 .../RuleBasedVendorExtensionSelector.java | 0 .../codec/profile/VendorExtensionCatalog.java | 0 .../profile/VendorExtensionSelector.java | 0 .../config/Gb32960AutoConfiguration.java | 33 +- .../gb32960/config/Gb32960Properties.java | 24 + .../handler/Gb32960RealtimeHandler.java | 0 .../gb32960/inbound/Gb32960AccessService.java | 72 ++ .../gb32960/inbound/Gb32960AckService.java | 116 +++ .../inbound/Gb32960ChannelHandler.java | 190 ++--- .../inbound/Gb32960FrameDiagnostics.java | 83 ++ .../gb32960/inbound/Gb32960NettyServer.java | 23 +- .../gb32960/mapper/Gb32960EventMapper.java | 40 +- .../protocol/gb32960/model/CommandBody.java | 0 .../protocol/gb32960/model/CommandType.java | 0 .../protocol/gb32960/model/EncryptType.java | 0 .../protocol/gb32960/model/Gb32960Header.java | 0 .../gb32960/model/Gb32960Message.java | 0 .../gb32960/model/GeneralAlarmFlagBit.java | 0 .../protocol/gb32960/model/InfoBlock.java | 0 .../protocol/gb32960/model/InfoBlockType.java | 0 .../gb32960/model/ProtocolVersion.java | 0 .../protocol/gb32960/model/ResponseFlag.java | 0 .../protocol/gb32960/model/SignatureInfo.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../codec/Gb32960BodyParserIsolationTest.java | 0 .../codec/Gb32960DecoderGoldenTest.java | 0 .../gb32960/codec/Gb32960DecoderTest.java | 48 ++ .../codec/Gb32960FrameDecoderTest.java | 59 ++ .../gb32960/codec/Gb32960FullBlocksTest.java | 0 .../codec/GuangdongFcEndToEndTest.java | 0 .../vendor/guangdong/GdFcParserTest.java | 0 .../GdFcVendorTlvBlockParserTest.java | 0 .../RuleBasedVendorExtensionSelectorTest.java | 0 .../handler/Gb32960RealtimeHandlerTest.java | 0 .../inbound/Gb32960AccessServiceTest.java | 52 ++ .../inbound/Gb32960AckServiceTest.java | 42 + .../inbound/Gb32960FrameDiagnosticsTest.java | 45 ++ .../mapper/Gb32960EventMapperTest.java | 82 ++ .../test/resources/samples/gb32960/README.md | 0 .../samples/gb32960/heartbeat_001.hex | 0 .../samples/gb32960/heartbeat_002.hex | 0 .../samples/gb32960/realtime_001.hex | 0 .../samples/gb32960/realtime_002.hex | 0 .../samples/gb32960/realtime_003.hex | 0 .../samples/gb32960/realtime_010.hex | 0 .../samples/gb32960/realtime_020.hex | 0 .../samples/gb32960/realtime_100.hex | 0 .../samples/gb32960/realtime_200.hex | 0 .../resources/samples/gb32960/resend_001.hex | 0 .../resources/samples/gb32960/resend_005.hex | 0 .../samples/gb32960/vehicle_login_001.hex | 0 .../protocols/protocol-jsatl12}/pom.xml | 15 + .../jsatl12/codec/Jsatl12FrameDecoder.java | 36 +- .../jsatl12/codec/Jsatl12RawFrame.java | 8 + .../config/Jsatl12AutoConfiguration.java | 58 ++ .../jsatl12/config/Jsatl12Properties.java | 10 +- .../handler/Jsatl12ArchiveEventHandler.java | 137 ++++ .../inbound/Jsatl12ArchiveFailure.java | 12 + .../jsatl12/inbound/Jsatl12ArchivedChunk.java | 45 ++ .../inbound/Jsatl12AttachmentTracker.java | 165 ++++ .../inbound/Jsatl12ChannelHandler.java | 249 ++++++ .../jsatl12/inbound/Jsatl12DataPacket.java | 66 ++ .../inbound/Jsatl12JtMessageBridge.java | 184 +++++ .../inbound/Jsatl12MalformedFrame.java | 10 + .../inbound/Jsatl12MalformedJtMessage.java | 10 + .../jsatl12/inbound/Jsatl12MessageId.java | 11 + .../jsatl12/inbound/Jsatl12NettyServer.java | 45 +- .../inbound/Jsatl12UploadCompletionAck.java | 17 + ...ot.autoconfigure.AutoConfiguration.imports | 0 .../codec/Jsatl12DataPacketDecoderTest.java | 47 ++ .../codec/Jsatl12FrameDecoderTest.java | 72 ++ .../Jsatl12ArchiveEventHandlerTest.java | 175 ++++ .../inbound/Jsatl12AttachmentTrackerTest.java | 101 +++ .../inbound/Jsatl12ChannelHandlerTest.java | 297 +++++++ .../inbound/Jsatl12JtMessageBridgeTest.java | 237 ++++++ modules/protocols/protocol-jt1078/pom.xml | 66 ++ .../codec/parser/FileListBodyParser.java | 14 +- .../parser/FileUploadCompleteBodyParser.java | 7 +- .../parser/MediaAttributesBodyParser.java | 39 + .../parser/PassengerVolumeBodyParser.java | 56 ++ .../config/Jt1078AutoConfiguration.java | 105 +++ .../jt1078/config/Jt1078Properties.java | 43 + .../config/Jt1078SignalAutoConfiguration.java | 25 + .../jt1078/downlink/Jt1078Commands.java | 198 +++++ .../handler/Jt1078MalformedRtpHandler.java | 88 ++ .../handler/Jt1078MediaSignalHandler.java | 36 + .../Jt1078MalformedRtpEventPublisher.java | 80 ++ .../media/Jt1078MalformedRtpPacket.java | 22 + .../media/Jt1078MediaArchiveService.java | 194 +++++ .../jt1078/media/Jt1078MediaMessageId.java | 9 + .../media/Jt1078MediaStreamHandler.java | 36 + .../jt1078/media/Jt1078MediaStreamServer.java | 83 ++ .../Jt1078ReceivedMediaStreamHandler.java | 27 + .../jt1078/media/Jt1078ReceivedRtpPacket.java | 10 + .../jt1078/media/Jt1078RtpPacket.java | 11 + .../jt1078/media/Jt1078RtpPacketDecoder.java | 83 ++ .../jt1078/media/Jt1078RtpPacketParser.java | 59 ++ .../media/Jt1078UdpMediaStreamServer.java | 78 ++ .../media/Jt1078UdpRtpPacketDecoder.java | 59 ++ .../jt1078/model/Jt1078MessageId.java | 21 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../jt1078/Jt1078EventMappingTest.java | 93 +++ .../Jt1078MalformedRtpEventPublisherTest.java | 108 +++ .../jt1078/Jt1078MalformedRtpHandlerTest.java | 120 +++ .../jt1078/Jt1078MediaArchiveServiceTest.java | 198 +++++ .../protocol/jt1078/Jt1078ParserTest.java | 87 ++ .../jt1078/Jt1078RtpPacketDecoderTest.java | 91 +++ .../jt1078/Jt1078UdpRtpPacketDecoderTest.java | 89 ++ .../config/Jt1078AutoConfigurationTest.java | 218 +++++ .../jt1078/downlink/Jt1078CommandsTest.java | 127 +++ .../protocols/protocol-jt808}/pom.xml | 5 + .../protocol/jt808/codec/BodyParser.java | 0 .../jt808/codec/BodyParserRegistry.java | 0 .../protocol/jt808/codec/Jt808Escape.java | 0 .../jt808/codec/Jt808FrameDecoder.java | 22 +- .../jt808/codec/Jt808FrameEncoder.java | 64 +- .../jt808/codec/Jt808MalformedFrame.java | 7 + .../jt808/codec/Jt808MessageDecoder.java | 0 .../jt808/codec/parser/AuthBodyParser.java | 49 ++ .../codec/parser/HeartbeatBodyParser.java | 0 .../codec/parser/LocationBatchBodyParser.java | 40 + .../codec/parser/LocationBodyParser.java | 37 +- .../codec/parser/MediaEventBodyParser.java | 26 + .../codec/parser/MediaUploadBodyParser.java | 38 + .../codec/parser/PassthroughBodyParser.java | 24 + .../codec/parser/RegisterBodyParser.java | 0 .../codec/parser/TerminalAttrsBodyParser.java | 58 ++ .../TerminalParamsReportBodyParser.java | 36 + .../codec/parser/UnregisterBodyParser.java | 22 + .../jt808/config/Jt808AutoConfiguration.java | 27 +- .../jt808/config/Jt808Properties.java | 0 .../downlink/Jt808CommandDispatcher.java | 38 +- .../jt808/downlink/Jt808Commands.java | 27 + .../jt808/handler/Jt808LocationHandler.java | 28 +- .../jt808/inbound/Jt808ChannelHandler.java | 327 ++++++++ .../jt808/inbound/Jt808NettyServer.java | 9 + .../jt808/mapper/Jt808EventMapper.java | 349 ++++++++ .../protocol/jt808/model/Jt808Body.java | 163 ++++ .../protocol/jt808/model/Jt808Header.java | 0 .../protocol/jt808/model/Jt808Message.java | 0 .../protocol/jt808/model/Jt808MessageId.java | 3 + .../jt808/session/Jt808ChannelRegistry.java | 7 +- .../jt808/session/Jt808PendingRequests.java | 0 ...ot.autoconfigure.AutoConfiguration.imports | 0 .../jt808/codec/Jt808DecoderGoldenTest.java | 0 .../jt808/codec/Jt808DecoderTest.java | 239 ++++++ .../protocol/jt808/codec/Jt808EscapeTest.java | 0 .../jt808/codec/Jt808FrameDecoderTest.java | 39 + .../jt808/codec/Jt808FrameEncoderTest.java | 34 + .../downlink/Jt808CommandDispatcherTest.java | 56 ++ .../jt808/downlink/Jt808CommandsTest.java | 21 + .../handler/Jt808LocationHandlerTest.java | 48 ++ .../inbound/Jt808ChannelHandlerTest.java | 495 ++++++++++++ .../jt808/mapper/Jt808EventMapperTest.java | 223 +++++ .../test/resources/samples/jt808/auth_001.hex | 0 .../test/resources/samples/jt808/auth_002.hex | 0 .../resources/samples/jt808/location_001.hex | 0 .../resources/samples/jt808/location_002.hex | 0 .../resources/samples/jt808/location_010.hex | 0 .../resources/samples/jt808/location_100.hex | 0 .../resources/samples/jt808/location_200.hex | 0 .../resources/samples/jt808/location_400.hex | 0 .../samples/jt808/location_batch_001.hex | 0 .../services/event-history-service/pom.xml | 69 ++ .../eventhistory/ApiExceptionHandler.java | 92 +++ .../eventhistory/EventHistoryController.java | 196 +++++ .../EventHistoryEnvelopeIngestor.java | 68 ++ .../Gb32960DecodedFrameService.java | 764 ++++++++++++++++++ .../eventhistory/Gb32960FieldDictionary.java | 190 +++++ .../eventhistory/Gb32960FrameController.java | 156 ++++ .../ingest/eventhistory/QueryTimeRange.java | 77 ++ .../TelemetryEnvelopeRecordMapper.java | 64 ++ .../config/EventHistoryAutoConfiguration.java | 84 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../eventhistory/ApiExceptionHandlerTest.java | 72 ++ .../EventHistoryControllerTest.java | 152 ++++ .../EventHistoryEnvelopeIngestorTest.java | 89 ++ .../Gb32960DecodedFrameServiceTest.java | 564 +++++++++++++ .../Gb32960FieldDictionaryTest.java | 30 + .../TelemetryEnvelopeRecordMapperTest.java | 94 +++ .../EventHistoryAutoConfigurationTest.java | 71 ++ modules/services/vehicle-stat-service/pom.xml | 57 ++ .../vehiclestat/DailyMileageCalculator.java | 71 ++ .../vehiclestat/DailyMileageStrategy.java | 6 + .../vehiclestat/DailyVehicleStatService.java | 48 ++ .../FileVehicleStatRepository.java | 123 +++ .../ingest/vehiclestat/MileagePoint.java | 15 + .../vehiclestat/VehicleDailyStatResult.java | 25 + .../vehiclestat/VehicleStatController.java | 60 ++ .../VehicleStatEnvelopeIngestor.java | 51 ++ .../VehicleStatEventProcessor.java | 53 ++ .../vehiclestat/VehicleStatEventSink.java | 61 ++ .../vehiclestat/VehicleStatRepository.java | 16 + .../ingest/vehiclestat/VehicleStatRule.java | 13 + .../VehicleStatRuleRepository.java | 6 + .../config/VehicleStatAutoConfiguration.java | 96 +++ .../config/VehicleStatProperties.java | 27 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../DailyMileageCalculatorTest.java | 59 ++ .../DailyVehicleStatServiceTest.java | 90 +++ .../FileVehicleStatRepositoryTest.java | 38 + .../VehicleStatControllerTest.java | 60 ++ .../VehicleStatEnvelopeIngestorTest.java | 116 +++ .../VehicleStatEventProcessorTest.java | 74 ++ .../vehiclestat/VehicleStatEventSinkTest.java | 54 ++ .../VehicleStatAutoConfigurationTest.java | 85 ++ .../services/vehicle-state-service/pom.xml | 61 ++ .../RedisVehicleStateRepository.java | 72 ++ .../vehiclestate/VehicleStateController.java | 55 ++ .../VehicleStateEnvelopeIngestor.java | 47 ++ .../vehiclestate/VehicleStateRepository.java | 22 + .../vehiclestate/VehicleStateUpdater.java | 142 ++++ .../config/VehicleStateAutoConfiguration.java | 56 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../InMemoryVehicleStateRepository.java | 50 ++ .../RedisVehicleStateRepositoryTest.java | 50 ++ .../VehicleStateControllerTest.java | 54 ++ .../VehicleStateEnvelopeIngestorTest.java | 77 ++ .../vehiclestate/VehicleStateUpdaterTest.java | 80 ++ .../VehicleStateAutoConfigurationTest.java | 56 ++ modules/sinks/event-file-store/pom.xml | 60 ++ .../DuckDbParquetEventFileStore.java | 485 +++++++++++ .../ingest/eventfilestore/EventFileQuery.java | 69 ++ .../eventfilestore/EventFileRecord.java | 44 + .../ingest/eventfilestore/EventFileStore.java | 19 + .../eventfilestore/EventFileStoreSink.java | 244 ++++++ .../EventFileStoreAutoConfiguration.java | 53 ++ .../config/EventFileStoreProperties.java | 72 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../DuckDbParquetEventFileStoreTest.java | 257 ++++++ .../EventFileStoreSinkTest.java | 154 ++++ .../EventFileStoreAutoConfigurationTest.java | 41 + .../sinks/sink-archive}/pom.xml | 1 + ...ot.autoconfigure.AutoConfiguration.imports | 0 {sink-mq => modules/sinks/sink-mq}/pom.xml | 16 + .../ingest/sink/mq/EnvelopeMapper.java | 45 +- .../sink/mq/KafkaEnvelopeConsumerFactory.java | 123 +++ .../sink/mq/KafkaEnvelopeConsumerRunner.java | 125 +++ .../sink/mq/KafkaEnvelopeConsumerWorker.java | 60 ++ .../sink/mq/KafkaEnvelopeDeadLetterSink.java | 52 ++ .../ingest/sink/mq/KafkaEventSink.java | 0 .../sink/mq/SinkMqAutoConfiguration.java | 29 + .../ingest/sink/mq/SinkMqProperties.java | 51 +- .../lingniu/ingest/sink/mq/TopicRouter.java | 0 .../src/main/proto/vehicle_envelope.proto | 18 + ...ot.autoconfigure.AutoConfiguration.imports | 0 .../EnvelopeMapperTelemetrySnapshotTest.java | 94 +++ .../mq/KafkaEnvelopeConsumerFactoryTest.java | 52 ++ .../mq/KafkaEnvelopeConsumerWorkerTest.java | 47 ++ .../mq/KafkaEnvelopeDeadLetterSinkTest.java | 54 ++ .../SinkMqConsumerAutoConfigurationTest.java | 34 + .../ingest/sink/mq/SinkMqPropertiesTest.java | 18 + pom.xml | 72 +- .../mapper/Gb32960EventMapperTest.java | 37 - .../jsatl12/codec/Jsatl12RawFrame.java | 8 - .../config/Jsatl12AutoConfiguration.java | 23 - .../inbound/Jsatl12ChannelHandler.java | 89 -- protocol-jt1078/pom.xml | 34 - .../config/Jt1078AutoConfiguration.java | 28 - .../jt1078/model/Jt1078MessageId.java | 12 - ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../jt808/inbound/Jt808ChannelHandler.java | 165 ---- .../jt808/mapper/Jt808EventMapper.java | 58 -- .../protocol/jt808/model/Jt808Body.java | 43 - .../jt808/codec/Jt808DecoderTest.java | 98 --- .../jt808/mapper/Jt808EventMapperTest.java | 46 -- .../config/SessionCoreAutoConfiguration.java | 25 - .../ingest/sink/archive/ArchiveEventSink.java | 82 -- .../ingest/sink/archive/ArchiveStore.java | 32 - .../sink/archive/LocalArchiveStore.java | 83 -- .../config/SinkArchiveAutoConfiguration.java | 41 - .../archive/config/SinkArchiveProperties.java | 20 - .../sink/archive/ArchiveEventSinkTest.java | 114 --- .../sink/archive/LocalArchiveStoreTest.java | 38 - 462 files changed, 23639 insertions(+), 2341 deletions(-) delete mode 100644 bootstrap-all/src/main/resources/application.yml delete mode 100644 command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java create mode 100644 docs/module-data-flow.html create mode 100644 docs/superpowers/plans/2026-04-20-gb32960-body-parser-block-isolation.md create mode 100644 docs/target-architecture.md create mode 100644 docs/vehicle-telemetry-internal-fields.md delete mode 100644 inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java delete mode 100644 inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java delete mode 100644 inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java delete mode 100644 inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java delete mode 100644 ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java delete mode 100644 ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java rename {bootstrap-all => modules/apps/bootstrap-all}/pom.xml (82%) rename {bootstrap-all => modules/apps/bootstrap-all}/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java (100%) create mode 100644 modules/apps/bootstrap-all/src/main/resources/application.yml create mode 100644 modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/BootstrapApplicationDefaultsTest.java rename {bootstrap-all => modules/apps/bootstrap-all}/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java (100%) create mode 100644 modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/ProtocolAutoConfigurationCompositionTest.java rename {bootstrap-all => modules/apps/bootstrap-all}/src/test/java/com/lingniu/ingest/bootstrap/RawArchiveEndToEndTest.java (100%) rename {command-gateway => modules/apps/command-gateway}/pom.xml (72%) rename {command-gateway => modules/apps/command-gateway}/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java (100%) rename {command-gateway => modules/apps/command-gateway}/src/main/java/com/lingniu/ingest/gateway/CommandResult.java (100%) create mode 100644 modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java rename {command-gateway => modules/apps/command-gateway}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/apps/command-gateway/src/test/java/com/lingniu/ingest/gateway/TerminalCommandControllerTest.java rename {ingest-api => modules/core/ingest-api}/pom.xml (94%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/ProtocolId.java (95%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java (100%) create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerRecord.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterRecord.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterSink.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestResult.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestor.java rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java (62%) create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java (100%) create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java (100%) create mode 100644 modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapper.java rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/package-info.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/sink/EventSink.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java (100%) rename {ingest-api => modules/core/ingest-api}/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java (100%) create mode 100644 modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessorTest.java create mode 100644 modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/RawArchiveKeysTest.java create mode 100644 modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetryFieldValueTest.java create mode 100644 modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetrySnapshotTest.java create mode 100644 modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapperTest.java rename {ingest-codec-common => modules/core/ingest-codec-common}/pom.xml (94%) rename {ingest-codec-common => modules/core/ingest-codec-common}/src/main/java/com/lingniu/ingest/codec/BccChecksum.java (100%) rename {ingest-codec-common => modules/core/ingest-codec-common}/src/main/java/com/lingniu/ingest/codec/BcdCodec.java (100%) rename {ingest-codec-common => modules/core/ingest-codec-common}/src/main/java/com/lingniu/ingest/codec/BitUtils.java (100%) rename {ingest-codec-common => modules/core/ingest-codec-common}/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java (100%) rename {ingest-core => modules/core/ingest-core}/pom.xml (97%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java (74%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java (100%) create mode 100644 modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java (96%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java (75%) rename {ingest-core => modules/core/ingest-core}/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java (100%) rename {ingest-core => modules/core/ingest-core}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/DispatcherRawArchiveMetadataTest.java create mode 100644 modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/HandlerRegistryTest.java create mode 100644 modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptorTest.java rename {observability => modules/core/observability}/pom.xml (95%) rename {observability => modules/core/observability}/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java (100%) rename {observability => modules/core/observability}/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java (100%) rename {observability => modules/core/observability}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) rename {session-core => modules/core/session-core}/pom.xml (71%) rename {session-core => modules/core/session-core}/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java (100%) rename {session-core => modules/core/session-core}/src/main/java/com/lingniu/ingest/session/DeviceSession.java (100%) rename {session-core => modules/core/session-core}/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java (100%) rename {session-core => modules/core/session-core}/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java (100%) create mode 100644 modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java create mode 100644 modules/core/session-core/src/main/java/com/lingniu/ingest/session/SessionProperties.java rename {session-core => modules/core/session-core}/src/main/java/com/lingniu/ingest/session/SessionStore.java (100%) create mode 100644 modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java rename {session-core => modules/core/session-core}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/core/session-core/src/test/java/com/lingniu/ingest/session/RedisSessionStoreTest.java create mode 100644 modules/core/session-core/src/test/java/com/lingniu/ingest/session/config/SessionCoreAutoConfigurationTest.java create mode 100644 modules/core/vehicle-identity/pom.xml create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentity.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityBinding.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityLookup.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityRegistry.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityResolver.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentitySource.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java create mode 100644 modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityProperties.java create mode 100644 modules/core/vehicle-identity/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/FileVehicleIdentityServiceTest.java create mode 100644 modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityServiceTest.java create mode 100644 modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfigurationTest.java rename {inbound-mqtt => modules/inbound/inbound-mqtt}/pom.xml (90%) create mode 100644 modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java create mode 100644 modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttTlsSupport.java rename {inbound-mqtt => modules/inbound/inbound-mqtt}/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java (63%) rename {inbound-mqtt => modules/inbound/inbound-mqtt}/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java (81%) rename {inbound-mqtt => modules/inbound/inbound-mqtt}/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java (68%) create mode 100644 modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java rename {inbound-mqtt => modules/inbound/inbound-mqtt}/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java (52%) rename {inbound-mqtt => modules/inbound/inbound-mqtt}/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java (75%) create mode 100644 modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfile.java create mode 100644 modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfileRegistry.java create mode 100644 modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/YutongMqttProfile.java rename {inbound-mqtt => modules/inbound/inbound-mqtt}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttEndpointManagerTest.java create mode 100644 modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttProfileRegistryTest.java create mode 100644 modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttTlsSupportTest.java create mode 100644 modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java rename {inbound-xinda-push => modules/inbound/inbound-xinda-push}/pom.xml (81%) create mode 100644 modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java create mode 100644 modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java create mode 100644 modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushMessage.java create mode 100644 modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandler.java rename {inbound-xinda-push => modules/inbound/inbound-xinda-push}/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java (64%) rename {inbound-xinda-push => modules/inbound/inbound-xinda-push}/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java (100%) rename {inbound-xinda-push => modules/inbound/inbound-xinda-push}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushClientTest.java create mode 100644 modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapperTest.java create mode 100644 modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandlerTest.java rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/pom.xml (97%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java (97%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java (99%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java (80%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java (91%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAirConditionerBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAuxiliaryBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDemoExtensionBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java (90%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java (92%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java (100%) create mode 100644 modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java create mode 100644 modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java (70%) create mode 100644 modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java (90%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java (87%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java (67%) create mode 100644 modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoderTest.java rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcParserTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandlerTest.java (100%) create mode 100644 modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessServiceTest.java create mode 100644 modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckServiceTest.java create mode 100644 modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnosticsTest.java create mode 100644 modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/README.md (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/heartbeat_001.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/heartbeat_002.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_001.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_002.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_003.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_010.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_020.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_100.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/realtime_200.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/resend_001.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/resend_005.hex (100%) rename {protocol-gb32960 => modules/protocols/protocol-gb32960}/src/test/resources/samples/gb32960/vehicle_login_001.hex (100%) rename {protocol-jsatl12 => modules/protocols/protocol-jsatl12}/pom.xml (75%) rename {protocol-jsatl12 => modules/protocols/protocol-jsatl12}/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java (65%) create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java rename {protocol-jsatl12 => modules/protocols/protocol-jsatl12}/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java (65%) create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandler.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchiveFailure.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchivedChunk.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTracker.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12DataPacket.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridge.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedFrame.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedJtMessage.java create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MessageId.java rename {protocol-jsatl12 => modules/protocols/protocol-jsatl12}/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java (53%) create mode 100644 modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12UploadCompletionAck.java rename {protocol-jsatl12 => modules/protocols/protocol-jsatl12}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12DataPacketDecoderTest.java create mode 100644 modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoderTest.java create mode 100644 modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandlerTest.java create mode 100644 modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTrackerTest.java create mode 100644 modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandlerTest.java create mode 100644 modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridgeTest.java create mode 100644 modules/protocols/protocol-jt1078/pom.xml rename protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java => modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileListBodyParser.java (55%) rename {protocol-jt1078 => modules/protocols/protocol-jt1078}/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java (68%) create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/PassengerVolumeBodyParser.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078SignalAutoConfiguration.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078Commands.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MalformedRtpHandler.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MediaSignalHandler.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpEventPublisher.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpPacket.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaMessageId.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamHandler.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamServer.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedMediaStreamHandler.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedRtpPacket.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacket.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketDecoder.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpMediaStreamServer.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpRtpPacketDecoder.java create mode 100644 modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java create mode 100644 modules/protocols/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078EventMappingTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpEventPublisherTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpHandlerTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MediaArchiveServiceTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078ParserTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078RtpPacketDecoderTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078UdpRtpPacketDecoderTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfigurationTest.java create mode 100644 modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078CommandsTest.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/pom.xml (90%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java (63%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java (52%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MalformedFrame.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java (100%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/AuthBodyParser.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/HeartbeatBodyParser.java (100%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBatchBodyParser.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java (63%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaEventBodyParser.java create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaUploadBodyParser.java create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/PassthroughBodyParser.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java (100%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalAttrsBodyParser.java create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalParamsReportBodyParser.java create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/UnregisterBodyParser.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java (67%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java (81%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java (74%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java (62%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java (87%) create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java create mode 100644 modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java (90%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java (92%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java (100%) create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java (100%) create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoderTest.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java (60%) create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcherTest.java create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandsTest.java create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandlerTest.java create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandlerTest.java create mode 100644 modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/auth_001.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/auth_002.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_001.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_002.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_010.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_100.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_200.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_400.hex (100%) rename {protocol-jt808 => modules/protocols/protocol-jt808}/src/test/resources/samples/jt808/location_batch_001.hex (100%) create mode 100644 modules/services/event-history-service/pom.xml create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java create mode 100644 modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java create mode 100644 modules/services/event-history-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/ApiExceptionHandlerTest.java create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryControllerTest.java create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestorTest.java create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionaryTest.java create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapperTest.java create mode 100644 modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfigurationTest.java create mode 100644 modules/services/vehicle-stat-service/pom.xml create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/MileagePoint.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatController.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestor.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRule.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRuleRepository.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java create mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java create mode 100644 modules/services/vehicle-stat-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculatorTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatServiceTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepositoryTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestorTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessorTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSinkTest.java create mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java create mode 100644 modules/services/vehicle-state-service/pom.xml create mode 100644 modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java create mode 100644 modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java create mode 100644 modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java create mode 100644 modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateRepository.java create mode 100644 modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java create mode 100644 modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java create mode 100644 modules/services/vehicle-state-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/InMemoryVehicleStateRepository.java create mode 100644 modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepositoryTest.java create mode 100644 modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateControllerTest.java create mode 100644 modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestorTest.java create mode 100644 modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdaterTest.java create mode 100644 modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfigurationTest.java create mode 100644 modules/sinks/event-file-store/pom.xml create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java create mode 100644 modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java create mode 100644 modules/sinks/event-file-store/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStoreTest.java create mode 100644 modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/EventFileStoreSinkTest.java create mode 100644 modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java rename {sink-archive => modules/sinks/sink-archive}/pom.xml (96%) rename {sink-archive => modules/sinks/sink-archive}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) rename {sink-mq => modules/sinks/sink-mq}/pom.xml (83%) rename {sink-mq => modules/sinks/sink-mq}/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java (75%) create mode 100644 modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java create mode 100644 modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java create mode 100644 modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java create mode 100644 modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java rename {sink-mq => modules/sinks/sink-mq}/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java (100%) rename {sink-mq => modules/sinks/sink-mq}/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java (71%) rename {sink-mq => modules/sinks/sink-mq}/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java (57%) rename {sink-mq => modules/sinks/sink-mq}/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java (100%) rename {sink-mq => modules/sinks/sink-mq}/src/main/proto/vehicle_envelope.proto (87%) rename {sink-mq => modules/sinks/sink-mq}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java create mode 100644 modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactoryTest.java create mode 100644 modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorkerTest.java create mode 100644 modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSinkTest.java create mode 100644 modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqConsumerAutoConfigurationTest.java create mode 100644 modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqPropertiesTest.java delete mode 100644 protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java delete mode 100644 protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java delete mode 100644 protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java delete mode 100644 protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java delete mode 100644 protocol-jt1078/pom.xml delete mode 100644 protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java delete mode 100644 protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java delete mode 100644 protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java delete mode 100644 protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java delete mode 100644 protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java delete mode 100644 protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java delete mode 100644 protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java delete mode 100644 session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java delete mode 100644 sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveEventSink.java delete mode 100644 sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java delete mode 100644 sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java delete mode 100644 sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java delete mode 100644 sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java delete mode 100644 sink-archive/src/test/java/com/lingniu/ingest/sink/archive/ArchiveEventSinkTest.java delete mode 100644 sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java diff --git a/.gitignore b/.gitignore index 9ca1778d..62e750fb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ logs/ data/ build/ out/ +archive/ # Local config / secrets application-local.yml diff --git a/README.md b/README.md index 34a83e78..33524d7d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | 消息队列 | **Kafka**(vin 分区,保证单车有序) | | 线上消息格式 | **Protobuf**,调试走 JSON | | MQTT 客户端 | HiveMQ MQTT Client | -| 会话缓存 | Caffeine + Redis 兜底 | +| 会话缓存 | Redis 生产索引 + Caffeine 内存降级 | | 熔断/限流 | Resilience4j 2 | | 可观测 | Micrometer + Prometheus + OpenTelemetry | | 冷存 | S3 / OSS / 本地 | @@ -31,22 +31,35 @@ ``` lingniu-vehicle-ingest/ -├── bom/ 依赖版本统一 -├── ingest-api/ SPI + sealed 领域事件 + 注解 -├── ingest-codec-common/ 公共编解码工具(BCD/CRC/BCC/bit utils) -├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥 -├── session-core/ 设备会话 + 鉴权 + Token -├── observability/ metrics / tracing / health -├── sink-mq/ Kafka producer + Protobuf Envelope -├── sink-archive/ 原始报文冷存 -├── protocol-gb32960/ GB/T 32960 -├── protocol-jt808/ JT/T 808 -├── protocol-jt1078/ JT/T 1078 -├── protocol-jsatl12/ 苏标主动安全报警附件 -├── inbound-mqtt/ MQTT 接入(宇通等) -├── inbound-xinda-push/ 信达 Push 接入(重写) -├── command-gateway/ HTTP → 设备下行命令 -└── bootstrap-all/ 一体化启动 +├── modules/ +│ ├── core/ +│ │ ├── ingest-api/ SPI + sealed 领域事件 + 注解 +│ │ ├── ingest-codec-common/ 公共编解码工具(BCD/CRC/BCC/bit utils) +│ │ ├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥 +│ │ ├── session-core/ 设备会话 + 鉴权 + Token + Redis/Memory SessionStore +│ │ ├── vehicle-identity/ 跨协议车辆身份解析 + 外部标识绑定(memory/file) +│ │ └── observability/ metrics / tracing / health +│ ├── protocols/ +│ │ ├── protocol-gb32960/ GB/T 32960 +│ │ ├── protocol-jt808/ JT/T 808(统一身份映射 + 事件 metadata 内部 VIN + 注册/鉴权/心跳/注销/位置/批量位置/参数/属性/媒体/透传/未知上行和坏帧兜底/断链清理会话/下行分包) +│ │ ├── protocol-jt1078/ JT/T 1078(808 信令按需桥接 + 常用下行信令编码 + TCP/UDP RTP 媒体流分段归档 + 事件 metadata 内部 VIN + 坏 RTP 统一 RawArchive/Passthrough + 归档失败兜底 + SIM 身份映射) +│ │ └── protocol-jsatl12/ 苏标主动安全报警附件(真实 DataPacket 分帧 + 附件流归档 + T1210 文件清单身份继承 + 9212 补传应答 + MediaMeta 引用事件 + 坏帧/坏信令/归档失败兜底) +│ ├── inbound/ +│ │ ├── inbound-mqtt/ MQTT 接入(endpoint 生命周期 + profile 注册扩展 + 统一身份映射 + PEM 双向 TLS + 未知 profile/解析失败/profile异常/连接订阅失败兜底 + 统一 UNKNOWN 身份 metadata) +│ │ └── inbound-xinda-push/ 信达 Push 接入(统一身份映射 + 事件 metadata 内部 VIN + 未知业务 cmd/解析失败/启动配置失败/业务处理异常兜底) +│ ├── sinks/ +│ │ ├── sink-mq/ Kafka producer + Protobuf Envelope +│ │ ├── sink-archive/ 原始报文冷存 +│ │ └── event-file-store/ Parquet + DuckDB 文件型明细库 +│ ├── services/ +│ │ ├── event-history-service/ Kafka 全字段事件消费 + 历史查询/导出 +│ │ ├── vehicle-state-service/ Kafka 全字段事件消费 + Redis 热状态查询 +│ │ └── vehicle-stat-service/ Kafka 全字段事件消费 + 可配置日统计 +│ └── apps/ +│ ├── command-gateway/ HTTP → 设备下行命令(808 位置/参数/属性/控制/区域删除/报警确认 + 1078 音视频控制) +│ └── bootstrap-all/ 一体化启动 +├── docs/ 架构文档、模块图、实施计划 +└── reference/ 参考资料 ``` ## 快速开始 @@ -55,17 +68,23 @@ lingniu-vehicle-ingest/ # 要求:JDK 25, Maven 3.9+ mvn -v mvn clean install -DskipTests -cd bootstrap-all && mvn spring-boot:run +mvn -pl :bootstrap-all spring-boot:run ``` ## 迁移说明 本项目是 `lingniu-vehicle-data-reception` 的 v2 重构,采用 strangler fig 渐进式迁移,旧项目保留只读参考。迁移路径与决策参见 `../REFRACTOR_PLAN.md`。 +## 架构文档 + +- 目标架构:`docs/target-architecture.md` +- 内部字段模型:`docs/vehicle-telemetry-internal-fields.md` +- 模块与数据流:`docs/module-data-flow.html` + ## 核心原则 1. **本服务不写业务库**:历史、行程、在线检测、里程统计全部由 Kafka 下游消费者实现 2. **协议即插拔**:每个 `protocol-*` / `inbound-*` 都可独立开关(`lingniu.ingest..enabled`) 3. **顺序保证**:同一 VIN 严格有序(Disruptor hash + Kafka 分区 key) 4. **幂等消费**:Envelope 带 `eventId`,下游去重 -5. **原始可回放**:`vehicle.raw.archive` topic + 对象存储 +5. **原始可回放**:Dispatcher 会先发布 `RawArchive`,并把同一份 `rawArchiveKey/rawArchiveUri` 追加到业务事件 metadata;下游明细库、Kafka Envelope 和导出都使用 `archive://...` 逻辑 URI 追溯原始 bytes,实际文件由 `sink-archive`/`ArchiveStore` 管理;`event-file-store` 只额外保存 RawArchive 查询索引,不嵌入原始 bytes diff --git a/bootstrap-all/src/main/resources/application.yml b/bootstrap-all/src/main/resources/application.yml deleted file mode 100644 index a701826b..00000000 --- a/bootstrap-all/src/main/resources/application.yml +++ /dev/null @@ -1,153 +0,0 @@ -spring: - application: - name: lingniu-vehicle-ingest - threads: - virtual: - enabled: true - -server: - port: 20000 - -lingniu: - ingest: - gb32960: - enabled: true - port: 9000 - boss-threads: 1 - worker-threads: 0 # 0 = Runtime.availableProcessors() * 2 - # VIN 白名单认证(内存配置)。GB/T 32960 设备没有账号密码,身份由 VIN 决定。 - auth: - enabled: ${GB32960_AUTH_ENABLED:false} # true 启用白名单;false 放行所有 VIN - case-sensitive: false # 默认大小写不敏感 - whitelist: # VIN 列表,未列入的 0x01 登入返回 0x04 VIN_NOT_EXIST - # - LTEST000000000001 - # - LGWEF4A58RE123456 - # 平台登入 0x05 账号列表(GB/T 32960.3 表 29)。 - # 列表为空时放行所有平台登入(兼容旧行为),否则 username/password 必须匹配, - # 失败返回 0x02 OTHER_ERROR 应答并关闭连接。 - platforms: - - username: ${GB32960_PLATFORM_USER_1:lingniu} - password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#} - allowed-ips: # 可选:限制来源 IP,为空不限制 - description: 外部下级平台 (原旧服务客户端) - # TLS 双向认证(可选)。启用后 Netty pipeline 前置 SslHandler 并强制客户端证书。 - tls: - enabled: ${GB32960_TLS_ENABLED:false} - cert-path: ${GB32960_TLS_CERT:} # 服务端证书 PEM 路径 - key-path: ${GB32960_TLS_KEY:} # 服务端私钥 PEM 路径(PKCS#8 未加密) - trust-cert-path: ${GB32960_TLS_CA:} # 受信 CA 证书 PEM,用于校验客户端证书 - require-client-auth: true # 强制双向;false=单向 TLS - # ==================================================================== - # 厂商扩展协议路由(vendor extensions) - # 命中规则按 list 顺序首匹(first-match-wins),命中后用对应套件的 parser 集合 - # 在标准 0x01..0x09 之上叠加。已支持的套件: - # - guangdong-fc:广东燃料电池汽车示范规范 0x30~0x34、0x80 - # 来源:reference/广东燃料电池汽车示范应用城市群综合监管平台-...pdf - # ==================================================================== - vendor-extensions: - - name: guangdong-fc - match: - # 平台账号匹配(精确匹配,case-insensitive)。这里和上面 auth.platforms - # 中的 username 保持一致,确保 lingniu 账号登入后所有 0x02 帧都走广东 profile。 - platform-accounts: - - lingniu - # VIN 前缀匹配(startsWith);空 list = 不参与匹配 - vin-prefixes: [] - # VIN 精确匹配 - vins: [] - # 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成 - # InfoBlock.Raw 后继续解析下一个块,不再整帧丢弃。仅在需要严格失败语义 - # (灰度回滚或抓虫)时设为 false。 - parse: - lenient-block-failure: true - jt808: - enabled: false - port: 10808 - max-frame-length: 1048 - jt1078: - enabled: true - jsatl12: - enabled: true - port: 7612 - file-store: file:///var/lingniu/alarm-files - mqtt: - enabled: false - endpoints: - - name: yutong - uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883} - topic: ${MQTT_TOPIC:/ytforward/shln/+} - qos: 1 - client-id: ${MQTT_CLIENT_ID:shlnClientId} - username: ${MQTT_USER:shln} - password: ${MQTT_PWD:} - tls: - ca-pem: ${MQTT_CA_PEM:} - client-pem: ${MQTT_CLIENT_PEM:} - client-key: ${MQTT_CLIENT_KEY:} - xinda-push: - enabled: false - host: ${XINDA_HOST:115.231.168.135} - port: ${XINDA_PORT:10100} - username: ${XINDA_USER:浙江羚牛} - password: ${XINDA_PWD:xd123456+} - subscribe-msg-ids: 0200,0300,0401 - client-desc: 羚牛客户端 - - pipeline: - disruptor: - ring-buffer-size: 131072 # 2^17 - wait-strategy: yielding # blocking | sleeping | yielding | busy-spin - producer-type: multi - dedup: - enabled: true - cache-size: 200000 - ttl-seconds: 600 - rate-limit: - per-vin-qps: 50 - - session: - store: memory-and-redis - token-ttl-seconds: 86400 - - sink: - mq: - # MQ Sink 总开关。true=装配 Kafka Producer 并投递事件;false=完全不装配(适合本地开发或 Kafka 不可达)。 - enabled: ${KAFKA_ENABLED:false} - type: kafka # 后端选择:kafka | (rocketmq | pulsar 预留) - bootstrap-servers: ${KAFKA_BROKERS:localhost:9092} - compression-type: zstd - linger-ms: 20 - batch-size: 65536 - acks: all - enable-idempotence: true - topics: - realtime: vehicle.realtime - location: vehicle.location - alarm: vehicle.alarm - session: vehicle.session - media-meta: vehicle.media.meta - raw-archive: vehicle.raw.archive - dlq: vehicle.dlq - archive: - enabled: true - type: local # local | s3 | oss - path: ./archive/ - -management: - endpoints: - web: - exposure: - include: health,info,metrics,prometheus,env - metrics: - tags: - application: lingniu-vehicle-ingest - -logging: - level: - root: INFO - com.lingniu.ingest: INFO - com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler: DEBUG - com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder: DEBUG - com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser: DEBUG - # 信达 push 诊断:打开下面一行可以看到每一条收发的原始字节 hex dump - com.lingniu.ingest.inbound.xinda: DEBUG diff --git a/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java b/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java deleted file mode 100644 index abeb1e3c..00000000 --- a/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.lingniu.ingest.gateway; - -import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; -import com.lingniu.ingest.protocol.jt808.model.Jt808Message; -import com.lingniu.ingest.session.CommandDispatcher; -import com.lingniu.ingest.session.DeviceSession; -import com.lingniu.ingest.session.SessionStore; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.stream.Collectors; - -/** - * 下行命令 REST 入口。命令通过 {@link CommandDispatcher} 发往协议模块, - * 由 protocol-jt808 的 {@code Jt808CommandDispatcher} 实际发送到终端。 - * - *

路径参数 {@code clientId} 可以是 sessionId / phone / vin, - * 由 {@link SessionStore} 三索引解析。 - */ -@RestController -@RequestMapping("/terminal") -public class TerminalCommandController { - - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(20); - - private final SessionStore sessions; - private final CommandDispatcher dispatcher; - - public TerminalCommandController(SessionStore sessions, CommandDispatcher dispatcher) { - this.sessions = sessions; - this.dispatcher = dispatcher; - } - - @GetMapping("/session") - public CommandResult session(@RequestParam String clientId) { - Optional s = resolveSession(clientId); - return s.map(CommandResult::ok) - .orElseGet(() -> CommandResult.failure(404, "session not found: " + clientId)); - } - - @GetMapping("/location") - public CommandResult queryLocation(@RequestParam String clientId) { - return awaitSync(clientId, Jt808Commands.queryLocation(), "query-location"); - } - - @GetMapping("/parameters") - public CommandResult queryParameters(@RequestParam String clientId) { - return awaitSync(clientId, Jt808Commands.queryParameters(), "query-parameters"); - } - - @PutMapping("/parameters") - public CommandResult setParameters(@RequestParam String clientId, - @RequestBody Map body) { - List items = body.entrySet().stream() - .map(e -> new Jt808Commands.ParamItem( - parseId(e.getKey()), - e.getValue() == null ? new byte[0] : e.getValue().getBytes(StandardCharsets.UTF_8))) - .collect(Collectors.toList()); - return awaitSync(clientId, Jt808Commands.setParameters(items), "set-parameters"); - } - - @PostMapping("/control") - public CommandResult terminalControl(@RequestParam String clientId, - @RequestBody Map body) { - int word = Integer.parseInt(body.getOrDefault("command", "0").toString()); - String params = body.getOrDefault("params", "").toString(); - return awaitSync(clientId, Jt808Commands.terminalControl(word, params), "terminal-control"); - } - - @PostMapping("/ack") - public CommandResult ack(@RequestParam String clientId, @RequestBody Map body) { - int ackSerial = body.getOrDefault("ackSerial", 0); - int ackMsgId = body.getOrDefault("ackMsgId", 0); - int result = body.getOrDefault("result", 0); - return fireAndForget(clientId, Jt808Commands.platformAck(ackSerial, ackMsgId, result), "platform-ack"); - } - - @GetMapping("/attributes") - public CommandResult attributes(@RequestParam String clientId) { - return CommandResult.notImplemented("0x8107 query-attributes"); - } - - @DeleteMapping("/area") - public CommandResult deleteArea(@RequestParam String clientId, @RequestParam String ids) { - return CommandResult.notImplemented("0x8601 delete-area"); - } - - @PostMapping("/alarm_ack") - public CommandResult alarmAck(@RequestParam String clientId, @RequestBody Map body) { - return CommandResult.notImplemented("0x8203 alarm-ack"); - } - - // ===== helpers ===== - - private Optional resolveSession(String clientId) { - return sessions.findBySessionId(clientId) - .or(() -> sessions.findByPhone(clientId)) - .or(() -> sessions.findByVin(clientId)); - } - - private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { - CompletableFuture future = - dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT); - try { - Jt808Message resp = future.join(); - return CommandResult.ok(Map.of( - "messageId", "0x" + Integer.toHexString(resp.header().messageId()), - "serial", resp.header().serialNo(), - "phone", resp.header().phone())); - } catch (CompletionException e) { - Throwable cause = e.getCause() != null ? e.getCause() : e; - return CommandResult.failure(500, op + " failed: " + cause.getMessage()); - } catch (Exception e) { - return CommandResult.failure(500, op + " failed: " + e.getMessage()); - } - } - - private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { - try { - dispatcher.notify(clientId, cmd).join(); - return CommandResult.ok(Map.of("op", op)); - } catch (Exception e) { - return CommandResult.failure(500, op + " failed: " + e.getMessage()); - } - } - - private static long parseId(String key) { - if (key == null || key.isBlank()) return 0; - String k = key.startsWith("0x") ? key.substring(2) : key; - return Long.parseLong(k, 16); - } -} diff --git a/docs/module-data-flow.html b/docs/module-data-flow.html new file mode 100644 index 00000000..f28432b2 --- /dev/null +++ b/docs/module-data-flow.html @@ -0,0 +1,733 @@ + + + + + + lingniu-vehicle-ingest 模块与数据流 + + + +

+

lingniu-vehicle-ingest 模块与数据流

+

+ 本图基于当前 Maven 多模块项目整理,用于后续讨论协议接入、内部字段、氢能车辆运营统计、安全告警和下游消费边界。 + 当前架构将接入层、历史明细、Redis 热状态和日统计拆成独立模块,通过统一 Kafka Envelope 解耦。 +

+
+ +
+
+

一、系统定位

+

+ 当前项目不是业务库写入服务。它的核心职责是把 GB/T 32960、JT/T 808、JT/T 1078、JSATL12、 + MQTT、信达 Push 等不同来源的车辆数据统一转换为 VehicleEvent 和全字段 TelemetrySnapshot, + 再通过 Kafka、原始报文归档、Parquet 明细文件库、Redis 热状态和独立统计模块交给下游业务。 +

+
+
+ 接入方式 + Netty TCP、MQTT、HTTP Push、后续可扩展更多 Inbound Adapter。 +
+
+ 统一模型 + 所有协议归一到 VehicleEvent 和全字段内部 TelemetrySnapshot +
+
+ 输出方式 + Kafka Protobuf Envelope + 原始 bytes 冷存 + Parquet/DuckDB + Redis 热状态。 +
+
+ 业务边界 + 日统计由 vehicle-stat-service 消费 Kafka 实现,告警工单、资产业务继续由下游消费。 +
+
+
+ +
+

二、模块分层图

+
+
+flowchart TB
+  subgraph external["外部来源"]
+    vehicle["车辆终端
GB/T 32960 / JT808 / JT1078"] + mqttSource["车企或平台 MQTT"] + xindaSource["信达 Push"] + commandClient["业务系统 / 运维平台
HTTP 下行命令"] + end + + subgraph protocolModules["协议模块 modules/protocols"] + gb["protocol-gb32960
32960 Netty 接入、鉴权、ACK、解析"] + jt808["protocol-jt808
808 Netty 接入、会话、位置/批量位置、媒体、透传、下行"] + jt1078["protocol-jt1078
1078 信令 + TCP/UDP RTP 媒体流归档"] + jsatl12["protocol-jsatl12
主动安全附件流接入"] + end + + subgraph inbound["入口适配 modules/inbound"] + mqtt["inbound-mqtt
MQTT endpoint 生命周期、PEM TLS"] + mqttProfile["MqttProfileRegistry
按 endpoint profile 解析/映射"] + xinda["inbound-xinda-push
HTTP Push 适配"] + end + + subgraph protocolBase["核心与公共能力 modules/core"] + codec["ingest-codec-common
BCD / CRC / BCC / bit 工具"] + session["session-core
设备会话、命令分发接口、会话存储"] + identity["vehicle-identity
跨协议身份解析、外部标识绑定、文件持久化"] + obs["observability
metrics / tracing / health"] + api["ingest-api
ProtocolId / RawFrame / VehicleEvent / 注解 SPI"] + registry["HandlerRegistry
按协议、命令、infoType 路由"] + dispatcher["Dispatcher
RawFrame 到 Handler 到 EventBus"] + bus["DisruptorEventBus
高吞吐事件发布"] + end + + subgraph sink["输出与明细存储 modules/sinks"] + mq["sink-mq
VehicleEvent 到 Protobuf Envelope 到 Kafka"] + archive["sink-archive
RawArchive 到本地/S3/OSS 冷存"] + fileStore["event-file-store
VehicleEvent/RawArchive 索引到 Parquet,DuckDB 按日查询"] + end + + subgraph services["消费服务 modules/services"] + history["event-history-service
Kafka 全字段事件到历史查询/导出"] + state["vehicle-state-service
Kafka 全字段事件到 Redis 热状态"] + stat["vehicle-stat-service
Kafka 全字段事件到可配置日统计"] + end + + subgraph apps["应用入口 modules/apps"] + gateway["command-gateway
HTTP 到设备命令入口"] + bootall["bootstrap-all
聚合所有 AutoConfiguration 一体启动"] + end + + vehicle --> gb + vehicle --> jt808 + vehicle --> jt1078 + vehicle --> jsatl12 + mqttSource --> mqtt + mqtt --> mqttProfile + mqttProfile --> api + xindaSource --> xinda + commandClient --> gateway + + gb --> codec + jt808 --> codec + jt1078 --> codec + jsatl12 --> codec + + gb --> api + jt808 --> api + xinda --> api + xinda --> dispatcher + jsatl12 --> archive + jsatl12 --> jt808 + + api --> registry + registry --> dispatcher + dispatcher --> bus + bus --> mq + bus --> archive + bus --> fileStore + mq --> history + mq --> state + mq --> stat + + gateway --> session + session --> jt808 + session --> gb + identity --> jt808 + identity --> mqttProfile + identity --> xinda + + obs -.监控.-> dispatcher + obs -.监控.-> bus + obs -.监控.-> mq + bootall -.装配.-> gb + bootall -.装配.-> jt808 + bootall -.装配.-> mqtt + bootall -.装配.-> xinda + bootall -.装配.-> mq + bootall -.装配.-> archive + bootall -.装配.-> fileStore + bootall -.装配.-> history + bootall -.装配.-> state + bootall -.装配.-> stat + + classDef entry fill:#eff6ff,stroke:#2563eb,color:#1e3a8a; + classDef core fill:#ecfdf3,stroke:#0f766e,color:#134e48; + classDef sink fill:#fffbeb,stroke:#b45309,color:#7c2d12; + classDef command fill:#f5f3ff,stroke:#6d28d9,color:#4c1d95; + classDef support fill:#f8fafc,stroke:#64748b,color:#334155; + + class gb,jt808,jt1078,jsatl12,mqtt,mqttProfile,xinda entry; + class api,registry,dispatcher,bus,identity core; + class mq,archive,fileStore sink; + class gateway,session command; + class codec,obs,bootall support; +
+
+
+ 入口/协议模块 + 核心管线 + 输出层 + 下行命令/会话 +
+
+ +
+

三、上行数据流转

+
+
+sequenceDiagram
+  autonumber
+  participant Device as 车辆/平台
+  participant Inbound as Inbound Adapter
Netty/MQTT/Push + participant Decoder as FrameDecoder
MessageDecoder + participant Dispatcher as Dispatcher + participant Handler as Protocol Handler
Mapper + participant EventBus as DisruptorEventBus + participant MQ as sink-mq
Kafka Envelope + participant Archive as sink-archive
ArchiveStore + participant FileStore as event-file-store
Parquet + DuckDB + participant Consumer as 下游业务消费者 + + Device->>Inbound: 原始报文 bytes / MQTT message / HTTP payload + Inbound->>Decoder: 粘包拆帧、校验、协议体解析 + Inbound->>Inbound: MQTT 按 endpoint profile 选择厂商解析器 + Decoder->>Dispatcher: RawFrame(protocol, command, infoType, payload, rawBytes) + Dispatcher->>EventBus: 先发布 RawArchive 事件 + EventBus->>Archive: 保存原始 bytes,生成可回放材料 + Dispatcher->>Handler: 根据 protocol + command + infoType 路由 + Handler->>Handler: 协议字段映射为内部字段 + Dispatcher->>Handler: 绑定 rawArchiveKey / rawArchiveUri + Handler->>EventBus: VehicleEvent(Realtime / Location / Alarm / Login ...) + EventBus->>MQ: 投递规整后的业务事件 + EventBus->>FileStore: 写入解析后明细事件 + MQ->>MQ: EnvelopeMapper 转 Protobuf + MQ->>Consumer: Kafka topic,key=vin,单车有序 + FileStore->>Consumer: 按日期范围正序/倒序查询和导出 + Consumer->>Consumer: 历史明细查询、Redis 热状态、可配置日统计、告警工单 +
+
+
+ 关键边界: + 协议字段只在协议模块内解释,出了 Mapper 以后都应该使用内部字段。统计每日里程、每日用电量、每日用氢量、储氢安全和氢泄露告警时,应消费全字段 TelemetrySnapshot,不要直接依赖某个协议的原始字段名。 +
+
+ 原始追溯: + Dispatcher 为所有带 rawBytes 的 RawFrame 生成统一的 rawArchiveKey,业务事件 metadata 使用 rawArchiveUri=archive://... 指向同一份原始 bytes。归档文件的真实本地/S3/OSS 地址只属于 ArchiveStore,查询、导出和 Kafka Envelope 使用逻辑 URI 解耦存储实现。 +
+
+ +
+

四、32960 细化链路

+
+
+flowchart LR
+  terminal["氢能车 TBOX
GB/T 32960"] --> netty["Gb32960NettyServer
TCP / TLS / Idle 检测"] + netty --> access["Gb32960AccessService
VIN 白名单 / 平台登录鉴权"] + netty --> frame["Gb32960FrameDecoder
拆帧、转义、校验"] + frame --> decoder["Gb32960MessageDecoder
Header + Body 解析"] + decoder --> diag["Gb32960FrameDiagnostics
首帧、Raw 块、异常诊断"] + decoder --> handler["Gb32960ChannelHandler
ACK / NACK / dispatch"] + handler --> ack["Gb32960AckService
登录应答、失败断开"] + handler --> dispatcher["Dispatcher"] + dispatcher --> rtHandler["Gb32960RealtimeHandler"] + rtHandler --> mapper["Gb32960EventMapper
协议字段到内部字段"] + mapper --> realtime["RealtimePayload
速度、里程、电量、氢量、压力、温度"] + mapper --> alarm["AlarmPayload
安全分类、氢泄露、告警等级"] + mapper --> login["Login / Logout / Heartbeat"] + realtime --> bus["DisruptorEventBus"] + alarm --> bus + login --> bus + bus --> kafka["Kafka Envelope"] + bus --> archive["RawArchive 冷存"] + + classDef safety fill:#fff1f3,stroke:#b42318,color:#7a271a; + classDef core fill:#ecfdf3,stroke:#0f766e,color:#134e48; + classDef entry fill:#eff6ff,stroke:#2563eb,color:#1e3a8a; + classDef sink fill:#fffbeb,stroke:#b45309,color:#7c2d12; + + class terminal,netty,frame,decoder,handler entry; + class access,diag,ack,dispatcher,rtHandler,mapper,bus core; + class alarm safety; + class kafka,archive sink; +
+
+
+ +
+

五、模块职责表

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块职责主要输入主要输出业务开发关注点
modules/core/ingest-api定义系统边界:协议 ID、RawFrame、IngestContext、VehicleEvent、Payload、Handler 注解、Sink SPI;ProtocolId 提供 UNKNOWN 隔离桶,供消费侧兼容未来协议或脏数据,不作为正式接入协议使用;consumer 包定义 EnvelopeIngestor、EnvelopeConsumerProcessor 和 EnvelopeDeadLetterSink,统一 Kafka 消费结果与死信边界。无直接外部输入。全项目共享的接口和内部事件模型。内部字段定义新增事件类型字段兼容性
modules/core/ingest-codec-common公共编解码工具,承载 BCD、CRC、BCC、bit 操作等协议底层能力。协议模块传入的 byte、bit、校验材料。解析辅助结果。协议基础能力
modules/core/ingest-core核心管线。扫描 Handler,按协议和命令路由,执行拦截器,发布 VehicleEvent 到 Disruptor。RawFrame。VehicleEvent、RawArchive。吞吐路由原始可回放
modules/core/session-core设备会话、命令下发抽象、SessionStore、CommandDispatcher 默认实现。SessionStore 支持 memoryredis 两种后端;生产默认使用 Redis 保存 sessionId、VIN 和 phone 三索引及 TTL,内存实现作为本地开发/无 Redis 降级,协议模块只依赖 SPI,不感知存储后端。设备连接、命令请求。会话状态、命令分发结果、Redis 会话索引。在线状态下行控制多实例会话索引
modules/core/vehicle-identity跨协议车辆身份解析,维护 phone、deviceId、plate 到 VIN 的绑定关系;支持 memory 和 append-only JSONL file 后端,避免重启丢失绑定。协议模块传入的外部标识。稳定 VIN、解析来源、是否已命中绑定。统计主键多协议归一持久化绑定
modules/core/observability监控、指标、追踪、健康检查等横切能力。核心管线和 Sink 运行状态。Micrometer 指标、健康信息。可用性延迟监控
modules/protocols/protocol-gb32960GB/T 32960 接入、鉴权、ACK、报文解析、实时数据和告警映射。32960 TCP 报文。Realtime、Location、Alarm、Login、Logout、Heartbeat、RawFrame。氢泄露储罐安全电量/氢量里程
modules/protocols/protocol-jt808JT/T 808 接入、注册/鉴权/心跳/注销、位置/位置附加项、批量位置、参数/属性、多媒体、透传、未知上行兜底透传、分帧边界异常和协议解析异常坏帧兜底、下行命令和超长下行分包能力;0x0200 位置附加项 0x01 总里程按 0.1km 解码为内部字段 total_mileage_km,后续每日里程统计不依赖 808 原始字段;0x0102 鉴权帧解析会保留完整 token,并尽量提取 IMEI 和软件版本;注册和鉴权会话均使用共享身份解析后的内部 VIN,避免 deviceId/IMEI 污染后续会话和下行边界;终端连接断开时同步解绑 Channel 并清理 SessionStore 会话,避免 command-gateway 误判离线车辆仍可下行;正常上行 RawArchive 和业务事件 metadata 均使用共享身份解析后的内部 vin,并保留 phone、identityResolved、identitySource 便于冷存和业务事件按同一车辆查询;无法解析终端身份的坏帧 RawArchive 和 Passthrough 均按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 标记;0x0900 透传事件按原始消息 ID 归类,透传类型写入 passthroughType metadata。808 TCP 报文。Location、Login、Logout、Heartbeat、MediaMeta、Passthrough、未知上行 Raw 兜底、坏帧 Passthrough、会话状态、下行响应。GPS 位置在线状态媒体证据命令链路
modules/protocols/protocol-jt1078JT/T 1078 音视频能力。信令在 JT808 mapper 存在时桥接复用 JT808 连接和包头;0x1005 乘客流量保留原始 body,同时结构化 channelId、startTime、endTime、passengerGetOn、passengerGetOff metadata 便于查询/导出;0x1205 文件列表保留原始 body,同时结构化 responseSerialNo、fileCount 摘要;下行信令编码由本模块提供,覆盖 0x1003 音视频属性查询、0x9101 实时预览、0x9102 实时控制、0x9201 历史回放、0x9202 回放控制、0x9205 资源列表查询、0x9206 文件上传和 0x9207 文件上传控制,命令发送仍由 command-gateway 经 CommandDispatcher 复用 JT808 在线通道;TCP/UDP RTP 媒体流独立端口接入,生产默认端口按旧接收服务对齐为 11078,按 VIN/通道/时间分段写 ArchiveStore,Kafka 只发送 MediaMeta 引用,事件 metadata 暴露内部 vin、sim、channelId、dataType、packetType、segment、sequence、segmentSizeBytes、archiveKey 和 archiveRef,MediaMeta.sizeBytes 同步当前片段大小,MediaMeta 按 archiveKey 去重,避免同一 SIM 运行中从 fallback 身份切换到内部 VIN 时漏发新归档引用,便于文件明细库追踪、展示和导出;超长/短包/坏魔数等 RTP 入口异常和媒体归档失败都会兜底产出 Passthrough 错误事件,坏 RTP 统一走 Dispatcher 产出 RawArchive 和 Passthrough,统一标记 parseError 和 parseErrorMessage,并保留内部 vin、原始 bytes、peer、长度、原因、RawArchive 引用、segment 和 archiveKey 便于排障;坏 RTP 头部可读时会提取 SIM 并通过共享身份服务映射 VIN。1078 信令报文、TCP/UDP RTP 媒体流。MediaMeta、乘客流量 Passthrough、文件列表 Passthrough、坏 RTP/归档失败 Passthrough、归档媒体分段。视频扩展信令事件化大流冷存
modules/protocols/protocol-jsatl12苏标主动安全报警附件接入。依赖 ArchiveStore 和 JT808 decoder;生产默认端口按旧接收服务对齐为 7612;附件 DataPacket 按旧服务真实布局 01cd + 50B文件名 + offset + length + data 分帧解析,不再把文件名前 4 字节误判为帧长;附件数据写 ArchiveStore 后通过 Dispatcher 产出 MediaMeta 引用事件,事件 metadata 保留 fileName、fileOffset、declaredChunkSizeBytes、archiveKey 和 archiveRef,便于附件明细查询、导出和补传诊断;内置附件状态机处理 T1210 文件清单、DataPacket 分块区间和 T1212 上传完成消息,数据分块会按 T1210 文件清单中的文件名继承手机号并通过共享身份解析器得到内部 VIN,入口 RawFrame、ArchivedChunk 和 MediaMeta 均保留 phone、vin、identityResolved、identitySource,只有找不到文件归属时才标记 UNKNOWN;按文件名合并无身份数据分块并返回 0x9212 完成/补传应答,补传应答包含缺失 offset/length 区间;附件归档失败会产出 JSATL12 Passthrough 错误事件并保留原始分块;JT_MESSAGE 信令复用 JT808 decoder 后进入 Dispatcher,桥接 RawFrame 使用共享身份解析后的内部 VIN并保留 phone、identityResolved、identitySource,坏 JT 信令入口 RawFrame 同样标记 UNKNOWN 身份;超长/畸形附件帧、未闭合超长 JT 信令和坏 JT 信令都以 JSATL12 Passthrough 兜底,附件帧异常同时标记 frameError 与统一 parseError/parseErrorMessage,保留原始字节、peer、长度和错误元数据,帧长和 worker 线程支持部署配置。主动安全附件流、JT808 信令帧。归档文件、MediaMeta、JT808 统一事件、坏帧/坏信令/归档失败 Passthrough。附件归档报警证据信令复用错误隔离
modules/inbound/inbound-mqttMQTT 多 endpoint 生命周期管理,支持 PEM CA、客户端证书/私钥双向 TLS,按 endpoint profile 路由到厂商解析/映射实现,默认提供宇通 profile;生产默认接入口径参考旧接收服务:ssl://cpxlm.axxc.cn:38883、topic /ytforward/shln/+、QoS 2、cleanSession=false、keepAlive=20s、connectTimeout=10s,用户名和密码仍通过环境变量注入;入站 RawFrame 和事件映射均接入统一车辆身份解析,解析器保留 externalVin、phone、mqttDeviceId、plateNo,优先按设备号/终端 ID/IMEI 绑定解析内部 VIN,未绑定且字段形态为 VIN 时再作为显式 VIN 兜底,冷存和事件 metadata 均保留这些外部标识、identityResolved、identitySource;未知 profile、解析失败、profile 运行时异常、endpoint 初始化/连接/订阅失败均兜底为 Passthrough,解析失败诊断和 profile 异常诊断都会同步写入 RawArchive metadata,并在最终 Passthrough 事件保留 parseErrorMessage 或 operational reason,无法解析设备身份的兜底事件和运行类归档都按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 标记,保留原始 payload 或 operational 错误信息便于排障和归档追溯。MQTT topic/message、endpoint profile、TLS PEM 配置。统一 RawFrame、Realtime、Location、Passthrough。车企平台接入profile 扩展身份映射双向 TLS错误隔离
modules/inbound/inbound-xinda-push信达 Push 接入适配,基于 PushClient 接收真实协议帧并统一转换为 RawFrame;入站 RawFrame 和事件映射均接入统一车辆身份解析,冷存和事件 metadata 均携带内部 vin,并保留 externalVin、phone、deviceId、plateNo、identityResolved、identitySource;0200 映射 Location,并将 totalMileage、mileage 等里程别名归一为内部字段 total_mileage_km;0300 映射标准 AlarmPayload,氢气泄漏类报警统一提升为 HYDROGEN_LEAK/CRITICAL 并标记 hydrogenLeakDetected、hydrogenLeakActionRequired,报警元数据保留 alarmType、alarmName、alarmLevel;未知业务 cmd、非法十六进制 cmd、JSON 异常、身份解析/业务处理异常、启动配置缺失、启动异常、登录拒绝和坏登录应答均兜底为 Passthrough,运行类错误按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 归档,非法 cmd 命令码归零并标记 malformedCommand,业务消息 JSON 异常会在 RawArchive metadata 和最终 Passthrough 事件同时保留 parseErrorMessage,业务处理异常会保留原始 payload 并标记 operationalError、phase=message 和 reason,便于归档追溯。信达 Push payload。RawFrame、Location、Passthrough、operational error。第三方平台接入统一 Dispatcher身份映射
modules/sinks/sink-mqKafka Sink。把 VehicleEvent 转成 Protobuf Envelope,按 VIN 分区投递;业务事件携带全字段 TelemetrySnapshot 和 rawArchiveUri,显式 RawArchive Envelope 会填充 archive:// 逻辑 URI 与 size,默认 Kafka Sink 仍不发送原始 bytes 本体;同时提供 KafkaEnvelopeConsumerFactory、KafkaEnvelopeConsumerRunner、KafkaEnvelopeConsumerWorker 和 KafkaEnvelopeDeadLetterSink,统一服务侧 Kafka 消费启动、独立 group 绑定、死信发布和 topic 到 EnvelopeConsumerProcessor 的分发。VehicleEvent。Kafka Protobuf 消息、消费侧 DLQ 记录。单车有序Schema 演进安全字段下发给消费者
modules/sinks/sink-archive原始报文冷存,当前有本地文件系统实现,后续可替换 S3/OSS;写入键与业务事件 metadata 中的 rawArchiveKey 保持一致。RawArchive 事件、附件流。可回放原始文件、archive://... 逻辑引用。问题排查合规留痕
modules/sinks/event-file-store文件型明细库,使用 Parquet 存储解析后的事件明细和 RawArchive 可查询索引,使用 DuckDB 按日期范围正序/倒序查询;RawArchive 只存 archive:// 逻辑 URI、命令码、长度和 metadata,不嵌入原始 bytes,原始 bytes 本体仍由 sink-archive 管理;读取历史文件时未知 protocol 会降级为 UNKNOWN,避免查询中断。VehicleEvent 或 EventFileRecord。protocol/date 分区的 Parquet 文件和查询结果。按日查询导出字段配置
modules/services/event-history-service消费 Kafka 全字段事件,写入文件型明细库,并提供历史查询和 CSV 导出边界;导出支持通过 fields 参数选择内部字段并平铺为 CSV 列,也支持 metadata.xxx 导出协议侧诊断、媒体归档、报警类型等 metadata 字段;未知 Envelope source 会进入 UNKNOWN 协议分区,并在 metadata 保留 originalSource 便于回溯;生产消费可使用 tryIngest 和自动装配的 EnvelopeConsumerProcessor 将坏 protobuf、缺快照和存储异常收敛为结构化结果,并通过 EnvelopeDeadLetterSink 发布死信,避免阻塞 Kafka 分区。Kafka Protobuf Envelope。EventFileRecord、查询结果、可配置字段 CSV。历史明细多日导出字段配置
modules/services/vehicle-state-service消费 Kafka 全字段事件,更新车辆最新状态、位置、安全和最后事件到 Redis;生产消费可使用 tryIngest 和自动装配的 EnvelopeConsumerProcessor 隔离坏 protobuf 和缺快照 Envelope,并把失败记录交给死信出口。Kafka Protobuf Envelope。vehicle:state:{vin}vehicle:location:{vin}vehicle:safety:{vin}毫秒级热查询氢泄露安全
modules/services/vehicle-stat-service消费 Kafka 全字段事件,抽取统计源点,按车辆规则计算每日里程等日统计;生产消费可使用 tryIngest 和自动装配的 EnvelopeConsumerProcessor 隔离坏 protobuf,缺少 telemetry_snapshot 的消息会标记为 SKIPPED 并进入死信出口。Kafka Protobuf Envelope、车辆统计规则。vin + stat_date 保存的日统计结果。每日里程规则配置
modules/apps/command-gatewayHTTP 到设备下行命令入口。支持会话查询、808 位置查询、参数查询/设置、终端控制、平台通用应答、终端属性查询 0x8107、区域删除 0x8601、人工报警确认 0x8203,以及 1078 音视频属性查询、实时预览/控制、历史回放/控制、资源列表查询、文件上传/控制;通过 CommandDispatcher 复用 protocol-jt808 的在线通道、流水号和同步应答等待能力。业务系统命令请求。CommandDispatcher 调用、设备应答摘要。远程控制参数设置属性查询区域删除报警确认视频控制
modules/apps/bootstrap-all一体化启动模块,聚合各模块 AutoConfiguration 和运行配置。application.yml、Spring Boot 启动。完整接入服务实例。部署开关配置
+
+ +
+

六、氢能业务开发落点

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
业务主题当前建议落点说明
车辆状态、速度、位置RealtimePayloadLocationPayload32960 和 808 都可能提供位置。统计侧需要明确优先级,例如优先 32960,缺失时用 808 补点。
每日里程Kafka 下游统计服务接入层只投递总里程和实时点位;日增里程建议下游按 VIN、自然日、事件时间聚合,处理回补和乱序。
每日用电量内部电池字段 + 下游统计接入层保留 SOC、电压、电流等瞬时字段;若有累计电耗字段,可加入内部字段模型后统一投递。
每日用氢量hydrogenRemainingKg + 下游统计优先使用累计氢耗字段;没有累计值时用氢余量差值估算,并在统计结果里标记估算口径。
储氢安全RealtimePayload 压力/温度 + AlarmPayload.safetyCategory高压、低压、温度、异常 bit 都应统一归入储罐安全域,下游可做趋势、阈值和连续异常判断。
氢气泄露AlarmPayload.hydrogenLeakDetected当前已作为高优先级安全事件。32960 中出现 HYDROGEN_LEAK 时强制映射为 CRITICAL
原始报文追溯RawArchive + rawArchiveUri + sink-archive业务统计出现争议时,用事件 metadata 中的 archive://... 逻辑 URI 回查原始 bytes,结合 eventId、traceId、VIN、时间复现解析链路。
明细展示和导出event-file-store解析后的事件明细和 RawArchive 索引按 protocol/date 写 Parquet。DuckDB 查询时按日期范围读取,并按 event_time 正序或倒序返回。
+
+ 后续开发建议: + 当前 Kafka Protobuf 已加入全字段 TelemetrySnapshot。后续业务统计服务继续只依赖内部字段,不依赖 32960 原始字段名,这样接入 808、MQTT、车企私有协议时不会重写统计口径。 +
+
+
+ + + + diff --git a/docs/superpowers/plans/2026-04-20-gb32960-body-parser-block-isolation.md b/docs/superpowers/plans/2026-04-20-gb32960-body-parser-block-isolation.md new file mode 100644 index 00000000..9149676b --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-gb32960-body-parser-block-isolation.md @@ -0,0 +1,600 @@ +# GB32960 Body Parser — Per-Block Exception Isolation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `Gb32960BodyParser` 主循环对单信息块的解析异常实现**单块隔离**:一块解析失败不再整帧放弃,改为把失败块兜成 `InfoBlock.Raw` 并继续解析后续信息块。 + +**Architecture:** 在主循环每次 `parser.parse(body)` 外面套 try/catch(仅捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`,放行 RuntimeException 以免掩盖 parser bug)。失败时: +- 若 `fixedLen ≥ 0` 且剩余字节 ≥ fixedLen → 回滚 reader → 按 fixedLen 截取 Raw → position 前进 fixedLen → `continue` 循环 +- 否则(变长 parser 或剩余不足)→ 剩余字节全部兜成 Raw → `break` 循环(变长块无法安全找下一块边界) + +通过 `Gb32960Properties.Parse.lenientBlockFailure`(默认 true)控制开关,可以回退到严格模式。**不改 `InfoBlock.Raw` record 结构**(避免 downstream 兼容风险——`Gb32960EventMapper` 按 `findBlock(Class)` 类型查找,新增 Raw 不影响其行为)。 + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Spring Boot ConfigurationProperties, Maven。仅改动 `protocol-gb32960` 模块。 + +--- + +## File Structure + +- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java` — 新增 `Parse` 内嵌类 + `parse` 字段/getter/setter +- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java` — 主循环 try/catch + recovery 逻辑 + `lenientBlockFailure` 字段 +- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` — 把配置穿给 BodyParser +- **Create** `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java` — 3 个隔离场景 + 1 个严格模式回退场景 +- **Modify** `bootstrap-all/src/main/resources/application.yml` — 添加 `parse:` 注释段示例 +- **Modify** `CHANGELOG.md` — 追加本次变更条目 + +--- + +## Task 1: 基线验证 —— 现有测试全绿 + +**Files:** 无 + +- [ ] **Step 1.1: 跑 protocol-gb32960 模块测试** + +Run: `mvn -pl protocol-gb32960 test -q` +Expected: BUILD SUCCESS,所有现有测试通过(`Gb32960DecoderGoldenTest`、`Gb32960FullBlocksTest`、`Gb32960DecoderTest`、`GuangdongFcEndToEndTest`、parser/* 各 Block Parser 单测、profile/* 选择器单测)。 + +如果基线红了,**先停下来修复基线**再进入 Task 2。 + +--- + +## Task 2: 添加 Parse 配置子节点 + +**Files:** +- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java` + +- [ ] **Step 2.1: 在 `Gb32960Properties` 类体中(在 `Auth` 字段后、`Tls` 字段前)新增 `parse` 字段** + +位置参考:现在 L24 `private Auth auth = new Auth();` 下面一行,在 L27 `private Tls tls = new Tls();` 之前插入: + +```java + /** + * 报文解析行为配置。 + * + *

默认 {@code lenientBlockFailure=true}:单个信息块解析异常时兜成 + * {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} 继续解析, + * 不再整帧放弃。关闭后恢复旧行为(任意异常直接抛 {@code DecodeException} + * 放弃整帧),仅在灰度回滚时使用。 + */ + private Parse parse = new Parse(); +``` + +- [ ] **Step 2.2: 在类底部(`Tls` 静态类之后、`VendorExtension` 静态类之前)添加 `Parse` 静态内嵌类** + +```java + /** + * 报文解析容错策略。 + */ + public static class Parse { + /** + * 单块解析异常时是否兜底为 {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} + * 继续解析。默认开启。 + * + *

+ */ + private boolean lenientBlockFailure = true; + + public boolean isLenientBlockFailure() { return lenientBlockFailure; } + public void setLenientBlockFailure(boolean lenientBlockFailure) { + this.lenientBlockFailure = lenientBlockFailure; + } + } +``` + +- [ ] **Step 2.3: 在属性 getter/setter 区域(现有 `getTls` 之后)补充 `getParse` / `setParse`** + +```java + public Parse getParse() { return parse; } + public void setParse(Parse parse) { this.parse = parse; } +``` + +- [ ] **Step 2.4: 编译验证** + +Run: `mvn -pl protocol-gb32960 compile -q` +Expected: BUILD SUCCESS。 + +- [ ] **Step 2.5: 提交** + +```bash +git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java +git commit -m "$(cat <<'EOF' +config(gb32960): add parse.lenientBlockFailure toggle + +新增 lingniu.ingest.gb32960.parse.lenientBlockFailure 配置开关(默认 true), +为 Gb32960BodyParser 的单块异常隔离行为做回退开关。实现在后续 commit。 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: RED —— 写三个失败测试覆盖隔离场景 + +**Files:** +- Create: `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java` + +- [ ] **Step 3.1: 创建测试文件** + +完整内容: + +```java +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 验证 {@link Gb32960BodyParser} 单块异常隔离行为。 + * + *

三个隔离场景: + *

    + *
  1. 固定长度块 parser 抛异常 → 失败块兜 Raw(按 fixedLen 截取)+ 后续块继续解析 + *
  2. 固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环 + *
  3. 变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环 + *
+ * + *

外加一个严格模式回退测试:{@code lenientBlockFailure=false} 时任意异常应抛 DecodeException。 + */ +class Gb32960BodyParserIsolationTest { + + /** 构造一个"看起来合法但中途 parser 失败"用来注入失败的 Position parser。 */ + private static final InfoBlockParser EXPLODING_FIXED_LEN_POSITION = new InfoBlockParser() { + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x05; } + @Override public int fixedLength() { return 9; } + @Override public InfoBlock parse(ByteBuffer buffer) { + // 模拟 parser 读了几字节后发现不合法就抛 + buffer.get(); buffer.get(); + throw new DecodeException("simulated parser failure in Position"); + } + }; + + /** 变长块(fixedLength=-1),parse 时消费若干字节后抛。模拟 Alarm/Voltage 类列表读越界。 */ + private static final InfoBlockParser EXPLODING_VAR_LEN_ALARM = new InfoBlockParser() { + @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } + @Override public int typeCode() { return 0x07; } + @Override public int fixedLength() { return -1; } + @Override public InfoBlock parse(ByteBuffer buffer) { + // 假装读了个长度字段就爆 + buffer.get(); + throw new DecodeException("simulated parser failure in Alarm list-length read"); + } + }; + + @Test + void fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed() { + // 帧布局:Vehicle(0x01, 20B) + Position(0x05, 9B 但 parser 爆) + Engine(0x04) 不构造, + // 简化为 Vehicle + FailingPosition + Vehicle 再次,验证"Position 之后还能继续"。 + InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( + new VehicleV2016BlockParser(), + EXPLODING_FIXED_LEN_POSITION)); + + Gb32960BodyParser parser = new Gb32960BodyParser(registry); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeValidVehicle(os); // 1+20 = 21B + writePositionTypeAnd9ByteBody(os); // 1+9 = 10B (parser 会爆) + writeValidVehicle(os); // 1+20 = 21B + ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); + + var result = parser.parse(ProtocolVersion.V2016, body); + + assertThat(result.blocks()).hasSize(3); + assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); + assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> { + assertThat(raw.typeCode()).isEqualTo(0x05); + assertThat(raw.type()).isEqualTo(InfoBlockType.RAW); + assertThat(raw.bytes()).hasSize(9); // 按 fixedLen 截取 + }); + assertThat(result.blocks().get(2)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); + assertThat(body.hasRemaining()).isFalse(); + } + + @Test + void truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates() { + // Vehicle(21B) + Position(type 0x05)但 body 只给 3B —— 不足 9B + InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( + new VehicleV2016BlockParser(), + new PositionV2016BlockParser())); + + Gb32960BodyParser parser = new Gb32960BodyParser(registry); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeValidVehicle(os); + os.write(0x05); // Position typeCode + os.write(0); os.write(0); os.write(0); // 只 3B,远远不够 9B + ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); + + var result = parser.parse(ProtocolVersion.V2016, body); + + assertThat(result.blocks()).hasSize(2); + assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); + assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> { + assertThat(raw.typeCode()).isEqualTo(0x05); + assertThat(raw.bytes()).hasSize(3); // 剩余字节全兜成 Raw + }); + } + + @Test + void variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks() { + // Vehicle(21B) + FailingAlarm(0x07)后面还有一个 Vehicle,但因 Alarm 变长无法安全跳过, + // 整段 Alarm 起的剩余字节都被兜成 Raw 后 break。 + InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( + new VehicleV2016BlockParser(), + EXPLODING_VAR_LEN_ALARM)); + + Gb32960BodyParser parser = new Gb32960BodyParser(registry); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeValidVehicle(os); // 21B + os.write(0x07); // Alarm typeCode + for (int i = 0; i < 10; i++) os.write(0xAA); // 10B 的 alarm body(parser 爆) + writeValidVehicle(os); // 21B(应当不再被解析) + ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); + + var result = parser.parse(ProtocolVersion.V2016, body); + + assertThat(result.blocks()).hasSize(2); + assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); + assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> { + assertThat(raw.typeCode()).isEqualTo(0x07); + // 剩余 10B alarm body + 1+20=21B 后续 Vehicle = 31B + assertThat(raw.bytes()).hasSize(31); + }); + } + + @Test + void strictMode_throwsOnAnyBlockFailure() { + InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( + new VehicleV2016BlockParser(), + EXPLODING_FIXED_LEN_POSITION)); + + Gb32960BodyParser parser = new Gb32960BodyParser(registry); + parser.setLenientBlockFailure(false); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeValidVehicle(os); + writePositionTypeAnd9ByteBody(os); + ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); + + assertThatThrownBy(() -> parser.parse(ProtocolVersion.V2016, body)) + .isInstanceOf(DecodeException.class); + } + + // ------------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------------ + + /** 写一个完整的合法 V2016 Vehicle 块(typeCode + 20B body)。 */ + private static void writeValidVehicle(ByteArrayOutputStream os) { + os.write(0x01); // typeCode Vehicle + os.write(0x01); // vehicleState=1 + os.write(0x01); // chargingState=1 + os.write(0x01); // runningMode=1 + os.write(0); os.write(0); // speed=0 + os.write(0); os.write(0); os.write(0); os.write(0); // mileage=0 + os.write(0); os.write(0); // totalVoltage=0 + os.write(0); os.write(0); // totalCurrent=0(offset 1000,原值0) + os.write(50); // soc=50% + os.write(0x01); // dcdc + os.write(0); // gear + os.write(0); os.write(0); // insulation + os.write(0); // accelerator + os.write(0); // brake + } + + /** 写 Position typeCode(0x05) + 9 字节 body(内容不重要,parser 替换成 EXPLODING 的)。 */ + private static void writePositionTypeAnd9ByteBody(ByteArrayOutputStream os) { + os.write(0x05); + for (int i = 0; i < 9; i++) os.write(0); + } +} +``` + +- [ ] **Step 3.2: 跑测试验证 RED** + +Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q` +Expected: +- `fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed` FAIL(当前会抛 DecodeException) +- `truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates` FAIL(当前 L132 会抛 DecodeException) +- `variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks` FAIL(异常冒出整帧失败) +- `strictMode_throwsOnAnyBlockFailure` FAIL(没有 `setLenientBlockFailure` 方法,编译就红) + +**验证点**:编译失败(strictMode test 里 `setLenientBlockFailure` 尚未存在)是**预期** RED 信号——下一步实现。 + +--- + +## Task 4: GREEN —— 实现单块异常隔离 + +**Files:** +- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java` + +- [ ] **Step 4.1: 在类字段区新增 `lenientBlockFailure` 字段 + setter** + +在 `private final VendorExtensionSelector selector;`(现 L54)下面插入: + +```java + /** + * 单块解析异常时是否兜底为 Raw 后继续。由 + * {@link com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties.Parse#isLenientBlockFailure()} + * 注入;默认 true。 + */ + private boolean lenientBlockFailure = true; + + public void setLenientBlockFailure(boolean lenientBlockFailure) { + this.lenientBlockFailure = lenientBlockFailure; + } +``` + +- [ ] **Step 4.2: 改造主循环:替换 L130~L150 的整个 "parse + 长度校验" 段** + +**完整替换块**:以下代码替换原来从 `int fixedLen = parser.fixedLength();`(L130)开始到 `blocks.add(block);`(L150)结束的整块。 + +```java + int fixedLen = parser.fixedLength(); + int posBefore = body.position(); + + // 旧行为:fixedLen 预检不足 → 直接抛。新行为(lenient 模式):走统一恢复路径。 + if (fixedLen >= 0 && body.remaining() < fixedLen) { + if (!lenientBlockFailure) { + throw new DecodeException( + "info block 0x" + Integer.toHexString(typeCode) + + " needs " + fixedLen + " bytes but got " + body.remaining()); + } + // 剩余字节数不足 fixedLen —— 无法按块截取,整尾兜 Raw + break + int remaining = body.remaining(); + byte[] tail = new byte[remaining]; + body.get(tail); + log.warn("[gb32960] truncated block typeCode=0x{} declaredFixedLen={} remaining={} — wrapping tail as Raw", + Integer.toHexString(typeCode), fixedLen, remaining); + blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail)); + break; + } + + InfoBlock block; + try { + block = parser.parse(body); + } catch (DecodeException | java.nio.BufferUnderflowException | IndexOutOfBoundsException e) { + if (!lenientBlockFailure) { + if (e instanceof DecodeException de) throw de; + throw new DecodeException( + "parser " + parser.getClass().getSimpleName() + " failed: " + e.getMessage(), e); + } + // 恢复路径:先回滚 position 到 parse 入口 + body.position(posBefore); + if (fixedLen >= 0) { + // 固定长度块:按 fixedLen 截取 → 兜 Raw → continue 循环 + byte[] corrupt = new byte[fixedLen]; + body.get(corrupt); + log.warn("[gb32960] block parse failed typeCode=0x{} parser={} fixedLen={} — isolated as Raw, continuing", + Integer.toHexString(typeCode), parser.getClass().getSimpleName(), fixedLen, e); + blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, corrupt)); + continue; + } else { + // 变长块:无法安全找下一块边界 → 剩余整段兜 Raw + break + int remaining = body.remaining(); + byte[] tail = new byte[remaining]; + body.get(tail); + log.warn("[gb32960] variable-length block parse failed typeCode=0x{} parser={} remaining={} — wrapping remainder as Raw, stopping loop", + Integer.toHexString(typeCode), parser.getClass().getSimpleName(), remaining, e); + blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail)); + break; + } + } + + int consumed = body.position() - posBefore; + if (fixedLen >= 0 && consumed != fixedLen) { + // 这是 parser 契约违反(声明 fixedLen=X 但实际读了 Y),保持抛异常以暴露 parser bug。 + throw new DecodeException( + "parser " + parser.getClass().getSimpleName() + + " consumed " + consumed + + " bytes but declared " + fixedLen); + } + if (log.isDebugEnabled()) { + log.debug("[gb32960] block typeCode=0x{} parser={} pos {}→{} consumed={} declaredFixed={}", + Integer.toHexString(typeCode), parser.getClass().getSimpleName(), + typeStartPos, body.position(), consumed, fixedLen); + } + blocks.add(block); +``` + +- [ ] **Step 4.3: 运行隔离测试验证 GREEN** + +Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q` +Expected: 4 个测试全部 PASS。 + +- [ ] **Step 4.4: 运行全模块测试检查回归** + +Run: `mvn -pl protocol-gb32960 test -q` +Expected: BUILD SUCCESS,所有原有测试(含 Golden、FullBlocks、GuangdongFcEndToEnd)依旧通过。 + +如果有原有测试红了:**停下来诊断**。最可能的原因是某个现有测试之前靠"抛异常"行为验证错误帧的,需要手动判断这是测试问题还是实现问题。 + +- [ ] **Step 4.5: 提交** + +```bash +git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java \ + protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java +git commit -m "$(cat <<'EOF' +feat(gb32960): isolate per-block parse failures in body parser + +主循环改造:单信息块 parser 抛 DecodeException / BufferUnderflowException / +IndexOutOfBoundsException 时不再放弃整帧。 + +- 固定长度块(fixedLen≥0):回滚 reader,按 fixedLen 截取字节兜成 InfoBlock.Raw, + position 前进 fixedLen,continue 循环继续解析后续块 +- 变长块(fixedLen=-1)或剩余不足 fixedLen:剩余字节全部兜成 Raw 后 break +- parser 契约违反(consumed != declared fixedLen)仍抛异常,以暴露 parser bug +- 通过 lingniu.ingest.gb32960.parse.lenientBlockFailure=false 可回退严格模式 +- 新增 Gb32960BodyParserIsolationTest 覆盖 3 种失败场景 + 1 严格模式 + +不改 InfoBlock.Raw record 结构;下游 Gb32960EventMapper 按 findBlock(Class) 类型 +查找,新增 Raw 不影响其行为。 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: 把配置注入 BodyParser(AutoConfiguration 连线) + +**Files:** +- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` + +- [ ] **Step 5.1: 定位 `Gb32960BodyParser` bean 定义** + +先查看:`grep -n "Gb32960BodyParser" protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` + +根据返回的行号,在构造 `Gb32960BodyParser` 的 @Bean 方法里,构造完成后立即调用: + +```java + Gb32960BodyParser parser = new Gb32960BodyParser(profileRegistry, selector); + parser.setLenientBlockFailure(properties.getParse().isLenientBlockFailure()); + return parser; +``` + +(如果目前的构造返回一行表达式,改成先赋给 local 变量再设 flag 再 return。) + +- [ ] **Step 5.2: 编译并运行全模块测试** + +Run: `mvn -pl protocol-gb32960 test -q` +Expected: BUILD SUCCESS。 + +- [ ] **Step 5.3: 提交** + +```bash +git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java +git commit -m "$(cat <<'EOF' +config(gb32960): wire parse.lenientBlockFailure into BodyParser bean + +Gb32960AutoConfiguration 在创建 Gb32960BodyParser bean 后,将 +Gb32960Properties.Parse.lenientBlockFailure 通过 setter 注入,让运行期配置生效。 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: bootstrap-all 配置文档化 + +**Files:** +- Modify: `bootstrap-all/src/main/resources/application.yml` + +- [ ] **Step 6.1: 在 `lingniu.ingest.gb32960` 节点的 `vendor-extensions` 段之后、`jt808` 之前,插入 parse 配置示例** + +参考位置:现有 application.yml L57 末尾(`vendor-extensions` list 结束)。在 L58 `jt808:` 之前加入: + +```yaml + # 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成 Raw 后继续, + # 不再整帧丢弃。仅在需要严格失败语义(灰度回滚或抓虫)时设为 false。 + parse: + lenient-block-failure: true +``` + +- [ ] **Step 6.2: 启动一次 bootstrap-all 的 dry-run 编译** + +Run: `mvn -pl bootstrap-all compile -q` +Expected: BUILD SUCCESS(只是配置段注释变化,不会影响编译)。 + +- [ ] **Step 6.3: 提交** + +```bash +git add bootstrap-all/src/main/resources/application.yml +git commit -m "$(cat <<'EOF' +config(gb32960): document parse.lenient-block-failure in application.yml + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: 全仓库回归测试 + +**Files:** 无(只是验证) + +- [ ] **Step 7.1: 跑完整项目测试** + +Run: `mvn test -q` +Expected: BUILD SUCCESS。重点关注: +- `protocol-gb32960` 模块全绿(Golden、FullBlocks、Isolation、GuangdongFcEndToEnd、Decoder、Mapper、所有 parser 单测、profile) +- 其它模块(`ingest-core`、`sink-mq` 等)不受影响(无改动应自动通过) + +若有红,停下来诊断。 + +--- + +## Task 8: 更新 CHANGELOG + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 8.1: 在 CHANGELOG.md 文件顶部(在 `## [0.1.0] — 2026-04-15` 标题之前)新增一个 `## [Unreleased]` 段落;如已存在则追加** + +新增段落内容(如已存在 Unreleased 段则在其 `### Added` / `### Changed` 内部追加): + +```markdown +## [Unreleased] + +### Changed —— GB/T 32960 Body Parser 单块异常隔离 +- `Gb32960BodyParser` 主循环对单信息块的 parser 异常不再放弃整帧: + - 固定长度块(`fixedLength ≥ 0`):回滚 reader → 按 fixedLen 截取字节兜成 + `InfoBlock.Raw` → 继续解析后续块; + - 变长块或剩余字节不足:剩余字节全部兜成 `Raw` 后终止循环; + - `parser` 契约违反(声明 fixedLen=X 但实际读了 Y)仍抛 `DecodeException`, + 以暴露 parser bug,不被静默吞掉; + - 只捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`, + `RuntimeException` 继续向上抛,保留 bug 可见性。 +- 新增配置 `lingniu.ingest.gb32960.parse.lenient-block-failure`(默认 `true`); + 回退严格模式用 `false`。 +- `InfoBlock.Raw` record 结构**未变**,下游 `Gb32960EventMapper` 按 `findBlock(Class)` + 类型匹配,新增 Raw 不影响业务事件映射。 +- 覆盖测试:`Gb32960BodyParserIsolationTest` —— 3 种隔离场景 + 严格模式回退。 +``` + +- [ ] **Step 8.2: 提交** + +```bash +git add CHANGELOG.md +git commit -m "$(cat <<'EOF' +docs(changelog): record gb32960 body parser block isolation + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-Review Checklist + +- [x] **Spec coverage**: 原讨论的 A1~A7 全部覆盖——A1 状态机(Task 4 Step 4.2)、A2 三类错误策略(同)、A3 收窄异常种类(同,只 catch 三种)、A4 日志分类(warn 带 parser 名/typeCode/原因)、A5 配置开关(Task 2)、A6 合成帧测试(Task 3 三场景 + 严格回退)、A7 下游兼容(不改 Raw record,`findBlock` 类型匹配不受影响,plan 中已说明)。 +- [x] **Placeholder scan**: 无 TBD / TODO / "实现上面的" 等占位;每个 Step 含实际代码 or 命令。 +- [x] **Type consistency**: `lenientBlockFailure` 在 Properties / BodyParser 字段 / setter / 测试中拼写一致。`InfoBlockType.RAW` 枚举值依赖当前存在(已核对 InfoBlock.Raw record)。 +- [x] **测试命名一致**:`Gb32960BodyParserIsolationTest` 在 Task 3 创建、Task 4/7 引用一致。 +- [x] **未引入新枚举/proto 变更**:InfoBlock.Raw 沿用现有 `(int typeCode, InfoBlockType type, byte[] bytes)` 构造。 diff --git a/docs/target-architecture.md b/docs/target-architecture.md new file mode 100644 index 00000000..dd32fe9e --- /dev/null +++ b/docs/target-architecture.md @@ -0,0 +1,270 @@ +# Target Architecture + +This document defines the target architecture for Lingniu vehicle ingest. The +main goal is to keep protocol ingest, raw archive, historical detail storage, +realtime state, and statistics as separate responsibilities connected by stable +event contracts. + +## Goals + +- The ingest service receives protocol messages, archives raw frames, and + publishes normalized full-field events to Kafka. +- Historical detail storage consumes Kafka events and stores raw parsed event + details in Parquet, queried through DuckDB. +- Realtime vehicle state consumes Kafka events and writes hot state to Redis. +- Statistics consumes Kafka events and calculates daily operational metrics by + configurable rules. +- Kafka consumers expose non-throwing ingest results for bad protobuf payloads, + unsupported envelopes, and downstream failures so one bad record does not + block a vehicle partition. +- Consumer services expose `EnvelopeConsumerProcessor` beans. Kafka workers or + framework listeners should pass topic, partition, offset, key, and value into + the processor; `SKIPPED`, `INVALID_ENVELOPE`, and `FAILED` results are routed + through `EnvelopeDeadLetterSink`. +- `sink-mq` owns Kafka-specific consumer plumbing: `KafkaEnvelopeConsumerFactory` + creates one independent Kafka consumer per service processor binding, + `KafkaEnvelopeConsumerRunner` manages the long-running poll loop, and + `KafkaEnvelopeDeadLetterSink` publishes failed records to the configured DLQ + topic with diagnostic headers. +- Business consumers depend on internal fields, not protocol-specific field + names. + +## Non-Goals + +- The ingest service must not write business tables. +- The ingest service must not serve historical detail queries. +- Parquet history storage must not store derived daily statistics. +- Redis hot state must not become the long-term historical store. +- Statistics services must not parse GB/T 32960 frames directly. + +## Service Boundaries + +### Ingest Service + +The ingest service owns protocol IO and event publication. + +Responsibilities: + +- Accept GB/T 32960, JT/T 808, MQTT, Xinda Push, and future inbound protocols. +- Decode frames and acknowledge protocol messages where required. +- Emit `RawArchive` before deduplication and rate limiting when raw bytes are + available. +- Write raw bytes through `sink-archive`. +- Map protocol messages into internal telemetry events. +- Publish internal events to Kafka with VIN as the partition key. + +The ingest service must not depend on Redis, DuckDB query endpoints, or +statistics rule repositories. + +### Event Contract + +The event contract is the only dependency shared by ingest, history, realtime +state, and statistics. + +Core event identity: + +| Field | Meaning | +|---|---| +| `event_id` | Idempotency key for the parsed event | +| `trace_id` | Cross-service trace id | +| `vin` | Vehicle VIN and Kafka partition key | +| `source_protocol` | Protocol source such as `GB32960`; consumers may map unknown future source names to `UNKNOWN` and preserve the original source in metadata | +| `protocol_version` | Source protocol version | +| `event_type` | Business event type, for example `REALTIME`, `LOCATION`, `ALARM` | +| `event_time` | Device collection time | +| `ingest_time` | Platform receive time | +| `raw_archive_uri` | Optional reference to archived raw bytes | + +Full-field telemetry is represented as internal field values: + +| Field | Meaning | +|---|---| +| `field_key` | Stable internal field key such as `total_mileage_km` | +| `value_type` | `STRING`, `DOUBLE`, `LONG`, `BOOLEAN`, `INSTANT`, `JSON` | +| `value` | String representation used for durable serialization | +| `unit` | Stable unit when applicable | +| `quality` | `GOOD`, `ESTIMATED`, `INVALID`, `MISSING` | +| `source_path` | Protocol source path, for traceability | + +The internal field list is defined in +`docs/vehicle-telemetry-internal-fields.md`. New protocol-specific fields start +as extension fields and are promoted only after a business meaning and unit are +stable. + +### Historical Detail Service + +The historical detail service owns raw parsed event detail storage and exports. + +Responsibilities: + +- Consume normalized telemetry events from Kafka. +- Store events in Parquet partitioned by protocol and event date. +- Query Parquet through DuckDB. +- Provide HTTP APIs for date-range query and export. +- Support ascending and descending time order. +- Support multi-day concatenated export. + +Storage partition: + +```text +/ + protocol=GB32960/ + date=2026-06-22/ + part-*.parquet +``` + +The service stores detail events and field snapshots only. It does not calculate +or store daily mileage, daily hydrogen usage, or other derived metrics. + +### Realtime State Service + +The realtime state service owns hot vehicle state. + +Responsibilities: + +- Consume normalized telemetry events from Kafka. +- Keep latest vehicle state by VIN in Redis. +- Keep latest location by VIN in Redis. +- Keep latest safety state and hydrogen leak state by VIN in Redis. +- Optionally keep short recent windows for operations screens. + +Suggested Redis keys: + +| Key | Value | +|---|---| +| `vehicle:state:{vin}` | Latest full state snapshot | +| `vehicle:location:{vin}` | Latest location snapshot | +| `vehicle:safety:{vin}` | Latest safety snapshot | +| `vehicle:event:last:{vin}` | Latest event identity and timestamps | + +Redis is for millisecond-level reads and recent state. It is not the historical +source of truth. + +Current module: `vehicle-state-service`. + +Implemented boundaries: + +- `VehicleStateEnvelopeIngestor` parses Kafka envelope bytes. +- `VehicleStateUpdater` maps full-field telemetry snapshots to Redis JSON + payloads. +- `VehicleStateRepository` isolates storage from event parsing. +- `VehicleStateController` exposes read-only latest state APIs. + +### Statistics Service + +The statistics service owns derived operational metrics. + +Responsibilities: + +- Consume normalized telemetry events from Kafka. +- Maintain day-level vehicle point state needed for statistics. +- Run scheduled and event-triggered calculations. +- Store daily results in a statistics database. +- Support per-vehicle calculation rules. + +Initial daily mileage strategies: + +| Strategy | Formula | +|---|---| +| `CURRENT_LAST_MINUS_PREVIOUS_LAST` | Current day last valid total mileage minus previous day last valid total mileage | +| `DAY_MAX_MINUS_DAY_MIN` | Current day max valid total mileage minus current day min valid total mileage | + +Hydrogen and electricity statistics use internal fields from the same event +contract. Refueling and charging resets must be handled in statistics logic, not +in protocol mappers. + +Current module: `vehicle-stat-service`. + +Implemented boundaries: + +- `VehicleStatEnvelopeIngestor` parses Kafka envelope bytes. +- `VehicleStatEventProcessor` extracts statistic source points from internal + fields such as `total_mileage_km`. +- `VehicleStatRepository` isolates point/result persistence from calculation. +- `VehicleStatRuleRepository` isolates per-vehicle calculation rules. +- `DailyVehicleStatService` calculates and saves day-level results. + +## Data Flow + +```mermaid +flowchart LR + vehicle["Vehicle / Platform"] --> ingest["ingest service"] + ingest --> archive["sink-archive
raw bytes"] + ingest --> kafka["Kafka
full-field telemetry events"] + kafka --> history["event-history-service"] + kafka --> state["vehicle-state-service"] + kafka --> stat["vehicle-stat-service"] + history --> parquet["Parquet files"] + history --> duckdb["DuckDB query"] + state --> redis["Redis hot state"] + stat --> statdb["Statistics DB"] +``` + +## Kafka Topic Boundaries + +Suggested topics: + +| Topic | Producer | Consumer | +|---|---|---| +| `vehicle.realtime` | protocol ingress | history, realtime state, statistics | +| `vehicle.location` | protocol ingress | history, realtime state, statistics | +| `vehicle.alarm` | protocol ingress | history, realtime state | +| `vehicle.session` | protocol ingress | history | +| `vehicle.media.meta` | protocol ingress | history, media tooling | +| `vehicle.raw.archive` | optional archive publisher | replay or audit services | +| `vehicle.dlq` | any consumer | operations and replay tooling | + +All telemetry event topics use VIN as the partition key to preserve per-vehicle +ordering. + +## Failure Handling + +- Ingest must keep receiving traffic when history, Redis, or statistics services + are down. +- Kafka publish failures go through the existing sink circuit breaker and DLQ + strategy. +- History consumer commits Kafka offsets only after Parquet append succeeds. +- Redis state consumer can replay from Kafka to rebuild hot state. +- Statistics consumer stores checkpoints and can recompute a day from historical + detail events when rules change. +- Raw archive failures are logged and alerted, but parsed event publication + remains independent. + +## Implementation Phases + +### Phase 1: Full-Field Contract And Historical Detail + +- Add a full-field telemetry contract to `ingest-api`. +- Map GB/T 32960 parsed blocks into internal field snapshots. +- Extend Kafka envelope schema for full-field events. +- Make `event-file-store` store full-field telemetry records. +- Add query and export APIs for date-range historical detail. + +### Phase 2: Redis Hot State + +- Add a Redis-backed realtime state module. +- Consume full-field telemetry events. +- Store latest state, location, and safety snapshots by VIN. +- Provide query APIs for operations screens. + +### Phase 3: Statistics Service + +- Add a statistics module with rule configuration. +- Consume full-field telemetry events. +- Implement daily mileage strategies. +- Add result persistence boundary and calculation tests. + +## Acceptance Criteria + +- Ingest has no compile-time dependency on history query controllers, Redis + state repositories, or statistics calculators. +- Every service boundary communicates through Kafka events or explicit public + APIs, not shared mutable state. +- GB/T 32960 detail fields are traceable from internal field key back to + protocol source path. +- Historical query works across multiple days with ascending and descending + order. +- Redis state can be rebuilt from Kafka replay. +- Daily mileage can be calculated by at least the two initial strategies. +- Tests cover mapper output, Kafka serialization, Parquet query order, Redis + state update, and mileage strategy calculation. diff --git a/docs/vehicle-telemetry-internal-fields.md b/docs/vehicle-telemetry-internal-fields.md new file mode 100644 index 00000000..3ecc50ad --- /dev/null +++ b/docs/vehicle-telemetry-internal-fields.md @@ -0,0 +1,146 @@ +# Vehicle Telemetry Internal Fields + +This document defines the internal telemetry field model for Lingniu hydrogen +vehicle operations. Protocol fields such as GB/T 32960, JT808, MQTT, and vendor +extensions must be mapped into these fields before storage, statistics, or +business reporting. + +## Design Rules + +- Business statistics use internal fields, not protocol field names. +- Every internal field has one stable unit and one business meaning. +- Protocol-specific raw values remain traceable through `source_protocol`, + `protocol_version`, `metadata`, and raw archive references. +- Core operational fields are stored as typed columns or typed payload fields. +- Unstable vendor-specific fields can stay in extension metadata until promoted. +- Hydrogen leak safety is a first-class critical safety event, not a generic + alarm count. + +## Identity And Time + +| Internal field | Type | Unit | Meaning | GB/T 32960 source | +|---|---:|---|---|---| +| `vin` | string | - | Vehicle VIN and partition key | header VIN | +| `event_time` | instant | - | Device collection time | command body timestamp | +| `ingest_time` | instant | - | Platform receive time | ingest server clock | +| `source_protocol` | enum | - | Source adapter | `GB32960` | +| `protocol_version` | string | - | Source protocol version | `V2016` / `V2025` | + +## Real-Time Vehicle State + +| Internal field | Type | Unit | Meaning | GB/T 32960 source | +|---|---:|---|---|---| +| `vehicle_state` | enum | - | Started, shutdown, other, invalid | vehicle block `vehicleState` | +| `charging_state` | enum | - | Charging state | vehicle block `chargingState` | +| `running_mode` | enum | - | Electric, hybrid, fuel, other | vehicle block `runningMode` | +| `speed_kmh` | double | km/h | Current speed | vehicle block `speedKmh` | +| `total_mileage_km` | double | km | Odometer mileage | vehicle block `totalMileageKm` | +| `gear_level` | integer | - | Gear value | vehicle block `gearRaw` low 4 bits | +| `accelerator_pedal` | double | % | Accelerator pedal opening | V2016 vehicle block | +| `brake_pedal` | double | % | Brake pedal opening | V2016 vehicle block | + +## Location + +| Internal field | Type | Unit | Meaning | GB/T 32960 source | +|---|---:|---|---|---| +| `longitude` | double | deg | Longitude | position block | +| `latitude` | double | deg | Latitude | position block | +| `altitude_m` | double | m | Altitude when available | extension or other protocol | +| `direction_deg` | double | deg | Direction when available | extension or other protocol | +| `location_status_raw` | long | - | Raw location status bits | position block `statusFlag` | + +## Battery And Electricity + +| Internal field | Type | Unit | Meaning | GB/T 32960 source | +|---|---:|---|---|---| +| `battery_soc` | double | % | Battery SOC | vehicle block `socPercent` | +| `battery_voltage_v` | double | V | Battery total voltage | vehicle block `totalVoltageV` | +| `battery_current_a` | double | A | Battery total current | vehicle block `totalCurrentA` | +| `battery_power_kw` | double | kW | Derived instantaneous battery power | `voltage * current / 1000` in statistics | +| `battery_temperature_max_c` | integer | C | Max battery temperature | temperature or extreme blocks | +| `battery_temperature_min_c` | integer | C | Min battery temperature | temperature or extreme blocks | +| `daily_electricity_kwh` | double | kWh | Daily electricity usage | statistics time integral | + +Daily electricity must be computed in the statistics layer from typed telemetry +points. Do not accumulate protocol raw values directly. + +## Hydrogen And Fuel Cell + +| Internal field | Type | Unit | Meaning | GB/T 32960 source | +|---|---:|---|---|---| +| `fc_voltage_v` | double | V | Fuel cell voltage | fuel cell block | +| `fc_current_a` | double | A | Fuel cell current | fuel cell block | +| `fc_temp_c` | double | C | Fuel cell or hydrogen system temperature | fuel cell block | +| `hydrogen_remaining_kg` | double | kg | Remaining hydrogen mass | vendor extension or capacity conversion | +| `hydrogen_remaining_percent` | double | % | Remaining hydrogen percent | V2025 fuel cell block | +| `hydrogen_high_pressure_mpa` | double | MPa | Hydrogen high pressure | fuel cell block | +| `hydrogen_low_pressure_mpa` | double | MPa | Hydrogen low pressure when available | extension or other protocol | +| `daily_hydrogen_kg` | double | kg | Daily hydrogen consumed | statistics layer | +| `hydrogen_mileage_efficiency` | double | km/kg | Mileage per kg hydrogen | statistics layer | + +Daily hydrogen usage should be calculated from adjacent hydrogen remaining +values. A rising value indicates refueling and must not be counted as negative +consumption. + +## Hydrogen Tank Safety + +| Internal field | Type | Unit | Meaning | GB/T 32960 source | +|---|---:|---|---|---| +| `safety_category` | enum | - | `GENERAL`, `TANK_PRESSURE`, `TANK_TEMPERATURE`, `HYDROGEN_LEAK` | alarm bit classification | +| `hydrogen_leak_detected` | boolean | - | Whether hydrogen leak is detected | general alarm bit `HYDROGEN_LEAK` | +| `hydrogen_leak_level` | enum | - | `NONE`, `WARNING`, `CRITICAL`, `UNKNOWN` | internal rule | +| `hydrogen_leak_action_required` | boolean | - | Whether immediate handling is required | internal rule | +| `tank_pressure_status` | enum | - | Normal, warning, critical | hydrogen pressure bit or threshold rule | +| `tank_temperature_status` | enum | - | Normal, warning, critical | hydrogen temp bit or threshold rule | + +Safety rule: + +```text +If HYDROGEN_LEAK is present: + alarm.level = CRITICAL + safety_category = HYDROGEN_LEAK + hydrogen_leak_detected = true + hydrogen_leak_level = CRITICAL + hydrogen_leak_action_required = true +``` + +Hydrogen leak overrides otherwise normal vehicle, speed, battery, or hydrogen +state. It must be counted independently from generic alarms. + +## Daily Operation Statistics + +Daily statistics are generated by `stat_date + vin`. + +| Internal field | Type | Unit | Meaning | +|---|---:|---|---| +| `stat_date` | date | - | Statistics date | +| `vin` | string | - | Vehicle VIN | +| `first_event_time` | instant | - | First valid telemetry time | +| `last_event_time` | instant | - | Last valid telemetry time | +| `online_minutes` | double | min | Online duration | +| `running_minutes` | double | min | Running duration | +| `daily_mileage_km` | double | km | Daily mileage | +| `daily_electricity_kwh` | double | kWh | Daily electricity usage | +| `daily_hydrogen_kg` | double | kg | Daily hydrogen usage | +| `hydrogen_added_kg` | double | kg | Refueled hydrogen | +| `km_per_kg_hydrogen` | double | km/kg | Hydrogen efficiency | +| `kwh_per_100km` | double | kWh/100km | Electricity efficiency | +| `alarm_count` | integer | - | Generic alarm count | +| `tank_safety_alarm_count` | integer | - | Tank safety alarm count | +| `hydrogen_leak_alarm_count` | integer | - | Hydrogen leak alarm count | +| `hydrogen_leak_duration_seconds` | long | s | Hydrogen leak duration | +| `data_quality_level` | enum | - | Good, partial, bad | + +## Implementation Status + +- `RealtimePayload` already uses internal operational field names for state, + location, battery, fuel cell, and hydrogen fields. +- `AlarmPayload` carries internal hydrogen safety fields. +- `Gb32960EventMapper` maps GB/T 32960 alarm bits into internal safety fields. +- `TelemetrySnapshot` publishes full-field internal telemetry values through the + Kafka protobuf envelope. +- `event-history-service`, `vehicle-state-service`, and `vehicle-stat-service` + consume the same full-field telemetry snapshot contract. +- `event-history-service` CSV export can flatten selected internal fields with + `fields=field_a,field_b`; media archive references are exposed through + `rawArchiveUri` by falling back to `MediaMeta.archiveRef`. diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java deleted file mode 100644 index 660378c6..00000000 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.lingniu.ingest.inbound.mqtt.client; - -import com.hivemq.client.mqtt.datatypes.MqttQos; -import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; -import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.pipeline.RawFrame; -import com.lingniu.ingest.core.dispatcher.Dispatcher; -import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties; -import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; -import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * MQTT 多 endpoint 生命周期管理。 - * - *

为每个 endpoint 创建一个 HiveMQ 异步客户端 → 连接 → 订阅 → 注册回调 → - * 回调里解析 payload → {@link Dispatcher#dispatch(RawFrame)}。 - * - *

Netty EventLoop 回调不做业务处理,虚拟线程由 Dispatcher 侧的 Disruptor 承接。 - */ -public final class MqttEndpointManager implements AutoCloseable { - - private static final Logger log = LoggerFactory.getLogger(MqttEndpointManager.class); - - private final MqttInboundProperties props; - private final MqttPayloadParser payloadParser; - private final Dispatcher dispatcher; - private final List clients = new ArrayList<>(); - - public MqttEndpointManager(MqttInboundProperties props, - MqttPayloadParser payloadParser, - Dispatcher dispatcher) { - this.props = props; - this.payloadParser = payloadParser; - this.dispatcher = dispatcher; - } - - public void start() { - for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) { - try { - Mqtt3AsyncClient client = buildClient(ep); - clients.add(client); - connectAndSubscribe(client, ep); - } catch (Exception e) { - log.error("mqtt endpoint [{}] init failed, skipped", ep.getName(), e); - } - } - } - - private Mqtt3AsyncClient buildClient(MqttInboundProperties.Endpoint ep) { - URI uri = URI.create(ep.getUri()); - int port = uri.getPort() > 0 ? uri.getPort() : defaultPort(uri.getScheme()); - var builder = Mqtt3Client.builder() - .identifier(ep.getClientId() == null || ep.getClientId().isBlank() - ? "lingniu-" + UUID.randomUUID() : ep.getClientId()) - .serverHost(uri.getHost()) - .serverPort(port) - .automaticReconnectWithDefaultConfig(); - - if ("ssl".equalsIgnoreCase(uri.getScheme()) || "tls".equalsIgnoreCase(uri.getScheme()) - || "mqtts".equalsIgnoreCase(uri.getScheme())) { - // TODO: 基于 ep.getTls() 的 caPem / clientPem / clientKey 构造自定义 SslConfig - builder = builder.sslWithDefaultConfig(); - } - return builder.buildAsync(); - } - - private void connectAndSubscribe(Mqtt3AsyncClient client, MqttInboundProperties.Endpoint ep) { - var connectBuilder = client.connectWith(); - if (ep.getUsername() != null && !ep.getUsername().isBlank()) { - connectBuilder = connectBuilder.simpleAuth() - .username(ep.getUsername()) - .password(ep.getPassword() == null ? new byte[0] : ep.getPassword().getBytes()) - .applySimpleAuth(); - } - connectBuilder.send().whenComplete((ack, err) -> { - if (err != null) { - log.error("mqtt endpoint [{}] connect failed", ep.getName(), err); - return; - } - log.info("mqtt endpoint [{}] connected code={}", ep.getName(), ack.getReturnCode()); - client.subscribeWith() - .topicFilter(ep.getTopic()) - .qos(mapQos(ep.getQos())) - .callback(publish -> onMessage(ep, publish.getTopic().toString(), publish.getPayloadAsBytes())) - .send() - .whenComplete((subAck, subErr) -> { - if (subErr != null) { - log.error("mqtt endpoint [{}] subscribe failed topic={}", ep.getName(), ep.getTopic(), subErr); - } else { - log.info("mqtt endpoint [{}] subscribed topic={} qos={}", - ep.getName(), ep.getTopic(), ep.getQos()); - } - }); - }); - } - - private void onMessage(MqttInboundProperties.Endpoint ep, String topic, byte[] payload) { - try { - MqttPayload parsed = payloadParser.parse(ep.getName(), ep.getProfile(), topic, payload); - Map meta = new HashMap<>(4); - meta.put("vin", parsed.vin() == null ? "unknown" : parsed.vin()); - meta.put("endpoint", ep.getName()); - meta.put("topic", topic); - RawFrame rf = new RawFrame( - ProtocolId.MQTT_YUTONG, 0, 0, - parsed, payload, meta, Instant.now()); - dispatcher.dispatch(rf); - } catch (Exception e) { - log.warn("mqtt endpoint [{}] message handling failed topic={} len={}", - ep.getName(), topic, payload.length, e); - } - } - - private static MqttQos mapQos(int level) { - return switch (level) { - case 0 -> MqttQos.AT_MOST_ONCE; - case 2 -> MqttQos.EXACTLY_ONCE; - default -> MqttQos.AT_LEAST_ONCE; - }; - } - - private static int defaultPort(String scheme) { - if (scheme == null) return 1883; - return switch (scheme.toLowerCase()) { - case "ssl", "tls", "mqtts" -> 8883; - default -> 1883; - }; - } - - @Override - public void close() { - for (Mqtt3AsyncClient c : clients) { - try { - c.disconnect(); - } catch (Exception ignored) { - // best-effort - } - } - log.info("mqtt endpoint manager stopped, clients={}", clients.size()); - } -} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java b/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java deleted file mode 100644 index 457abc8d..00000000 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.lingniu.ingest.inbound.mqtt.mapper; - -import com.fasterxml.jackson.databind.JsonNode; -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.LocationPayload; -import com.lingniu.ingest.api.event.RealtimePayload; -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.api.spi.EventMapper; -import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * 宇通 MQTT payload → 领域事件。 - * - *

字段名取自旧项目的 {@code ReceptionDataVO}(大写 + 下划线),对字段映射一致性做出显式断言, - * 便于与旧服务双跑对账。 - */ -public final class YutongEventMapper implements EventMapper { - - @Override - public List toEvents(MqttPayload payload) { - JsonNode d = payload.data(); - if (d == null || !d.isObject()) return List.of(); - - Instant ingestTime = Instant.now(); - String vin = payload.vin(); - Map meta = Map.of( - "endpoint", payload.endpointName() == null ? "" : payload.endpointName(), - "topic", payload.topic() == null ? "" : payload.topic(), - "profile", payload.profile() == null ? "" : payload.profile()); - - Double speed = optDouble(d, "METER_SPEED"); - Double mileage = optDouble(d, "TOTAL_MILEAGE"); - Double soc = optDouble(d, "BATTERY_CAPACITY_SOC"); - Double fcVoltage = optDouble(d, "VOLTAGE_OF_FC"); - Double fcCurrent = optDouble(d, "CURRENT_OF_FC"); - Double fcTemp = optDouble(d, "TEMPT_OF_FC"); - Double leftH2 = optDouble(d, "LEFT_HYDROGEN"); - Double lowH2Pressure = optDouble(d, "HYDROGEN_LOW_PRESSURE"); - Double longitude = optCoord(d, "LONGITUDE"); - Double latitude = optCoord(d, "LATITUDE"); - Double direction = optDouble(d, "GPSDirection"); - Double acc = optDouble(d, "ACC_PEDAL_APT"); - Double brake = optDouble(d, "BRAKEPEDALAPT"); - Integer gear = optInt(d, "ON_GEAR"); - - RealtimePayload realtime = new RealtimePayload( - speed, mileage, soc, null, null, - fcVoltage, fcCurrent, fcTemp, leftH2, null, lowH2Pressure, - null, null, null, gear, acc, brake, - longitude, latitude, null, direction, - null, null); - - List out = new ArrayList<>(2); - out.add(new VehicleEvent.Realtime( - UUID.randomUUID().toString(), - vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime, - null, meta, realtime)); - - if (longitude != null && latitude != null) { - LocationPayload loc = new LocationPayload( - longitude, latitude, 0.0, - speed == null ? 0.0 : speed, - direction == null ? 0.0 : direction, - 0, 0); - out.add(new VehicleEvent.Location( - UUID.randomUUID().toString(), - vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime, - null, meta, loc)); - } - return out; - } - - private static Double optDouble(JsonNode n, String field) { - JsonNode v = n.get(field); - if (v == null || v.isNull() || !v.isNumber()) return null; - return v.asDouble(); - } - - private static Integer optInt(JsonNode n, String field) { - JsonNode v = n.get(field); - if (v == null || v.isNull() || !v.isNumber()) return null; - return v.asInt(); - } - - /** 宇通坐标约定:1e6 整数 或 已经是小数。同时过滤无效值 999999 / 0。 */ - private static Double optCoord(JsonNode n, String field) { - Double v = optDouble(n, field); - if (v == null) return null; - if (v.intValue() == 999_999 || v == 0.0) return null; - if (Math.abs(v) > 1000) return v / 1_000_000.0; - return v; - } -} diff --git a/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java b/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java deleted file mode 100644 index 94df6207..00000000 --- a/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.lingniu.ingest.inbound.mqtt; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; -import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; -import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class YutongEventMapperTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - private final MqttPayloadParser parser = new MqttPayloadParser(objectMapper); - private final YutongEventMapper mapper = new YutongEventMapper(); - - @Test - void parsesYutongSamplePayload() { - String json = """ - { - "id": "abc", - "device": "LTEST000000000001", - "code": "C1", - "time": "20260413100000", - "data": { - "METER_SPEED": 52.3, - "TOTAL_MILEAGE": 123456.7, - "BATTERY_CAPACITY_SOC": 70, - "LONGITUDE": 116397128, - "LATITUDE": 39916527, - "GPSDirection": 90, - "LEFT_HYDROGEN": 5.5, - "VOLTAGE_OF_FC": 600.0, - "CURRENT_OF_FC": 120.0, - "TEMPT_OF_FC": 65.0, - "ON_GEAR": 3, - "ACC_PEDAL_APT": 25, - "BRAKEPEDALAPT": 0 - } - } - """; - - MqttPayload payload = parser.parse("yutong", "yutong", "/ytforward/shln/dev1", json.getBytes()); - assertThat(payload.vin()).isEqualTo("LTEST000000000001"); - - List events = mapper.toEvents(payload); - assertThat(events).hasSize(2); - - VehicleEvent.Realtime rt = (VehicleEvent.Realtime) events.stream() - .filter(e -> e instanceof VehicleEvent.Realtime).findFirst().orElseThrow(); - assertThat(rt.source()).isEqualTo(ProtocolId.MQTT_YUTONG); - assertThat(rt.vin()).isEqualTo("LTEST000000000001"); - assertThat(rt.payload().speedKmh()).isEqualTo(52.3); - assertThat(rt.payload().batterySoc()).isEqualTo(70.0); - assertThat(rt.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); - assertThat(rt.payload().latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); - assertThat(rt.payload().hydrogenRemainingKg()).isEqualTo(5.5); - - VehicleEvent.Location loc = (VehicleEvent.Location) events.stream() - .filter(e -> e instanceof VehicleEvent.Location).findFirst().orElseThrow(); - assertThat(loc.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); - } -} diff --git a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java b/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java deleted file mode 100644 index 820d8d86..00000000 --- a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java +++ /dev/null @@ -1,267 +0,0 @@ -package com.lingniu.ingest.inbound.xinda; - -import com.alibaba.fastjson.JSON; -import com.alibaba.fastjson.JSONObject; -import com.gps31.push.netty.PushClient; -import com.gps31.push.netty.PushMsg; -import com.gps31.push.netty.client.TcpClient; -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.LocationPayload; -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.core.concurrency.DisruptorEventBus; -import com.lingniu.ingest.core.dispatcher.Dispatcher; -import com.lingniu.ingest.inbound.xinda.config.XindaPushProperties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Map; -import java.util.UUID; - -/** - * 信达 Push 接入客户端。基于 {@code org.lingniu:gps-push-client:1.0} 的 {@link PushClient} - * 实现,完全使用信达真实协议帧格式(帧头 / 长度 / cmd / json / 帧尾 + 序列号 + 心跳)。 - * - *

行为: - *

    - *
  • {@link #start()} 调用基类 {@code TcpClient.start()} 建立连接并自动重连 - *
  • 基类在 {@code channelActive} 自动发送 {@code 0x0001 登录} 帧,携带 userName / pwd / csTag / desc - *
  • 登录成功收到应答后(由基类内部处理)会自动订阅 {@code subMsgIds}(默认空,需显式调用 setSubMsgIds) - *
  • {@link #messageReceived(TcpClient, PushMsg)} 为业务入口:cmd 字符串区分消息类型 - *
  • 位置 {@code 0200} → {@link VehicleEvent.Location} - *
  • 报警 {@code 0300} → {@link VehicleEvent.Alarm}(转为 Passthrough 保留原始 json 供下游分析) - *
  • 透传 {@code 0401} → {@link VehicleEvent.Passthrough} - *
- * - *

类继承基类 {@code PushClient},不再需要手工处理帧编解码、心跳、重连、订阅等机制。 - */ -public class XindaPushClient extends PushClient { - - private static final Logger log = LoggerFactory.getLogger(XindaPushClient.class); - - private final XindaPushProperties props; - private final Dispatcher dispatcher; - private final DisruptorEventBus eventBus; - - public XindaPushClient(XindaPushProperties props, Dispatcher dispatcher, DisruptorEventBus eventBus) { - super(); - this.props = props; - this.dispatcher = dispatcher; - this.eventBus = eventBus; - this.isLog = false; - } - - /** - * 启动客户端:填入 host/port/user/pwd/desc,配置订阅消息 ID,交给基类建立连接。 - */ - public void start() { - if (props.getHost() == null || props.getHost().isBlank()) { - log.error("[xinda-push] host not configured, module stays idle"); - return; - } - if (props.getUsername() == null || props.getPassword() == null) { - log.error("[xinda-push] credentials missing, module stays idle " - + "(set lingniu.ingest.xinda-push.username/password)"); - return; - } - setHost(props.getHost()); - setPort(props.getPort()); - setUserName(props.getUsername()); - setPwd(props.getPassword()); - setDesc(props.getClientDesc()); - // 订阅消息 ID:信达基类 PushClient.setSubMsgIds 用管道符 "|" 作为分隔符 - // (内部调用 StringUtil.splitStr(s, "|")) - if (props.getSubscribeMsgIds() != null && !props.getSubscribeMsgIds().isEmpty()) { - setSubMsgIds(String.join("|", props.getSubscribeMsgIds())); - } - // 基类 PushClient 构造器默认 isLog=false / isLogBytes=false; - // 这里尊重默认值,不再覆盖为 true,避免 <<>>Log接收 每条帧都被 ERROR 级打印。 - // 真要打开 wire-level 诊断时,临时改为 setLog(true) 即可。 - try { - log.info("[xinda-push] starting host={} port={} user={} subMsgIds={}", - getHost(), getPort(), getUserName(), getSubMsgIds()); - super.start(); - } catch (Exception e) { - log.error("[xinda-push] start failed", e); - } - } - - /** - * Spring 生命周期停止钩子。 - */ - public void stop() { - try { - destroy(); - log.info("[xinda-push] stopped"); - } catch (Exception e) { - log.warn("[xinda-push] stop error", e); - } - } - - // ======================================================================== - // 业务回调:收到完整的 PushMsg 后分派事件 - // ======================================================================== - - @Override - public void messageReceived(TcpClient client, PushMsg msg) throws Exception { - super.messageReceived(client, msg); - String cmd = msg.getCmd(); - if (cmd == null) return; - try { - switch (cmd) { - case "8001" -> handleLoginAck(msg); - case "8002" -> log.debug("[xinda-push] heartbeat ack"); - case "8003" -> log.info("[xinda-push] subscribe ack json={}", msg.getJson()); - case "0200" -> handleLocation(msg); - case "0300" -> handleAlarm(msg); - case "0401" -> handlePassthrough(msg); - default -> log.debug("[xinda-push] rx cmd={} json={}", cmd, msg.getJson()); - } - } catch (Exception e) { - log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e); - } - } - - /** - * 收到 8001 登录应答后处理:若 {@code rspResult=0} 则主动发送 0003 订阅命令。 - * - *

信达订阅命令的 body 格式(从旧 {@code PushClientHandler} 和 8003 错误提示反推得到): - *

{@code
-     * {
-     *   "seq":     "1",                             // 序列号
-     *   "action":  "add",                           // 操作: add 订阅 / del 取消
-     *   "msgIds":  "[\"0200\",\"0300\",\"0401\"]"  // JSON 数组字符串(注意是字符串,不是数组)
-     * }
-     * }
- *

- * 其中 {@code msgIds} 的值来自基类 {@link PushClient#getSubCmdSet()},内容由 - * {@link PushClient#setSubMsgIds(String)} 填充(分隔符是 {@code |})。 - */ - private void handleLoginAck(PushMsg msg) throws Exception { - String json = msg.getJson() == null ? "" : msg.getJson(); - log.info("[xinda-push] login ack json={}", json); - JSONObject parsed = JSON.parseObject(json); - String rsp = parsed == null ? null : parsed.getString("rspResult"); - if (!"0".equals(rsp)) { - log.error("[xinda-push] login REJECTED rspResult={}", rsp); - return; - } - Map body = new java.util.HashMap<>(); - body.put("seq", "1"); - body.put("action", "add"); - body.put("msgIds", JSON.toJSONString(getSubCmdSet())); - PushMsg subscribe = getInstance("0003", body); - sendMsg(subscribe); - log.info("[xinda-push] subscribe sent action=add msgIds={}", getSubCmdSet()); - } - - @Override - public void channelActive(TcpClient client) throws Exception { - super.channelActive(client); - log.info("[xinda-push] channel active, login frame sent"); - } - - @Override - public void channelInactive(TcpClient client) throws Exception { - super.channelInactive(client); - log.warn("[xinda-push] channel inactive, will auto-reconnect"); - } - - // ======================================================================== - // 业务处理(0200/0300/0401 → VehicleEvent) - // ======================================================================== - - /** - * 0200 定位消息 → {@link VehicleEvent.Location}。 - *

信达 0200 常见字段(不同版本可能略有差异): - *

-     *   plateNo  车牌号
-     *   lat/lng  WGS84 度 * 1e6 (整数)
-     *   speed    km/h
-     *   drct     方向
-     *   height   海拔 m
-     *   alarmStts 报警状态位
-     *   statusStts 状态位
-     *   time     yyyyMMddHHmmss
-     * 
- */ - private void handleLocation(PushMsg msg) { - JSONObject json = JSON.parseObject(msg.getJson()); - String vin = firstNonBlank(json, "vin", "plateNo", "carNo"); - double lon = asDouble(json, "lng", "lon", "longitude") / 1_000_000.0; - double lat = asDouble(json, "lat", "latitude") / 1_000_000.0; - // 有的实现已经是十进制度,做一次保护:如果 |lon| > 1000 显然是 1e6 单位再除一次 - if (Math.abs(lon) > 1000) lon /= 1.0; // already divided above - double speed = asDouble(json, "speed"); - double direction = asDouble(json, "drct", "direction"); - long alarmFlag = (long) asDouble(json, "alarmStts", "alarmFlag"); - long statusFlag = (long) asDouble(json, "statusStts", "statusFlag"); - - VehicleEvent.Location loc = new VehicleEvent.Location( - UUID.randomUUID().toString(), - vin.isBlank() ? "unknown" : vin, - ProtocolId.XINDA_PUSH, - Instant.now(), Instant.now(), - null, - Map.of("source", "xinda-push", "cmd", "0200"), - new LocationPayload(lon, lat, 0.0, speed, direction, alarmFlag, statusFlag)); - eventBus.publish(loc); - log.debug("[xinda-push] 0200 location vin={} lon={} lat={} speed={}", vin, lon, lat, speed); - } - - private void handleAlarm(PushMsg msg) { - emitPassthrough(0x0300, msg); - log.info("[xinda-push] 0300 alarm json={}", msg.getJson()); - } - - private void handlePassthrough(PushMsg msg) { - emitPassthrough(0x0401, msg); - log.debug("[xinda-push] 0401 passthrough json={}", msg.getJson()); - } - - private void emitPassthrough(int type, PushMsg msg) { - JSONObject json = JSON.parseObject(msg.getJson()); - String vin = firstNonBlank(json, "vin", "plateNo", "carNo"); - byte[] raw = msg.getJson() == null - ? new byte[0] : msg.getJson().getBytes(StandardCharsets.UTF_8); - VehicleEvent.Passthrough pt = new VehicleEvent.Passthrough( - UUID.randomUUID().toString(), - vin.isBlank() ? "unknown" : vin, - ProtocolId.XINDA_PUSH, - Instant.now(), Instant.now(), - null, - Map.of("source", "xinda-push", "cmd", String.format("%04x", type), - "rawJson", msg.getJson() == null ? "" : msg.getJson()), - type, raw); - eventBus.publish(pt); - } - - // ======================================================================== - // JSON helpers - // ======================================================================== - - private static String firstNonBlank(JSONObject node, String... keys) { - if (node == null) return ""; - for (String k : keys) { - Object v = node.get(k); - if (v != null && !v.toString().isBlank()) return v.toString(); - } - return ""; - } - - private static double asDouble(JSONObject node, String... keys) { - if (node == null) return 0.0; - for (String k : keys) { - Object v = node.get(k); - if (v instanceof Number n) return n.doubleValue(); - if (v != null) { - try { - return Double.parseDouble(v.toString()); - } catch (NumberFormatException ignored) { - } - } - } - return 0.0; - } -} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java b/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java deleted file mode 100644 index 132f8921..00000000 --- a/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.lingniu.ingest.api.event; - -/** 位置事件载荷。坐标已统一转换为 WGS84 十进制度。 */ -public record LocationPayload( - double longitude, - double latitude, - double altitudeM, - double speedKmh, - double directionDeg, - long alarmFlag, - long statusFlag -) {} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java b/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java deleted file mode 100644 index e4ac4dd9..00000000 --- a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.lingniu.ingest.core.dispatcher; - -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.api.pipeline.IngestContext; -import com.lingniu.ingest.api.pipeline.RawFrame; -import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; -import com.lingniu.ingest.core.concurrency.DisruptorEventBus; -import com.lingniu.ingest.core.pipeline.InterceptorChain; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * 核心分发器:RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。 - * - *

所有 Inbound Adapter(Netty / MQTT / PushClient)都调用 {@link #dispatch(RawFrame)}, - * 协议差异在这里被彻底抹平。 - * - *

对于带 {@code @AsyncBatch} 的 Handler,Dispatcher 不直接反射调用,而是把消息交给 - * {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。 - */ -public final class Dispatcher { - - private static final Logger log = LoggerFactory.getLogger(Dispatcher.class); - - private final HandlerRegistry registry; - private final InterceptorChain interceptors; - private final HandlerInvoker invoker; - private final DisruptorEventBus eventBus; - private final AsyncBatchExecutor batchExecutor; - - public Dispatcher(HandlerRegistry registry, - InterceptorChain interceptors, - HandlerInvoker invoker, - DisruptorEventBus eventBus, - AsyncBatchExecutor batchExecutor) { - this.registry = registry; - this.interceptors = interceptors; - this.invoker = invoker; - this.eventBus = eventBus; - this.batchExecutor = batchExecutor; - } - - public void dispatch(RawFrame frame) { - IngestContext ctx = new IngestContext(UUID.randomUUID().toString()); - try { - // 在 interceptor 之前发 RawArchive,保证原始字节被无条件落盘(dedup/rate-limit - // 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 ArchiveEventSink 消费。 - emitRawArchive(frame, ctx); - - if (!interceptors.before(frame, ctx)) { - log.debug("frame aborted: {}", ctx.abortReason()); - return; - } - - List handlers = registry.resolve( - frame.protocolId(), frame.command(), frame.infoType()); - if (handlers.isEmpty()) { - log.debug("no handler for {} cmd=0x{} info=0x{}", - frame.protocolId(), Integer.toHexString(frame.command()), - Integer.toHexString(frame.infoType())); - return; - } - - for (HandlerDefinition def : handlers) { - if (def.asyncBatch() != null) { - batchExecutor.submit(def, frame.payload()); - continue; - } - List events = invoker.invoke(def, frame, ctx); - for (VehicleEvent e : events) { - interceptors.after(e, ctx); - eventBus.publish(e); - } - } - } catch (Throwable t) { - log.error("dispatch failure traceId={}", ctx.traceId(), t); - interceptors.onError(t, ctx); - } - } - - /** - * 从 {@link RawFrame} 构造一条 {@link VehicleEvent.RawArchive} 发到 EventBus。 - * 仅在 {@code rawBytes} 非空时发——有些入站适配器(未来)可能只传解析后的对象。 - * - *

VIN 取自 sourceMeta 里的 {@code vin} key(由各入站适配器负责填充);缺失时 - * 留空字符串,archive sink 会用 "unknown-vin" 占位保证 key 可解析。 - */ - private void emitRawArchive(RawFrame frame, IngestContext ctx) { - byte[] bytes = frame.rawBytes(); - if (bytes == null || bytes.length == 0) return; - - Map meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta(); - String vin = meta.getOrDefault("vin", ""); - Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now(); - - VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( - UUID.randomUUID().toString(), - vin, - frame.protocolId(), - ingestTime, - ingestTime, - ctx.traceId(), - meta, - frame.command(), - frame.infoType(), - bytes); - eventBus.publish(raw); - } -} diff --git a/bootstrap-all/pom.xml b/modules/apps/bootstrap-all/pom.xml similarity index 82% rename from bootstrap-all/pom.xml rename to modules/apps/bootstrap-all/pom.xml index 8af5baa0..c63df75c 100644 --- a/bootstrap-all/pom.xml +++ b/modules/apps/bootstrap-all/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml bootstrap-all bootstrap-all @@ -34,6 +35,22 @@ com.lingniu.ingest sink-archive + + com.lingniu.ingest + event-file-store + + + com.lingniu.ingest + event-history-service + + + com.lingniu.ingest + vehicle-state-service + + + com.lingniu.ingest + vehicle-stat-service + @@ -73,6 +90,10 @@ org.springframework.boot spring-boot-starter + + org.springdoc + springdoc-openapi-starter-webmvc-ui + diff --git a/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java b/modules/apps/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java similarity index 100% rename from bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java rename to modules/apps/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java diff --git a/modules/apps/bootstrap-all/src/main/resources/application.yml b/modules/apps/bootstrap-all/src/main/resources/application.yml new file mode 100644 index 00000000..7109ef7c --- /dev/null +++ b/modules/apps/bootstrap-all/src/main/resources/application.yml @@ -0,0 +1,235 @@ +spring: + application: + name: lingniu-vehicle-ingest + threads: + virtual: + enabled: true + +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /v3/api-docs + +server: + port: 20000 + +lingniu: + ingest: + gb32960: + enabled: true + port: ${GB32960_PORT:32960} + boss-threads: 1 + worker-threads: 0 # 0 = Runtime.availableProcessors() * 2 + # VIN 白名单认证(内存配置)。GB/T 32960 设备没有账号密码,身份由 VIN 决定。 + auth: + enabled: ${GB32960_AUTH_ENABLED:false} # true 启用白名单;false 放行所有 VIN + case-sensitive: false # 默认大小写不敏感 + whitelist: # VIN 列表,未列入的 0x01 登入返回 0x04 VIN_NOT_EXIST + # - LTEST000000000001 + # - LGWEF4A58RE123456 + # 平台登入 0x05 账号列表(GB/T 32960.3 表 29)。 + # 列表为空时放行所有平台登入(兼容旧行为),否则 username/password 必须匹配, + # 失败返回 0x02 OTHER_ERROR 应答并关闭连接。 + platforms: + # 现代 (XIANDAI) + - username: ${GB32960_PLATFORM_USER_1:lingniu} + password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#} + allowed-ips: # 可选:限制来源 IP,为空不限制 + description: 外部下级平台 - 现代 (原旧服务客户端) + # 现代平台真实 GB32960 接入账号 + - username: ${GB32960_PLATFORM_USER_HYUNDAI:Hyundai} + password: ${GB32960_PLATFORM_PWD_HYUNDAI:f2e3445d7cda409fb4f278f6fb890734} + allowed-ips: + - ${GB32960_PLATFORM_IP_HYUNDAI:8.134.95.166} + description: 外部下级平台 - Hyundai + # 飞驰 (FEICHI) + - username: ${GB32960_PLATFORM_USER_2:ln@feichi} + password: ${GB32960_PLATFORM_PWD_2:Lingniu.2024#} + allowed-ips: + description: 外部下级平台 - 飞驰 + # 海珀特 (HAIPOTE) + - username: ${GB32960_PLATFORM_USER_3:hbt@lingniu} + password: ${GB32960_PLATFORM_PWD_3:sgmi88b@&9} + allowed-ips: + description: 外部下级平台 - 海珀特 + # 跃进 (YUEJIN) + - username: ${GB32960_PLATFORM_USER_4:yj01@lingniu} + password: ${GB32960_PLATFORM_PWD_4:01234567890123456789} + allowed-ips: + description: 外部下级平台 - 跃进 + # TLS 双向认证(可选)。启用后 Netty pipeline 前置 SslHandler 并强制客户端证书。 + tls: + enabled: ${GB32960_TLS_ENABLED:false} + cert-path: ${GB32960_TLS_CERT:} # 服务端证书 PEM 路径 + key-path: ${GB32960_TLS_KEY:} # 服务端私钥 PEM 路径(PKCS#8 未加密) + trust-cert-path: ${GB32960_TLS_CA:} # 受信 CA 证书 PEM,用于校验客户端证书 + require-client-auth: true # 强制双向;false=单向 TLS + # ==================================================================== + # 厂商扩展协议路由(vendor extensions) + # 命中规则按 list 顺序首匹(first-match-wins),命中后用对应套件的 parser 集合 + # 在标准 0x01..0x09 之上叠加。已支持的套件: + # - guangdong-fc:广东燃料电池汽车示范规范 0x30~0x34、0x80 + # 来源:reference/广东燃料电池汽车示范应用城市群综合监管平台-...pdf + # ==================================================================== + vendor-extensions: + - name: guangdong-fc + match: + # 平台账号匹配(精确匹配,case-insensitive)。这里和上面 auth.platforms + # 中的 username 保持一致,确保 lingniu 账号登入后所有 0x02 帧都走广东 profile。 + platform-accounts: + - lingniu + - Hyundai + - ln@feichi + # VIN 前缀匹配(startsWith);空 list = 不参与匹配 + vin-prefixes: [] + # VIN 精确匹配 + vins: [] + # 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成 + # InfoBlock.Raw 后继续解析下一个块,不再整帧丢弃。仅在需要严格失败语义 + # (灰度回滚或抓虫)时设为 false。 + parse: + lenient-block-failure: true + diagnostics: + max-logged-frame-keys-per-channel: 128 + jt808: + # 1078 信令和 JSATL12 嵌入 JT808 信令都依赖本模块;一体化启动默认开启, + # 需要只跑 GB32960 时可用 JT808_ENABLED=false 显式关闭。 + enabled: ${JT808_ENABLED:true} + port: ${JT808_PORT:10808} + max-frame-length: ${JT808_MAX_FRAME_LENGTH:1048} + jt1078: + # 1078 媒体流归档可独立启用;1078 信令事件复用 JT808 mapper, + # 只有同时启用 jt808.enabled=true 时才会装配信令桥接 handler。 + enabled: ${JT1078_ENABLED:true} + media-stream: + enabled: ${JT1078_MEDIA_STREAM_ENABLED:true} + tcp-enabled: ${JT1078_MEDIA_STREAM_TCP_ENABLED:true} + udp-enabled: ${JT1078_MEDIA_STREAM_UDP_ENABLED:false} + port: ${JT1078_MEDIA_STREAM_PORT:11078} + udp-port: ${JT1078_MEDIA_STREAM_UDP_PORT:11078} + worker-threads: ${JT1078_MEDIA_STREAM_WORKERS:0} + max-packet-length: ${JT1078_MEDIA_STREAM_MAX_PACKET_LENGTH:1048576} + segment-seconds: ${JT1078_MEDIA_STREAM_SEGMENT_SECONDS:300} + jsatl12: + # JSATL12 附件流依赖 ArchiveStore;嵌入的 JT808 信令解析依赖 + # jt808.enabled=true。缺任一依赖时不会装配附件接入 server。 + enabled: ${JSATL12_ENABLED:true} + port: ${JSATL12_PORT:7612} + worker-threads: ${JSATL12_WORKERS:0} + max-frame-length: ${JSATL12_MAX_FRAME_LENGTH:65536} + file-store: ${JSATL12_FILE_STORE:file:///var/lingniu/alarm-files} + mqtt: + enabled: ${MQTT_ENABLED:false} + endpoints: + - name: yutong + uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883} + topic: ${MQTT_TOPIC:/ytforward/shln/+} + qos: 2 + clean-session: false + keep-alive-seconds: 20 + connection-timeout-seconds: 10 + client-id: ${MQTT_CLIENT_ID:shlnClientId} + username: ${MQTT_USER:shln} + password: ${MQTT_PWD:} + tls: + ca-pem: ${MQTT_CA_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/ca.crt} + client-pem: ${MQTT_CLIENT_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/client.crt} + client-key: ${MQTT_CLIENT_KEY:/opt/lingniuServices/certificate/yutong/vehicledatareception/client-key-pkcs8.pem} + xinda-push: + enabled: ${XINDA_PUSH_ENABLED:false} + host: ${XINDA_HOST:} + port: ${XINDA_PORT:10100} + username: ${XINDA_USER:} + password: ${XINDA_PWD:} + subscribe-msg-ids: ${XINDA_SUBSCRIBE_MSG_IDS:0200,0300,0401} + client-desc: ${XINDA_CLIENT_DESC:lingniu-ingest} + + pipeline: + disruptor: + ring-buffer-size: 131072 # 2^17 + wait-strategy: yielding # blocking | sleeping | yielding | busy-spin + producer-type: multi + dedup: + enabled: true + cache-size: 200000 + ttl-seconds: 600 + rate-limit: + per-vin-qps: 50 + + session: + # memory | redis。生产默认 Redis,会话索引用于多实例/重启后的下行命令解析。 + store: ${SESSION_STORE:redis} + ttl: ${SESSION_TTL:30m} + + identity: + # memory 适合本地开发;file 是生产最低可用持久化后端,后续可替换 DB/配置中心实现。 + store: ${VEHICLE_IDENTITY_STORE:file} + file: + path: ${VEHICLE_IDENTITY_FILE:./data/vehicle-identity.jsonl} + + sink: + mq: + # MQ Sink 总开关。true=装配 Kafka Producer 并投递事件;false=完全不装配(适合本地开发或 Kafka 不可达)。 + enabled: ${KAFKA_ENABLED:false} + type: kafka # 后端选择:kafka | (rocketmq | pulsar 预留) + bootstrap-servers: ${KAFKA_BROKERS:114.55.58.251:9092} + compression-type: zstd + linger-ms: 20 + batch-size: 65536 + acks: all + enable-idempotence: true + topics: + realtime: vehicle.realtime + location: vehicle.location + alarm: vehicle.alarm + session: vehicle.session + media-meta: vehicle.media.meta + raw-archive: vehicle.raw.archive + dlq: vehicle.dlq + consumer: + # true=启动服务侧 Kafka Envelope 消费 worker;默认绑定 event-history/state/stat 三类 processor。 + enabled: ${KAFKA_CONSUMER_ENABLED:false} + auto-startup: true + client-id-prefix: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX:lingniu-envelope-consumer} + poll-timeout-millis: ${KAFKA_CONSUMER_POLL_TIMEOUT_MILLIS:1000} + loop-backoff-millis: ${KAFKA_CONSUMER_LOOP_BACKOFF_MILLIS:1000} + auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET:earliest} + max-poll-records: ${KAFKA_CONSUMER_MAX_POLL_RECORDS:500} + archive: + enabled: true + type: local # local | s3 | oss + path: ./archive/ + event-file-store: + enabled: ${EVENT_FILE_STORE_ENABLED:true} + path: ${EVENT_FILE_STORE_PATH:./target/event-store/} + zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai} + batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500} + flush-interval-millis: ${EVENT_FILE_STORE_FLUSH_INTERVAL_MILLIS:1000} + event-history: + enabled: ${EVENT_HISTORY_ENABLED:true} + vehicle-state: + enabled: ${VEHICLE_STATE_ENABLED:false} + vehicle-stat: + enabled: ${VEHICLE_STAT_ENABLED:true} + file-path: ${VEHICLE_STAT_FILE_PATH:./target/vehicle-stat/} + zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai} + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env + metrics: + tags: + application: lingniu-vehicle-ingest + +logging: + level: + root: INFO + com.lingniu.ingest: INFO + com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler: INFO + com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder: INFO + com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser: INFO + # 信达 push 诊断:打开下面一行可以看到每一条收发的原始字节 hex dump + com.lingniu.ingest.inbound.xinda: INFO diff --git a/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/BootstrapApplicationDefaultsTest.java b/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/BootstrapApplicationDefaultsTest.java new file mode 100644 index 00000000..421d6db1 --- /dev/null +++ b/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/BootstrapApplicationDefaultsTest.java @@ -0,0 +1,134 @@ +package com.lingniu.ingest.bootstrap; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class BootstrapApplicationDefaultsTest { + + @Test + void defaultConfigEnablesJt808WhenDependentProtocolsAreEnabledByDefault() { + Properties props = loadApplicationProperties(); + + assertThat(defaultEnabled(props.getProperty("lingniu.ingest.jsatl12.enabled"))).isTrue(); + assertThat(defaultEnabled(props.getProperty("lingniu.ingest.jt1078.enabled"))).isTrue(); + assertThat(defaultEnabled(props.getProperty("lingniu.ingest.jt808.enabled"))) + .as("JSATL12 and JT1078 signaling depend on JT808 decoder/mapper, so bootstrap defaults must not disable JT808") + .isTrue(); + } + + @Test + void productionProtocolEntrypointsAreControlledByEnvironmentFlags() { + Properties props = loadApplicationProperties(); + + assertThat(props.getProperty("lingniu.ingest.jt808.enabled")).isEqualTo("${JT808_ENABLED:true}"); + assertThat(props.getProperty("lingniu.ingest.jt1078.enabled")).isEqualTo("${JT1078_ENABLED:true}"); + assertThat(props.getProperty("lingniu.ingest.jsatl12.enabled")).isEqualTo("${JSATL12_ENABLED:true}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.enabled")).isEqualTo("${MQTT_ENABLED:false}"); + assertThat(props.getProperty("lingniu.ingest.xinda-push.enabled")).isEqualTo("${XINDA_PUSH_ENABLED:false}"); + } + + @Test + void defaultConfigKeepsLegacyProductionIngressPortsAndYutongMqttShape() { + Properties props = loadApplicationProperties(); + + assertThat(props.getProperty("lingniu.ingest.gb32960.port")).isEqualTo("${GB32960_PORT:32960}"); + assertThat(props.getProperty("lingniu.ingest.jt808.port")).isEqualTo("${JT808_PORT:10808}"); + assertThat(props.getProperty("lingniu.ingest.jsatl12.port")).isEqualTo("${JSATL12_PORT:7612}"); + assertThat(props.getProperty("lingniu.ingest.jt1078.media-stream.port")) + .isEqualTo("${JT1078_MEDIA_STREAM_PORT:11078}"); + assertThat(props.getProperty("lingniu.ingest.jt1078.media-stream.udp-port")) + .isEqualTo("${JT1078_MEDIA_STREAM_UDP_PORT:11078}"); + + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].name")).isEqualTo("yutong"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].uri")) + .isEqualTo("${MQTT_URI:ssl://cpxlm.axxc.cn:38883}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].topic")) + .isEqualTo("${MQTT_TOPIC:/ytforward/shln/+}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].qos")).isEqualTo("2"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].clean-session")).isEqualTo("false"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].keep-alive-seconds")).isEqualTo("20"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].connection-timeout-seconds")).isEqualTo("10"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].client-id")) + .isEqualTo("${MQTT_CLIENT_ID:shlnClientId}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].username")) + .isEqualTo("${MQTT_USER:shln}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].password")) + .isEqualTo("${MQTT_PWD:}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].tls.ca-pem")) + .isEqualTo("${MQTT_CA_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/ca.crt}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].tls.client-pem")) + .isEqualTo("${MQTT_CLIENT_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/client.crt}"); + assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].tls.client-key")) + .isEqualTo("${MQTT_CLIENT_KEY:/opt/lingniuServices/certificate/yutong/vehicledatareception/client-key-pkcs8.pem}"); + + assertThat(props.getProperty("lingniu.ingest.sink.mq.bootstrap-servers")) + .isEqualTo("${KAFKA_BROKERS:114.55.58.251:9092}"); + assertThat(props.getProperty("lingniu.ingest.session.store")).isEqualTo("${SESSION_STORE:redis}"); + assertThat(props.getProperty("lingniu.ingest.session.ttl")).isEqualTo("${SESSION_TTL:30m}"); + } + + @Test + void productionEventHistoryQueryApiIsEnabledByDefault() { + Properties props = loadApplicationProperties(); + + assertThat(props.getProperty("lingniu.ingest.event-history.enabled")) + .isEqualTo("${EVENT_HISTORY_ENABLED:true}"); + } + + @Test + void productionVehicleStatisticsAreEnabledByDefault() { + Properties props = loadApplicationProperties(); + + assertThat(props.getProperty("lingniu.ingest.vehicle-stat.enabled")) + .isEqualTo("${VEHICLE_STAT_ENABLED:true}"); + assertThat(props.getProperty("lingniu.ingest.vehicle-stat.file-path")) + .isEqualTo("${VEHICLE_STAT_FILE_PATH:./target/vehicle-stat/}"); + assertThat(props.getProperty("lingniu.ingest.vehicle-stat.zone-id")) + .isEqualTo("${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}"); + } + + @Test + void swaggerUiAndOpenApiDocsUseStableDefaultPaths() { + Properties props = loadApplicationProperties(); + + assertThat(props.getProperty("springdoc.swagger-ui.path")).isEqualTo("/swagger-ui.html"); + assertThat(props.getProperty("springdoc.api-docs.path")).isEqualTo("/v3/api-docs"); + } + + @Test + void hyundaiPlatformUsesGuangdongFuelCellVendorProfileByDefault() { + Properties props = loadApplicationProperties(); + + assertThat(props.getProperty("lingniu.ingest.gb32960.vendor-extensions[0].name")) + .isEqualTo("guangdong-fc"); + assertThat(props.getProperty("lingniu.ingest.gb32960.vendor-extensions[0].match.platform-accounts[1]")) + .isEqualTo("Hyundai"); + } + + private static Properties loadApplicationProperties() { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application.yml")); + Properties props = yaml.getObject(); + assertThat(props).isNotNull(); + return props; + } + + private static boolean defaultEnabled(String value) { + if (value == null) { + return false; + } + String normalized = value.trim(); + if ("true".equalsIgnoreCase(normalized)) { + return true; + } + if ("false".equalsIgnoreCase(normalized)) { + return false; + } + return normalized.matches("\\$\\{[^:}]+:true}"); + } +} diff --git a/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java b/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java similarity index 100% rename from bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java rename to modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/IngestEndToEndTest.java diff --git a/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/ProtocolAutoConfigurationCompositionTest.java b/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/ProtocolAutoConfigurationCompositionTest.java new file mode 100644 index 00000000..f59644cc --- /dev/null +++ b/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/ProtocolAutoConfigurationCompositionTest.java @@ -0,0 +1,73 @@ +package com.lingniu.ingest.bootstrap; + +import com.lingniu.ingest.core.config.IngestCoreAutoConfiguration; +import com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration; +import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager; +import com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration; +import com.lingniu.ingest.inbound.xinda.XindaPushClient; +import com.lingniu.ingest.inbound.xinda.config.XindaPushAutoConfiguration; +import com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer; +import com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration; +import com.lingniu.ingest.protocol.jt1078.config.Jt1078SignalAutoConfiguration; +import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MediaSignalHandler; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaStreamServer; +import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; +import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer; +import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration; +import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProtocolAutoConfigurationCompositionTest { + + @TempDir + Path archiveRoot; + + @Test + void nonGb32960ProtocolAdaptersCanBeComposedWithoutExternalConnections() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + IngestCoreAutoConfiguration.class, + SessionCoreAutoConfiguration.class, + VehicleIdentityAutoConfiguration.class, + SinkArchiveAutoConfiguration.class, + Jt1078AutoConfiguration.class, + Jt808AutoConfiguration.class, + Jt1078SignalAutoConfiguration.class, + Jsatl12AutoConfiguration.class, + MqttInboundAutoConfiguration.class, + XindaPushAutoConfiguration.class)) + .withPropertyValues( + "lingniu.ingest.sink.archive.enabled=true", + "lingniu.ingest.sink.archive.type=local", + "lingniu.ingest.sink.archive.path=" + archiveRoot, + "lingniu.ingest.jt808.enabled=true", + "lingniu.ingest.jt808.port=0", + "lingniu.ingest.jt1078.enabled=true", + "lingniu.ingest.jt1078.media-stream.enabled=true", + "lingniu.ingest.jt1078.media-stream.tcp-enabled=true", + "lingniu.ingest.jt1078.media-stream.udp-enabled=false", + "lingniu.ingest.jt1078.media-stream.port=0", + "lingniu.ingest.jsatl12.enabled=true", + "lingniu.ingest.jsatl12.port=0", + "lingniu.ingest.mqtt.enabled=true", + "lingniu.ingest.xinda-push.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(Jt808NettyServer.class); + assertThat(context).hasSingleBean(Jt1078MediaSignalHandler.class); + assertThat(context).hasSingleBean(Jt1078MediaArchiveService.class); + assertThat(context).hasSingleBean(Jt1078MediaStreamServer.class); + assertThat(context).hasSingleBean(Jsatl12NettyServer.class); + assertThat(context).hasSingleBean(MqttEndpointManager.class); + assertThat(context).hasSingleBean(XindaPushClient.class); + }); + } +} diff --git a/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/RawArchiveEndToEndTest.java b/modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/RawArchiveEndToEndTest.java similarity index 100% rename from bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/RawArchiveEndToEndTest.java rename to modules/apps/bootstrap-all/src/test/java/com/lingniu/ingest/bootstrap/RawArchiveEndToEndTest.java diff --git a/command-gateway/pom.xml b/modules/apps/command-gateway/pom.xml similarity index 72% rename from command-gateway/pom.xml rename to modules/apps/command-gateway/pom.xml index 8da24131..4efd2c7e 100644 --- a/command-gateway/pom.xml +++ b/modules/apps/command-gateway/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml command-gateway command-gateway @@ -26,6 +27,10 @@ com.lingniu.ingest protocol-jt808 + + com.lingniu.ingest + protocol-jt1078 + org.springframework.boot spring-boot-starter-web @@ -34,5 +39,15 @@ org.springframework.boot spring-boot-starter-validation + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + diff --git a/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java similarity index 100% rename from command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java rename to modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java diff --git a/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java similarity index 100% rename from command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java rename to modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandResult.java diff --git a/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java new file mode 100644 index 00000000..1aee3351 --- /dev/null +++ b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java @@ -0,0 +1,278 @@ +package com.lingniu.ingest.gateway; + +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt1078.downlink.Jt1078Commands; +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.DeviceSession; +import com.lingniu.ingest.session.SessionStore; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.stream.Collectors; + +/** + * 下行命令 REST 入口。命令通过 {@link CommandDispatcher} 发往协议模块, + * 由 protocol-jt808 的 {@code Jt808CommandDispatcher} 实际发送到终端。 + * + *

路径参数 {@code clientId} 可以是 sessionId / phone / vin, + * 由 {@link SessionStore} 三索引解析。 + */ +@RestController +@RequestMapping("/terminal") +public class TerminalCommandController { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(20); + + private final SessionStore sessions; + private final CommandDispatcher dispatcher; + + public TerminalCommandController(SessionStore sessions, CommandDispatcher dispatcher) { + this.sessions = sessions; + this.dispatcher = dispatcher; + } + + @GetMapping("/session") + public CommandResult session(@RequestParam String clientId) { + Optional s = resolveSession(clientId); + return s.map(CommandResult::ok) + .orElseGet(() -> CommandResult.failure(404, "session not found: " + clientId)); + } + + @GetMapping("/location") + public CommandResult queryLocation(@RequestParam String clientId) { + return awaitSync(clientId, Jt808Commands.queryLocation(), "query-location"); + } + + @GetMapping("/parameters") + public CommandResult queryParameters(@RequestParam String clientId) { + return awaitSync(clientId, Jt808Commands.queryParameters(), "query-parameters"); + } + + @PutMapping("/parameters") + public CommandResult setParameters(@RequestParam String clientId, + @RequestBody Map body) { + List items = body.entrySet().stream() + .map(e -> new Jt808Commands.ParamItem( + parseId(e.getKey()), + e.getValue() == null ? new byte[0] : e.getValue().getBytes(StandardCharsets.UTF_8))) + .collect(Collectors.toList()); + return awaitSync(clientId, Jt808Commands.setParameters(items), "set-parameters"); + } + + @PostMapping("/control") + public CommandResult terminalControl(@RequestParam String clientId, + @RequestBody Map body) { + int word = Integer.parseInt(body.getOrDefault("command", "0").toString()); + String params = body.getOrDefault("params", "").toString(); + return awaitSync(clientId, Jt808Commands.terminalControl(word, params), "terminal-control"); + } + + @PostMapping("/ack") + public CommandResult ack(@RequestParam String clientId, @RequestBody Map body) { + int ackSerial = body.getOrDefault("ackSerial", 0); + int ackMsgId = body.getOrDefault("ackMsgId", 0); + int result = body.getOrDefault("result", 0); + return fireAndForget(clientId, Jt808Commands.platformAck(ackSerial, ackMsgId, result), "platform-ack"); + } + + @GetMapping("/attributes") + public CommandResult attributes(@RequestParam String clientId) { + return awaitSync(clientId, Jt808Commands.queryAttributes(), "query-attributes"); + } + + @DeleteMapping("/area") + public CommandResult deleteArea(@RequestParam String clientId, @RequestParam String ids) { + return awaitSync(clientId, Jt808Commands.deleteArea(parseIds(ids)), "delete-area"); + } + + @PostMapping("/alarm_ack") + public CommandResult alarmAck(@RequestParam String clientId, @RequestBody Map body) { + int responseSerialNo = Integer.parseInt(body.getOrDefault("responseSerialNo", "0").toString()); + long type = parseLong(body.getOrDefault("type", "0").toString()); + return fireAndForget(clientId, Jt808Commands.alarmAck(responseSerialNo, type), "alarm-ack"); + } + + @GetMapping("/jt1078/attributes") + public CommandResult jt1078Attributes(@RequestParam String clientId) { + return awaitSync(clientId, Jt1078Commands.queryMediaAttributes(), "jt1078-query-media-attributes"); + } + + @PostMapping("/jt1078/realtime") + public CommandResult jt1078RealtimePlay(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.realtimePlay( + stringValue(body, "ip", ""), + intValue(body, "tcpPort", 0), + intValue(body, "udpPort", 0), + intValue(body, "channelNo", 0), + intValue(body, "mediaType", 0), + intValue(body, "streamType", 0)), "jt1078-realtime-play"); + } + + @PostMapping("/jt1078/realtime_control") + public CommandResult jt1078RealtimeControl(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.realtimeControl( + intValue(body, "channelNo", 0), + intValue(body, "command", 0), + intValue(body, "closeType", 0), + intValue(body, "streamType", 0)), "jt1078-realtime-control"); + } + + @PostMapping("/jt1078/history") + public CommandResult jt1078HistoryPlay(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.historyPlay( + stringValue(body, "ip", ""), + intValue(body, "tcpPort", 0), + intValue(body, "udpPort", 0), + intValue(body, "channelNo", 0), + intValue(body, "mediaType", 0), + intValue(body, "streamType", 0), + intValue(body, "storageType", 0), + intValue(body, "playbackMode", 0), + intValue(body, "playbackSpeed", 0), + stringValue(body, "startTime", ""), + stringValue(body, "endTime", "")), "jt1078-history-play"); + } + + @PostMapping("/jt1078/history_control") + public CommandResult jt1078HistoryControl(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.historyControl( + intValue(body, "channelNo", 0), + intValue(body, "playbackMode", 0), + intValue(body, "playbackSpeed", 0), + stringValue(body, "playbackTime", "")), "jt1078-history-control"); + } + + @PostMapping("/jt1078/resource_search") + public CommandResult jt1078ResourceSearch(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.resourceSearch( + intValue(body, "channelNo", 0), + stringValue(body, "startTime", ""), + stringValue(body, "endTime", ""), + longValue(body, "warnBit1", 0), + longValue(body, "warnBit2", 0), + intValue(body, "mediaType", 0), + intValue(body, "streamType", 0), + intValue(body, "storageType", 0)), "jt1078-resource-search"); + } + + @PostMapping("/jt1078/file_upload") + public CommandResult jt1078FileUpload(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.fileUpload( + stringValue(body, "ip", ""), + intValue(body, "port", 0), + stringValue(body, "username", ""), + stringValue(body, "password", ""), + stringValue(body, "path", ""), + intValue(body, "channelNo", 0), + stringValue(body, "startTime", ""), + stringValue(body, "endTime", ""), + longValue(body, "warnBit1", 0), + longValue(body, "warnBit2", 0), + intValue(body, "mediaType", 0), + intValue(body, "streamType", 0), + intValue(body, "storageType", 0), + intValue(body, "condition", 0)), "jt1078-file-upload"); + } + + @PostMapping("/jt1078/file_upload_control") + public CommandResult jt1078FileUploadControl(@RequestParam String clientId, + @RequestBody Map body) { + return awaitSync(clientId, Jt1078Commands.fileUploadControl( + intValue(body, "responseSerialNo", 0), + intValue(body, "command", 0)), "jt1078-file-upload-control"); + } + + // ===== helpers ===== + + private Optional resolveSession(String clientId) { + return sessions.findBySessionId(clientId) + .or(() -> sessions.findByPhone(clientId)) + .or(() -> sessions.findByVin(clientId)); + } + + private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { + CompletableFuture future = + dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT); + try { + Jt808Message resp = future.join(); + return CommandResult.ok(Map.of( + "messageId", "0x" + Integer.toHexString(resp.header().messageId()), + "serial", resp.header().serialNo(), + "phone", resp.header().phone())); + } catch (CompletionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + return CommandResult.failure(500, op + " failed: " + cause.getMessage()); + } catch (Exception e) { + return CommandResult.failure(500, op + " failed: " + e.getMessage()); + } + } + + private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { + try { + dispatcher.notify(clientId, cmd).join(); + return CommandResult.ok(Map.of("op", op)); + } catch (Exception e) { + return CommandResult.failure(500, op + " failed: " + e.getMessage()); + } + } + + private static long parseId(String key) { + if (key == null || key.isBlank()) return 0; + String k = key.startsWith("0x") ? key.substring(2) : key; + return Long.parseLong(k, 16); + } + + private static List parseIds(String ids) { + if (ids == null || ids.isBlank()) { + return List.of(); + } + return java.util.Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .map(TerminalCommandController::parseLong) + .toList(); + } + + private static long parseLong(String value) { + if (value == null || value.isBlank()) return 0; + String v = value.startsWith("0x") || value.startsWith("0X") ? value.substring(2) : value; + return value.startsWith("0x") || value.startsWith("0X") + ? Long.parseLong(v, 16) + : Long.parseLong(v); + } + + private static String stringValue(Map body, String key, String defaultValue) { + Object value = body.get(key); + return value == null ? defaultValue : value.toString(); + } + + private static int intValue(Map body, String key, int defaultValue) { + Object value = body.get(key); + return value == null ? defaultValue : (int) parseLong(value.toString()); + } + + private static long longValue(Map body, String key, long defaultValue) { + Object value = body.get(key); + return value == null ? defaultValue : parseLong(value.toString()); + } +} diff --git a/command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/apps/command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/apps/command-gateway/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/apps/command-gateway/src/test/java/com/lingniu/ingest/gateway/TerminalCommandControllerTest.java b/modules/apps/command-gateway/src/test/java/com/lingniu/ingest/gateway/TerminalCommandControllerTest.java new file mode 100644 index 00000000..7f18f127 --- /dev/null +++ b/modules/apps/command-gateway/src/test/java/com/lingniu/ingest/gateway/TerminalCommandControllerTest.java @@ -0,0 +1,147 @@ +package com.lingniu.ingest.gateway; + +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.DeviceSession; +import com.lingniu.ingest.session.SessionStore; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalCommandControllerTest { + + @Test + void queryAttributesDispatchesJt8088107InsteadOfNotImplemented() { + RecordingDispatcher dispatcher = new RecordingDispatcher(); + TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher); + + CommandResult result = controller.attributes("13800138000"); + + assertThat(result.success()).isTrue(); + assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x8107); + assertThat(dispatcher.lastRequestCommand.body()).isEmpty(); + } + + @Test + void alarmAckDispatchesJt8088203WithResponseSerialAndAlarmType() { + RecordingDispatcher dispatcher = new RecordingDispatcher(); + TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher); + + CommandResult result = controller.alarmAck("13800138000", Map.of( + "responseSerialNo", 0x1234, + "type", 0x0000_0001)); + + assertThat(result.success()).isTrue(); + assertThat(dispatcher.lastNotifyCommand.messageId()).isEqualTo(0x8203); + assertThat(dispatcher.lastNotifyCommand.body()).containsExactly( + 0x12, 0x34, + 0x00, 0x00, 0x00, 0x01); + } + + @Test + void deleteAreaDispatchesJt8088601WithCommaSeparatedAreaIds() { + RecordingDispatcher dispatcher = new RecordingDispatcher(); + TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher); + + CommandResult result = controller.deleteArea("13800138000", "1,0x01020304"); + + assertThat(result.success()).isTrue(); + assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x8601); + assertThat(dispatcher.lastRequestCommand.body()).containsExactly( + 0x02, + 0x00, 0x00, 0x00, 0x01, + 0x01, 0x02, 0x03, 0x04); + } + + @Test + void jt1078RealtimePlayDispatches9101ThroughSharedDispatcher() { + RecordingDispatcher dispatcher = new RecordingDispatcher(); + TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher); + + CommandResult result = controller.jt1078RealtimePlay("13800138000", Map.of( + "ip", "10.1.2.3", + "tcpPort", 11078, + "udpPort", 11079, + "channelNo", 2, + "mediaType", 1, + "streamType", 0)); + + assertThat(result.success()).isTrue(); + assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x9101); + assertThat(dispatcher.lastRequestCommand.body()).containsExactly( + 0x08, '1', '0', '.', '1', '.', '2', '.', '3', + 0x2B, 0x46, + 0x2B, 0x47, + 0x02, + 0x01, + 0x00); + } + + @Test + void jt1078ResourceSearchDispatches9205AndAwaitsFileListResponse() { + RecordingDispatcher dispatcher = new RecordingDispatcher(); + TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher); + + CommandResult result = controller.jt1078ResourceSearch("13800138000", Map.of( + "channelNo", 3, + "startTime", "2026-06-22 08:09:10", + "endTime", "260622180000", + "warnBit1", "0x01020304", + "warnBit2", "0x05060708", + "mediaType", 3, + "streamType", 2, + "storageType", 1)); + + assertThat(result.success()).isTrue(); + assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x9205); + assertThat(dispatcher.lastRequestCommand.body()).containsExactly( + 0x03, + 0x26, 0x06, 0x22, 0x08, 0x09, 0x10, + 0x26, 0x06, 0x22, 0x18, 0x00, 0x00, + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x03, + 0x02, + 0x01); + } + + private static final class RecordingDispatcher implements CommandDispatcher { + private Jt808Commands.DownlinkCommand lastNotifyCommand; + private Jt808Commands.DownlinkCommand lastRequestCommand; + + @Override + public CompletableFuture notify(String sessionId, Object command) { + this.lastNotifyCommand = (Jt808Commands.DownlinkCommand) command; + return CompletableFuture.completedFuture(null); + } + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture request(String sessionId, Object command, Class responseType, Duration timeout) { + this.lastRequestCommand = (Jt808Commands.DownlinkCommand) command; + Jt808Message response = new Jt808Message( + new Jt808Header(0x0107, 0, 0, false, Jt808Header.ProtocolVersion.V2013, + "13800138000", 1, 0, 0), + new com.lingniu.ingest.protocol.jt808.model.Jt808Body.Raw(0x0107, new byte[0])); + return CompletableFuture.completedFuture((T) response); + } + } + + private static final class EmptySessionStore implements SessionStore { + @Override public void put(DeviceSession session) {} + @Override public Optional findBySessionId(String sessionId) { return Optional.empty(); } + @Override public Optional findByVin(String vin) { return Optional.empty(); } + @Override public Optional findByPhone(String phone) { return Optional.empty(); } + @Override public Optional update(String sessionId, UnaryOperator updater) { return Optional.empty(); } + @Override public void remove(String sessionId) {} + @Override public int size() { return 0; } + } +} diff --git a/ingest-api/pom.xml b/modules/core/ingest-api/pom.xml similarity index 94% rename from ingest-api/pom.xml rename to modules/core/ingest-api/pom.xml index 6d484399..88cf89fb 100644 --- a/ingest-api/pom.xml +++ b/modules/core/ingest-api/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml ingest-api ingest-api diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java similarity index 95% rename from ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java index 785c4411..53b8ec2a 100644 --- a/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/ProtocolId.java @@ -5,6 +5,7 @@ package com.lingniu.ingest.api; * 新增协议需要在此处登记,避免散落的字符串常量。 */ public enum ProtocolId { + UNKNOWN, GB32960, JT808, JT1078, diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/EventEmit.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/ProtocolHandler.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/TraceSpan.java diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java new file mode 100644 index 00000000..95db7358 --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java @@ -0,0 +1,56 @@ +package com.lingniu.ingest.api.consumer; + +import java.time.Instant; +import java.util.EnumSet; +import java.util.Set; + +public final class EnvelopeConsumerProcessor { + + private static final Set DEAD_LETTER_STATUSES = EnumSet.of( + EnvelopeIngestResult.Status.SKIPPED, + EnvelopeIngestResult.Status.INVALID_ENVELOPE, + EnvelopeIngestResult.Status.FAILED + ); + + private final String service; + private final EnvelopeIngestor ingestor; + private final EnvelopeDeadLetterSink deadLetterSink; + + public EnvelopeConsumerProcessor(String service, EnvelopeIngestor ingestor, EnvelopeDeadLetterSink deadLetterSink) { + if (ingestor == null) { + throw new IllegalArgumentException("ingestor must not be null"); + } + if (deadLetterSink == null) { + throw new IllegalArgumentException("deadLetterSink must not be null"); + } + this.service = service == null || service.isBlank() ? "unknown" : service; + this.ingestor = ingestor; + this.deadLetterSink = deadLetterSink; + } + + public EnvelopeIngestResult process(EnvelopeConsumerRecord record) { + if (record == null) { + throw new IllegalArgumentException("record must not be null"); + } + EnvelopeIngestResult result = ingestor.tryIngest(record.payload()); + if (DEAD_LETTER_STATUSES.contains(result.status())) { + deadLetterSink.publish(toDeadLetter(record, result)); + } + return result; + } + + private EnvelopeDeadLetterRecord toDeadLetter(EnvelopeConsumerRecord record, EnvelopeIngestResult result) { + return new EnvelopeDeadLetterRecord( + service, + record.topic(), + record.partition(), + record.offset(), + record.key(), + result.status(), + result.eventId(), + result.vin(), + result.message(), + record.payload(), + Instant.now()); + } +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerRecord.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerRecord.java new file mode 100644 index 00000000..40bb948e --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerRecord.java @@ -0,0 +1,20 @@ +package com.lingniu.ingest.api.consumer; + +public record EnvelopeConsumerRecord( + String topic, + int partition, + long offset, + String key, + byte[] payload +) { + public EnvelopeConsumerRecord { + topic = topic == null ? "" : topic; + key = key == null ? "" : key; + payload = payload == null ? new byte[0] : payload.clone(); + } + + @Override + public byte[] payload() { + return payload.clone(); + } +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterRecord.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterRecord.java new file mode 100644 index 00000000..45cb7901 --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterRecord.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.api.consumer; + +import java.time.Instant; + +public record EnvelopeDeadLetterRecord( + String service, + String topic, + int partition, + long offset, + String key, + EnvelopeIngestResult.Status status, + String eventId, + String vin, + String message, + byte[] payload, + Instant createdAt +) { + public EnvelopeDeadLetterRecord { + service = service == null ? "" : service; + topic = topic == null ? "" : topic; + key = key == null ? "" : key; + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + eventId = eventId == null ? "" : eventId; + vin = vin == null ? "" : vin; + message = message == null ? "" : message; + payload = payload == null ? new byte[0] : payload.clone(); + createdAt = createdAt == null ? Instant.now() : createdAt; + } + + @Override + public byte[] payload() { + return payload.clone(); + } +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterSink.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterSink.java new file mode 100644 index 00000000..5de699af --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeDeadLetterSink.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.api.consumer; + +@FunctionalInterface +public interface EnvelopeDeadLetterSink { + void publish(EnvelopeDeadLetterRecord record); +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestResult.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestResult.java new file mode 100644 index 00000000..c1a7f43b --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestResult.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.api.consumer; + +/** + * Non-throwing result for Kafka envelope consumers. It lets production + * listeners isolate bad records without blocking the partition. + */ +public record EnvelopeIngestResult(Status status, String eventId, String vin, String message) { + + public enum Status { + STORED, + PROCESSED, + SKIPPED, + INVALID_ENVELOPE, + FAILED + } + + public EnvelopeIngestResult { + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + eventId = eventId == null ? "" : eventId; + vin = vin == null ? "" : vin; + message = message == null ? "" : message; + } + + public static EnvelopeIngestResult stored(String eventId, String vin) { + return new EnvelopeIngestResult(Status.STORED, eventId, vin, ""); + } + + public static EnvelopeIngestResult processed(String eventId, String vin) { + return new EnvelopeIngestResult(Status.PROCESSED, eventId, vin, ""); + } + + public static EnvelopeIngestResult skipped(String eventId, String vin, String message) { + return new EnvelopeIngestResult(Status.SKIPPED, eventId, vin, message); + } + + public static EnvelopeIngestResult invalid(String message) { + return new EnvelopeIngestResult(Status.INVALID_ENVELOPE, "", "", message); + } + + public static EnvelopeIngestResult failed(String eventId, String vin, String message) { + return new EnvelopeIngestResult(Status.FAILED, eventId, vin, message); + } +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestor.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestor.java new file mode 100644 index 00000000..471dae68 --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeIngestor.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.api.consumer; + +@FunctionalInterface +public interface EnvelopeIngestor { + EnvelopeIngestResult tryIngest(byte[] kafkaValue); +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java similarity index 62% rename from ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java index b56cb03e..48c9e6bf 100644 --- a/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/AlarmPayload.java @@ -13,6 +13,10 @@ import java.util.Set; * @param activeBits 结构化的通用报警位集合(2016 版:0~15;2025 版:0~27),对照 GB/T 32960.3 表 24 * @param longitude 经度(可选,若事件伴随位置) * @param latitude 纬度(可选) + * @param safetyCategory 内部安全分类。氢气泄露独立成类,不与普通故障混用 + * @param hydrogenLeakDetected 是否检测到氢气泄露 + * @param hydrogenLeakLevel 氢气泄露等级 + * @param hydrogenLeakActionRequired 是否需要立即处置 */ public record AlarmPayload( AlarmLevel level, @@ -21,7 +25,11 @@ public record AlarmPayload( List faultCodes, Set activeBits, Double longitude, - Double latitude + Double latitude, + SafetyCategory safetyCategory, + boolean hydrogenLeakDetected, + HydrogenLeakLevel hydrogenLeakLevel, + boolean hydrogenLeakActionRequired ) { /** * 报警等级(归一化的跨协议分类)。 @@ -34,4 +42,24 @@ public record AlarmPayload( * */ public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL } + + /** + * 面向运营统计的内部安全分类。 + */ + public enum SafetyCategory { + GENERAL, + TANK_PRESSURE, + TANK_TEMPERATURE, + HYDROGEN_LEAK + } + + /** + * 氢气泄露内部等级。明确泄露信号一律视为 CRITICAL。 + */ + public enum HydrogenLeakLevel { + NONE, + WARNING, + CRITICAL, + UNKNOWN + } } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java new file mode 100644 index 00000000..f786fde9 --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/LocationPayload.java @@ -0,0 +1,23 @@ +package com.lingniu.ingest.api.event; + +/** 位置事件载荷。坐标已统一转换为 WGS84 十进制度。 */ +public record LocationPayload( + double longitude, + double latitude, + double altitudeM, + double speedKmh, + double directionDeg, + long alarmFlag, + long statusFlag, + Double totalMileageKm +) { + public LocationPayload(double longitude, + double latitude, + double altitudeM, + double speedKmh, + double directionDeg, + long alarmFlag, + long statusFlag) { + this(longitude, latitude, altitudeM, speedKmh, directionDeg, alarmFlag, statusFlag, null); + } +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java new file mode 100644 index 00000000..ae88ffb4 --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java @@ -0,0 +1,53 @@ +package com.lingniu.ingest.api.event; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +/** + * Shared raw-archive key conventions used by Dispatcher, archive sink, and query services. + */ +public final class RawArchiveKeys { + + public static final String META_EVENT_ID = "rawArchiveEventId"; + public static final String META_KEY = "rawArchiveKey"; + public static final String META_URI = "rawArchiveUri"; + + private static final DateTimeFormatter DATE_KEY = + DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.of("Asia/Shanghai")); + + private RawArchiveKeys() { + } + + public static String key(Instant ingestTime, ProtocolId source, String vin, String eventId) { + String dateKey = DATE_KEY.format(ingestTime == null ? Instant.EPOCH : ingestTime); + String safeVin = vin == null || vin.isBlank() ? "unknown-vin" : vin; + String safeEventId = eventId == null || eventId.isBlank() + ? Long.toHexString(System.nanoTime()) : eventId; + String safeSource = source == null ? "UNKNOWN" : source.name(); + return dateKey + "/" + safeSource + "/" + safeVin + "/" + safeEventId + ".bin"; + } + + public static String key(VehicleEvent.RawArchive raw) { + String key = metadataValue(raw.metadata(), META_KEY); + if (!key.isBlank()) { + return key; + } + return key(raw.ingestTime(), raw.source(), raw.vin(), raw.eventId()); + } + + public static String logicalUri(String key) { + return key == null || key.isBlank() ? "" : "archive://" + key; + } + + private static String metadataValue(Map metadata, String key) { + if (metadata == null || key == null) { + return ""; + } + String value = metadata.get(key); + return value == null ? "" : value; + } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RealtimePayload.java diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java new file mode 100644 index 00000000..a88ee6aa --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java @@ -0,0 +1,74 @@ +package com.lingniu.ingest.api.event; + +/** + * One normalized telemetry field value. + * + *

Protocol mappers use stable internal field keys so Kafka, Parquet, Redis, + * and statistics do not depend on protocol-specific names. + */ +public record TelemetryFieldValue( + String key, + ValueType valueType, + String value, + String unit, + Quality quality, + String sourcePath +) { + + public TelemetryFieldValue { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("key must not be blank"); + } + if (valueType == null) { + throw new IllegalArgumentException("valueType must not be null"); + } + value = value == null ? "" : value; + unit = unit == null ? "" : unit; + quality = quality == null ? Quality.GOOD : quality; + sourcePath = sourcePath == null ? "" : sourcePath; + } + + public static TelemetryFieldValue stringValue(String key, String value, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.STRING, value, unit, Quality.GOOD, sourcePath); + } + + public static TelemetryFieldValue doubleValue(String key, double value, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.DOUBLE, Double.toString(value), unit, Quality.GOOD, sourcePath); + } + + public static TelemetryFieldValue longValue(String key, long value, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.LONG, Long.toString(value), unit, Quality.GOOD, sourcePath); + } + + public static TelemetryFieldValue booleanValue(String key, boolean value, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.BOOLEAN, Boolean.toString(value), unit, Quality.GOOD, sourcePath); + } + + public static TelemetryFieldValue instantValue(String key, String value, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.INSTANT, value, unit, Quality.GOOD, sourcePath); + } + + public static TelemetryFieldValue jsonValue(String key, String value, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.JSON, value, unit, Quality.GOOD, sourcePath); + } + + public static TelemetryFieldValue missing(String key, String unit, String sourcePath) { + return new TelemetryFieldValue(key, ValueType.STRING, "", unit, Quality.MISSING, sourcePath); + } + + public enum ValueType { + STRING, + DOUBLE, + LONG, + BOOLEAN, + INSTANT, + JSON + } + + public enum Quality { + GOOD, + ESTIMATED, + INVALID, + MISSING + } +} diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java new file mode 100644 index 00000000..0739620f --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java @@ -0,0 +1,144 @@ +package com.lingniu.ingest.api.event; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Normalized full-field telemetry snapshot for one parsed vehicle event. + * + *

This is the shared contract for Kafka, Parquet history, Redis hot state, + * and statistics. It intentionally lives in {@code ingest-api} and has no + * Spring, Kafka, Redis, or DuckDB dependency. + */ +public record TelemetrySnapshot( + String eventId, + String vin, + ProtocolId protocol, + String eventType, + Instant eventTime, + Instant ingestTime, + String rawArchiveUri, + Map metadata, + List fields +) { + + public TelemetrySnapshot { + if (eventId == null || eventId.isBlank()) { + throw new IllegalArgumentException("eventId must not be blank"); + } + if (vin == null || vin.isBlank()) { + throw new IllegalArgumentException("vin must not be blank"); + } + if (protocol == null) { + throw new IllegalArgumentException("protocol must not be null"); + } + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("eventType must not be blank"); + } + if (eventTime == null) { + throw new IllegalArgumentException("eventTime must not be null"); + } + if (ingestTime == null) { + throw new IllegalArgumentException("ingestTime must not be null"); + } + rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri; + metadata = Map.copyOf(metadata == null ? Map.of() : metadata); + fields = List.copyOf(fields == null ? List.of() : fields); + validateUniqueKeys(fields); + } + + public Optional field(String key) { + if (key == null || key.isBlank()) { + return Optional.empty(); + } + for (TelemetryFieldValue field : fields) { + if (field.key().equals(key)) { + return Optional.of(field); + } + } + return Optional.empty(); + } + + public Map fieldsByKey() { + Map index = new LinkedHashMap<>(); + for (TelemetryFieldValue field : fields) { + index.put(field.key(), field); + } + return Map.copyOf(index); + } + + public static Builder builder(String eventId, + String vin, + ProtocolId protocol, + String eventType, + Instant eventTime, + Instant ingestTime) { + return new Builder(eventId, vin, protocol, eventType, eventTime, ingestTime); + } + + private static void validateUniqueKeys(List fields) { + Map seen = new LinkedHashMap<>(); + for (TelemetryFieldValue field : fields) { + if (field == null) { + throw new IllegalArgumentException("field must not be null"); + } + TelemetryFieldValue previous = seen.put(field.key(), field); + if (previous != null) { + throw new IllegalArgumentException("duplicate telemetry field key: " + field.key()); + } + } + } + + public static final class Builder { + private final String eventId; + private final String vin; + private final ProtocolId protocol; + private final String eventType; + private final Instant eventTime; + private final Instant ingestTime; + private String rawArchiveUri = ""; + private Map metadata = Map.of(); + private final List fields = new ArrayList<>(); + + private Builder(String eventId, + String vin, + ProtocolId protocol, + String eventType, + Instant eventTime, + Instant ingestTime) { + this.eventId = eventId; + this.vin = vin; + this.protocol = protocol; + this.eventType = eventType; + this.eventTime = eventTime; + this.ingestTime = ingestTime; + } + + public Builder rawArchiveUri(String rawArchiveUri) { + this.rawArchiveUri = rawArchiveUri; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + return this; + } + + public Builder addField(TelemetryFieldValue field) { + this.fields.add(field); + return this; + } + + public TelemetrySnapshot build() { + return new TelemetrySnapshot( + eventId, vin, protocol, eventType, eventTime, ingestTime, + rawArchiveUri, metadata, fields); + } + } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapper.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapper.java new file mode 100644 index 00000000..d4392d43 --- /dev/null +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapper.java @@ -0,0 +1,206 @@ +package com.lingniu.ingest.api.event; + +import java.util.Map; +import java.util.Optional; + +/** + * Converts current {@link VehicleEvent} variants into normalized full-field + * telemetry snapshots. + * + *

Protocol-specific mappers can add richer fields later, but all downstream + * modules should consume {@link TelemetrySnapshot} instead of protocol objects. + */ +public final class VehicleEventTelemetrySnapshotMapper { + + private VehicleEventTelemetrySnapshotMapper() { + } + + public static Optional toSnapshot(VehicleEvent event) { + if (event == null || event instanceof VehicleEvent.RawArchive) { + return Optional.empty(); + } + TelemetrySnapshot.Builder builder = TelemetrySnapshot.builder( + event.eventId(), + event.vin(), + event.source(), + eventType(event), + event.eventTime(), + event.ingestTime()) + .metadata(event.metadata()) + .rawArchiveUri(rawArchiveUri(event)); + + switch (event) { + case VehicleEvent.Realtime realtime -> addRealtimeFields(builder, realtime.payload()); + case VehicleEvent.Location location -> addLocationFields(builder, location.payload()); + case VehicleEvent.Alarm alarm -> addAlarmFields(builder, alarm.payload()); + case VehicleEvent.Login login -> { + addString(builder, "iccid", login.iccid(), "", "event.login.iccid"); + addString(builder, "protocol_version", login.protocolVersion(), "", "event.login.protocolVersion"); + } + case VehicleEvent.Logout ignored -> { + } + case VehicleEvent.Heartbeat ignored -> { + } + case VehicleEvent.MediaMeta media -> { + addString(builder, "media_id", media.mediaId(), "", "event.media.mediaId"); + addString(builder, "media_type", media.mediaType(), "", "event.media.mediaType"); + builder.addField(TelemetryFieldValue.longValue( + "media_size_bytes", media.sizeBytes(), "bytes", "event.media.sizeBytes")); + addString(builder, "media_archive_ref", media.archiveRef(), "", "event.media.archiveRef"); + } + case VehicleEvent.Passthrough passthrough -> { + builder.addField(TelemetryFieldValue.longValue( + "passthrough_type", passthrough.passthroughType(), "", "event.passthrough.type")); + builder.addField(TelemetryFieldValue.longValue( + "passthrough_size_bytes", + passthrough.data() == null ? 0 : passthrough.data().length, + "bytes", + "event.passthrough.data")); + } + case VehicleEvent.RawArchive ignored -> { + return Optional.empty(); + } + } + return Optional.of(builder.build()); + } + + private static void addRealtimeFields(TelemetrySnapshot.Builder builder, RealtimePayload p) { + if (p == null) return; + addDouble(builder, "speed_kmh", p.speedKmh(), "km/h", "event.realtime.speedKmh"); + addDouble(builder, "total_mileage_km", p.totalMileageKm(), "km", "event.realtime.totalMileageKm"); + addDouble(builder, "battery_soc", p.batterySoc(), "%", "event.realtime.batterySoc"); + addDouble(builder, "battery_voltage_v", p.batteryVoltageV(), "V", "event.realtime.batteryVoltageV"); + addDouble(builder, "battery_current_a", p.batteryCurrentA(), "A", "event.realtime.batteryCurrentA"); + addDouble(builder, "fc_voltage_v", p.fcVoltageV(), "V", "event.realtime.fcVoltageV"); + addDouble(builder, "fc_current_a", p.fcCurrentA(), "A", "event.realtime.fcCurrentA"); + addDouble(builder, "fc_temp_c", p.fcTempC(), "C", "event.realtime.fcTempC"); + addDouble(builder, "hydrogen_remaining_kg", p.hydrogenRemainingKg(), "kg", "event.realtime.hydrogenRemainingKg"); + addDouble(builder, "hydrogen_high_pressure_mpa", p.hydrogenHighPressureMpa(), "MPa", "event.realtime.hydrogenHighPressureMpa"); + addDouble(builder, "hydrogen_low_pressure_mpa", p.hydrogenLowPressureMpa(), "MPa", "event.realtime.hydrogenLowPressureMpa"); + addEnum(builder, "vehicle_state", p.vehicleState(), "", "event.realtime.vehicleState"); + addEnum(builder, "charging_state", p.chargingState(), "", "event.realtime.chargingState"); + addEnum(builder, "running_mode", p.runningMode(), "", "event.realtime.runningMode"); + addLong(builder, "gear_level", p.gearLevel(), "", "event.realtime.gearLevel"); + addDouble(builder, "accelerator_pedal", p.acceleratorPedal(), "%", "event.realtime.acceleratorPedal"); + addDouble(builder, "brake_pedal", p.brakePedal(), "%", "event.realtime.brakePedal"); + addDouble(builder, "longitude", p.longitude(), "deg", "event.realtime.longitude"); + addDouble(builder, "latitude", p.latitude(), "deg", "event.realtime.latitude"); + addDouble(builder, "altitude_m", p.altitudeM(), "m", "event.realtime.altitudeM"); + addDouble(builder, "direction_deg", p.directionDeg(), "deg", "event.realtime.directionDeg"); + addDouble(builder, "ambient_temp_c", p.ambientTempC(), "C", "event.realtime.ambientTempC"); + addDouble(builder, "coolant_temp_c", p.coolantTempC(), "C", "event.realtime.coolantTempC"); + } + + private static void addLocationFields(TelemetrySnapshot.Builder builder, LocationPayload p) { + if (p == null) return; + builder.addField(TelemetryFieldValue.doubleValue("longitude", p.longitude(), "deg", "event.location.longitude")); + builder.addField(TelemetryFieldValue.doubleValue("latitude", p.latitude(), "deg", "event.location.latitude")); + builder.addField(TelemetryFieldValue.doubleValue("altitude_m", p.altitudeM(), "m", "event.location.altitudeM")); + builder.addField(TelemetryFieldValue.doubleValue("speed_kmh", p.speedKmh(), "km/h", "event.location.speedKmh")); + addDouble(builder, "total_mileage_km", p.totalMileageKm(), "km", "event.location.totalMileageKm"); + builder.addField(TelemetryFieldValue.doubleValue("direction_deg", p.directionDeg(), "deg", "event.location.directionDeg")); + builder.addField(TelemetryFieldValue.longValue("location_alarm_flag", p.alarmFlag(), "", "event.location.alarmFlag")); + builder.addField(TelemetryFieldValue.longValue("location_status_raw", p.statusFlag(), "", "event.location.statusFlag")); + } + + private static void addAlarmFields(TelemetrySnapshot.Builder builder, AlarmPayload p) { + if (p == null) return; + addEnum(builder, "alarm_level", p.level(), "", "event.alarm.level"); + builder.addField(TelemetryFieldValue.longValue("alarm_type_code", p.alarmTypeCode(), "", "event.alarm.alarmTypeCode")); + addString(builder, "alarm_type_name", p.alarmTypeName(), "", "event.alarm.alarmTypeName"); + if (p.faultCodes() != null && !p.faultCodes().isEmpty()) { + builder.addField(TelemetryFieldValue.stringValue( + "fault_codes", String.join(",", p.faultCodes()), "", "event.alarm.faultCodes")); + } + if (p.activeBits() != null && !p.activeBits().isEmpty()) { + builder.addField(TelemetryFieldValue.stringValue( + "active_alarm_bits", String.join(",", p.activeBits()), "", "event.alarm.activeBits")); + } + addDouble(builder, "longitude", p.longitude(), "deg", "event.alarm.longitude"); + addDouble(builder, "latitude", p.latitude(), "deg", "event.alarm.latitude"); + addEnum(builder, "safety_category", p.safetyCategory(), "", "event.alarm.safetyCategory"); + builder.addField(TelemetryFieldValue.booleanValue( + "hydrogen_leak_detected", p.hydrogenLeakDetected(), "", "event.alarm.hydrogenLeakDetected")); + addEnum(builder, "hydrogen_leak_level", p.hydrogenLeakLevel(), "", "event.alarm.hydrogenLeakLevel"); + builder.addField(TelemetryFieldValue.booleanValue( + "hydrogen_leak_action_required", + p.hydrogenLeakActionRequired(), + "", + "event.alarm.hydrogenLeakActionRequired")); + } + + private static String eventType(VehicleEvent event) { + if (event instanceof VehicleEvent.Realtime) return "REALTIME"; + if (event instanceof VehicleEvent.Location) return "LOCATION"; + if (event instanceof VehicleEvent.Alarm) return "ALARM"; + if (event instanceof VehicleEvent.Login) return "LOGIN"; + if (event instanceof VehicleEvent.Logout) return "LOGOUT"; + if (event instanceof VehicleEvent.Heartbeat) return "HEARTBEAT"; + if (event instanceof VehicleEvent.MediaMeta) return "MEDIA_META"; + if (event instanceof VehicleEvent.Passthrough) return "PASSTHROUGH"; + return "UNKNOWN"; + } + + private static String rawArchiveUri(VehicleEvent event) { + Map metadata = event.metadata(); + if (metadata == null) { + return mediaArchiveRef(event); + } + String uri = metadata.getOrDefault(RawArchiveKeys.META_URI, ""); + if (uri != null && !uri.isBlank()) { + return uri; + } + String key = metadata.getOrDefault(RawArchiveKeys.META_KEY, ""); + if (key != null && !key.isBlank()) { + return RawArchiveKeys.logicalUri(key); + } + return mediaArchiveRef(event); + } + + private static String mediaArchiveRef(VehicleEvent event) { + if (event instanceof VehicleEvent.MediaMeta media && media.archiveRef() != null) { + return media.archiveRef(); + } + return ""; + } + + private static void addDouble(TelemetrySnapshot.Builder builder, + String key, + Double value, + String unit, + String sourcePath) { + if (value != null) { + builder.addField(TelemetryFieldValue.doubleValue(key, value, unit, sourcePath)); + } + } + + private static void addLong(TelemetrySnapshot.Builder builder, + String key, + Integer value, + String unit, + String sourcePath) { + if (value != null) { + builder.addField(TelemetryFieldValue.longValue(key, value, unit, sourcePath)); + } + } + + private static void addString(TelemetrySnapshot.Builder builder, + String key, + String value, + String unit, + String sourcePath) { + if (value != null && !value.isBlank()) { + builder.addField(TelemetryFieldValue.stringValue(key, value, unit, sourcePath)); + } + } + + private static void addEnum(TelemetrySnapshot.Builder builder, + String key, + Enum value, + String unit, + String sourcePath) { + if (value != null) { + builder.addField(TelemetryFieldValue.stringValue(key, value.name(), unit, sourcePath)); + } + } +} diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/package-info.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestInterceptor.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/DecodeException.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/EventMapper.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameDecoder.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/FrameEncoder.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/MessageHandler.java diff --git a/ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java similarity index 100% rename from ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java rename to modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/spi/ProtocolPlugin.java diff --git a/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessorTest.java b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessorTest.java new file mode 100644 index 00000000..80f142f2 --- /dev/null +++ b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessorTest.java @@ -0,0 +1,55 @@ +package com.lingniu.ingest.api.consumer; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnvelopeConsumerProcessorTest { + + @Test + void invalidEnvelopePublishesDeadLetterRecordWithKafkaPosition() { + CapturingDeadLetterSink deadLetters = new CapturingDeadLetterSink(); + EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor( + "event-history", ignored -> EnvelopeIngestResult.invalid("VehicleEnvelope bytes are invalid"), deadLetters); + + EnvelopeIngestResult result = processor.process( + new EnvelopeConsumerRecord("vehicle.realtime", 2, 42L, "VIN001", new byte[]{0x01, 0x02})); + + assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE); + assertThat(deadLetters.records).singleElement().satisfies(record -> { + assertThat(record.service()).isEqualTo("event-history"); + assertThat(record.topic()).isEqualTo("vehicle.realtime"); + assertThat(record.partition()).isEqualTo(2); + assertThat(record.offset()).isEqualTo(42L); + assertThat(record.key()).isEqualTo("VIN001"); + assertThat(record.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE); + assertThat(record.message()).contains("VehicleEnvelope"); + assertThat(record.payload()).containsExactly(0x01, 0x02); + }); + } + + @Test + void processedEnvelopeDoesNotPublishDeadLetter() { + CapturingDeadLetterSink deadLetters = new CapturingDeadLetterSink(); + EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor( + "vehicle-state", ignored -> EnvelopeIngestResult.processed("event-1", "VIN001"), deadLetters); + + EnvelopeIngestResult result = processor.process( + new EnvelopeConsumerRecord("vehicle.realtime", 0, 7L, "VIN001", new byte[]{0x0a})); + + assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.PROCESSED); + assertThat(deadLetters.records).isEmpty(); + } + + private static final class CapturingDeadLetterSink implements EnvelopeDeadLetterSink { + private final List records = new ArrayList<>(); + + @Override + public void publish(EnvelopeDeadLetterRecord record) { + records.add(record); + } + } +} diff --git a/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/RawArchiveKeysTest.java b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/RawArchiveKeysTest.java new file mode 100644 index 00000000..a8e92eab --- /dev/null +++ b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/RawArchiveKeysTest.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.api.event; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RawArchiveKeysTest { + + @Test + void keyDateUsesShanghaiBusinessDayInsteadOfUtcDay() { + String key = RawArchiveKeys.key( + Instant.parse("2026-06-22T16:30:00Z"), + ProtocolId.GB32960, + "VIN001", + "event-1"); + + assertThat(key).isEqualTo("2026/06/23/GB32960/VIN001/event-1.bin"); + } + + @Test + void keyForRawArchiveUsesPrecomputedMetadataKey() { + VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( + "event-1", + "VIN001", + ProtocolId.GB32960, + Instant.parse("2026-06-22T16:30:00Z"), + Instant.parse("2026-06-22T16:30:00Z"), + "trace-1", + Map.of(RawArchiveKeys.META_KEY, "precomputed/key.bin"), + 0x02, + 0, + new byte[]{0x23, 0x23}); + + assertThat(RawArchiveKeys.key(raw)).isEqualTo("precomputed/key.bin"); + } +} diff --git a/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetryFieldValueTest.java b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetryFieldValueTest.java new file mode 100644 index 00000000..e38170ec --- /dev/null +++ b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetryFieldValueTest.java @@ -0,0 +1,39 @@ +package com.lingniu.ingest.api.event; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TelemetryFieldValueTest { + + @Test + void createsTypedDoubleFieldWithStableMetadata() { + TelemetryFieldValue field = TelemetryFieldValue.doubleValue( + "total_mileage_km", 12345.6, "km", "gb32960.vehicle.totalMileageKm"); + + assertThat(field.key()).isEqualTo("total_mileage_km"); + assertThat(field.valueType()).isEqualTo(TelemetryFieldValue.ValueType.DOUBLE); + assertThat(field.value()).isEqualTo("12345.6"); + assertThat(field.unit()).isEqualTo("km"); + assertThat(field.quality()).isEqualTo(TelemetryFieldValue.Quality.GOOD); + assertThat(field.sourcePath()).isEqualTo("gb32960.vehicle.totalMileageKm"); + } + + @Test + void rejectsBlankFieldKey() { + assertThatThrownBy(() -> TelemetryFieldValue.stringValue(" ", "STARTED", "", "gb32960.vehicle.state")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("key"); + } + + @Test + void marksMissingValueExplicitly() { + TelemetryFieldValue field = TelemetryFieldValue.missing( + "hydrogen_remaining_kg", "kg", "gb32960.vendor.hydrogenRemaining"); + + assertThat(field.valueType()).isEqualTo(TelemetryFieldValue.ValueType.STRING); + assertThat(field.value()).isEmpty(); + assertThat(field.quality()).isEqualTo(TelemetryFieldValue.Quality.MISSING); + } +} diff --git a/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetrySnapshotTest.java b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetrySnapshotTest.java new file mode 100644 index 00000000..3b22ccf7 --- /dev/null +++ b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/TelemetrySnapshotTest.java @@ -0,0 +1,58 @@ +package com.lingniu.ingest.api.event; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TelemetrySnapshotTest { + + @Test + void indexesFieldsByInternalKey() { + TelemetrySnapshot snapshot = new TelemetrySnapshot( + "event-1", + "VIN001", + ProtocolId.GB32960, + "REALTIME", + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "archive://raw/event-1.bin", + Map.of("protocolVersion", "V2016"), + List.of( + TelemetryFieldValue.doubleValue("total_mileage_km", 120.5, "km", "gb32960.vehicle.totalMileageKm"), + TelemetryFieldValue.booleanValue("hydrogen_leak_detected", true, "", "gb32960.alarm.HYDROGEN_LEAK") + ) + ); + + assertThat(snapshot.field("total_mileage_km")).isPresent(); + assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("120.5"); + assertThat(snapshot.field("hydrogen_leak_detected").orElseThrow().value()).isEqualTo("true"); + assertThat(snapshot.field("unknown")).isEmpty(); + assertThat(snapshot.metadata()).containsEntry("protocolVersion", "V2016"); + } + + @Test + void rejectsDuplicateFieldKeys() { + assertThatThrownBy(() -> new TelemetrySnapshot( + "event-1", + "VIN001", + ProtocolId.GB32960, + "REALTIME", + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "", + Map.of(), + List.of( + TelemetryFieldValue.doubleValue("speed_kmh", 1.0, "km/h", "a"), + TelemetryFieldValue.doubleValue("speed_kmh", 2.0, "km/h", "b") + ) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + } +} diff --git a/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapperTest.java b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapperTest.java new file mode 100644 index 00000000..ab3caeba --- /dev/null +++ b/modules/core/ingest-api/src/test/java/com/lingniu/ingest/api/event/VehicleEventTelemetrySnapshotMapperTest.java @@ -0,0 +1,175 @@ +package com.lingniu.ingest.api.event; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleEventTelemetrySnapshotMapperTest { + + @Test + void mapsRealtimeEventToInternalTelemetryFields() { + VehicleEvent.Realtime event = new VehicleEvent.Realtime( + "event-1", + "VIN001", + ProtocolId.GB32960, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-1", + Map.of("protocolVersion", "V2016", "rawArchiveUri", "archive://raw/event-1.bin"), + new RealtimePayload( + 80.5, + 12345.6, + 55.0, + 610.0, + -20.5, + 220.0, + 100.0, + 65.0, + 8.2, + 35.0, + 1.1, + RealtimePayload.VehicleState.STARTED, + RealtimePayload.ChargingState.UNCHARGED, + RealtimePayload.RunningMode.FUEL, + 3, + 20.0, + 0.0, + 113.12, + 23.45, + 10.0, + 180.0, + 30.0, + 70.0 + ) + ); + + TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow(); + + assertThat(snapshot.eventId()).isEqualTo("event-1"); + assertThat(snapshot.rawArchiveUri()).isEqualTo("archive://raw/event-1.bin"); + assertThat(snapshot.field("speed_kmh").orElseThrow().value()).isEqualTo("80.5"); + assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("12345.6"); + assertThat(snapshot.field("vehicle_state").orElseThrow().value()).isEqualTo("STARTED"); + assertThat(snapshot.field("hydrogen_high_pressure_mpa").orElseThrow().value()).isEqualTo("35.0"); + assertThat(snapshot.field("longitude").orElseThrow().value()).isEqualTo("113.12"); + } + + @Test + void mapsHydrogenLeakAlarmToInternalSafetyFields() { + VehicleEvent.Alarm event = new VehicleEvent.Alarm( + "event-2", + "VIN001", + ProtocolId.GB32960, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-2", + Map.of("protocolVersion", "V2016"), + new AlarmPayload( + AlarmPayload.AlarmLevel.CRITICAL, + 1, + "GB32960_ALARM", + java.util.List.of("OTH-1"), + java.util.Set.of("HYDROGEN_LEAK"), + 113.12, + 23.45, + AlarmPayload.SafetyCategory.HYDROGEN_LEAK, + true, + AlarmPayload.HydrogenLeakLevel.CRITICAL, + true + ) + ); + + TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow(); + + assertThat(snapshot.eventType()).isEqualTo("ALARM"); + assertThat(snapshot.field("hydrogen_leak_detected").orElseThrow().value()).isEqualTo("true"); + assertThat(snapshot.field("hydrogen_leak_level").orElseThrow().value()).isEqualTo("CRITICAL"); + assertThat(snapshot.field("hydrogen_leak_action_required").orElseThrow().value()).isEqualTo("true"); + assertThat(snapshot.field("safety_category").orElseThrow().value()).isEqualTo("HYDROGEN_LEAK"); + } + + @Test + void mapsLocationMileageToInternalTelemetryField() { + VehicleEvent.Location event = new VehicleEvent.Location( + "event-location-1", + "VIN001", + ProtocolId.JT808, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-location-1", + Map.of("messageId", "0x200"), + new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1, 1234.5) + ); + + TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow(); + + assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("1234.5"); + assertThat(snapshot.field("total_mileage_km").orElseThrow().sourcePath()) + .isEqualTo("event.location.totalMileageKm"); + } + + @Test + void mediaMetaUsesArchiveRefAsQueryableRawArchiveUriFallback() { + VehicleEvent.MediaMeta event = new VehicleEvent.MediaMeta( + "media-1", + "VIN001", + ProtocolId.JT1078, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-4", + Map.of("channel", "1"), + "media-001", + "jt1078-video-h264-channel-1", + 1024, + "file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp" + ); + + TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow(); + + assertThat(snapshot.eventType()).isEqualTo("MEDIA_META"); + assertThat(snapshot.rawArchiveUri()) + .isEqualTo("file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp"); + assertThat(snapshot.field("media_archive_ref").orElseThrow().value()) + .isEqualTo("file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp"); + } + + @Test + void derivesRawArchiveUriFromRawArchiveKeyMetadata() { + VehicleEvent.Heartbeat event = new VehicleEvent.Heartbeat( + "heartbeat-1", + "VIN001", + ProtocolId.JT808, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-5", + Map.of("rawArchiveKey", "2026/06/22/JT808/VIN001/raw-1.bin") + ); + + TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow(); + + assertThat(snapshot.rawArchiveUri()) + .isEqualTo("archive://2026/06/22/JT808/VIN001/raw-1.bin"); + } + + @Test + void skipsRawArchiveBecauseItIsStoredByArchiveSink() { + VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( + "raw-1", + "VIN001", + ProtocolId.GB32960, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-3", + Map.of(), + 0x02, + 0, + new byte[]{0x23, 0x23} + ); + + assertThat(VehicleEventTelemetrySnapshotMapper.toSnapshot(raw)).isEmpty(); + } +} diff --git a/ingest-codec-common/pom.xml b/modules/core/ingest-codec-common/pom.xml similarity index 94% rename from ingest-codec-common/pom.xml rename to modules/core/ingest-codec-common/pom.xml index cc116cd7..80d9e6a3 100644 --- a/ingest-codec-common/pom.xml +++ b/modules/core/ingest-codec-common/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml ingest-codec-common ingest-codec-common diff --git a/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java b/modules/core/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java similarity index 100% rename from ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java rename to modules/core/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BccChecksum.java diff --git a/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java b/modules/core/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java similarity index 100% rename from ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java rename to modules/core/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BcdCodec.java diff --git a/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java b/modules/core/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java similarity index 100% rename from ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java rename to modules/core/ingest-codec-common/src/main/java/com/lingniu/ingest/codec/BitUtils.java diff --git a/ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java b/modules/core/ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java similarity index 100% rename from ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java rename to modules/core/ingest-codec-common/src/test/java/com/lingniu/ingest/codec/BcdCodecTest.java diff --git a/ingest-core/pom.xml b/modules/core/ingest-core/pom.xml similarity index 97% rename from ingest-core/pom.xml rename to modules/core/ingest-core/pom.xml index 6a497004..a436d69a 100644 --- a/ingest-core/pom.xml +++ b/modules/core/ingest-core/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml ingest-core ingest-core diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java similarity index 74% rename from ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java index aebb16a9..6e2839f6 100644 --- a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java @@ -16,6 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.function.UnaryOperator; /** * {@link AsyncBatch} 注解的执行器:把 Handler 方法从"单条调用"变成"批量调用"。 @@ -51,7 +52,12 @@ public final class AsyncBatchExecutor implements AutoCloseable { /** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */ public void submit(HandlerDefinition def, Object message) { Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher)); - b.offer(message); + b.offer(new BatchItem(message, UnaryOperator.identity(), false)); + } + + public void submit(HandlerDefinition def, Object message, UnaryOperator eventTransformer) { + Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher)); + b.offer(new BatchItem(message, eventTransformer == null ? UnaryOperator.identity() : eventTransformer, true)); } @Override @@ -68,7 +74,7 @@ public final class AsyncBatchExecutor implements AutoCloseable { private final Consumer publisher; private final int batchSize; private final long waitMs; - private final BlockingQueue queue; + private final BlockingQueue queue; private final Thread[] workers; private volatile boolean running = true; @@ -92,7 +98,7 @@ public final class AsyncBatchExecutor implements AutoCloseable { def.method().getName(), batchSize, waitMs, pool); } - void offer(Object item) { + void offer(BatchItem item) { try { if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) { log.warn("batcher queue full, dropping item for {}", def.method().getName()); @@ -105,15 +111,15 @@ public final class AsyncBatchExecutor implements AutoCloseable { private void loop() { while (running) { try { - Object first = queue.poll(200, TimeUnit.MILLISECONDS); + BatchItem first = queue.poll(200, TimeUnit.MILLISECONDS); if (first == null) continue; - List buf = new ArrayList<>(batchSize); + List buf = new ArrayList<>(batchSize); buf.add(first); long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L; while (buf.size() < batchSize) { long remain = deadlineNanos - System.nanoTime(); if (remain <= 0) break; - Object next = queue.poll(remain, TimeUnit.NANOSECONDS); + BatchItem next = queue.poll(remain, TimeUnit.NANOSECONDS); if (next == null) break; buf.add(next); } @@ -127,11 +133,35 @@ public final class AsyncBatchExecutor implements AutoCloseable { } } + private void flush(List buf) { + if (requiresPerItemTransform(buf)) { + for (BatchItem item : buf) { + flushTransformedItem(item); + } + return; + } + + publishEvents(invoke(buf.stream().map(BatchItem::message).toList()), UnaryOperator.identity()); + } + + private boolean requiresPerItemTransform(List buf) { + for (BatchItem item : buf) { + if (item.transformRequired()) { + return true; + } + } + return false; + } + + private void flushTransformedItem(BatchItem item) { + publishEvents(invoke(List.of(item.message())), item.eventTransformer()); + } + @SuppressWarnings("unchecked") - private void flush(List buf) { + private List invoke(List messages) { List events; try { - Object result = def.method().invoke(def.bean(), buf); + Object result = def.method().invoke(def.bean(), messages); events = switch (result) { case null -> List.of(); case List list -> (List) list; @@ -141,14 +171,18 @@ public final class AsyncBatchExecutor implements AutoCloseable { }; } catch (InvocationTargetException e) { log.error("batcher invoke failed method={}", def.method().getName(), e.getCause()); - return; + return List.of(); } catch (IllegalAccessException e) { log.error("batcher access denied method={}", def.method().getName(), e); - return; + return List.of(); } + return events; + } + + private void publishEvents(List events, UnaryOperator eventTransformer) { for (VehicleEvent e : events) { try { - publisher.accept(e); + publisher.accept(eventTransformer.apply(e)); } catch (Exception ex) { log.warn("batcher publish failed eventId={}", e.eventId(), ex); } @@ -161,4 +195,9 @@ public final class AsyncBatchExecutor implements AutoCloseable { for (Thread t : workers) t.interrupt(); } } + + private record BatchItem(Object message, + UnaryOperator eventTransformer, + boolean transformRequired) { + } } diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/VehicleEventSlot.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java new file mode 100644 index 00000000..754fcf3f --- /dev/null +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java @@ -0,0 +1,183 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.event.RawArchiveKeys; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.pipeline.InterceptorChain; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 核心分发器:RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。 + * + *

所有 Inbound Adapter(Netty / MQTT / PushClient)都调用 {@link #dispatch(RawFrame)}, + * 协议差异在这里被彻底抹平。 + * + *

对于带 {@code @AsyncBatch} 的 Handler,Dispatcher 不直接反射调用,而是把消息交给 + * {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。 + */ +public final class Dispatcher { + + private static final Logger log = LoggerFactory.getLogger(Dispatcher.class); + private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong(); + + private final HandlerRegistry registry; + private final InterceptorChain interceptors; + private final HandlerInvoker invoker; + private final DisruptorEventBus eventBus; + private final AsyncBatchExecutor batchExecutor; + + public Dispatcher(HandlerRegistry registry, + InterceptorChain interceptors, + HandlerInvoker invoker, + DisruptorEventBus eventBus, + AsyncBatchExecutor batchExecutor) { + this.registry = registry; + this.interceptors = interceptors; + this.invoker = invoker; + this.eventBus = eventBus; + this.batchExecutor = batchExecutor; + } + + public void dispatch(RawFrame frame) { + IngestContext ctx = new IngestContext(UUID.randomUUID().toString()); + try { + // 在 interceptor 之前发 RawArchive,保证原始字节被无条件落盘(dedup/rate-limit + // 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 ArchiveEventSink 消费。 + RawArchiveLookup rawArchive = emitRawArchive(frame, ctx); + + if (!interceptors.before(frame, ctx)) { + log.debug("frame aborted: {}", ctx.abortReason()); + return; + } + + List handlers = registry.resolve( + frame.protocolId(), frame.command(), frame.infoType()); + if (handlers.isEmpty()) { + log.debug("no handler for {} cmd=0x{} info=0x{}", + frame.protocolId(), Integer.toHexString(frame.command()), + Integer.toHexString(frame.infoType())); + return; + } + + for (HandlerDefinition def : handlers) { + if (def.asyncBatch() != null) { + batchExecutor.submit(def, frame.payload(), event -> enrichWithRawArchive(event, rawArchive)); + continue; + } + List events = invoker.invoke(def, frame, ctx); + for (VehicleEvent e : events) { + e = enrichWithRawArchive(e, rawArchive); + interceptors.after(e, ctx); + eventBus.publish(e); + } + } + } catch (Throwable t) { + log.error("dispatch failure traceId={}", ctx.traceId(), t); + interceptors.onError(t, ctx); + } + } + + /** + * 从 {@link RawFrame} 构造一条 {@link VehicleEvent.RawArchive} 发到 EventBus。 + * 仅在 {@code rawBytes} 非空时发——有些入站适配器(未来)可能只传解析后的对象。 + * + *

VIN 取自 sourceMeta 里的 {@code vin} key(由各入站适配器负责填充);缺失时 + * 留空字符串,archive sink 会用 "unknown-vin" 占位保证 key 可解析。 + */ + private RawArchiveLookup emitRawArchive(RawFrame frame, IngestContext ctx) { + byte[] bytes = frame.rawBytes(); + if (bytes == null || bytes.length == 0) return RawArchiveLookup.empty(); + + Map meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta(); + String vin = meta.getOrDefault("vin", ""); + Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now(); + String eventId = nextRawArchiveEventId(ingestTime); + String key = RawArchiveKeys.key(ingestTime, frame.protocolId(), vin, eventId); + Map archiveMeta = addRawArchiveMetadata(meta, eventId, key); + + VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( + eventId, + vin, + frame.protocolId(), + ingestTime, + ingestTime, + ctx.traceId(), + archiveMeta, + frame.command(), + frame.infoType(), + bytes); + eventBus.publish(raw); + return new RawArchiveLookup(eventId, key, RawArchiveKeys.logicalUri(key)); + } + + private static String nextRawArchiveEventId(Instant ingestTime) { + long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L; + long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base)); + return Long.toString(next); + } + + private static Map addRawArchiveMetadata(Map metadata, + String eventId, + String key) { + Map out = new LinkedHashMap<>(metadata == null ? Map.of() : metadata); + out.putIfAbsent(RawArchiveKeys.META_EVENT_ID, eventId); + out.putIfAbsent(RawArchiveKeys.META_KEY, key); + out.putIfAbsent(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(key)); + return Map.copyOf(out); + } + + private static VehicleEvent enrichWithRawArchive(VehicleEvent event, RawArchiveLookup rawArchive) { + if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) { + return event; + } + Map metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key()); + return switch (event) { + case VehicleEvent.Realtime e -> new VehicleEvent.Realtime( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata, e.payload()); + case VehicleEvent.Location e -> new VehicleEvent.Location( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata, e.payload()); + case VehicleEvent.Alarm e -> new VehicleEvent.Alarm( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata, e.payload()); + case VehicleEvent.Login e -> new VehicleEvent.Login( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata, e.iccid(), e.protocolVersion()); + case VehicleEvent.Logout e -> new VehicleEvent.Logout( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata); + case VehicleEvent.Heartbeat e -> new VehicleEvent.Heartbeat( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata); + case VehicleEvent.MediaMeta e -> new VehicleEvent.MediaMeta( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata, e.mediaId(), e.mediaType(), e.sizeBytes(), e.archiveRef()); + case VehicleEvent.Passthrough e -> new VehicleEvent.Passthrough( + e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(), + e.traceId(), metadata, e.passthroughType(), e.data()); + case VehicleEvent.RawArchive e -> e; + }; + } + + private record RawArchiveLookup(String eventId, String key, String uri) { + private static RawArchiveLookup empty() { + return new RawArchiveLookup("", "", ""); + } + + private boolean isEmpty() { + return key == null || key.isBlank(); + } + } +} diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerDefinition.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java similarity index 96% rename from ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java index d8d7524e..90a7d158 100644 --- a/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerRegistry.java @@ -34,6 +34,9 @@ public final class HandlerRegistry { if (d.matches(protocol, command, infoType)) result.add(d); } } + if (!result.isEmpty()) { + return result; + } for (HandlerDefinition d : wildcardHandlers) { if (d.matches(protocol, command, infoType)) result.add(d); } diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java similarity index 75% rename from ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java index e9c91a86..7816fba9 100644 --- a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java @@ -7,6 +7,7 @@ import com.lingniu.ingest.api.pipeline.IngestInterceptor; import com.lingniu.ingest.api.pipeline.RawFrame; import org.springframework.core.Ordered; +import java.util.Arrays; import java.time.Duration; /** @@ -29,7 +30,10 @@ public class DedupInterceptor implements IngestInterceptor, Ordered { @Override public boolean before(RawFrame frame, IngestContext ctx) { String vin = frame.sourceMeta().getOrDefault("vin", "unknown"); - String seq = frame.sourceMeta().getOrDefault("seq", "0"); + String seq = frame.sourceMeta().get("seq"); + if (seq == null || seq.isBlank()) { + seq = rawFingerprint(frame); + } String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq; if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) { ctx.abort("duplicate:" + key); @@ -42,4 +46,12 @@ public class DedupInterceptor implements IngestInterceptor, Ordered { public int getOrder() { return 100; } + + private static String rawFingerprint(RawFrame frame) { + byte[] rawBytes = frame.rawBytes(); + if (rawBytes != null && rawBytes.length > 0) { + return "raw:" + Integer.toHexString(Arrays.hashCode(rawBytes)); + } + return "receivedAt:" + frame.receivedAt(); + } } diff --git a/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java similarity index 100% rename from ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java rename to modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java diff --git a/ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/core/ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/core/ingest-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/DispatcherRawArchiveMetadataTest.java b/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/DispatcherRawArchiveMetadataTest.java new file mode 100644 index 00000000..58d90ac3 --- /dev/null +++ b/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/DispatcherRawArchiveMetadataTest.java @@ -0,0 +1,218 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.AsyncBatch; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.api.sink.EventSink; +import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.pipeline.InterceptorChain; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +class DispatcherRawArchiveMetadataTest { + + @Test + void enrichesBusinessEventsWithRawArchiveLookupMetadata() throws Exception { + CollectingSink sink = new CollectingSink(); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + try { + Dispatcher dispatcher = new Dispatcher( + registryWithLocationHandler(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + + dispatcher.dispatch(new RawFrame( + ProtocolId.JT808, + 0x0200, + 0, + new Object(), + new byte[]{0x7e, 0x01, 0x7e}, + Map.of("vin", "VIN001", "peer", "127.0.0.1:10001"), + Instant.parse("2026-06-22T08:00:00Z"))); + + VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class); + VehicleEvent.Location location = awaitEvent(sink, VehicleEvent.Location.class); + + String rawArchiveKey = raw.metadata().get("rawArchiveKey"); + assertThat(rawArchiveKey).isNotBlank(); + assertThat(raw.eventId()).containsOnlyDigits(); + assertThat(rawArchiveKey).endsWith("/" + raw.eventId() + ".bin"); + assertThat(location.metadata()) + .containsEntry("rawArchiveEventId", raw.eventId()) + .containsEntry("rawArchiveKey", rawArchiveKey) + .containsEntry("rawArchiveUri", "archive://" + rawArchiveKey); + } finally { + batchExecutor.close(); + eventBus.close(); + } + } + + @Test + void enrichesAsyncBatchEventsWithTheirRawArchiveLookupMetadata() throws Exception { + CollectingSink sink = new CollectingSink(); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + try { + Dispatcher dispatcher = new Dispatcher( + registryWithAsyncLocationHandler(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + + dispatcher.dispatch(new RawFrame( + ProtocolId.JT808, + 0x0201, + 0, + "payload-1", + new byte[]{0x7e, 0x02, 0x7e}, + Map.of("vin", "VIN002"), + Instant.parse("2026-06-22T08:01:00Z"))); + + VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class); + VehicleEvent.Location location = awaitLocationEvent(sink, "async-location-1"); + + String rawArchiveKey = raw.metadata().get("rawArchiveKey"); + assertThat(location.metadata()) + .containsEntry("rawArchiveEventId", raw.eventId()) + .containsEntry("rawArchiveKey", rawArchiveKey) + .containsEntry("rawArchiveUri", "archive://" + rawArchiveKey); + } finally { + batchExecutor.close(); + eventBus.close(); + } + } + + private static HandlerRegistry registryWithLocationHandler() throws NoSuchMethodException { + LocationHandler bean = new LocationHandler(); + Method method = LocationHandler.class.getDeclaredMethod("onLocation", Object.class); + HandlerRegistry registry = new HandlerRegistry(); + registry.register(new HandlerDefinition( + ProtocolId.JT808, + 0x0200, + 0, + "test-location", + bean, + method, + Object.class, + null, + null, + null)); + return registry; + } + + private static HandlerRegistry registryWithAsyncLocationHandler() throws NoSuchMethodException { + AsyncLocationHandler bean = new AsyncLocationHandler(); + Method method = AsyncLocationHandler.class.getDeclaredMethod("onLocationBatch", List.class); + HandlerRegistry registry = new HandlerRegistry(); + registry.register(new HandlerDefinition( + ProtocolId.JT808, + 0x0201, + 0, + "test-async-location", + bean, + method, + Object.class, + null, + null, + method.getAnnotation(AsyncBatch.class))); + return registry; + } + + private static T awaitEvent(CollectingSink sink, Class type) throws InterruptedException { + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + synchronized (sink.events) { + for (VehicleEvent event : sink.events) { + if (type.isInstance(event)) { + return type.cast(event); + } + } + } + Thread.sleep(25); + } + throw new AssertionError("event not published: " + type.getSimpleName() + ", actual=" + sink.events); + } + + private static VehicleEvent.Location awaitLocationEvent(CollectingSink sink, String eventId) throws InterruptedException { + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + synchronized (sink.events) { + for (VehicleEvent event : sink.events) { + if (event instanceof VehicleEvent.Location location && location.eventId().equals(eventId)) { + return location; + } + } + } + Thread.sleep(25); + } + throw new AssertionError("location event not published: " + eventId + ", actual=" + sink.events); + } + + @ProtocolHandler(protocol = ProtocolId.JT808) + static final class LocationHandler { + @MessageMapping(command = 0x0200) + VehicleEvent.Location onLocation(Object ignored) { + return new VehicleEvent.Location( + "location-1", + "VIN001", + ProtocolId.JT808, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-location", + Map.of(), + new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1)); + } + } + + @ProtocolHandler(protocol = ProtocolId.JT808) + public static final class AsyncLocationHandler { + @MessageMapping(command = 0x0201) + @AsyncBatch(size = 2, waitMs = 10, poolSize = 1) + public List onLocationBatch(List batch) { + return List.of(new VehicleEvent.Location( + "async-location-1", + "VIN002", + ProtocolId.JT808, + Instant.parse("2026-06-22T08:01:00Z"), + Instant.parse("2026-06-22T08:01:01Z"), + "trace-async-location", + Map.of(), + new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1))); + } + } + + private static final class CollectingSink implements EventSink { + private final List events = new ArrayList<>(); + + @Override + public String name() { + return "collecting"; + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + synchronized (events) { + events.add(event); + } + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/HandlerRegistryTest.java b/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/HandlerRegistryTest.java new file mode 100644 index 00000000..03cd2f27 --- /dev/null +++ b/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/dispatcher/HandlerRegistryTest.java @@ -0,0 +1,51 @@ +package com.lingniu.ingest.core.dispatcher; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class HandlerRegistryTest { + + @Test + void exactCommandMatchTakesPrecedenceOverWildcardHandlers() throws Exception { + HandlerRegistry registry = new HandlerRegistry(); + Object bean = new SampleHandlers(); + Method exact = SampleHandlers.class.getMethod("exact", Object.class); + Method wildcard = SampleHandlers.class.getMethod("wildcard", Object.class); + HandlerDefinition exactDef = definition(0x0200, "exact", bean, exact); + HandlerDefinition wildcardDef = definition(0, "wildcard", bean, wildcard); + + registry.register(exactDef); + registry.register(wildcardDef); + + assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0200, 0)) + .containsExactly(exactDef); + assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0500, 0)) + .containsExactly(wildcardDef); + } + + private static HandlerDefinition definition(int command, String desc, Object bean, Method method) { + return new HandlerDefinition( + ProtocolId.XINDA_PUSH, + command, + 0, + desc, + bean, + method, + Object.class, + null, + null, + null); + } + + public static final class SampleHandlers { + public void exact(Object ignored) { + } + + public void wildcard(Object ignored) { + } + } +} diff --git a/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptorTest.java b/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptorTest.java new file mode 100644 index 00000000..9b3076b0 --- /dev/null +++ b/modules/core/ingest-core/src/test/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptorTest.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.core.pipeline.builtin; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.RawFrame; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DedupInterceptorTest { + + @Test + void usesRawFingerprintWhenSequenceIsMissing() { + DedupInterceptor interceptor = new DedupInterceptor(100, 60); + IngestContext ctx = new IngestContext("trace-1"); + + RawFrame first = frame(new byte[]{0x23, 0x23, 0x02, 0x01}); + RawFrame differentPayload = frame(new byte[]{0x23, 0x23, 0x02, 0x02}); + RawFrame duplicate = frame(new byte[]{0x23, 0x23, 0x02, 0x01}); + + assertThat(interceptor.before(first, ctx)).isTrue(); + assertThat(interceptor.before(differentPayload, ctx)).isTrue(); + assertThat(interceptor.before(duplicate, ctx)).isFalse(); + assertThat(ctx.aborted()).isTrue(); + } + + private static RawFrame frame(byte[] rawBytes) { + return new RawFrame( + ProtocolId.GB32960, + 0x02, + 0, + new Object(), + rawBytes, + Map.of("vin", "TESTSTATVIN000001"), + Instant.parse("2026-06-22T13:00:00Z")); + } +} diff --git a/observability/pom.xml b/modules/core/observability/pom.xml similarity index 95% rename from observability/pom.xml rename to modules/core/observability/pom.xml index bb7b166f..88cbaa32 100644 --- a/observability/pom.xml +++ b/modules/core/observability/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml observability observability diff --git a/observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java b/modules/core/observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java similarity index 100% rename from observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java rename to modules/core/observability/src/main/java/com/lingniu/ingest/observability/IngestMetrics.java diff --git a/observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java b/modules/core/observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java similarity index 100% rename from observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java rename to modules/core/observability/src/main/java/com/lingniu/ingest/observability/ObservabilityAutoConfiguration.java diff --git a/observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/core/observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/core/observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/session-core/pom.xml b/modules/core/session-core/pom.xml similarity index 71% rename from session-core/pom.xml rename to modules/core/session-core/pom.xml index a24f02ab..076d077f 100644 --- a/session-core/pom.xml +++ b/modules/core/session-core/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml session-core session-core @@ -23,6 +24,10 @@ org.springframework.boot spring-boot-autoconfigure + + org.springframework.boot + spring-boot-starter-data-redis + com.github.ben-manes.caffeine caffeine @@ -37,5 +42,15 @@ assertj-core test + + org.mockito + mockito-core + test + + + org.springframework.boot + spring-boot-test + test + diff --git a/session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java similarity index 100% rename from session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java rename to modules/core/session-core/src/main/java/com/lingniu/ingest/session/CommandDispatcher.java diff --git a/session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java similarity index 100% rename from session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java rename to modules/core/session-core/src/main/java/com/lingniu/ingest/session/DeviceSession.java diff --git a/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java similarity index 100% rename from session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java rename to modules/core/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java diff --git a/session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java similarity index 100% rename from session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java rename to modules/core/session-core/src/main/java/com/lingniu/ingest/session/NoopCommandDispatcher.java diff --git a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java new file mode 100644 index 00000000..7e222c15 --- /dev/null +++ b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java @@ -0,0 +1,187 @@ +package com.lingniu.ingest.session; + +import com.lingniu.ingest.api.ProtocolId; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.UnaryOperator; + +/** + * Redis-backed session store for multi-instance command routing metadata. + * + *

The Netty Channel itself stays local to the protocol instance. Redis keeps + * the stable terminal indexes so HTTP callers can resolve sessionId / VIN / + * phone consistently across restarts and replicas. + */ +public final class RedisSessionStore implements SessionStore { + + private static final String PREFIX = "vehicle:session:"; + private static final String ID_SET_KEY = PREFIX + "index:ids"; + + private final StringRedisTemplate redis; + private final Duration ttl; + + public RedisSessionStore(StringRedisTemplate redis, Duration ttl) { + this.redis = redis; + this.ttl = ttl; + } + + @Override + public void put(DeviceSession session) { + findBySessionId(session.sessionId()).ifPresent(old -> deleteIndexes(old)); + redis.opsForHash().putAll(sessionKey(session.sessionId()), toHash(session)); + redis.expire(sessionKey(session.sessionId()), ttl); + if (hasText(session.vin())) { + redis.opsForValue().set(vinIndexKey(session.vin()), session.sessionId(), ttl); + } + if (hasText(session.phone())) { + redis.opsForValue().set(phoneIndexKey(session.phone()), session.sessionId(), ttl); + } + redis.opsForSet().add(ID_SET_KEY, session.sessionId()); + redis.expire(ID_SET_KEY, ttl); + } + + @Override + public Optional findBySessionId(String sessionId) { + if (!hasText(sessionId)) { + return Optional.empty(); + } + Map values = redis.opsForHash().entries(sessionKey(sessionId)); + if (values == null || values.isEmpty()) { + return Optional.empty(); + } + redis.expire(sessionKey(sessionId), ttl); + return Optional.of(fromHash(values)); + } + + @Override + public Optional findByVin(String vin) { + if (!hasText(vin)) { + return Optional.empty(); + } + String sessionId = redis.opsForValue().get(vinIndexKey(vin)); + Optional session = findBySessionId(sessionId); + if (session.isEmpty() && hasText(sessionId)) { + redis.delete(vinIndexKey(vin)); + } + return session; + } + + @Override + public Optional findByPhone(String phone) { + if (!hasText(phone)) { + return Optional.empty(); + } + String sessionId = redis.opsForValue().get(phoneIndexKey(phone)); + Optional session = findBySessionId(sessionId); + if (session.isEmpty() && hasText(sessionId)) { + redis.delete(phoneIndexKey(phone)); + } + return session; + } + + @Override + public synchronized Optional update(String sessionId, UnaryOperator updater) { + Optional current = findBySessionId(sessionId); + if (current.isEmpty()) { + return Optional.empty(); + } + DeviceSession next = updater.apply(current.orElseThrow()); + put(next); + return Optional.of(next); + } + + @Override + public void remove(String sessionId) { + Optional current = findBySessionId(sessionId); + redis.delete(sessionKey(sessionId)); + current.ifPresent(this::deleteIndexes); + redis.opsForSet().remove(ID_SET_KEY, sessionId); + } + + @Override + public int size() { + Long size = redis.opsForSet().size(ID_SET_KEY); + if (size == null || size <= 0) { + return 0; + } + return size > Integer.MAX_VALUE ? Integer.MAX_VALUE : size.intValue(); + } + + private void deleteIndexes(DeviceSession session) { + if (hasText(session.vin())) { + redis.delete(vinIndexKey(session.vin())); + } + if (hasText(session.phone())) { + redis.delete(phoneIndexKey(session.phone())); + } + } + + private static Map toHash(DeviceSession session) { + Map values = new LinkedHashMap<>(); + put(values, "sessionId", session.sessionId()); + put(values, "protocol", session.protocol() == null ? null : session.protocol().name()); + put(values, "vin", session.vin()); + put(values, "phone", session.phone()); + put(values, "token", session.token()); + put(values, "peerAddress", session.peerAddress()); + put(values, "registeredAt", session.registeredAt() == null ? null : session.registeredAt().toString()); + put(values, "lastSeenAt", session.lastSeenAt() == null ? null : session.lastSeenAt().toString()); + if (session.attributes() != null) { + session.attributes().forEach((key, value) -> put(values, "attr." + key, value)); + } + return values; + } + + private static DeviceSession fromHash(Map values) { + Map attributes = new LinkedHashMap<>(); + values.forEach((key, value) -> { + String k = String.valueOf(key); + if (k.startsWith("attr.") && value != null) { + attributes.put(k.substring("attr.".length()), String.valueOf(value)); + } + }); + return new DeviceSession( + value(values, "sessionId"), + ProtocolId.valueOf(value(values, "protocol")), + value(values, "vin"), + value(values, "phone"), + value(values, "token"), + value(values, "peerAddress"), + Instant.parse(value(values, "registeredAt")), + Instant.parse(value(values, "lastSeenAt")), + Map.copyOf(attributes)); + } + + private static void put(Map values, String key, String value) { + if (value != null) { + values.put(key, value); + } + } + + private static String value(Map values, String key) { + Object value = values.get(key); + return value == null ? null : String.valueOf(value); + } + + private static String sessionKey(String sessionId) { + return PREFIX + sessionId; + } + + private static String vinIndexKey(String vin) { + return PREFIX + "index:vin:" + vin; + } + + private static String phoneIndexKey(String phone) { + return PREFIX + "index:phone:" + phone; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/SessionProperties.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/SessionProperties.java new file mode 100644 index 00000000..01c4d03a --- /dev/null +++ b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/SessionProperties.java @@ -0,0 +1,34 @@ +package com.lingniu.ingest.session; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "lingniu.ingest.session") +public class SessionProperties { + + private Store store = Store.MEMORY; + + private Duration ttl = Duration.ofMinutes(30); + + public Store getStore() { + return store; + } + + public void setStore(Store store) { + this.store = store; + } + + public Duration getTtl() { + return ttl; + } + + public void setTtl(Duration ttl) { + this.ttl = ttl; + } + + public enum Store { + MEMORY, + REDIS + } +} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java similarity index 100% rename from session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java rename to modules/core/session-core/src/main/java/com/lingniu/ingest/session/SessionStore.java diff --git a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java new file mode 100644 index 00000000..63f32893 --- /dev/null +++ b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.session.config; + +import com.lingniu.ingest.session.CommandDispatcher; +import com.lingniu.ingest.session.InMemorySessionStore; +import com.lingniu.ingest.session.NoopCommandDispatcher; +import com.lingniu.ingest.session.RedisSessionStore; +import com.lingniu.ingest.session.SessionProperties; +import com.lingniu.ingest.session.SessionStore; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +@AutoConfiguration +@EnableConfigurationProperties(SessionProperties.class) +public class SessionCoreAutoConfiguration { + + @Bean + @ConditionalOnBean(StringRedisTemplate.class) + @ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis") + @ConditionalOnMissingBean + public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) { + return new RedisSessionStore(redis, properties.getTtl()); + } + + @Bean + @ConditionalOnMissingBean + public SessionStore sessionStore() { + return new InMemorySessionStore(); + } + + @Bean + @ConditionalOnMissingBean + public CommandDispatcher commandDispatcher() { + return new NoopCommandDispatcher(); + } +} diff --git a/session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/core/session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/core/session-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/core/session-core/src/test/java/com/lingniu/ingest/session/RedisSessionStoreTest.java b/modules/core/session-core/src/test/java/com/lingniu/ingest/session/RedisSessionStoreTest.java new file mode 100644 index 00000000..1b4df081 --- /dev/null +++ b/modules/core/session-core/src/test/java/com/lingniu/ingest/session/RedisSessionStoreTest.java @@ -0,0 +1,137 @@ +package com.lingniu.ingest.session; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RedisSessionStoreTest { + + @Test + void putStoresSessionHashAndThreeIndexesWithTtl() { + RedisFixture fixture = new RedisFixture(); + RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(45)); + DeviceSession session = sampleSession("sid-1", "VIN001", "13800138000"); + + store.put(session); + + verify(fixture.hashOps).putAll(eq("vehicle:session:sid-1"), any(Map.class)); + verify(fixture.valueOps).set("vehicle:session:index:vin:VIN001", "sid-1", Duration.ofMinutes(45)); + verify(fixture.valueOps).set("vehicle:session:index:phone:13800138000", "sid-1", Duration.ofMinutes(45)); + verify(fixture.setOps).add("vehicle:session:index:ids", "sid-1"); + verify(fixture.redis).expire("vehicle:session:sid-1", Duration.ofMinutes(45)); + verify(fixture.redis).expire("vehicle:session:index:ids", Duration.ofMinutes(45)); + } + + @Test + void findsBySessionIdVinAndPhoneFromRedisIndexes() { + RedisFixture fixture = new RedisFixture(); + RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30)); + Map stored = storedSession("sid-1", "VIN001", "13800138000"); + when(fixture.hashOps.entries("vehicle:session:sid-1")).thenReturn(stored); + when(fixture.valueOps.get("vehicle:session:index:vin:VIN001")).thenReturn("sid-1"); + when(fixture.valueOps.get("vehicle:session:index:phone:13800138000")).thenReturn("sid-1"); + + Optional byId = store.findBySessionId("sid-1"); + Optional byVin = store.findByVin("VIN001"); + Optional byPhone = store.findByPhone("13800138000"); + + assertThat(byId).isPresent(); + assertThat(byVin).contains(byId.orElseThrow()); + assertThat(byPhone).contains(byId.orElseThrow()); + assertThat(byId.orElseThrow().attributes()).containsEntry("identitySource", "BINDING"); + verify(fixture.redis, atLeastOnce()).expire("vehicle:session:sid-1", Duration.ofMinutes(30)); + } + + @Test + void updateRewritesIndexesWhenVinChangesAndRemoveDeletesAllKeys() { + RedisFixture fixture = new RedisFixture(); + RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30)); + when(fixture.hashOps.entries("vehicle:session:sid-1")) + .thenReturn(storedSession("sid-1", "VIN001", "13800138000")) + .thenReturn(storedSession("sid-1", "VIN001", "13800138000")) + .thenReturn(storedSession("sid-1", "VIN002", "13800138000")); + + Optional updated = store.update("sid-1", session -> session.withVin("VIN002")); + store.remove("sid-1"); + + assertThat(updated).isPresent(); + verify(fixture.redis).delete("vehicle:session:index:vin:VIN001"); + verify(fixture.valueOps).set("vehicle:session:index:vin:VIN002", "sid-1", Duration.ofMinutes(30)); + verify(fixture.redis).delete("vehicle:session:sid-1"); + verify(fixture.redis).delete("vehicle:session:index:vin:VIN002"); + verify(fixture.redis, atLeastOnce()).delete("vehicle:session:index:phone:13800138000"); + verify(fixture.setOps).remove("vehicle:session:index:ids", "sid-1"); + } + + @Test + void sizeReturnsRedisIdSetCardinality() { + RedisFixture fixture = new RedisFixture(); + RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30)); + when(fixture.setOps.size("vehicle:session:index:ids")).thenReturn(12L); + + assertThat(store.size()).isEqualTo(12); + } + + private static DeviceSession sampleSession(String sid, String vin, String phone) { + return new DeviceSession( + sid, + ProtocolId.JT808, + vin, + phone, + "token-1", + "127.0.0.1:9000", + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:01:00Z"), + Map.of("identitySource", "BINDING")); + } + + private static Map storedSession(String sid, String vin, String phone) { + Map values = new LinkedHashMap<>(); + values.put("sessionId", sid); + values.put("protocol", "JT808"); + values.put("vin", vin); + values.put("phone", phone); + values.put("token", "token-1"); + values.put("peerAddress", "127.0.0.1:9000"); + values.put("registeredAt", "2026-06-22T08:00:00Z"); + values.put("lastSeenAt", "2026-06-22T08:01:00Z"); + values.put("attr.identitySource", "BINDING"); + return values; + } + + private static final class RedisFixture { + private final StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + private final HashOperations hashOps = mock(HashOperations.class); + @SuppressWarnings("unchecked") + private final ValueOperations valueOps = mock(ValueOperations.class); + @SuppressWarnings("unchecked") + private final SetOperations setOps = mock(SetOperations.class); + + private RedisFixture() { + when(redis.opsForHash()).thenReturn(hashOps); + when(redis.opsForValue()).thenReturn(valueOps); + when(redis.opsForSet()).thenReturn(setOps); + when(hashOps.entries("vehicle:session:sid-1")).thenReturn(Map.of()); + when(setOps.members("vehicle:session:index:ids")).thenReturn(Set.of()); + } + } +} diff --git a/modules/core/session-core/src/test/java/com/lingniu/ingest/session/config/SessionCoreAutoConfigurationTest.java b/modules/core/session-core/src/test/java/com/lingniu/ingest/session/config/SessionCoreAutoConfigurationTest.java new file mode 100644 index 00000000..a713a835 --- /dev/null +++ b/modules/core/session-core/src/test/java/com/lingniu/ingest/session/config/SessionCoreAutoConfigurationTest.java @@ -0,0 +1,39 @@ +package com.lingniu.ingest.session.config; + +import com.lingniu.ingest.session.InMemorySessionStore; +import com.lingniu.ingest.session.RedisSessionStore; +import com.lingniu.ingest.session.SessionStore; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class SessionCoreAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SessionCoreAutoConfiguration.class)); + + @Test + void usesInMemorySessionStoreByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(SessionStore.class); + assertThat(context).hasSingleBean(InMemorySessionStore.class); + }); + } + + @Test + void usesRedisSessionStoreWhenEnabledAndRedisTemplateExists() { + contextRunner + .withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class)) + .withPropertyValues( + "lingniu.ingest.session.store=redis", + "lingniu.ingest.session.ttl=45m") + .run(context -> { + assertThat(context).hasSingleBean(SessionStore.class); + assertThat(context).hasSingleBean(RedisSessionStore.class); + }); + } +} diff --git a/modules/core/vehicle-identity/pom.xml b/modules/core/vehicle-identity/pom.xml new file mode 100644 index 00000000..3bc5f43d --- /dev/null +++ b/modules/core/vehicle-identity/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + ../../../pom.xml + + vehicle-identity + vehicle-identity + 跨协议车辆身份解析与外部标识绑定。 + + + + com.lingniu.ingest + ingest-api + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-autoconfigure + + + com.fasterxml.jackson.core + jackson-databind + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework.boot + spring-boot-test + test + + + diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java new file mode 100644 index 00000000..f1805e41 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java @@ -0,0 +1,101 @@ +package com.lingniu.ingest.identity; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** + * 文件型车辆身份绑定表。 + * + *

写入采用 append-only JSONL,启动时顺序重放到内存索引。这样协议层只依赖 identity SPI, + * 不直接耦合业务库;后续替换成 DB/配置中心时只需新增同接口实现。 + */ +public final class FileVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry { + + private static final Logger log = LoggerFactory.getLogger(FileVehicleIdentityService.class); + + private final Path path; + private final ObjectMapper mapper; + private final InMemoryVehicleIdentityService delegate = new InMemoryVehicleIdentityService(); + private final Object writeLock = new Object(); + + public FileVehicleIdentityService(Path path) { + this(path, new ObjectMapper()); + } + + public FileVehicleIdentityService(Path path, ObjectMapper mapper) { + this.path = path.toAbsolutePath(); + this.mapper = mapper; + load(); + } + + @Override + public void bind(VehicleIdentityBinding binding) { + delegate.bind(binding); + synchronized (writeLock) { + try { + Files.createDirectories(path.getParent()); + try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) { + writer.write(mapper.writeValueAsString(BindingLine.from(binding))); + writer.newLine(); + } + } catch (IOException e) { + throw new IllegalStateException("vehicle identity binding persist failed: " + path, e); + } + } + } + + @Override + public VehicleIdentity resolve(VehicleIdentityLookup lookup) { + return delegate.resolve(lookup); + } + + private void load() { + if (!Files.exists(path)) { + return; + } + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + try { + BindingLine binding = mapper.readValue(line, BindingLine.class); + delegate.bind(binding.toBinding()); + } catch (Exception e) { + log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e); + } + } + } catch (IOException e) { + throw new IllegalStateException("vehicle identity binding load failed: " + path, e); + } + } + + private record BindingLine( + ProtocolId protocol, + String vin, + String phone, + String deviceId, + String plate + ) { + private static BindingLine from(VehicleIdentityBinding binding) { + return new BindingLine( + binding.protocol(), binding.vin(), binding.phone(), binding.deviceId(), binding.plate()); + } + + private VehicleIdentityBinding toBinding() { + return new VehicleIdentityBinding(protocol, vin, phone, deviceId, plate); + } + } +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java new file mode 100644 index 00000000..80bec208 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java @@ -0,0 +1,75 @@ +package com.lingniu.ingest.identity; + +import com.lingniu.ingest.api.ProtocolId; + +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public final class InMemoryVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry { + + private final ConcurrentMap phoneToVin = new ConcurrentHashMap<>(); + private final ConcurrentMap deviceIdToVin = new ConcurrentHashMap<>(); + private final ConcurrentMap plateToVin = new ConcurrentHashMap<>(); + + @Override + public void bind(VehicleIdentityBinding binding) { + if (!binding.phone().isBlank()) { + phoneToVin.put(key(binding.protocol(), binding.phone()), binding.vin()); + } + if (!binding.deviceId().isBlank()) { + deviceIdToVin.put(key(binding.protocol(), binding.deviceId()), binding.vin()); + } + if (!binding.plate().isBlank()) { + plateToVin.put(key(binding.protocol(), binding.plate()), binding.vin()); + } + } + + @Override + public VehicleIdentity resolve(VehicleIdentityLookup lookup) { + if (lookup == null) { + return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN); + } + if (!lookup.vin().isBlank()) { + return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN); + } + VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE); + if (phone != null) return phone; + + VehicleIdentity device = resolveBound(deviceIdToVin, lookup.protocol(), lookup.deviceId(), VehicleIdentitySource.BOUND_DEVICE_ID); + if (device != null) return device; + + VehicleIdentity plate = resolveBound(plateToVin, lookup.protocol(), lookup.plate(), VehicleIdentitySource.BOUND_PLATE); + if (plate != null) return plate; + + if (!lookup.deviceId().isBlank()) { + return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID); + } + if (!lookup.phone().isBlank()) { + return new VehicleIdentity(lookup.phone(), false, VehicleIdentitySource.FALLBACK_PHONE); + } + if (!lookup.plate().isBlank()) { + return new VehicleIdentity(lookup.plate(), false, VehicleIdentitySource.FALLBACK_PLATE); + } + return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN); + } + + private static VehicleIdentity resolveBound(ConcurrentMap map, + ProtocolId protocol, + String externalId, + VehicleIdentitySource source) { + if (externalId == null || externalId.isBlank()) { + return null; + } + String vin = map.get(key(protocol, externalId)); + if (vin == null) { + return null; + } + return new VehicleIdentity(vin, true, source); + } + + private static String key(ProtocolId protocol, String value) { + String p = protocol == null ? "UNKNOWN" : protocol.name(); + return p + ":" + value.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentity.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentity.java new file mode 100644 index 00000000..06449c86 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentity.java @@ -0,0 +1,16 @@ +package com.lingniu.ingest.identity; + +public record VehicleIdentity( + String vin, + boolean resolved, + VehicleIdentitySource source +) { + public VehicleIdentity { + vin = normalize(vin); + source = source == null ? VehicleIdentitySource.UNKNOWN : source; + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityBinding.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityBinding.java new file mode 100644 index 00000000..d393dbe6 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityBinding.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.identity; + +import com.lingniu.ingest.api.ProtocolId; + +public record VehicleIdentityBinding( + ProtocolId protocol, + String vin, + String phone, + String deviceId, + String plate +) { + public VehicleIdentityBinding { + vin = normalize(vin); + phone = normalize(phone); + deviceId = normalize(deviceId); + plate = normalize(plate); + if (vin.isBlank()) { + throw new IllegalArgumentException("vin must not be blank"); + } + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityLookup.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityLookup.java new file mode 100644 index 00000000..4d9ea38c --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityLookup.java @@ -0,0 +1,22 @@ +package com.lingniu.ingest.identity; + +import com.lingniu.ingest.api.ProtocolId; + +public record VehicleIdentityLookup( + ProtocolId protocol, + String vin, + String phone, + String deviceId, + String plate +) { + public VehicleIdentityLookup { + vin = normalize(vin); + phone = normalize(phone); + deviceId = normalize(deviceId); + plate = normalize(plate); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityRegistry.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityRegistry.java new file mode 100644 index 00000000..08fb3bf8 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityRegistry.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.identity; + +public interface VehicleIdentityRegistry { + + void bind(VehicleIdentityBinding binding); +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityResolver.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityResolver.java new file mode 100644 index 00000000..00228696 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentityResolver.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.identity; + +public interface VehicleIdentityResolver { + + VehicleIdentity resolve(VehicleIdentityLookup lookup); +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentitySource.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentitySource.java new file mode 100644 index 00000000..3420788d --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/VehicleIdentitySource.java @@ -0,0 +1,12 @@ +package com.lingniu.ingest.identity; + +public enum VehicleIdentitySource { + EXPLICIT_VIN, + BOUND_PHONE, + BOUND_DEVICE_ID, + BOUND_PLATE, + FALLBACK_DEVICE_ID, + FALLBACK_PHONE, + FALLBACK_PLATE, + UNKNOWN +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java new file mode 100644 index 00000000..dc6b0c45 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java @@ -0,0 +1,41 @@ +package com.lingniu.ingest.identity.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.identity.FileVehicleIdentityService; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityRegistry; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.nio.file.Path; + +@AutoConfiguration +@EnableConfigurationProperties(VehicleIdentityProperties.class) +public class VehicleIdentityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ObjectMapper vehicleIdentityObjectMapper() { + return new ObjectMapper(); + } + + @Bean + @ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class}) + @ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file") + public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties, + ObjectMapper objectMapper) { + return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper); + } + + @Bean + @ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class}) + @ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "memory", + matchIfMissing = true) + public InMemoryVehicleIdentityService vehicleIdentityService() { + return new InMemoryVehicleIdentityService(); + } +} diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityProperties.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityProperties.java new file mode 100644 index 00000000..50acf4b0 --- /dev/null +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityProperties.java @@ -0,0 +1,22 @@ +package com.lingniu.ingest.identity.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.identity") +public class VehicleIdentityProperties { + + private String store = "memory"; + private File file = new File(); + + public String getStore() { return store; } + public void setStore(String store) { this.store = store; } + public File getFile() { return file; } + public void setFile(File file) { this.file = file; } + + public static class File { + private String path = "./data/vehicle-identity.jsonl"; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + } +} diff --git a/modules/core/vehicle-identity/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/core/vehicle-identity/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..2ad78acc --- /dev/null +++ b/modules/core/vehicle-identity/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration diff --git a/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/FileVehicleIdentityServiceTest.java b/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/FileVehicleIdentityServiceTest.java new file mode 100644 index 00000000..163f3898 --- /dev/null +++ b/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/FileVehicleIdentityServiceTest.java @@ -0,0 +1,41 @@ +package com.lingniu.ingest.identity; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class FileVehicleIdentityServiceTest { + + @TempDir + Path tempDir; + + @Test + void persistsBindingsAndReloadsThemAfterRestart() throws Exception { + Path store = tempDir.resolve("vehicle-identity.jsonl"); + FileVehicleIdentityService first = new FileVehicleIdentityService(store); + + first.bind(new VehicleIdentityBinding( + ProtocolId.JT808, "LNVIN000000000099", "13900000099", "DEV099", "粤B09999")); + + FileVehicleIdentityService restarted = new FileVehicleIdentityService(store); + + VehicleIdentity byPhone = restarted.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "13900000099", "", "")); + VehicleIdentity byDevice = restarted.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "", "DEV099", "")); + VehicleIdentity byPlate = restarted.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "", "", "粤B09999")); + + assertThat(byPhone.vin()).isEqualTo("LNVIN000000000099"); + assertThat(byPhone.resolved()).isTrue(); + assertThat(byPhone.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE); + assertThat(byDevice.vin()).isEqualTo("LNVIN000000000099"); + assertThat(byPlate.vin()).isEqualTo("LNVIN000000000099"); + assertThat(Files.readString(store)).contains("\"vin\":\"LNVIN000000000099\""); + } +} diff --git a/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityServiceTest.java b/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityServiceTest.java new file mode 100644 index 00000000..b6139703 --- /dev/null +++ b/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityServiceTest.java @@ -0,0 +1,47 @@ +package com.lingniu.ingest.identity; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryVehicleIdentityServiceTest { + + private final InMemoryVehicleIdentityService service = new InMemoryVehicleIdentityService(); + + @Test + void explicitVinWinsOverExternalIdentifiers() { + VehicleIdentity identity = service.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "LNVIN000000000001", "13900000000", "DEV001", "粤B12345")); + + assertThat(identity.vin()).isEqualTo("LNVIN000000000001"); + assertThat(identity.resolved()).isTrue(); + assertThat(identity.source()).isEqualTo(VehicleIdentitySource.EXPLICIT_VIN); + } + + @Test + void resolvesBoundPhoneDeviceIdAndPlateToVin() { + service.bind(new VehicleIdentityBinding( + ProtocolId.JT808, "LNVIN000000000002", "13900000001", "DEV002", "粤B22222")); + + assertThat(service.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "13900000001", "", "")).vin()) + .isEqualTo("LNVIN000000000002"); + assertThat(service.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "", "DEV002", "")).vin()) + .isEqualTo("LNVIN000000000002"); + assertThat(service.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "", "", "粤B22222")).vin()) + .isEqualTo("LNVIN000000000002"); + } + + @Test + void fallsBackToStableExternalIdentifierWhenNoBindingExists() { + VehicleIdentity identity = service.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", "13900000003", "DEV003", "粤B33333")); + + assertThat(identity.vin()).isEqualTo("DEV003"); + assertThat(identity.resolved()).isFalse(); + assertThat(identity.source()).isEqualTo(VehicleIdentitySource.FALLBACK_DEVICE_ID); + } +} diff --git a/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfigurationTest.java b/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfigurationTest.java new file mode 100644 index 00000000..298bb862 --- /dev/null +++ b/modules/core/vehicle-identity/src/test/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfigurationTest.java @@ -0,0 +1,44 @@ +package com.lingniu.ingest.identity.config; + +import com.lingniu.ingest.identity.VehicleIdentityRegistry; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleIdentityAutoConfigurationTest { + + @TempDir + Path tempDir; + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VehicleIdentityAutoConfiguration.class)); + + @Test + void createsDefaultIdentityService() { + runner.run(context -> { + assertThat(context).hasSingleBean(VehicleIdentityResolver.class); + assertThat(context).hasSingleBean(VehicleIdentityRegistry.class); + assertThat(context.getBean(VehicleIdentityResolver.class)) + .isSameAs(context.getBean(VehicleIdentityRegistry.class)); + }); + } + + @Test + void createsFileIdentityServiceWhenConfigured() { + runner + .withPropertyValues( + "lingniu.ingest.identity.store=file", + "lingniu.ingest.identity.file.path=" + tempDir.resolve("identity.jsonl")) + .run(context -> { + assertThat(context).hasSingleBean(VehicleIdentityResolver.class); + assertThat(context.getBean(VehicleIdentityResolver.class).getClass().getSimpleName()) + .isEqualTo("FileVehicleIdentityService"); + }); + } +} diff --git a/inbound-mqtt/pom.xml b/modules/inbound/inbound-mqtt/pom.xml similarity index 90% rename from inbound-mqtt/pom.xml rename to modules/inbound/inbound-mqtt/pom.xml index ec98ea2f..856f185e 100644 --- a/inbound-mqtt/pom.xml +++ b/modules/inbound/inbound-mqtt/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml inbound-mqtt inbound-mqtt @@ -19,6 +20,10 @@ com.lingniu.ingest ingest-core + + com.lingniu.ingest + vehicle-identity + org.springframework.boot spring-boot-starter diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java new file mode 100644 index 00000000..50940d46 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java @@ -0,0 +1,330 @@ +package com.lingniu.ingest.inbound.mqtt.client; + +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3Client; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * MQTT 多 endpoint 生命周期管理。 + * + *

为每个 endpoint 创建一个 HiveMQ 异步客户端 → 连接 → 订阅 → 注册回调 → + * 回调里解析 payload → {@link Dispatcher#dispatch(RawFrame)}。 + * + *

Netty EventLoop 回调不做业务处理,虚拟线程由 Dispatcher 侧的 Disruptor 承接。 + */ +public final class MqttEndpointManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(MqttEndpointManager.class); + + private final MqttInboundProperties props; + private final MqttProfileRegistry profileRegistry; + private final VehicleIdentityResolver identityResolver; + private final Dispatcher dispatcher; + private final List clients = new ArrayList<>(); + + public MqttEndpointManager(MqttInboundProperties props, + MqttProfileRegistry profileRegistry, + Dispatcher dispatcher) { + this(props, profileRegistry, new InMemoryVehicleIdentityService(), dispatcher); + } + + public MqttEndpointManager(MqttInboundProperties props, + MqttProfileRegistry profileRegistry, + VehicleIdentityResolver identityResolver, + Dispatcher dispatcher) { + this.props = props; + this.profileRegistry = profileRegistry; + this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver; + this.dispatcher = dispatcher; + } + + public void start() { + for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) { + try { + Mqtt3AsyncClient client = buildClient(ep); + clients.add(client); + connectAndSubscribe(client, ep); + } catch (Exception e) { + log.error("mqtt endpoint [{}] init failed, skipped", ep.getName(), e); + publishOperationalError(ep, "init", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + } + } + } + + private Mqtt3AsyncClient buildClient(MqttInboundProperties.Endpoint ep) { + URI uri = URI.create(ep.getUri()); + int port = uri.getPort() > 0 ? uri.getPort() : defaultPort(uri.getScheme()); + var builder = Mqtt3Client.builder() + .identifier(ep.getClientId() == null || ep.getClientId().isBlank() + ? "lingniu-" + UUID.randomUUID() : ep.getClientId()) + .serverHost(uri.getHost()) + .serverPort(port) + .automaticReconnectWithDefaultConfig(); + if (ep.getConnectionTimeoutSeconds() > 0) { + builder = builder.transportConfig() + .serverHost(uri.getHost()) + .serverPort(port) + .socketConnectTimeout(ep.getConnectionTimeoutSeconds(), TimeUnit.SECONDS) + .mqttConnectTimeout(ep.getConnectionTimeoutSeconds(), TimeUnit.SECONDS) + .applyTransportConfig(); + } + + if ("ssl".equalsIgnoreCase(uri.getScheme()) || "tls".equalsIgnoreCase(uri.getScheme()) + || "mqtts".equalsIgnoreCase(uri.getScheme())) { + if (hasCustomTls(ep.getTls())) { + builder = builder.sslConfig(MqttTlsSupport.build(ep.getTls())); + } else { + builder = builder.sslWithDefaultConfig(); + } + } + return builder.buildAsync(); + } + + private void connectAndSubscribe(Mqtt3AsyncClient client, MqttInboundProperties.Endpoint ep) { + var connectBuilder = client.connectWith() + .cleanSession(ep.isCleanSession()); + if (ep.getKeepAliveSeconds() > 0) { + connectBuilder = connectBuilder.keepAlive(ep.getKeepAliveSeconds()); + } + if (ep.getUsername() != null && !ep.getUsername().isBlank()) { + connectBuilder = connectBuilder.simpleAuth() + .username(ep.getUsername()) + .password(ep.getPassword() == null ? new byte[0] : ep.getPassword().getBytes()) + .applySimpleAuth(); + } + connectBuilder.send().whenComplete((ack, err) -> { + if (err != null) { + log.error("mqtt endpoint [{}] connect failed", ep.getName(), err); + publishOperationalError(ep, "connect", err.getMessage() == null ? err.getClass().getSimpleName() : err.getMessage()); + return; + } + log.info("mqtt endpoint [{}] connected code={}", ep.getName(), ack.getReturnCode()); + client.subscribeWith() + .topicFilter(ep.getTopic()) + .qos(mapQos(ep.getQos())) + .callback(publish -> onMessage(ep, publish.getTopic().toString(), publish.getPayloadAsBytes())) + .send() + .whenComplete((subAck, subErr) -> { + if (subErr != null) { + log.error("mqtt endpoint [{}] subscribe failed topic={}", ep.getName(), ep.getTopic(), subErr); + publishOperationalError(ep, "subscribe", + subErr.getMessage() == null ? subErr.getClass().getSimpleName() : subErr.getMessage()); + } else { + log.info("mqtt endpoint [{}] subscribed topic={} qos={}", + ep.getName(), ep.getTopic(), ep.getQos()); + } + }); + }); + } + + private void onMessage(MqttInboundProperties.Endpoint ep, String topic, byte[] payload) { + try { + MqttPayload parsed = profileRegistry.parse(ep.getName(), ep.getProfile(), topic, payload); + String externalDeviceId = firstNonBlank(parsed.deviceId(), parsed.vin()); + IdentityResolution identity = resolveIdentity(parsed); + Map meta = new HashMap<>(4); + meta.put("vin", identity.identity().vin()); + meta.put("externalVin", parsed.externalVin() == null ? "" : parsed.externalVin()); + meta.put("phone", parsed.phone() == null ? "" : parsed.phone()); + meta.put("mqttDeviceId", externalDeviceId); + meta.put("plateNo", parsed.plateNo() == null ? "" : parsed.plateNo()); + meta.put("identityResolved", Boolean.toString(identity.identity().resolved())); + meta.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + meta.put("identityError", "true"); + meta.put("identityErrorMessage", identity.errorMessage()); + } + meta.put("endpoint", ep.getName()); + meta.put("topic", topic); + meta.put("profile", parsed.profile() == null ? "" : parsed.profile()); + if (parsed.data() != null && parsed.data().path("_parseError").asBoolean(false)) { + meta.put("parseError", "true"); + meta.put("parseErrorMessage", parsed.data().path("_parseErrorMessage").asText("")); + meta.put("unsupportedProfile", + Boolean.toString(parsed.data().path("_unsupportedProfile").asBoolean(false))); + } + RawFrame rf = new RawFrame( + ProtocolId.MQTT_YUTONG, 0, 0, + parsed, payload, meta, Instant.now()); + dispatcher.dispatch(rf); + } catch (Exception e) { + log.warn("mqtt endpoint [{}] message handling failed topic={} len={}", + ep.getName(), topic, payload.length, e); + publishMessageError(ep, topic, payload, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + } + } + + private IdentityResolution resolveIdentity(MqttPayload payload) { + String externalDeviceId = firstNonBlank(payload.deviceId(), payload.vin()); + String externalVin = payload.externalVin() == null ? "" : payload.externalVin(); + String phone = payload.phone() == null ? "" : payload.phone(); + String plateNo = payload.plateNo() == null ? "" : payload.plateNo(); + try { + VehicleIdentity bound = identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.MQTT_YUTONG, + externalVin, + phone, + externalDeviceId, + plateNo)); + if (bound.resolved() || !looksLikeVin(externalDeviceId)) { + return new IdentityResolution(bound, null); + } + return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.MQTT_YUTONG, + externalDeviceId, + phone, + externalDeviceId, + plateNo)), null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private void publishOperationalError(MqttInboundProperties.Endpoint ep, String phase, String reason) { + String endpointName = ep == null ? "" : ep.getName(); + String profile = ep == null ? "" : ep.getProfile(); + String topic = ep == null ? "" : ep.getTopic(); + String json = "{\"operationalError\":true" + + ",\"endpoint\":\"" + escape(endpointName) + "\"" + + ",\"profile\":\"" + escape(profile) + "\"" + + ",\"topic\":\"" + escape(topic) + "\"" + + ",\"phase\":\"" + escape(phase) + "\"" + + ",\"reason\":\"" + escape(reason) + "\"}"; + byte[] raw = json.getBytes(StandardCharsets.UTF_8); + MqttPayload payload = MqttProfileRegistry.operationalErrorPayload( + endpointName, profile, topic, raw, phase, reason); + Map meta = new HashMap<>(); + meta.put("vin", "unknown"); + meta.put("identityResolved", "false"); + meta.put("identitySource", "UNKNOWN"); + meta.put("endpoint", endpointName == null ? "" : endpointName); + meta.put("topic", topic == null ? "" : topic); + meta.put("profile", profile == null ? "" : profile); + meta.put("operationalError", "true"); + meta.put("phase", phase == null ? "" : phase); + meta.put("reason", reason == null ? "" : reason); + dispatcher.dispatch(new RawFrame( + ProtocolId.MQTT_YUTONG, + 0, + 0, + payload, + raw, + meta, + Instant.now())); + } + + private void publishMessageError(MqttInboundProperties.Endpoint ep, String topic, byte[] raw, String reason) { + String endpointName = ep == null ? "" : ep.getName(); + String profile = ep == null ? "" : ep.getProfile(); + byte[] payloadBytes = raw == null ? new byte[0] : raw; + MqttPayload payload = MqttProfileRegistry.operationalErrorPayload( + endpointName, + profile, + topic, + payloadBytes, + "message", + reason); + Map meta = new HashMap<>(); + meta.put("vin", "unknown"); + meta.put("identityResolved", "false"); + meta.put("identitySource", "UNKNOWN"); + meta.put("endpoint", endpointName == null ? "" : endpointName); + meta.put("topic", topic == null ? "" : topic); + meta.put("profile", profile == null ? "" : profile); + meta.put("operationalError", "true"); + meta.put("phase", "message"); + meta.put("reason", reason == null ? "" : reason); + dispatcher.dispatch(new RawFrame( + ProtocolId.MQTT_YUTONG, + 0, + 0, + payload, + payloadBytes, + meta, + Instant.now())); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static MqttQos mapQos(int level) { + return switch (level) { + case 0 -> MqttQos.AT_MOST_ONCE; + case 2 -> MqttQos.EXACTLY_ONCE; + default -> MqttQos.AT_LEAST_ONCE; + }; + } + + private static int defaultPort(String scheme) { + if (scheme == null) return 1883; + return switch (scheme.toLowerCase()) { + case "ssl", "tls", "mqtts" -> 8883; + default -> 1883; + }; + } + + private static boolean looksLikeVin(String value) { + return value != null && value.trim().length() == 17; + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) {} + + private static boolean hasCustomTls(MqttInboundProperties.Tls tls) { + return tls != null && (hasText(tls.getCaPem()) + || (hasText(tls.getClientPem()) && hasText(tls.getClientKey()))); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + @Override + public void close() { + for (Mqtt3AsyncClient c : clients) { + try { + c.disconnect(); + } catch (Exception ignored) { + // best-effort + } + } + log.info("mqtt endpoint manager stopped, clients={}", clients.size()); + } +} diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttTlsSupport.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttTlsSupport.java new file mode 100644 index 00000000..383ac9f3 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttTlsSupport.java @@ -0,0 +1,113 @@ +package com.lingniu.ingest.inbound.mqtt.client; + +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.hivemq.client.mqtt.MqttClientSslConfigBuilder; +import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.List; + +public final class MqttTlsSupport { + + private MqttTlsSupport() {} + + public static MqttClientSslConfig build(MqttInboundProperties.Tls tls) { + try { + MqttClientSslConfigBuilder builder = MqttClientSslConfig.builder(); + if (tls != null && hasText(tls.getCaPem())) { + builder.trustManagerFactory(trustManagerFactory(Path.of(tls.getCaPem()))); + } + if (tls != null && hasText(tls.getClientPem()) && hasText(tls.getClientKey())) { + builder.keyManagerFactory(keyManagerFactory(Path.of(tls.getClientPem()), Path.of(tls.getClientKey()))); + } + return builder.build(); + } catch (Exception e) { + throw new IllegalStateException("mqtt tls config build failed", e); + } + } + + private static TrustManagerFactory trustManagerFactory(Path caPem) throws Exception { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + int i = 0; + for (Certificate cert : readCertificates(caPem)) { + trustStore.setCertificateEntry("ca-" + i++, cert); + } + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + return tmf; + } + + private static KeyManagerFactory keyManagerFactory(Path clientPem, Path clientKey) throws Exception { + List chain = readCertificates(clientPem); + PrivateKey key = readPrivateKey(clientKey); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + char[] password = new char[0]; + keyStore.setKeyEntry("mqtt-client", key, password, chain.toArray(Certificate[]::new)); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, password); + return kmf; + } + + private static List readCertificates(Path pem) throws Exception { + String text = Files.readString(pem, StandardCharsets.UTF_8); + List certificates = new ArrayList<>(); + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + for (String block : pemBlocks(text, "CERTIFICATE")) { + try (ByteArrayInputStream in = new ByteArrayInputStream(Base64.getMimeDecoder().decode(block))) { + certificates.add(factory.generateCertificate(in)); + } + } + if (certificates.isEmpty()) { + throw new IllegalArgumentException("no certificate found in " + pem); + } + return certificates; + } + + private static PrivateKey readPrivateKey(Path pem) throws Exception { + String text = Files.readString(pem, StandardCharsets.UTF_8); + List blocks = pemBlocks(text, "PRIVATE KEY"); + if (blocks.isEmpty()) { + throw new IllegalArgumentException("no PKCS#8 private key found in " + pem); + } + byte[] der = Base64.getMimeDecoder().decode(blocks.getFirst()); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + + private static List pemBlocks(String text, String type) { + String begin = "-----BEGIN " + type + "-----"; + String end = "-----END " + type + "-----"; + List blocks = new ArrayList<>(); + int pos = 0; + while (true) { + int start = text.indexOf(begin, pos); + if (start < 0) break; + int contentStart = start + begin.length(); + int stop = text.indexOf(end, contentStart); + if (stop < 0) break; + blocks.add(text.substring(contentStart, stop).replaceAll("\\s+", "")); + pos = stop + end.length(); + } + return blocks; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java similarity index 63% rename from inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java rename to modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java index 17fdc5e9..e0f72f57 100644 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundAutoConfiguration.java @@ -2,10 +2,14 @@ package com.lingniu.ingest.inbound.mqtt.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.VehicleIdentityResolver; import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager; import com.lingniu.ingest.inbound.mqtt.handler.MqttRealtimeHandler; import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import com.lingniu.ingest.inbound.mqtt.profile.MqttProfile; +import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry; +import com.lingniu.ingest.inbound.mqtt.profile.YutongMqttProfile; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -14,6 +18,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import java.util.List; + @AutoConfiguration @EnableConfigurationProperties(MqttInboundProperties.class) @ConditionalOnProperty(prefix = "lingniu.ingest.mqtt", name = "enabled", havingValue = "true") @@ -33,22 +39,35 @@ public class MqttInboundAutoConfiguration { @Bean @ConditionalOnMissingBean - public YutongEventMapper yutongEventMapper() { - return new YutongEventMapper(); + public YutongEventMapper yutongEventMapper(VehicleIdentityResolver identityResolver) { + return new YutongEventMapper(identityResolver); } @Bean @ConditionalOnMissingBean - public MqttRealtimeHandler mqttRealtimeHandler(YutongEventMapper mapper) { - return new MqttRealtimeHandler(mapper); + public YutongMqttProfile yutongMqttProfile(MqttPayloadParser parser, YutongEventMapper mapper) { + return new YutongMqttProfile(parser, mapper); + } + + @Bean + @ConditionalOnMissingBean + public MqttProfileRegistry mqttProfileRegistry(List profiles) { + return new MqttProfileRegistry(profiles); + } + + @Bean + @ConditionalOnMissingBean + public MqttRealtimeHandler mqttRealtimeHandler(MqttProfileRegistry profileRegistry) { + return new MqttRealtimeHandler(profileRegistry); } @Bean(destroyMethod = "close") @ConditionalOnMissingBean public MqttEndpointManager mqttEndpointManager(MqttInboundProperties props, - MqttPayloadParser parser, + MqttProfileRegistry profileRegistry, + VehicleIdentityResolver identityResolver, Dispatcher dispatcher) { - return new MqttEndpointManager(props, parser, dispatcher); + return new MqttEndpointManager(props, profileRegistry, identityResolver, dispatcher); } /** diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java similarity index 81% rename from inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java rename to modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java index 47e5456a..abfd5e30 100644 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java @@ -31,6 +31,9 @@ public class MqttInboundProperties { private String clientId; private String username; private String password; + private boolean cleanSession = false; + private int keepAliveSeconds = 20; + private int connectionTimeoutSeconds = 10; /** JSON 字段适配器 profile。默认 yutong。未来可支持 haipote / others。 */ private String profile = "yutong"; private Tls tls = new Tls(); @@ -49,6 +52,12 @@ public class MqttInboundProperties { public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } + public boolean isCleanSession() { return cleanSession; } + public void setCleanSession(boolean cleanSession) { this.cleanSession = cleanSession; } + public int getKeepAliveSeconds() { return keepAliveSeconds; } + public void setKeepAliveSeconds(int keepAliveSeconds) { this.keepAliveSeconds = keepAliveSeconds; } + public int getConnectionTimeoutSeconds() { return connectionTimeoutSeconds; } + public void setConnectionTimeoutSeconds(int connectionTimeoutSeconds) { this.connectionTimeoutSeconds = connectionTimeoutSeconds; } public String getProfile() { return profile; } public void setProfile(String profile) { this.profile = profile; } public Tls getTls() { return tls; } diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java similarity index 68% rename from inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java rename to modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java index 9293f18e..75b978f8 100644 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/handler/MqttRealtimeHandler.java @@ -6,13 +6,13 @@ import com.lingniu.ingest.api.annotation.MessageMapping; import com.lingniu.ingest.api.annotation.ProtocolHandler; import com.lingniu.ingest.api.annotation.RateLimited; import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry; import java.util.List; /** - * MQTT 接入示例 Handler。 + * MQTT 接入 Handler。 * *

MQTT 没有"命令字"概念,使用默认的 wildcard 匹配({@code command} 为空)。 * 同一 Dispatcher 仍能正确路由,因为协议 ID 做了隔离。 @@ -20,16 +20,16 @@ import java.util.List; @ProtocolHandler(protocol = ProtocolId.MQTT_YUTONG) public class MqttRealtimeHandler { - private final YutongEventMapper mapper; + private final MqttProfileRegistry profileRegistry; - public MqttRealtimeHandler(YutongEventMapper mapper) { - this.mapper = mapper; + public MqttRealtimeHandler(MqttProfileRegistry profileRegistry) { + this.profileRegistry = profileRegistry; } - @MessageMapping(desc = "宇通 MQTT 实时数据") + @MessageMapping(desc = "MQTT profile 实时数据") @RateLimited(perVin = 20) - @EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class}) + @EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class, VehicleEvent.Passthrough.class}) public List onMqtt(MqttPayload payload) { - return mapper.toEvents(payload); + return profileRegistry.toEvents(payload); } } diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java new file mode 100644 index 00000000..756c0706 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/mapper/YutongEventMapper.java @@ -0,0 +1,247 @@ +package com.lingniu.ingest.inbound.mqtt.mapper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.EventMapper; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 宇通 MQTT payload → 领域事件。 + * + *

字段名取自旧项目的 {@code ReceptionDataVO}(大写 + 下划线),对字段映射一致性做出显式断言, + * 便于与旧服务双跑对账。 + */ +public final class YutongEventMapper implements EventMapper { + + private final VehicleIdentityResolver identityResolver; + + public YutongEventMapper() { + this(new InMemoryVehicleIdentityService()); + } + + public YutongEventMapper(VehicleIdentityResolver identityResolver) { + this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver; + } + + @Override + public List toEvents(MqttPayload payload) { + JsonNode d = payload.data(); + if (d == null || !d.isObject()) return List.of(); + + Instant ingestTime = Instant.now(); + String externalDeviceId = firstNonBlank(payload.deviceId(), payload.vin()); + IdentityResolution identity = resolveIdentity(payload); + String vin = identity.vin(); + Map meta = metadata(payload, externalDeviceId, vin, identity); + + if (d.path("_parseError").asBoolean(false)) { + return List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin == null || vin.isBlank() ? "unknown" : vin, + ProtocolId.MQTT_YUTONG, + payload.deviceTime(), + ingestTime, + null, + withMeta(meta, + "parseError", "true", + "parseErrorMessage", d.path("_parseErrorMessage").asText("")), + 0, + rawBytes(d.path("_rawBase64").asText("")))); + } + + Double speed = optDouble(d, "METER_SPEED"); + Double mileage = optDouble(d, "TOTAL_MILEAGE"); + Double soc = optDouble(d, "BATTERY_CAPACITY_SOC"); + Double fcVoltage = optDouble(d, "VOLTAGE_OF_FC"); + Double fcCurrent = optDouble(d, "CURRENT_OF_FC"); + Double fcTemp = optDouble(d, "TEMPT_OF_FC"); + Double leftH2 = optDouble(d, "LEFT_HYDROGEN"); + Double lowH2Pressure = optDouble(d, "HYDROGEN_LOW_PRESSURE"); + Double longitude = optCoord(d, "LONGITUDE"); + Double latitude = optCoord(d, "LATITUDE"); + Double direction = optDouble(d, "GPSDirection"); + Double acc = optDouble(d, "ACC_PEDAL_APT"); + Double brake = optDouble(d, "BRAKEPEDALAPT"); + Integer gear = optInt(d, "ON_GEAR"); + + RealtimePayload realtime = new RealtimePayload( + speed, mileage, soc, null, null, + fcVoltage, fcCurrent, fcTemp, leftH2, null, lowH2Pressure, + null, null, null, gear, acc, brake, + longitude, latitude, null, direction, + null, null); + + List out = new ArrayList<>(2); + out.add(new VehicleEvent.Realtime( + UUID.randomUUID().toString(), + vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime, + null, meta, realtime)); + + if (longitude != null && latitude != null) { + LocationPayload loc = new LocationPayload( + longitude, latitude, 0.0, + speed == null ? 0.0 : speed, + direction == null ? 0.0 : direction, + 0, 0); + out.add(new VehicleEvent.Location( + UUID.randomUUID().toString(), + vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime, + null, meta, loc)); + } + return out; + } + + private IdentityResolution resolveIdentity(MqttPayload payload) { + String externalDeviceId = firstNonBlank(payload.deviceId(), payload.vin()); + String externalVin = payload.externalVin() == null ? "" : payload.externalVin(); + String phone = payload.phone() == null ? "" : payload.phone(); + String plateNo = payload.plateNo() == null ? "" : payload.plateNo(); + try { + if (externalDeviceId == null + || externalDeviceId.isBlank() + || "unknown".equalsIgnoreCase(externalDeviceId.trim())) { + if (!externalVin.isBlank() || !phone.isBlank() || !plateNo.isBlank()) { + return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.MQTT_YUTONG, + externalVin, + phone, + "", + plateNo))); + } + return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN)); + } + VehicleIdentity bound = identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.MQTT_YUTONG, + externalVin, + phone, + externalDeviceId, + plateNo)); + if (bound.resolved() || !looksLikeVin(externalDeviceId)) { + return IdentityResolution.ok(bound); + } + return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.MQTT_YUTONG, + externalDeviceId, + phone, + externalDeviceId, + plateNo))); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private static boolean looksLikeVin(String value) { + if (value == null || value.isBlank()) { + return false; + } + String trimmed = value.trim(); + return trimmed.length() == 17; + } + + private static Double optDouble(JsonNode n, String field) { + JsonNode v = n.get(field); + if (v == null || v.isNull() || !v.isNumber()) return null; + return v.asDouble(); + } + + private static Integer optInt(JsonNode n, String field) { + JsonNode v = n.get(field); + if (v == null || v.isNull() || !v.isNumber()) return null; + return v.asInt(); + } + + /** 宇通坐标约定:1e6 整数 或 已经是小数。同时过滤无效值 999999 / 0。 */ + private static Double optCoord(JsonNode n, String field) { + Double v = optDouble(n, field); + if (v == null) return null; + if (v.intValue() == 999_999 || v == 0.0) return null; + if (Math.abs(v) > 1000) return v / 1_000_000.0; + return v; + } + + private static Map withMeta(Map meta, String key, String value) { + java.util.HashMap copy = new java.util.HashMap<>(meta); + copy.put(key, value == null ? "" : value); + return Map.copyOf(copy); + } + + private static Map metadata(MqttPayload payload, + String externalDeviceId, + String vin, + IdentityResolution identity) { + java.util.HashMap meta = new java.util.HashMap<>(); + meta.put("endpoint", payload.endpointName() == null ? "" : payload.endpointName()); + meta.put("topic", payload.topic() == null ? "" : payload.topic()); + meta.put("profile", payload.profile() == null ? "" : payload.profile()); + meta.put("externalVin", payload.externalVin() == null ? "" : payload.externalVin()); + meta.put("phone", payload.phone() == null ? "" : payload.phone()); + meta.put("mqttDeviceId", externalDeviceId == null ? "" : externalDeviceId); + meta.put("plateNo", payload.plateNo() == null ? "" : payload.plateNo()); + meta.put("vin", vin == null || vin.isBlank() ? "unknown" : vin); + meta.put("identityResolved", Boolean.toString(identity.identity().resolved())); + meta.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + meta.put("identityError", "true"); + meta.put("identityErrorMessage", identity.errorMessage()); + } + return Map.copyOf(meta); + } + + private static Map withMeta(Map meta, + String firstKey, + String firstValue, + String secondKey, + String secondValue) { + java.util.HashMap copy = new java.util.HashMap<>(meta); + copy.put(firstKey, firstValue == null ? "" : firstValue); + copy.put(secondKey, secondValue == null ? "" : secondValue); + return Map.copyOf(copy); + } + + private static byte[] rawBytes(String base64) { + if (base64 == null || base64.isBlank()) { + return new byte[0]; + } + try { + return Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + return new byte[0]; + } + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) { + private static IdentityResolution ok(VehicleIdentity identity) { + return new IdentityResolution(identity, null); + } + + private String vin() { + return identity.vin(); + } + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java similarity index 52% rename from inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java rename to modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java index 38f0662b..7b382ff9 100644 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/model/MqttPayload.java @@ -10,7 +10,11 @@ import java.time.Instant; * @param endpointName 来源 endpoint 逻辑名 * @param profile 字段映射 profile(如 yutong / haipote) * @param topic 原始 topic - * @param vin VIN(解析自 payload 的 device / vin 字段) + * @param vin VIN(解析自 payload 的 device / vin 字段,保留兼容旧调用) + * @param externalVin 外部 VIN 字段 + * @param phone 外部手机号 / SIM 字段 + * @param deviceId 外部设备号 / 终端 ID / IMEI 字段 + * @param plateNo 外部车牌字段 * @param deviceTime 设备上报时刻 * @param data 原始 JSON 树,供 Mapper 按 profile 提取字段 */ @@ -19,6 +23,19 @@ public record MqttPayload( String profile, String topic, String vin, + String externalVin, + String phone, + String deviceId, + String plateNo, Instant deviceTime, JsonNode data -) {} +) { + public MqttPayload(String endpointName, + String profile, + String topic, + String vin, + Instant deviceTime, + JsonNode data) { + this(endpointName, profile, topic, vin, "", "", vin, "", deviceTime, data); + } +} diff --git a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java similarity index 75% rename from inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java rename to modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java index c1980966..bc9bae9e 100644 --- a/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/parser/MqttPayloadParser.java @@ -11,7 +11,7 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; /** - * 宇通 MQTT 消息字段适配器(对标旧 {@code VehicleDataReceptionVO} + {@code ReceptionDataVO})。 + * 宇通 MQTT JSON 消息基础解析器(对标旧 {@code VehicleDataReceptionVO} + {@code ReceptionDataVO})。 * *

约定宇通 MQTT 的 payload JSON 形如: *

{@code
@@ -24,7 +24,7 @@ import java.time.format.DateTimeFormatter;
  * }
  * }
* - *

其它厂商 profile 只需新增一个 Parser 实现即可,由 profile 路由。 + *

其它厂商只需新增 {@code MqttProfile} 实现,公共 MQTT endpoint 生命周期不需要修改。 */ public final class MqttPayloadParser { @@ -40,10 +40,15 @@ public final class MqttPayloadParser { public MqttPayload parse(String endpointName, String profile, String topic, byte[] bytes) { try { JsonNode root = mapper.readTree(bytes); - String vin = text(root, "device", "vin"); + String externalVin = text(root, "vin", "VIN"); + String deviceId = text(root, "device", "deviceId", "terminalId", "imei", "IMEI"); + String phone = text(root, "sim", "phone", "mobile"); + String plateNo = text(root, "plateNo", "carNo", "plate"); + String vin = !externalVin.isBlank() ? externalVin : deviceId; Instant deviceTime = parseTime(text(root, "time")); JsonNode data = root.has("data") ? root.get("data") : root; - return new MqttPayload(endpointName, profile, topic, vin, deviceTime, data); + return new MqttPayload(endpointName, profile, topic, + vin, externalVin, phone, deviceId, plateNo, deviceTime, data); } catch (Exception e) { throw new DecodeException("mqtt payload parse failed: " + e.getMessage(), e); } diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfile.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfile.java new file mode 100644 index 00000000..45ddeb85 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfile.java @@ -0,0 +1,20 @@ +package com.lingniu.ingest.inbound.mqtt.profile; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; + +import java.util.List; + +/** + * MQTT 厂商 profile 边界。 + * + *

endpoint 只关心 profile 名称,具体 payload 字段结构、VIN/时间提取和事件映射都收敛在 profile 内。 + */ +public interface MqttProfile { + + String name(); + + MqttPayload parse(String endpointName, String topic, byte[] bytes); + + List toEvents(MqttPayload payload); +} diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfileRegistry.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfileRegistry.java new file mode 100644 index 00000000..e1521b23 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/MqttProfileRegistry.java @@ -0,0 +1,164 @@ +package com.lingniu.ingest.inbound.mqtt.profile; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; + +import java.time.Instant; +import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +public final class MqttProfileRegistry { + + private final Map profiles; + + public MqttProfileRegistry(Collection profiles) { + Map indexed = new LinkedHashMap<>(); + for (MqttProfile profile : profiles) { + String key = normalize(profile.name()); + if (key.isEmpty()) { + throw new IllegalArgumentException("mqtt profile name must not be blank"); + } + MqttProfile old = indexed.putIfAbsent(key, profile); + if (old != null) { + throw new IllegalArgumentException("duplicated mqtt profile: " + key); + } + } + this.profiles = Map.copyOf(indexed); + } + + public MqttPayload parse(String endpointName, String profileName, String topic, byte[] bytes) { + MqttProfile profile; + try { + profile = resolve(profileName); + } catch (DecodeException e) { + return parseErrorPayload(endpointName, profileName, topic, bytes, e.getMessage(), true); + } + try { + return profile.parse(endpointName, topic, bytes); + } catch (DecodeException e) { + return parseErrorPayload(endpointName, profile.name(), topic, bytes, e.getMessage(), false); + } + } + + public List toEvents(MqttPayload payload) { + if (payload != null && payload.data() != null + && payload.data().path("_operationalError").asBoolean(false)) { + return List.of(operationalErrorEvent(payload)); + } + if (payload != null && payload.data() != null + && payload.data().path("_unsupportedProfile").asBoolean(false)) { + return List.of(parseErrorEvent(payload)); + } + return resolve(payload.profile()).toEvents(payload); + } + + public MqttProfile resolve(String profileName) { + String key = normalize(profileName); + MqttProfile profile = profiles.get(key); + if (profile == null) { + throw new DecodeException("unsupported mqtt profile: " + profileName); + } + return profile; + } + + private static String normalize(String raw) { + return raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + } + + private static MqttPayload parseErrorPayload(String endpointName, + String profileName, + String topic, + byte[] bytes, + String message, + boolean unsupportedProfile) { + ObjectNode data = JsonNodeFactory.instance.objectNode(); + data.put("_parseError", true); + data.put("_parseErrorMessage", message == null ? "" : message); + data.put("_unsupportedProfile", unsupportedProfile); + data.put("_rawBase64", Base64.getEncoder().encodeToString(bytes == null ? new byte[0] : bytes)); + return new MqttPayload(endpointName, normalize(profileName), topic, "unknown", Instant.now(), data); + } + + public static MqttPayload operationalErrorPayload(String endpointName, + String profileName, + String topic, + byte[] bytes, + String phase, + String reason) { + ObjectNode data = JsonNodeFactory.instance.objectNode(); + data.put("_operationalError", true); + data.put("_phase", phase == null ? "" : phase); + data.put("_reason", reason == null ? "" : reason); + data.put("_rawBase64", Base64.getEncoder().encodeToString(bytes == null ? new byte[0] : bytes)); + return new MqttPayload(endpointName, normalize(profileName), topic, "unknown", Instant.now(), data); + } + + private static VehicleEvent.Passthrough parseErrorEvent(MqttPayload payload) { + Map metadata = new HashMap<>(); + metadata.put("endpoint", payload.endpointName() == null ? "" : payload.endpointName()); + metadata.put("topic", payload.topic() == null ? "" : payload.topic()); + metadata.put("profile", payload.profile() == null ? "" : payload.profile()); + metadata.put("parseError", "true"); + metadata.put("unsupportedProfile", Boolean.toString(payload.data().path("_unsupportedProfile").asBoolean(false))); + metadata.put("parseErrorMessage", payload.data().path("_parseErrorMessage").asText("")); + addUnknownIdentity(metadata); + return new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + "unknown", + ProtocolId.MQTT_YUTONG, + payload.deviceTime(), + Instant.now(), + null, + Map.copyOf(metadata), + 0, + rawBytes(payload.data().path("_rawBase64").asText(""))); + } + + private static VehicleEvent.Passthrough operationalErrorEvent(MqttPayload payload) { + Map metadata = new HashMap<>(); + metadata.put("endpoint", payload.endpointName() == null ? "" : payload.endpointName()); + metadata.put("topic", payload.topic() == null ? "" : payload.topic()); + metadata.put("profile", payload.profile() == null ? "" : payload.profile()); + metadata.put("operationalError", "true"); + metadata.put("phase", payload.data().path("_phase").asText("")); + metadata.put("reason", payload.data().path("_reason").asText("")); + addUnknownIdentity(metadata); + return new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + "unknown", + ProtocolId.MQTT_YUTONG, + payload.deviceTime(), + Instant.now(), + null, + Map.copyOf(metadata), + 0, + rawBytes(payload.data().path("_rawBase64").asText(""))); + } + + private static void addUnknownIdentity(Map metadata) { + metadata.put("vin", "unknown"); + metadata.put("identityResolved", "false"); + metadata.put("identitySource", "UNKNOWN"); + } + + private static byte[] rawBytes(String base64) { + if (base64 == null || base64.isBlank()) { + return new byte[0]; + } + try { + return Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + return new byte[0]; + } + } +} diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/YutongMqttProfile.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/YutongMqttProfile.java new file mode 100644 index 00000000..426f2efb --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/profile/YutongMqttProfile.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.inbound.mqtt.profile; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; + +import java.util.List; + +public final class YutongMqttProfile implements MqttProfile { + + public static final String NAME = "yutong"; + + private final MqttPayloadParser parser; + private final YutongEventMapper mapper; + + public YutongMqttProfile(MqttPayloadParser parser, YutongEventMapper mapper) { + this.parser = parser; + this.mapper = mapper; + } + + @Override + public String name() { + return NAME; + } + + @Override + public MqttPayload parse(String endpointName, String topic, byte[] bytes) { + return parser.parse(endpointName, NAME, topic, bytes); + } + + @Override + public List toEvents(MqttPayload payload) { + return mapper.toEvents(payload); + } +} diff --git a/inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/inbound/inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/inbound/inbound-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttEndpointManagerTest.java b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttEndpointManagerTest.java new file mode 100644 index 00000000..138c7392 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttEndpointManagerTest.java @@ -0,0 +1,377 @@ +package com.lingniu.ingest.inbound.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; +import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.core.dispatcher.HandlerDefinition; +import com.lingniu.ingest.core.dispatcher.HandlerInvoker; +import com.lingniu.ingest.core.dispatcher.HandlerRegistry; +import com.lingniu.ingest.core.pipeline.InterceptorChain; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager; +import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties; +import com.lingniu.ingest.inbound.mqtt.handler.MqttRealtimeHandler; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry; +import com.lingniu.ingest.inbound.mqtt.profile.YutongMqttProfile; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class MqttEndpointManagerTest { + + @Test + void inboundMessageRawArchiveUsesResolvedVehicleIdentity() throws Exception { + MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint(); + endpoint.setName("endpoint-a"); + endpoint.setTopic("/ytforward/#"); + endpoint.setProfile("yutong"); + byte[] payload = """ + {"device":"YT-DEVICE-001","time":"20260622150000","data":{"METER_SPEED":31,"TOTAL_MILEAGE":1024}} + """.trim().getBytes(StandardCharsets.UTF_8); + InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService(); + identities.bind(new VehicleIdentityBinding( + ProtocolId.MQTT_YUTONG, + "LNVIN000000MQTT01", + "", + "YT-DEVICE-001", + "")); + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of( + new YutongMqttProfile( + new MqttPayloadParser(new ObjectMapper()), + new YutongEventMapper(identities)))); + Dispatcher dispatcher = new Dispatcher( + registry(profileRegistry), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + MqttEndpointManager manager = new MqttEndpointManager( + new MqttInboundProperties(), profileRegistry, identities, dispatcher); + + invokeOnMessage(manager, endpoint, "/ytforward/vehicle/YT-DEVICE-001", payload); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.vin()).isEqualTo("LNVIN000000MQTT01"); + assertThat(archive.metadata()) + .containsEntry("vin", "LNVIN000000MQTT01") + .containsEntry("mqttDeviceId", "YT-DEVICE-001") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_DEVICE_ID"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void malformedInboundMessageArchivesParseFailureMetadataAndPassthrough() throws Exception { + MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint(); + endpoint.setName("endpoint-a"); + endpoint.setTopic("/ytforward/#"); + endpoint.setProfile("yutong"); + byte[] badPayload = "{bad-json".getBytes(StandardCharsets.UTF_8); + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of( + new YutongMqttProfile( + new MqttPayloadParser(new ObjectMapper()), + new YutongEventMapper()))); + Dispatcher dispatcher = new Dispatcher( + registry(profileRegistry), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + MqttEndpointManager manager = new MqttEndpointManager( + new MqttInboundProperties(), profileRegistry, dispatcher); + + invokeOnMessage(manager, endpoint, "/ytforward/bad", badPayload); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(archive.rawBytes()).containsExactly(badPayload); + assertThat(archive.metadata()) + .containsEntry("endpoint", "endpoint-a") + .containsEntry("topic", "/ytforward/bad") + .containsEntry("profile", "yutong") + .containsEntry("parseError", "true") + .containsEntry("vin", "unknown"); + assertThat(archive.metadata().get("parseErrorMessage")) + .contains("mqtt payload parse failed"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(passthrough.data()).containsExactly(badPayload); + assertThat(passthrough.metadata()) + .containsEntry("parseError", "true") + .containsEntry("endpoint", "endpoint-a") + .containsEntry("profile", "yutong"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void endpointInitializationFailureIsArchivedAndDispatchedAsPassthrough() throws Exception { + MqttInboundProperties props = new MqttInboundProperties(); + MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint(); + endpoint.setName("bad-endpoint"); + endpoint.setUri("://bad-uri"); + endpoint.setTopic("/vehicles/#"); + endpoint.setProfile("yutong"); + props.setEndpoints(List.of(endpoint)); + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of()); + Dispatcher dispatcher = new Dispatcher( + registry(profileRegistry), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + MqttEndpointManager manager = new MqttEndpointManager(props, profileRegistry, dispatcher); + + manager.start(); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(archive.metadata()) + .containsEntry("endpoint", "bad-endpoint") + .containsEntry("profile", "yutong") + .containsEntry("operationalError", "true") + .containsEntry("phase", "init") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(passthrough.metadata()) + .containsEntry("operationalError", "true") + .containsEntry("endpoint", "bad-endpoint") + .containsEntry("profile", "yutong") + .containsEntry("phase", "init"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void profileRuntimeFailureIsArchivedAndDispatchedAsPassthrough() throws Exception { + MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint(); + endpoint.setName("endpoint-a"); + endpoint.setTopic("/vehicles/#"); + endpoint.setProfile("broken"); + byte[] payload = "{\"device\":\"YT-DEVICE-001\"}".getBytes(StandardCharsets.UTF_8); + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of(new BrokenProfile())); + Dispatcher dispatcher = new Dispatcher( + registry(profileRegistry), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + MqttEndpointManager manager = new MqttEndpointManager( + new MqttInboundProperties(), profileRegistry, dispatcher); + + invokeOnMessage(manager, endpoint, "/vehicles/broken", payload); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(archive.rawBytes()).containsExactly(payload); + assertThat(archive.metadata()) + .containsEntry("endpoint", "endpoint-a") + .containsEntry("topic", "/vehicles/broken") + .containsEntry("profile", "broken") + .containsEntry("operationalError", "true") + .containsEntry("phase", "message") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(archive.metadata().get("reason")).contains("profile blew up"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(passthrough.data()).containsExactly(payload); + assertThat(passthrough.metadata()) + .containsEntry("operationalError", "true") + .containsEntry("endpoint", "endpoint-a") + .containsEntry("profile", "broken") + .containsEntry("phase", "message"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void identityResolverFailureStillArchivesAndDispatchesRealtimeWithUnknownIdentity() throws Exception { + MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint(); + endpoint.setName("endpoint-a"); + endpoint.setTopic("/ytforward/#"); + endpoint.setProfile("yutong"); + byte[] payload = """ + {"device":"YT-DEVICE-001","time":"20260622150000","data":{"METER_SPEED":31,"TOTAL_MILEAGE":1024}} + """.trim().getBytes(StandardCharsets.UTF_8); + var failingIdentity = (com.lingniu.ingest.identity.VehicleIdentityResolver) lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }; + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of( + new YutongMqttProfile( + new MqttPayloadParser(new ObjectMapper()), + new YutongEventMapper(failingIdentity)))); + Dispatcher dispatcher = new Dispatcher( + registry(profileRegistry), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + MqttEndpointManager manager = new MqttEndpointManager( + new MqttInboundProperties(), profileRegistry, failingIdentity, dispatcher); + + invokeOnMessage(manager, endpoint, "/ytforward/vehicle/YT-DEVICE-001", payload); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(archive.vin()).isEqualTo("unknown"); + assertThat(archive.rawBytes()).containsExactly(payload); + assertThat(archive.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("mqttDeviceId", "YT-DEVICE-001") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Realtime.class); + assertThat(event.vin()).isEqualTo("unknown"); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("mqttDeviceId", "YT-DEVICE-001") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + private static HandlerRegistry registry(MqttProfileRegistry profileRegistry) throws NoSuchMethodException { + HandlerRegistry registry = new HandlerRegistry(); + MqttRealtimeHandler handler = new MqttRealtimeHandler(profileRegistry); + registry.register(new HandlerDefinition( + ProtocolId.MQTT_YUTONG, + 0, + 0, + "test mqtt", + handler, + MqttRealtimeHandler.class.getMethod("onMqtt", MqttPayload.class), + MqttPayload.class, + null, + null, + null)); + return registry; + } + + private static void invokeOnMessage(MqttEndpointManager manager, + MqttInboundProperties.Endpoint endpoint, + String topic, + byte[] payload) throws Exception { + Method method = MqttEndpointManager.class.getDeclaredMethod( + "onMessage", MqttInboundProperties.Endpoint.class, String.class, byte[].class); + method.setAccessible(true); + method.invoke(manager, endpoint, topic, payload); + } + + private static final class RecordingSink implements EventSink { + private final List events = new CopyOnWriteArrayList<>(); + private final CountDownLatch latch; + + private RecordingSink(int expectedEvents) { + this.latch = new CountDownLatch(expectedEvents); + } + + @Override + public String name() { + return "recording"; + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + events.add(event); + latch.countDown(); + return CompletableFuture.completedFuture(null); + } + + private boolean await() throws InterruptedException { + return latch.await(3, TimeUnit.SECONDS); + } + } + + private static final class BrokenProfile implements com.lingniu.ingest.inbound.mqtt.profile.MqttProfile { + @Override + public String name() { + return "broken"; + } + + @Override + public MqttPayload parse(String endpointName, String topic, byte[] bytes) { + throw new IllegalStateException("profile blew up"); + } + + @Override + public List toEvents(MqttPayload payload) { + return List.of(); + } + } +} diff --git a/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttProfileRegistryTest.java b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttProfileRegistryTest.java new file mode 100644 index 00000000..f4e58f7b --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttProfileRegistryTest.java @@ -0,0 +1,137 @@ +package com.lingniu.ingest.inbound.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry; +import com.lingniu.ingest.inbound.mqtt.profile.YutongMqttProfile; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MqttProfileRegistryTest { + + private final MqttProfileRegistry registry = new MqttProfileRegistry(List.of( + new YutongMqttProfile(new MqttPayloadParser(new ObjectMapper()), new YutongEventMapper()))); + + @Test + void resolvesProfileCaseInsensitiveAndRoutesParseAndMapping() { + byte[] payload = """ + { + "device": "LTEST000000000001", + "time": "20260413100000", + "data": { + "METER_SPEED": 52.3, + "TOTAL_MILEAGE": 123456.7, + "BATTERY_CAPACITY_SOC": 70, + "LONGITUDE": 116397128, + "LATITUDE": 39916527 + } + } + """.getBytes(StandardCharsets.UTF_8); + + var parsed = registry.parse("endpoint-a", "YuTong", "/ytforward/shln/dev1", payload); + + assertThat(parsed.profile()).isEqualTo("yutong"); + assertThat(parsed.vin()).isEqualTo("LTEST000000000001"); + assertThat(registry.toEvents(parsed)) + .extracting(VehicleEvent::vin) + .containsOnly("LTEST000000000001"); + } + + @Test + void parserKeepsCommonExternalIdentityFieldsForColdStorageAndMapping() { + byte[] payload = """ + { + "vin": "LVIN0000000000001", + "terminalId": "terminal-001", + "sim": "13800138000", + "plateNo": "粤B12345", + "time": "20260413100000", + "data": { "METER_SPEED": 12.3 } + } + """.getBytes(StandardCharsets.UTF_8); + + var parsed = registry.parse("endpoint-a", "yutong", "/ytforward/shln/terminal-001", payload); + + assertThat(parsed.externalVin()).isEqualTo("LVIN0000000000001"); + assertThat(parsed.deviceId()).isEqualTo("terminal-001"); + assertThat(parsed.phone()).isEqualTo("13800138000"); + assertThat(parsed.plateNo()).isEqualTo("粤B12345"); + } + + @Test + void unsupportedProfileBecomesPassthroughEventForOperationalTraceability() { + byte[] payload = "{\"device\":\"D1\"}".getBytes(StandardCharsets.UTF_8); + + var parsed = registry.parse("endpoint-a", "unknown", "/x", payload); + + assertThat(parsed.profile()).isEqualTo("unknown"); + assertThat(parsed.data().path("_parseError").asBoolean()).isTrue(); + assertThat(parsed.data().path("_unsupportedProfile").asBoolean()).isTrue(); + assertThat(registry.toEvents(parsed)).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.data()).containsExactly(payload); + assertThat(passthrough.metadata()) + .containsEntry("parseError", "true") + .containsEntry("unsupportedProfile", "true") + .containsEntry("profile", "unknown") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } + + @Test + void malformedPayloadBecomesPassthroughEventForOperationalTraceability() { + byte[] payload = "{bad-json".getBytes(StandardCharsets.UTF_8); + + var parsed = registry.parse("endpoint-a", "yutong", "/ytforward/bad", payload); + + assertThat(parsed.vin()).isEqualTo("unknown"); + assertThat(parsed.data().path("_parseError").asBoolean()).isTrue(); + assertThat(registry.toEvents(parsed)).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(com.lingniu.ingest.api.ProtocolId.MQTT_YUTONG); + assertThat(passthrough.data()).containsExactly(payload); + assertThat(passthrough.metadata()) + .containsEntry("parseError", "true") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } + + @Test + void operationalPayloadBecomesPassthroughEventWithFailureMetadata() { + byte[] payload = """ + {"operationalError":true,"endpoint":"endpoint-a","phase":"init","reason":"bad uri"} + """.getBytes(StandardCharsets.UTF_8); + + var parsed = MqttProfileRegistry.operationalErrorPayload( + "endpoint-a", "yutong", "/operational", payload, "init", "bad uri"); + + assertThat(registry.toEvents(parsed)).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(com.lingniu.ingest.api.ProtocolId.MQTT_YUTONG); + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.data()).containsExactly(payload); + assertThat(passthrough.metadata()) + .containsEntry("operationalError", "true") + .containsEntry("endpoint", "endpoint-a") + .containsEntry("profile", "yutong") + .containsEntry("phase", "init") + .containsEntry("reason", "bad uri") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } +} diff --git a/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttTlsSupportTest.java b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttTlsSupportTest.java new file mode 100644 index 00000000..7012e012 --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/MqttTlsSupportTest.java @@ -0,0 +1,103 @@ +package com.lingniu.ingest.inbound.mqtt; + +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.lingniu.ingest.inbound.mqtt.client.MqttTlsSupport; +import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MqttTlsSupportTest { + + @TempDir + Path tempDir; + + @Test + void buildsTrustManagerFromCaPem() throws Exception { + Path ca = tempDir.resolve("ca.pem"); + Files.writeString(ca, CERT); + MqttInboundProperties.Tls tls = new MqttInboundProperties.Tls(); + tls.setCaPem(ca.toString()); + + MqttClientSslConfig config = MqttTlsSupport.build(tls); + + assertThat(config.getTrustManagerFactory()).isPresent(); + assertThat(config.getKeyManagerFactory()).isEmpty(); + } + + @Test + void buildsMutualTlsFromClientCertificateAndKey() throws Exception { + Path ca = tempDir.resolve("ca.pem"); + Path cert = tempDir.resolve("client.pem"); + Path key = tempDir.resolve("client.key"); + Files.writeString(ca, CERT); + Files.writeString(cert, CERT); + Files.writeString(key, KEY); + MqttInboundProperties.Tls tls = new MqttInboundProperties.Tls(); + tls.setCaPem(ca.toString()); + tls.setClientPem(cert.toString()); + tls.setClientKey(key.toString()); + + MqttClientSslConfig config = MqttTlsSupport.build(tls); + + assertThat(config.getTrustManagerFactory()).isPresent(); + assertThat(config.getKeyManagerFactory()).isPresent(); + } + + private static final String CERT = """ + -----BEGIN CERTIFICATE----- + MIIC/zCCAeegAwIBAgIUPg+j3PAaXVWYykrese0Ssb/blk4wDQYJKoZIhvcNAQEL + BQAwDzENMAsGA1UEAwwEdGVzdDAeFw0yNjA2MjIwNTA0MzNaFw0yNjA2MjMwNTA0 + MzNaMA8xDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK + AoIBAQChuMSubmmNY13b4PUGBoUO+EIy074ZfbRMPHCGJhfSnZVWdETBflOe3kNy + ouejxN2eT3Xoqf7slqTPySJo8hZQjukORNtudMPWQkiBrvaWPCttlYtCxpxhxRw0 + VnIo/jYP2Fmwt2buHV//Kn039amDDr3auZWVXEIGLcafVpd/3v7c+1NLbMPR/uK8 + 62Yk6UG1rlvOYBIttNp2Y9fGZLkMLA5fE3xsPbg8VeWuN9/xjPtZGZ5qUYHaQn3Z + DzmZ8U9q5TGCbcpoVEIooRSb2lIILAqw2FRAR7ashCcJf6QzPVdhoZ8QIovJoViB + nzuFP0I4VYp9fT1pRmBYjcZtIsSfAgMBAAGjUzBRMB0GA1UdDgQWBBSzG5+aGFJr + qgnVlOmtdz8iGzU5RjAfBgNVHSMEGDAWgBSzG5+aGFJrqgnVlOmtdz8iGzU5RjAP + BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB7/6Q1e+acJobo/wnW + 9DiGzNaownFRs4yz8EgIYVcc7iROjBrVFrcORJAcFrg1uh0Q4rFcslMmEqFM8ryV + IJ1/L6PcQUp4QlKGbbc9LarhalEdFxhX8IAIp2QCNaG8h9/WczZ5/TczwODfy1tW + BFoLG19/dXd5HXLqxiLIpez2f2a/bbUkTu1Yj9QQUFUX2r6SO6LOWmZQsfboq040 + oeJhDzhL218ze1t7qhBk4rbju8NLFwtdhWZ8M7JBzlj5PfACBewd0eosFtb1Tz3N + eAZn4M/GkzBh4t1M55Jt+GwYBD2A7trDRUCUQxtJ1QVzr2gbIB/FfoGvP0qnE4gk + R3PW + -----END CERTIFICATE----- + """; + + private static final String KEY = """ + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChuMSubmmNY13b + 4PUGBoUO+EIy074ZfbRMPHCGJhfSnZVWdETBflOe3kNyouejxN2eT3Xoqf7slqTP + ySJo8hZQjukORNtudMPWQkiBrvaWPCttlYtCxpxhxRw0VnIo/jYP2Fmwt2buHV// + Kn039amDDr3auZWVXEIGLcafVpd/3v7c+1NLbMPR/uK862Yk6UG1rlvOYBIttNp2 + Y9fGZLkMLA5fE3xsPbg8VeWuN9/xjPtZGZ5qUYHaQn3ZDzmZ8U9q5TGCbcpoVEIo + oRSb2lIILAqw2FRAR7ashCcJf6QzPVdhoZ8QIovJoViBnzuFP0I4VYp9fT1pRmBY + jcZtIsSfAgMBAAECggEAQu+cbIwjoRM3NnpqQAezzAniMHJmlNtsJD/B3SxoINL7 + jDCMgr/cMX3SUeDuWmDxz4QZC+dMrbT+W0hnNyO4K7iy6qaCYjnvEsAVjaOSyYT2 + /qDuZoGZGXiBn4IGN0RcsPs9yEBo2HaNFKqL8Hz8H9QarayxpoPsie0pcCrhgtlr + fsVN2AMhbEU15SpD0nkjWkqG51m1EY36seSzAwQod2UasD+eNdZ89a2o849cfuA3 + 1F1TyghVIm0RmAdC1yWuud8YY4xvtpfy1+3RjShW0jgA0nVHfP2UgYfBAJ8+TFGy + +hVCgnnt/eMQ1fCszwLkq5spRcqNr4oOVcm+nR718QKBgQDSvEXaaQgqhARfyN/c + /1qFgOBT34a7pEJXotEC6aDV3YxivAt2jDB4TC0NVS5IGP9NYm4oi0Fiw1v0V5eZ + n8nmq0gkfBjW1y37U4mP74QyKY9rwyjyjjsYqr/aTS8e2ZY4QWmWxm9y5a3Q0Ifu + UwzLD5eqiXNLNvvNZoggo+dVsQKBgQDEdWAntCdTZEYO3fp4/ygCJisPeFgy/0Gw + GsgHCkWR4ZyzmBmFSvf3Es3MmcH3QgkfIROCOMaZ0YcGCLzr/RMdYkgpa9DvhJnG + WZDtumxQAW3G3moAYu2r2wXfOjgsMBDlRBH0mBWTBjpovbWBs1NMFfxsetQYY7zG + aQGLDPJDTwKBgQCD6QQUrlBFRLP0PSocDN9d2AkTl0SgKja44prQputdU7vvhePr + Bd/FPXGp+drpmHQevXFVAa4hI0ZpEXc822+naynSZLerq7AFtQnTxkrKl4dGHjiA + dBV74E4NWOkY93x3pEJy9a2Hj0uY/R9JSEUmypDWWAmKWFWQAhFN1SsWUQKBgD7e + xTfPimpAg78MQLTqCvatGkioHamsUGw4Fd1S5zKpPcmnmjsy46nZBa09Y3pqUpr4 + rdKVstDU4d4He9YVtkFIC4nd7A5KpB962EuLxk/QNT5YPRoEjsTZocZvTjyt4SpN + n2VkKjtT2etdErIAHl8SBib9I9TuTiI8xnamXP03AoGATMdUyehOBqG3+YlS31c8 + z9vcIn2egPOjFCWK64FQIuwpmVU5sx6aYIRDb3/jxYqYCaMRcnMSSp/Mrn2yBnui + oRry31MaC/aTSumn/r6WMz7FEYrbsfr9//bYr+tiTrtimWAe4yTwrqFve1Bw2bOQ + P16PTZ2/SMJDmiLR1MIkCns= + -----END PRIVATE KEY----- + """; +} diff --git a/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java new file mode 100644 index 00000000..9677e82e --- /dev/null +++ b/modules/inbound/inbound-mqtt/src/test/java/com/lingniu/ingest/inbound/mqtt/YutongEventMapperTest.java @@ -0,0 +1,226 @@ +package com.lingniu.ingest.inbound.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper; +import com.lingniu.ingest.inbound.mqtt.model.MqttPayload; +import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class YutongEventMapperTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final MqttPayloadParser parser = new MqttPayloadParser(objectMapper); + private final YutongEventMapper mapper = new YutongEventMapper(); + + @Test + void parsesYutongSamplePayload() { + String json = """ + { + "id": "abc", + "device": "LTEST000000000001", + "code": "C1", + "time": "20260413100000", + "data": { + "METER_SPEED": 52.3, + "TOTAL_MILEAGE": 123456.7, + "BATTERY_CAPACITY_SOC": 70, + "LONGITUDE": 116397128, + "LATITUDE": 39916527, + "GPSDirection": 90, + "LEFT_HYDROGEN": 5.5, + "VOLTAGE_OF_FC": 600.0, + "CURRENT_OF_FC": 120.0, + "TEMPT_OF_FC": 65.0, + "ON_GEAR": 3, + "ACC_PEDAL_APT": 25, + "BRAKEPEDALAPT": 0 + } + } + """; + + MqttPayload payload = parser.parse("yutong", "yutong", "/ytforward/shln/dev1", json.getBytes()); + assertThat(payload.vin()).isEqualTo("LTEST000000000001"); + + List events = mapper.toEvents(payload); + assertThat(events).hasSize(2); + + VehicleEvent.Realtime rt = (VehicleEvent.Realtime) events.stream() + .filter(e -> e instanceof VehicleEvent.Realtime).findFirst().orElseThrow(); + assertThat(rt.source()).isEqualTo(ProtocolId.MQTT_YUTONG); + assertThat(rt.vin()).isEqualTo("LTEST000000000001"); + assertThat(rt.payload().speedKmh()).isEqualTo(52.3); + assertThat(rt.payload().batterySoc()).isEqualTo(70.0); + assertThat(rt.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(rt.payload().latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(rt.payload().hydrogenRemainingKg()).isEqualTo(5.5); + + VehicleEvent.Location loc = (VehicleEvent.Location) events.stream() + .filter(e -> e instanceof VehicleEvent.Location).findFirst().orElseThrow(); + assertThat(loc.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + } + + @Test + void resolvesVinThroughSharedIdentityServiceWhenDeviceIsAnExternalId() throws Exception { + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.MQTT_YUTONG, + "VIN-INTERNAL-001", + "", + "mqtt-device-001", + "")); + YutongEventMapper mapperWithIdentity = new YutongEventMapper(identity); + + MqttPayload payload = new MqttPayload( + "yutong", + "yutong", + "/ytforward/shln/mqtt-device-001", + "mqtt-device-001", + java.time.Instant.parse("2026-06-22T08:00:00Z"), + objectMapper.readTree(""" + { + "METER_SPEED": 18.5, + "TOTAL_MILEAGE": 99.1 + } + """)); + + List events = mapperWithIdentity.toEvents(payload); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event.vin()).isEqualTo("VIN-INTERNAL-001"); + assertThat(event.metadata()) + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_DEVICE_ID") + .containsEntry("mqttDeviceId", "mqtt-device-001"); + }); + } + + @Test + void resolvesVinThroughSharedIdentityServiceWhenPayloadUsesTerminalId() { + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.MQTT_YUTONG, + "VIN-INTERNAL-TID", + "", + "terminal-001", + "")); + YutongEventMapper mapperWithIdentity = new YutongEventMapper(identity); + String json = """ + { + "terminalId": "terminal-001", + "time": "20260413100000", + "data": { + "METER_SPEED": 18.5, + "TOTAL_MILEAGE": 99.1 + } + } + """; + + MqttPayload payload = parser.parse( + "yutong", "yutong", "/ytforward/shln/terminal-001", json.getBytes()); + List events = mapperWithIdentity.toEvents(payload); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event.vin()).isEqualTo("VIN-INTERNAL-TID"); + assertThat(event.metadata()) + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_DEVICE_ID") + .containsEntry("mqttDeviceId", "terminal-001"); + }); + } + + @Test + void prefersBoundDeviceIdentityOverVinLengthGuess() throws Exception { + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.MQTT_YUTONG, + "VIN-INTERNAL-017", + "", + "DEVICEID000000000", + "")); + YutongEventMapper mapperWithIdentity = new YutongEventMapper(identity); + + MqttPayload payload = new MqttPayload( + "yutong", + "yutong", + "/ytforward/shln/DEVICEID000000000", + "DEVICEID000000000", + java.time.Instant.parse("2026-06-22T08:00:00Z"), + objectMapper.readTree("{\"METER_SPEED\":18.5}")); + + List events = mapperWithIdentity.toEvents(payload); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event.vin()).isEqualTo("VIN-INTERNAL-017"); + assertThat(event.metadata()).containsEntry("identitySource", "BOUND_DEVICE_ID"); + }); + } + + @Test + void identityResolverFailureStillProducesRealtimeEventWithUnknownVin() throws Exception { + YutongEventMapper mapperWithFailingIdentity = new YutongEventMapper(lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }); + MqttPayload payload = new MqttPayload( + "yutong", + "yutong", + "/ytforward/shln/mqtt-device-001", + "mqtt-device-001", + java.time.Instant.parse("2026-06-22T08:00:00Z"), + objectMapper.readTree(""" + { + "METER_SPEED": 18.5, + "TOTAL_MILEAGE": 99.1 + } + """)); + + List events = mapperWithFailingIdentity.toEvents(payload); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Realtime.class); + assertThat(event.vin()).isEqualTo("unknown"); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("mqttDeviceId", "mqtt-device-001") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + } + + @Test + void parseErrorPayloadKeepsDiagnosticMessageOnPassthroughEvent() throws Exception { + MqttPayload payload = new MqttPayload( + "endpoint-a", + "yutong", + "/ytforward/shln/bad-device", + "unknown", + java.time.Instant.parse("2026-06-22T08:00:00Z"), + objectMapper.readTree(""" + { + "_parseError": true, + "_parseErrorMessage": "Unexpected character at byte 1", + "_rawBase64": "e2JhZC1qc29u" + } + """)); + + List events = mapper.toEvents(payload); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.data()).containsExactly("{bad-json".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(event.metadata()) + .containsEntry("parseError", "true") + .containsEntry("parseErrorMessage", "Unexpected character at byte 1"); + }); + } +} diff --git a/inbound-xinda-push/pom.xml b/modules/inbound/inbound-xinda-push/pom.xml similarity index 81% rename from inbound-xinda-push/pom.xml rename to modules/inbound/inbound-xinda-push/pom.xml index 39633339..00e6e4ed 100644 --- a/inbound-xinda-push/pom.xml +++ b/modules/inbound/inbound-xinda-push/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml inbound-xinda-push inbound-xinda-push @@ -23,6 +24,10 @@ com.lingniu.ingest ingest-core + + com.lingniu.ingest + vehicle-identity + org.springframework.boot spring-boot-starter @@ -70,5 +75,20 @@ commons-codec + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java new file mode 100644 index 00000000..639b95c2 --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java @@ -0,0 +1,336 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.gps31.push.netty.PushClient; +import com.gps31.push.netty.PushMsg; +import com.gps31.push.netty.client.TcpClient; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.inbound.xinda.config.XindaPushProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * 信达 Push 接入客户端。基于 {@code org.lingniu:gps-push-client:1.0} 的 {@link PushClient} + * 实现,完全使用信达真实协议帧格式(帧头 / 长度 / cmd / json / 帧尾 + 序列号 + 心跳)。 + * + *

行为: + *

    + *
  • {@link #start()} 调用基类 {@code TcpClient.start()} 建立连接并自动重连 + *
  • 基类在 {@code channelActive} 自动发送 {@code 0x0001 登录} 帧,携带 userName / pwd / csTag / desc + *
  • 登录成功收到应答后(由基类内部处理)会自动订阅 {@code subMsgIds}(默认空,需显式调用 setSubMsgIds) + *
  • {@link #messageReceived(TcpClient, PushMsg)} 为业务入口:cmd 字符串区分消息类型 + *
  • 位置 {@code 0200} / 报警 {@code 0300} / 透传 {@code 0401} → RawFrame → Dispatcher + *
+ * + *

类继承基类 {@code PushClient},不再需要手工处理帧编解码、心跳、重连、订阅等机制。 + */ +public class XindaPushClient extends PushClient { + + private static final Logger log = LoggerFactory.getLogger(XindaPushClient.class); + + private final XindaPushProperties props; + private final VehicleIdentityResolver identityResolver; + private final Dispatcher dispatcher; + + public XindaPushClient(XindaPushProperties props, Dispatcher dispatcher) { + this(props, new InMemoryVehicleIdentityService(), dispatcher); + } + + public XindaPushClient(XindaPushProperties props, + VehicleIdentityResolver identityResolver, + Dispatcher dispatcher) { + super(); + this.props = props; + this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver; + this.dispatcher = dispatcher; + this.isLog = false; + } + + /** + * 启动客户端:填入 host/port/user/pwd/desc,配置订阅消息 ID,交给基类建立连接。 + */ + public void start() { + if (props.getHost() == null || props.getHost().isBlank()) { + publishOperationalError("startup", "host not configured"); + return; + } + if (props.getUsername() == null || props.getPassword() == null) { + publishOperationalError("startup", "credentials missing"); + return; + } + setHost(props.getHost()); + setPort(props.getPort()); + setUserName(props.getUsername()); + setPwd(props.getPassword()); + setDesc(props.getClientDesc()); + // 订阅消息 ID:信达基类 PushClient.setSubMsgIds 用管道符 "|" 作为分隔符 + // (内部调用 StringUtil.splitStr(s, "|")) + if (props.getSubscribeMsgIds() != null && !props.getSubscribeMsgIds().isEmpty()) { + setSubMsgIds(String.join("|", props.getSubscribeMsgIds())); + } + // 基类 PushClient 构造器默认 isLog=false / isLogBytes=false; + // 这里尊重默认值,不再覆盖为 true,避免 <<>>Log接收 每条帧都被 ERROR 级打印。 + // 真要打开 wire-level 诊断时,临时改为 setLog(true) 即可。 + try { + log.info("[xinda-push] starting host={} port={} user={} subMsgIds={}", + getHost(), getPort(), getUserName(), getSubMsgIds()); + super.start(); + } catch (Exception e) { + log.error("[xinda-push] start failed", e); + publishOperationalError("startup", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + } + } + + /** + * Spring 生命周期停止钩子。 + */ + public void stop() { + try { + destroy(); + log.info("[xinda-push] stopped"); + } catch (Exception e) { + log.warn("[xinda-push] stop error", e); + } + } + + // ======================================================================== + // 业务回调:收到完整的 PushMsg 后分派事件 + // ======================================================================== + + @Override + public void messageReceived(TcpClient client, PushMsg msg) throws Exception { + super.messageReceived(client, msg); + String cmd = msg.getCmd(); + if (cmd == null) return; + try { + switch (cmd) { + case "8001" -> handleLoginAck(msg); + case "8002" -> log.debug("[xinda-push] heartbeat ack"); + case "8003" -> log.info("[xinda-push] subscribe ack json={}", msg.getJson()); + case "0200", "0300", "0401" -> dispatchBusinessMessage(msg, false); + default -> { + log.debug("[xinda-push] rx unknown business cmd={} json={}", cmd, msg.getJson()); + dispatchBusinessMessage(msg, true); + } + } + } catch (Exception e) { + log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e); + publishMessageError(msg, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + } + } + + /** + * 收到 8001 登录应答后处理:若 {@code rspResult=0} 则主动发送 0003 订阅命令。 + * + *

信达订阅命令的 body 格式(从旧 {@code PushClientHandler} 和 8003 错误提示反推得到): + *

{@code
+     * {
+     *   "seq":     "1",                             // 序列号
+     *   "action":  "add",                           // 操作: add 订阅 / del 取消
+     *   "msgIds":  "[\"0200\",\"0300\",\"0401\"]"  // JSON 数组字符串(注意是字符串,不是数组)
+     * }
+     * }
+ *

+ * 其中 {@code msgIds} 的值来自基类 {@link PushClient#getSubCmdSet()},内容由 + * {@link PushClient#setSubMsgIds(String)} 填充(分隔符是 {@code |})。 + */ + private void handleLoginAck(PushMsg msg) throws Exception { + String json = msg.getJson() == null ? "" : msg.getJson(); + log.info("[xinda-push] login ack json={}", json); + JSONObject parsed; + try { + parsed = JSON.parseObject(json); + } catch (Exception e) { + publishOperationalError("login", "login ack parse failed: " + + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + return; + } + String rsp = parsed == null ? null : parsed.getString("rspResult"); + if (!"0".equals(rsp)) { + log.error("[xinda-push] login REJECTED rspResult={}", rsp); + publishOperationalError("login", "login rejected rspResult=" + (rsp == null ? "" : rsp)); + return; + } + Map body = new java.util.HashMap<>(); + body.put("seq", "1"); + body.put("action", "add"); + body.put("msgIds", JSON.toJSONString(getSubCmdSet())); + PushMsg subscribe = getInstance("0003", body); + sendMsg(subscribe); + log.info("[xinda-push] subscribe sent action=add msgIds={}", getSubCmdSet()); + } + + @Override + public void channelActive(TcpClient client) throws Exception { + super.channelActive(client); + log.info("[xinda-push] channel active, login frame sent"); + } + + @Override + public void channelInactive(TcpClient client) throws Exception { + super.channelInactive(client); + log.warn("[xinda-push] channel inactive, will auto-reconnect"); + } + + private void dispatchBusinessMessage(PushMsg msg, boolean unknownCommand) { + String json = msg.getJson() == null ? "" : msg.getJson(); + XindaPushMessage payload = new XindaPushMessage(msg.getCmd(), json); + byte[] raw = json.getBytes(StandardCharsets.UTF_8); + JsonParseResult parsed = parseForMetadata(json); + JSONObject identityFields = parsed.json(); + IdentityResolution identity = resolveIdentity(identityFields); + Map meta = new HashMap<>(); + meta.put("source", "xinda-push"); + meta.put("cmd", payload.command()); + meta.put("vin", identity.identity().vin()); + meta.put("externalVin", firstNonBlank(identityFields, "vin")); + meta.put("phone", firstNonBlank(identityFields, "sim", "phone", "mobile")); + meta.put("deviceId", firstNonBlank(identityFields, "deviceId", "terminalId", "imei")); + meta.put("plateNo", firstNonBlank(identityFields, "plateNo", "carNo")); + meta.put("identityResolved", Boolean.toString(identity.identity().resolved())); + meta.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + meta.put("identityError", "true"); + meta.put("identityErrorMessage", identity.errorMessage()); + } + if (unknownCommand) { + meta.put("unknownCommand", "true"); + } + if (payload.malformedCommand()) { + meta.put("malformedCommand", "true"); + } + if (parsed.parseError()) { + meta.put("parseError", "true"); + meta.put("parseErrorMessage", parsed.errorMessage()); + } + dispatcher.dispatch(new RawFrame( + ProtocolId.XINDA_PUSH, + payload.commandCode(), + 0, + payload, + raw, + meta, + Instant.now())); + } + + private void publishOperationalError(String phase, String reason) { + log.error("[xinda-push] operational error phase={} reason={}", phase, reason); + JSONObject json = new JSONObject(); + json.put("operationalError", true); + json.put("phase", phase == null ? "" : phase); + json.put("reason", reason == null ? "" : reason); + XindaPushMessage payload = new XindaPushMessage("0000", json.toJSONString()); + byte[] raw = payload.json().getBytes(StandardCharsets.UTF_8); + Map meta = new HashMap<>(); + meta.put("source", "xinda-push"); + meta.put("cmd", payload.command()); + meta.put("vin", "unknown"); + meta.put("identityResolved", "false"); + meta.put("identitySource", "UNKNOWN"); + meta.put("operationalError", "true"); + meta.put("phase", phase == null ? "" : phase); + meta.put("reason", reason == null ? "" : reason); + dispatcher.dispatch(new RawFrame( + ProtocolId.XINDA_PUSH, + payload.commandCode(), + 0, + payload, + raw, + meta, + Instant.now())); + } + + private void publishMessageError(PushMsg msg, String reason) { + String cmd = msg == null || msg.getCmd() == null ? "0000" : msg.getCmd(); + String json = msg == null || msg.getJson() == null ? "" : msg.getJson(); + XindaPushMessage payload = new XindaPushMessage(cmd, json); + byte[] raw = json.getBytes(StandardCharsets.UTF_8); + Map meta = new HashMap<>(); + meta.put("source", "xinda-push"); + meta.put("cmd", cmd); + meta.put("vin", "unknown"); + meta.put("identityResolved", "false"); + meta.put("identitySource", "UNKNOWN"); + meta.put("operationalError", "true"); + meta.put("phase", "message"); + meta.put("reason", reason == null ? "" : reason); + dispatcher.dispatch(new RawFrame( + ProtocolId.XINDA_PUSH, + 0, + 0, + payload, + raw, + meta, + Instant.now())); + } + + // ======================================================================== + // JSON helpers + // ======================================================================== + + private static String firstNonBlank(JSONObject node, String... keys) { + if (node == null) return ""; + for (String k : keys) { + Object v = node.get(k); + if (v != null && !v.toString().isBlank()) return v.toString(); + } + return ""; + } + + private IdentityResolution resolveIdentity(JSONObject json) { + try { + return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.XINDA_PUSH, + firstNonBlank(json, "vin"), + firstNonBlank(json, "sim", "phone", "mobile"), + firstNonBlank(json, "deviceId", "terminalId", "imei"), + firstNonBlank(json, "plateNo", "carNo"))), null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static JSONObject parseQuietly(String rawJson) { + if (rawJson == null || rawJson.isBlank()) { + return new JSONObject(); + } + try { + JSONObject parsed = JSON.parseObject(rawJson); + return parsed == null ? new JSONObject() : parsed; + } catch (Exception ignored) { + return new JSONObject(); + } + } + + private static JsonParseResult parseForMetadata(String rawJson) { + if (rawJson == null || rawJson.isBlank()) { + return new JsonParseResult(new JSONObject(), false, ""); + } + try { + JSONObject parsed = JSON.parseObject(rawJson); + return new JsonParseResult(parsed == null ? new JSONObject() : parsed, false, ""); + } catch (Exception e) { + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + return new JsonParseResult(new JSONObject(), true, message); + } + } + + private record JsonParseResult(JSONObject json, boolean parseError, String errorMessage) {} + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) {} +} diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java new file mode 100644 index 00000000..4c66cbd2 --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java @@ -0,0 +1,353 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.AlarmPayload; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.EventMapper; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public final class XindaPushEventMapper implements EventMapper { + + private final VehicleIdentityResolver identityResolver; + + public XindaPushEventMapper() { + this(new InMemoryVehicleIdentityService()); + } + + public XindaPushEventMapper(VehicleIdentityResolver identityResolver) { + this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver; + } + + @Override + public List toEvents(XindaPushMessage message) { + if (message == null || message.command().isBlank()) { + return List.of(); + } + JSONObject json; + try { + json = parse(message.json()); + } catch (Exception e) { + return List.of(passthrough(message, new JSONObject(), true, false, + e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + if (json.getBooleanValue("operationalError")) { + return List.of(passthrough(message, json, false, false)); + } + return switch (message.command()) { + case "0200" -> List.of(location(message, json)); + case "0300" -> List.of(alarm(message, json)); + case "0401" -> List.of(passthrough(message, json, false, false)); + default -> List.of(passthrough(message, json, false, true)); + }; + } + + private VehicleEvent.Location location(XindaPushMessage message, JSONObject json) { + IdentityResolution identity = resolveIdentity(json); + double lon = normalizeCoord(asDouble(json, "lng", "lon", "longitude")); + double lat = normalizeCoord(asDouble(json, "lat", "latitude")); + double speed = asDouble(json, "speed"); + Double totalMileageKm = optionalDouble(json, + "totalMileage", "totalMileageKm", "mileage", "mileageKm", "total_mileage"); + double direction = asDouble(json, "drct", "direction"); + long alarmFlag = (long) asDouble(json, "alarmStts", "alarmFlag"); + long statusFlag = (long) asDouble(json, "statusStts", "statusFlag"); + Instant now = Instant.now(); + return new VehicleEvent.Location( + UUID.randomUUID().toString(), + identity.vin(), + ProtocolId.XINDA_PUSH, + now, + now, + null, + metadata(message, identity, false, false), + new LocationPayload(lon, lat, 0.0, speed, direction, alarmFlag, statusFlag, totalMileageKm)); + } + + private VehicleEvent.Alarm alarm(XindaPushMessage message, JSONObject json) { + IdentityResolution identity = resolveIdentity(json); + double lon = normalizeCoord(asDouble(json, "lng", "lon", "longitude")); + double lat = normalizeCoord(asDouble(json, "lat", "latitude")); + String alarmType = firstNonBlank(json, "alarmType", "alarmCode", "type", "warnType"); + String alarmName = firstNonBlank(json, "alarmName", "alarmDesc", "alarmContent", "content", "warnName"); + int alarmTypeCode = alarmTypeCode(alarmType); + boolean hydrogenLeak = isHydrogenLeak(alarmType, alarmName, firstNonBlank(json, "safetyCategory")); + AlarmPayload.AlarmLevel level = alarmLevel(asInt(json, "alarmLevel", "level"), hydrogenLeak); + Instant now = Instant.now(); + return new VehicleEvent.Alarm( + UUID.randomUUID().toString(), + identity.vin(), + ProtocolId.XINDA_PUSH, + now, + now, + null, + alarmMetadata(message, identity, hydrogenLeak), + new AlarmPayload( + level, + alarmTypeCode, + alarmType.isBlank() ? alarmName : alarmType, + faultCodes(json, alarmType, alarmName), + activeBits(json, alarmType, alarmName, hydrogenLeak), + lon, + lat, + hydrogenLeak ? AlarmPayload.SafetyCategory.HYDROGEN_LEAK : AlarmPayload.SafetyCategory.GENERAL, + hydrogenLeak, + hydrogenLeak ? AlarmPayload.HydrogenLeakLevel.CRITICAL : AlarmPayload.HydrogenLeakLevel.NONE, + hydrogenLeak)); + } + + private VehicleEvent.Passthrough passthrough(XindaPushMessage message, + JSONObject json, + boolean parseError, + boolean unknownCommand) { + return passthrough(message, json, parseError, unknownCommand, ""); + } + + private VehicleEvent.Passthrough passthrough(XindaPushMessage message, + JSONObject json, + boolean parseError, + boolean unknownCommand, + String parseErrorMessage) { + IdentityResolution identity = resolveIdentity(json); + byte[] raw = message.json().getBytes(StandardCharsets.UTF_8); + Instant now = Instant.now(); + return new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + identity.vin(), + ProtocolId.XINDA_PUSH, + now, + now, + null, + metadata(message, identity, parseError, unknownCommand, parseErrorMessage), + message.commandCode(), + raw); + } + + private IdentityResolution resolveIdentity(JSONObject json) { + try { + return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.XINDA_PUSH, + firstNonBlank(json, "vin"), + firstNonBlank(json, "sim", "phone", "mobile"), + firstNonBlank(json, "deviceId", "terminalId", "imei"), + firstNonBlank(json, "plateNo", "carNo")))); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static Map metadata(XindaPushMessage message, + IdentityResolution identity, + boolean parseError, + boolean unknownCommand) { + return metadata(message, identity, parseError, unknownCommand, ""); + } + + private static Map metadata(XindaPushMessage message, + IdentityResolution identity, + boolean parseError, + boolean unknownCommand, + String parseErrorMessage) { + JSONObject json = parseQuietly(message.json()); + Map out = new java.util.LinkedHashMap<>(); + out.put("source", "xinda-push"); + out.put("cmd", message.command()); + out.put("rawJson", message.json()); + out.put("vin", normalizedVin(identity)); + out.put("externalVin", firstNonBlank(json, "vin")); + out.put("phone", firstNonBlank(json, "sim", "phone", "mobile")); + out.put("deviceId", firstNonBlank(json, "deviceId", "terminalId", "imei")); + out.put("plateNo", firstNonBlank(json, "plateNo", "carNo")); + out.put("identityResolved", Boolean.toString(identity.identity().resolved())); + out.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + out.put("identityError", "true"); + out.put("identityErrorMessage", identity.errorMessage()); + } + out.put("parseError", Boolean.toString(parseError)); + if (parseError) { + out.put("parseErrorMessage", parseErrorMessage == null ? "" : parseErrorMessage); + } + out.put("unknownCommand", Boolean.toString(unknownCommand)); + out.put("malformedCommand", Boolean.toString(message.malformedCommand())); + if (json.getBooleanValue("operationalError")) { + out.put("operationalError", "true"); + out.put("phase", firstNonBlank(json, "phase")); + out.put("reason", firstNonBlank(json, "reason")); + } + return Map.copyOf(out); + } + + private static String normalizedVin(IdentityResolution identity) { + if (identity == null || identity.vin() == null || identity.vin().isBlank()) { + return "unknown"; + } + return identity.vin(); + } + + private static Map alarmMetadata(XindaPushMessage message, + IdentityResolution identity, + boolean hydrogenLeak) { + JSONObject json = parseQuietly(message.json()); + Map out = new java.util.LinkedHashMap<>(metadata(message, identity, false, false)); + out.put("alarmType", firstNonBlank(json, "alarmType", "alarmCode", "type", "warnType")); + out.put("alarmName", firstNonBlank(json, "alarmName", "alarmDesc", "alarmContent", "content", "warnName")); + out.put("alarmLevel", firstNonBlank(json, "alarmLevel", "level")); + out.put("hydrogenLeakDetected", Boolean.toString(hydrogenLeak)); + if (hydrogenLeak) { + out.put("safetyCategory", AlarmPayload.SafetyCategory.HYDROGEN_LEAK.name()); + out.put("hydrogenLeakLevel", AlarmPayload.HydrogenLeakLevel.CRITICAL.name()); + out.put("hydrogenLeakActionRequired", "true"); + } + return Map.copyOf(out); + } + + private static JSONObject parse(String raw) { + if (raw == null || raw.isBlank()) { + return new JSONObject(); + } + return JSON.parseObject(raw); + } + + private static JSONObject parseQuietly(String raw) { + try { + return parse(raw); + } catch (Exception ignored) { + return new JSONObject(); + } + } + + private static String firstNonBlank(JSONObject node, String... keys) { + if (node == null) return ""; + for (String k : keys) { + Object v = node.get(k); + if (v != null && !v.toString().isBlank()) return v.toString(); + } + return ""; + } + + private static double asDouble(JSONObject node, String... keys) { + Double value = optionalDouble(node, keys); + return value == null ? 0.0 : value; + } + + private static Double optionalDouble(JSONObject node, String... keys) { + if (node == null) return null; + for (String k : keys) { + Object v = node.get(k); + if (v instanceof Number n) return n.doubleValue(); + if (v != null) { + try { + return Double.parseDouble(v.toString()); + } catch (NumberFormatException ignored) { + } + } + } + return null; + } + + private static int asInt(JSONObject node, String... keys) { + return (int) asDouble(node, keys); + } + + private static double normalizeCoord(double value) { + if (Math.abs(value) > 1000) { + return value / 1_000_000.0; + } + return value; + } + + private static boolean isHydrogenLeak(String... values) { + for (String value : values) { + if (value == null || value.isBlank()) { + continue; + } + String v = value.trim().toLowerCase(java.util.Locale.ROOT); + if (v.contains("hydrogen_leak") + || v.contains("h2_leak") + || v.contains("hydrogenleak") + || v.contains("氢气泄") + || v.contains("氢泄") + || v.contains("漏氢")) { + return true; + } + } + return false; + } + + private static AlarmPayload.AlarmLevel alarmLevel(int code, boolean hydrogenLeak) { + if (hydrogenLeak || code >= 3) { + return AlarmPayload.AlarmLevel.CRITICAL; + } + if (code == 2) { + return AlarmPayload.AlarmLevel.MAJOR; + } + if (code == 1) { + return AlarmPayload.AlarmLevel.MINOR; + } + return AlarmPayload.AlarmLevel.INFO; + } + + private static int alarmTypeCode(String alarmType) { + if (alarmType == null || alarmType.isBlank()) { + return 0; + } + try { + return Integer.parseInt(alarmType.trim()); + } catch (NumberFormatException ignored) { + return Math.abs(alarmType.trim().hashCode()); + } + } + + private static List faultCodes(JSONObject json, String alarmType, String alarmName) { + LinkedHashSet codes = new LinkedHashSet<>(); + addIfPresent(codes, alarmType); + addIfPresent(codes, firstNonBlank(json, "faultCode", "faultCodes", "alarmCode")); + addIfPresent(codes, alarmName); + return List.copyOf(codes); + } + + private static java.util.Set activeBits(JSONObject json, + String alarmType, + String alarmName, + boolean hydrogenLeak) { + LinkedHashSet bits = new LinkedHashSet<>(); + addIfPresent(bits, alarmType); + addIfPresent(bits, alarmName); + if (hydrogenLeak) { + bits.add("HYDROGEN_LEAK"); + } + return java.util.Set.copyOf(bits); + } + + private static void addIfPresent(java.util.Set out, String value) { + if (value != null && !value.isBlank()) { + out.add(value.trim()); + } + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) { + private static IdentityResolution ok(VehicleIdentity identity) { + return new IdentityResolution(identity, null); + } + + private String vin() { + return identity.vin(); + } + } +} diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushMessage.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushMessage.java new file mode 100644 index 00000000..49ee818a --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushMessage.java @@ -0,0 +1,32 @@ +package com.lingniu.ingest.inbound.xinda; + +public record XindaPushMessage(String command, String json) { + + public XindaPushMessage { + command = command == null ? "" : command.trim(); + json = json == null ? "" : json; + } + + public int commandCode() { + if (command.isBlank()) { + return 0; + } + try { + return Integer.parseInt(command, 16); + } catch (NumberFormatException e) { + return 0; + } + } + + public boolean malformedCommand() { + if (command.isBlank()) { + return false; + } + try { + Integer.parseInt(command, 16); + return false; + } catch (NumberFormatException e) { + return true; + } + } +} diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandler.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandler.java new file mode 100644 index 00000000..680fb293 --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandler.java @@ -0,0 +1,37 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.event.VehicleEvent; + +import java.util.List; + +@ProtocolHandler(protocol = ProtocolId.XINDA_PUSH) +public final class XindaPushRealtimeHandler { + + private final XindaPushEventMapper mapper; + + public XindaPushRealtimeHandler(XindaPushEventMapper mapper) { + this.mapper = mapper; + } + + @MessageMapping(command = 0x0200, desc = "信达定位") + @EventEmit(VehicleEvent.Location.class) + public List onLocation(XindaPushMessage message) { + return mapper.toEvents(message); + } + + @MessageMapping(command = 0x0300, desc = "信达报警") + @EventEmit(VehicleEvent.Alarm.class) + public List onAlarm(XindaPushMessage message) { + return mapper.toEvents(message); + } + + @MessageMapping(desc = "信达透传/未知业务消息兜底") + @EventEmit(VehicleEvent.Passthrough.class) + public List onPassthrough(XindaPushMessage message) { + return mapper.toEvents(message); + } +} diff --git a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java similarity index 64% rename from inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java rename to modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java index ffdc51fe..b85ad49d 100644 --- a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushAutoConfiguration.java @@ -1,8 +1,10 @@ package com.lingniu.ingest.inbound.xinda.config; -import com.lingniu.ingest.core.concurrency.DisruptorEventBus; import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.VehicleIdentityResolver; import com.lingniu.ingest.inbound.xinda.XindaPushClient; +import com.lingniu.ingest.inbound.xinda.XindaPushEventMapper; +import com.lingniu.ingest.inbound.xinda.XindaPushRealtimeHandler; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -19,9 +21,21 @@ public class XindaPushAutoConfiguration { @Bean(destroyMethod = "stop") @ConditionalOnMissingBean public XindaPushClient xindaPushClient(XindaPushProperties props, - Dispatcher dispatcher, - DisruptorEventBus eventBus) { - return new XindaPushClient(props, dispatcher, eventBus); + VehicleIdentityResolver identityResolver, + Dispatcher dispatcher) { + return new XindaPushClient(props, identityResolver, dispatcher); + } + + @Bean + @ConditionalOnMissingBean + public XindaPushEventMapper xindaPushEventMapper(VehicleIdentityResolver identityResolver) { + return new XindaPushEventMapper(identityResolver); + } + + @Bean + @ConditionalOnMissingBean + public XindaPushRealtimeHandler xindaPushRealtimeHandler(XindaPushEventMapper mapper) { + return new XindaPushRealtimeHandler(mapper); } @Bean diff --git a/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java similarity index 100% rename from inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java rename to modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java diff --git a/inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/inbound/inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/inbound/inbound-xinda-push/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushClientTest.java b/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushClientTest.java new file mode 100644 index 00000000..c0788618 --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushClientTest.java @@ -0,0 +1,242 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.gps31.push.netty.PushMsg; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.inbound.xinda.config.XindaPushProperties; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class XindaPushClientTest { + + @Test + void missingHostIsDispatchedAsOperationalPassthroughCandidate() { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushProperties props = new XindaPushProperties(); + props.setUsername("user"); + props.setPassword("pwd"); + XindaPushClient client = new XindaPushClient(props, dispatcher); + + client.start(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isZero(); + assertThat(frame.payload()).isInstanceOf(XindaPushMessage.class); + assertThat(frame.rawBytes()).asString(StandardCharsets.UTF_8) + .contains("\"operationalError\":true") + .contains("\"phase\":\"startup\"") + .contains("\"reason\":\"host not configured\""); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("operationalError", "true") + .containsEntry("phase", "startup") + .containsEntry("reason", "host not configured") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + } + + @Test + void unknownBusinessCommandIsDispatchedForArchiveAndPassthrough() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher); + PushMsg msg = new PushMsg(); + msg.init("0500", "{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isEqualTo(0x0500); + assertThat(frame.payload()).isEqualTo(new XindaPushMessage("0500", + "{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}")); + assertThat(frame.rawBytes()).containsExactly( + "{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}".getBytes(StandardCharsets.UTF_8)); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("cmd", "0500") + .containsEntry("vin", "LNVIN000000000303") + .containsEntry("unknownCommand", "true"); + } + + @Test + void businessMessageRawFrameUsesResolvedVehicleIdentity() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService(); + identities.bind(new VehicleIdentityBinding( + ProtocolId.XINDA_PUSH, + "LNVIN000000XINDA1", + "", + "XD-DEVICE-001", + "粤B12345")); + XindaPushClient client = new XindaPushClient(new XindaPushProperties(), identities, dispatcher); + PushMsg msg = new PushMsg(); + msg.init("0200", "{\"deviceId\":\"XD-DEVICE-001\",\"plateNo\":\"粤B12345\",\"lng\":113.1,\"lat\":23.1}"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.sourceMeta()) + .containsEntry("vin", "LNVIN000000XINDA1") + .containsEntry("deviceId", "XD-DEVICE-001") + .containsEntry("plateNo", "粤B12345") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_DEVICE_ID"); + } + + @Test + void malformedCommandIsStillDispatchedForArchiveAndPassthrough() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher); + PushMsg msg = new PushMsg(); + msg.init("bad-cmd", "{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isZero(); + assertThat(frame.payload()).isEqualTo(new XindaPushMessage("bad-cmd", + "{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}")); + assertThat(frame.rawBytes()).containsExactly( + "{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}".getBytes(StandardCharsets.UTF_8)); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("cmd", "bad-cmd") + .containsEntry("vin", "LNVIN000000000404") + .containsEntry("unknownCommand", "true") + .containsEntry("malformedCommand", "true"); + } + + @Test + void malformedJsonBusinessMessageMarksRawFrameForArchiveDiagnostics() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher); + PushMsg msg = new PushMsg(); + msg.init("0200", "{bad-json"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isEqualTo(0x0200); + assertThat(frame.rawBytes()).containsExactly("{bad-json".getBytes(StandardCharsets.UTF_8)); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("cmd", "0200") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("parseError", "true") + .containsKey("parseErrorMessage"); + assertThat(frame.sourceMeta().get("parseErrorMessage")).contains("expect"); + } + + @Test + void identityResolverFailureStillDispatchesBusinessMessageWithUnknownIdentity() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushClient client = new XindaPushClient( + new XindaPushProperties(), + lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }, + dispatcher); + PushMsg msg = new PushMsg(); + msg.init("0200", "{\"deviceId\":\"XD-DEVICE-001\",\"lng\":113.1,\"lat\":23.1}"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isEqualTo(0x0200); + assertThat(frame.rawBytes()).containsExactly( + "{\"deviceId\":\"XD-DEVICE-001\",\"lng\":113.1,\"lat\":23.1}".getBytes(StandardCharsets.UTF_8)); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("cmd", "0200") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + assertThat(frame.sourceMeta()).doesNotContainKeys("operationalError", "phase", "reason"); + } + + @Test + void rejectedLoginAckIsDispatchedAsOperationalPassthroughCandidate() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher); + PushMsg msg = new PushMsg(); + msg.init("8001", "{\"rspResult\":\"1\",\"message\":\"bad credentials\"}"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isZero(); + assertThat(frame.rawBytes()).asString(StandardCharsets.UTF_8) + .contains("\"operationalError\":true") + .contains("\"phase\":\"login\"") + .contains("login rejected rspResult=1"); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("operationalError", "true") + .containsEntry("phase", "login") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(frame.sourceMeta().get("reason")).contains("login rejected rspResult=1"); + } + + @Test + void malformedLoginAckIsDispatchedAsOperationalPassthroughCandidate() throws Exception { + Dispatcher dispatcher = mock(Dispatcher.class); + XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher); + PushMsg msg = new PushMsg(); + msg.init("8001", "{bad-json"); + + client.messageReceived(null, msg); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RawFrame.class); + verify(dispatcher).dispatch(captor.capture()); + RawFrame frame = captor.getValue(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(frame.command()).isZero(); + assertThat(frame.rawBytes()).asString(StandardCharsets.UTF_8) + .contains("\"operationalError\":true") + .contains("\"phase\":\"login\"") + .contains("login ack parse failed"); + assertThat(frame.sourceMeta()) + .containsEntry("source", "xinda-push") + .containsEntry("operationalError", "true") + .containsEntry("phase", "login") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(frame.sourceMeta().get("reason")).contains("login ack parse failed"); + } +} diff --git a/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapperTest.java b/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapperTest.java new file mode 100644 index 00000000..e9d51f12 --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapperTest.java @@ -0,0 +1,215 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.AlarmPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class XindaPushEventMapperTest { + + private final InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + private final XindaPushEventMapper mapper = new XindaPushEventMapper(identity); + + @Test + void maps0200ToLocationEvent() { + XindaPushMessage message = new XindaPushMessage("0200", + "{\"vin\":\"LNVIN000000000101\",\"sim\":\"13800138000\",\"deviceId\":\"XD-101\",\"plateNo\":\"粤B10101\",\"lng\":116397128,\"lat\":39916527,\"speed\":52.3,\"drct\":90}"); + + List events = mapper.toEvents(message); + + assertThat(events).hasSize(1); + assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class); + VehicleEvent.Location loc = (VehicleEvent.Location) events.get(0); + assertThat(loc.source()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(loc.vin()).isEqualTo("LNVIN000000000101"); + assertThat(loc.payload().longitude()).isEqualTo(116.397128); + assertThat(loc.payload().latitude()).isEqualTo(39.916527); + assertThat(loc.metadata()) + .containsEntry("vin", "LNVIN000000000101") + .containsEntry("externalVin", "LNVIN000000000101") + .containsEntry("phone", "13800138000") + .containsEntry("deviceId", "XD-101") + .containsEntry("plateNo", "粤B10101"); + } + + @Test + void maps0200MileageToInternalLocationPayload() { + XindaPushMessage message = new XindaPushMessage("0200", + "{\"vin\":\"LNVIN000000000101\",\"lng\":116397128,\"lat\":39916527,\"totalMileage\":12345.6}"); + + VehicleEvent.Location loc = (VehicleEvent.Location) mapper.toEvents(message).getFirst(); + + assertThat(loc.payload().totalMileageKm()).isEqualTo(12345.6); + } + + @Test + void mapsAlarmAndPassthroughEvents() { + assertThat(mapper.toEvents(new XindaPushMessage("0300", "{\"plateNo\":\"粤B12345\"}"))) + .singleElement() + .isInstanceOf(VehicleEvent.Alarm.class) + .extracting(VehicleEvent::source) + .isEqualTo(ProtocolId.XINDA_PUSH); + + assertThat(mapper.toEvents(new XindaPushMessage("0401", "{\"plateNo\":\"粤B12345\"}"))) + .singleElement() + .isInstanceOf(VehicleEvent.Passthrough.class); + } + + @Test + void mapsHydrogenLeakAlarmToCriticalAlarmEvent() { + identity.bind(new VehicleIdentityBinding( + ProtocolId.XINDA_PUSH, "LNVIN000000H2LEAK", "", "XD-H2-001", "")); + XindaPushMessage message = new XindaPushMessage("0300", """ + { + "deviceId": "XD-H2-001", + "alarmType": "HYDROGEN_LEAK", + "alarmName": "氢气泄漏", + "alarmLevel": 3, + "lng": 116397128, + "lat": 39916527 + } + """); + + List events = mapper.toEvents(message); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Alarm.class); + VehicleEvent.Alarm alarm = (VehicleEvent.Alarm) event; + assertThat(alarm.vin()).isEqualTo("LNVIN000000H2LEAK"); + assertThat(alarm.payload().level()).isEqualTo(AlarmPayload.AlarmLevel.CRITICAL); + assertThat(alarm.payload().safetyCategory()).isEqualTo(AlarmPayload.SafetyCategory.HYDROGEN_LEAK); + assertThat(alarm.payload().hydrogenLeakDetected()).isTrue(); + assertThat(alarm.payload().hydrogenLeakLevel()).isEqualTo(AlarmPayload.HydrogenLeakLevel.CRITICAL); + assertThat(alarm.payload().hydrogenLeakActionRequired()).isTrue(); + assertThat(alarm.payload().longitude()).isEqualTo(116.397128); + assertThat(alarm.payload().latitude()).isEqualTo(39.916527); + assertThat(alarm.metadata()) + .containsEntry("alarmType", "HYDROGEN_LEAK") + .containsEntry("alarmName", "氢气泄漏") + .containsEntry("hydrogenLeakDetected", "true"); + }); + } + + @Test + void resolvesVehicleIdentityByPlate() { + identity.bind(new VehicleIdentityBinding( + ProtocolId.XINDA_PUSH, "LNVIN000000000202", "", "", "粤B12345")); + + List events = mapper.toEvents(new XindaPushMessage("0200", + "{\"plateNo\":\"粤B12345\",\"lng\":116397128,\"lat\":39916527}")); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event.vin()).isEqualTo("LNVIN000000000202"); + assertThat(event.metadata()) + .containsEntry("vin", "LNVIN000000000202") + .containsEntry("identityResolved", "true"); + }); + } + + @Test + void identityResolverFailureStillProducesLocationEventWithUnknownVin() { + XindaPushEventMapper mapperWithFailingIdentity = new XindaPushEventMapper(lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }); + + List events = mapperWithFailingIdentity.toEvents(new XindaPushMessage("0200", + "{\"deviceId\":\"XD-DEVICE-001\",\"lng\":116397128,\"lat\":39916527,\"speed\":52.3}")); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Location.class); + assertThat(event.vin()).isEqualTo("unknown"); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("deviceId", "XD-DEVICE-001") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + } + + @Test + void malformedJsonFallsBackToPassthroughEvent() { + List events = mapper.toEvents(new XindaPushMessage("0200", "{bad-json")); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.data()).containsExactly("{bad-json".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("parseError", "true") + .containsKey("parseErrorMessage"); + assertThat(event.metadata().get("parseErrorMessage")).contains("expect"); + }); + } + + @Test + void unknownBusinessCommandFallsBackToPassthroughEvent() { + List events = mapper.toEvents(new XindaPushMessage("0500", + "{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}")); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + assertThat(event.source()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(event.vin()).isEqualTo("LNVIN000000000303"); + assertThat(event.metadata()) + .containsEntry("vin", "LNVIN000000000303") + .containsEntry("cmd", "0500") + .containsEntry("unknownCommand", "true") + .containsEntry("parseError", "false") + .containsEntry("externalVin", "LNVIN000000000303"); + }); + } + + @Test + void malformedCommandFallsBackToPassthroughEventWithMetadata() { + List events = mapper.toEvents(new XindaPushMessage("bad-cmd", + "{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}")); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + assertThat(event.source()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(event.vin()).isEqualTo("LNVIN000000000404"); + assertThat(((VehicleEvent.Passthrough) event).passthroughType()).isZero(); + assertThat(event.metadata()) + .containsEntry("vin", "LNVIN000000000404") + .containsEntry("cmd", "bad-cmd") + .containsEntry("unknownCommand", "true") + .containsEntry("malformedCommand", "true") + .containsEntry("parseError", "false"); + }); + } + + @Test + void operationalFailureMessageBecomesPassthroughEventWithFailureMetadata() { + List events = mapper.toEvents(new XindaPushMessage("0000", + "{\"operationalError\":true,\"phase\":\"startup\",\"reason\":\"host not configured\"}")); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + assertThat(event.source()).isEqualTo(ProtocolId.XINDA_PUSH); + assertThat(event.vin()).isEqualTo("unknown"); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("cmd", "0000") + .containsEntry("operationalError", "true") + .containsEntry("phase", "startup") + .containsEntry("reason", "host not configured") + .containsEntry("unknownCommand", "false") + .containsEntry("parseError", "false"); + }); + } +} diff --git a/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandlerTest.java b/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandlerTest.java new file mode 100644 index 00000000..740e769e --- /dev/null +++ b/modules/inbound/inbound-xinda-push/src/test/java/com/lingniu/ingest/inbound/xinda/XindaPushRealtimeHandlerTest.java @@ -0,0 +1,30 @@ +package com.lingniu.ingest.inbound.xinda; + +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.event.VehicleEvent; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class XindaPushRealtimeHandlerTest { + + @Test + void declares0300AsAlarmHandler() throws Exception { + Method method = XindaPushRealtimeHandler.class.getMethod("onAlarm", XindaPushMessage.class); + + assertThat(method.getAnnotation(MessageMapping.class).command()).containsExactly(0x0300); + assertThat(method.getAnnotation(EventEmit.class).value()).containsExactly(VehicleEvent.Alarm.class); + } + + @Test + void alarmHandlerEmitsAlarmEvent() { + XindaPushRealtimeHandler handler = new XindaPushRealtimeHandler(new XindaPushEventMapper()); + + assertThat(handler.onAlarm(new XindaPushMessage("0300", "{\"alarmType\":\"HYDROGEN_LEAK\"}"))) + .singleElement() + .isInstanceOf(VehicleEvent.Alarm.class); + } +} diff --git a/protocol-gb32960/pom.xml b/modules/protocols/protocol-gb32960/pom.xml similarity index 97% rename from protocol-gb32960/pom.xml rename to modules/protocols/protocol-gb32960/pom.xml index b7f03abe..f1f523c6 100644 --- a/protocol-gb32960/pom.xml +++ b/modules/protocols/protocol-gb32960/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml protocol-gb32960 protocol-gb32960 diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java similarity index 97% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java index 4e52142d..4901e3fe 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java @@ -60,7 +60,7 @@ public final class Gb32960PlatformAuthorizer { Set allowed = entry.getAllowedIps() == null ? Set.of() : Set.copyOf(entry.getAllowedIps()); - if (!allowed.isEmpty() && peerIp != null && !allowed.contains(peerIp)) { + if (!allowed.isEmpty() && (peerIp == null || !allowed.contains(peerIp))) { return Result.DENY_IP_NOT_ALLOWED; } return Result.ALLOW; diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java similarity index 99% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java index 454cbb72..981a1105 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java @@ -83,7 +83,8 @@ public final class Gb32960CommandParser { Instant time = readTime(buf); int serial = buf.getShort() & 0xFFFF; String user = readAscii(buf, 12); - String pwd = readAscii(buf, 20); + int passwordLength = Math.max(0, buf.remaining() - 1); + String pwd = readAscii(buf, passwordLength); EncryptType enc = EncryptType.of(buf.get() & 0xFF); return new CommandBody.PlatformLogin(time, serial, user, pwd, enc); } diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java similarity index 80% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java index 3be0594f..8265b0d6 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java @@ -6,6 +6,7 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.lingniu.ingest.codec.BccChecksum; import java.util.List; @@ -48,7 +49,7 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder { in.skipBytes(drop); } int dataLen = in.getUnsignedShort(in.readerIndex() + 22); - int frameLen = HEADER_LEN + dataLen + 1; + int frameLen = frameLength(in, in.readerIndex(), dataLen); if (frameLen > MAX_FRAME) { log.warn("[gb32960] frame-decoder oversized frame len={} peer={}, skip start", frameLen, ctx.channel().remoteAddress()); @@ -69,6 +70,24 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder { } } + private static int frameLength(ByteBuf in, int readerIndex, int dataLen) { + int standardLen = HEADER_LEN + dataLen + 1; + int command = in.getUnsignedByte(readerIndex + 2); + if (command != 0x05 || dataLen != 41) { + return standardLen; + } + int extendedLen = HEADER_LEN + 53 + 1; + if (in.readableBytes() < extendedLen) { + return standardLen; + } + byte[] extended = new byte[extendedLen]; + in.getBytes(readerIndex, extended); + if (BccChecksum.verify(extended, 2, extended.length - 3, extended[extended.length - 1])) { + return extendedLen; + } + return standardLen; + } + /** 查找下一个合法起始位置:两字节连续 0x23 0x23 或 0x24 0x24。 */ private static int findStart(ByteBuf in) { int limit = in.writerIndex() - 1; diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java similarity index 91% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java index d9a7c530..fcfcad36 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java @@ -96,9 +96,11 @@ public final class Gb32960MessageDecoder implements FrameDecoder String vin = new String(frame, 4, 17, StandardCharsets.US_ASCII).trim(); int encryptCode = frame[21] & 0xFF; int dataLength = ((frame[22] & 0xFF) << 8) | (frame[23] & 0xFF); + int actualDataLength = frame.length - 25; int bodyStart = 24; - if (bodyStart + dataLength + 1 != frame.length) { + if (bodyStart + dataLength + 1 != frame.length + && !isExtendedPlatformLogin(commandCode, dataLength, actualDataLength)) { throw new DecodeException("declared body length " + dataLength + " does not match frame remainder " + (frame.length - bodyStart - 1)); } @@ -128,11 +130,18 @@ public final class Gb32960MessageDecoder implements FrameDecoder } Gb32960Header headerStub = new Gb32960Header( version, cmdType, rspFlag, vin, encType, dataLength, eventTime); - ByteBuffer body = ByteBuffer.wrap(frame, bodyStart, dataLength); + int commandBodyLength = isExtendedPlatformLogin(commandCode, dataLength, actualDataLength) + ? actualDataLength + : dataLength; + ByteBuffer body = ByteBuffer.wrap(frame, bodyStart, commandBodyLength); CommandBody cmdBody = commandParser.parse(cmdType, body); return new Gb32960Message(headerStub, Collections.emptyList(), cmdBody, null); } + private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) { + return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53; + } + private static ProtocolVersion detectVersion(byte[] frame) { if (frame[0] == 0x23 && frame[1] == 0x23) return ProtocolVersion.V2016; if (frame[0] == 0x24 && frame[1] == 0x24) return ProtocolVersion.V2025; diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/EngineV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/EngineV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/PositionV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAirConditionerBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAirConditionerBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAirConditionerBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAirConditionerBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAuxiliaryBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAuxiliaryBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAuxiliaryBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcAuxiliaryBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDemoExtensionBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDemoExtensionBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDemoExtensionBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDemoExtensionBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java similarity index 90% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java index 36d73b8e..41d57c0f 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java @@ -40,6 +40,9 @@ import com.lingniu.ingest.protocol.gb32960.codec.profile.RuleBasedVendorExtensio import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog; import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector; import com.lingniu.ingest.protocol.gb32960.handler.Gb32960RealtimeHandler; +import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960AccessService; +import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960AckService; +import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960FrameDiagnostics; import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer; import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -191,21 +194,43 @@ public class Gb32960AutoConfiguration { return new Gb32960PlatformAuthorizer(props.getAuth().getPlatforms()); } + @Bean + @ConditionalOnMissingBean + public Gb32960AccessService gb32960AccessService(Gb32960VinAuthorizer authorizer, + Gb32960PlatformAuthorizer platformAuthorizer) { + return new Gb32960AccessService(authorizer, platformAuthorizer); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960AckService gb32960AckService() { + return new Gb32960AckService(); + } + + @Bean + @ConditionalOnMissingBean + public Gb32960FrameDiagnostics gb32960FrameDiagnostics(Gb32960Properties props) { + return new Gb32960FrameDiagnostics( + props.getDiagnostics().getMaxLoggedFrameKeysPerChannel()); + } + @Bean @ConditionalOnMissingBean public Gb32960NettyServer gb32960NettyServer(Gb32960Properties props, Gb32960MessageDecoder decoder, Dispatcher dispatcher, - Gb32960VinAuthorizer authorizer, - Gb32960PlatformAuthorizer platformAuthorizer) { + Gb32960AccessService accessService, + Gb32960AckService ackService, + Gb32960FrameDiagnostics diagnostics) { return new Gb32960NettyServer( props.getPort(), props.getWorkerThreads(), props.getIdleReadSeconds(), decoder, dispatcher, - authorizer, - platformAuthorizer, + accessService, + ackService, + diagnostics, props.getTls()); } } diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java similarity index 92% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java index f4bf6bda..fd5166c6 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java @@ -36,6 +36,12 @@ public class Gb32960Properties { */ private Parse parse = new Parse(); + /** + * 高频入站诊断配置。用于限制每个连接保存的"首次见到帧形态"去重 key 数量, + * 避免异常设备制造大量不同 Raw 组合时造成 channel attribute 无界增长。 + */ + private Diagnostics diagnostics = new Diagnostics(); + /** * 厂商扩展协议(vendor extensions)路由配置。 * @@ -66,6 +72,8 @@ public class Gb32960Properties { public void setTls(Tls tls) { this.tls = tls; } public Parse getParse() { return parse; } public void setParse(Parse parse) { this.parse = parse; } + public Diagnostics getDiagnostics() { return diagnostics; } + public void setDiagnostics(Diagnostics diagnostics) { this.diagnostics = diagnostics; } public List getVendorExtensions() { return vendorExtensions; } public void setVendorExtensions(List vendorExtensions) { this.vendorExtensions = vendorExtensions; @@ -199,6 +207,22 @@ public class Gb32960Properties { } } + /** + * 入站诊断限流/限内存策略。 + */ + public static class Diagnostics { + /** 每个 TCP channel 最多保存的首次帧诊断 key 数量。 */ + private int maxLoggedFrameKeysPerChannel = 128; + + public int getMaxLoggedFrameKeysPerChannel() { + return maxLoggedFrameKeysPerChannel; + } + + public void setMaxLoggedFrameKeysPerChannel(int maxLoggedFrameKeysPerChannel) { + this.maxLoggedFrameKeysPerChannel = maxLoggedFrameKeysPerChannel; + } + } + /** * 单条 vendor 扩展路由配置。{@code name} 必须与 * {@code VendorExtensionCatalog} 注册的 key 一致。 diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java new file mode 100644 index 00000000..c807e439 --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java @@ -0,0 +1,72 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; +import io.netty.channel.Channel; +import io.netty.util.AttributeKey; + +import java.net.InetSocketAddress; + +/** + * Access boundary for GB/T 32960 inbound channels. + * + *

Keeps authentication and channel-scoped platform identity out of the Netty + * handler so the hot path can stay small and testable. + */ +public final class Gb32960AccessService { + + public static final AttributeKey PLATFORM_ACCOUNT_ATTR = + AttributeKey.valueOf("gb32960.platformAccount"); + + private final Gb32960VinAuthorizer vinAuthorizer; + private final Gb32960PlatformAuthorizer platformAuthorizer; + + public Gb32960AccessService(Gb32960VinAuthorizer vinAuthorizer, + Gb32960PlatformAuthorizer platformAuthorizer) { + this.vinAuthorizer = vinAuthorizer; + this.platformAuthorizer = platformAuthorizer; + } + + public boolean isVinAllowed(String vin) { + return vinAuthorizer.isAllowed(vin); + } + + public boolean isVinWhitelistEnforcing() { + return vinAuthorizer.isEnforcing(); + } + + public int vinWhitelistSize() { + return vinAuthorizer.whitelistSize(); + } + + public boolean isPlatformAuthEnforcing() { + return platformAuthorizer.isEnforcing(); + } + + public int platformPolicySize() { + return platformAuthorizer.size(); + } + + public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username, + String password, + Channel channel) { + return platformAuthorizer.authenticate(username, password, peerIp(channel)); + } + + public void bindPlatformAccount(Channel channel, String username) { + channel.attr(PLATFORM_ACCOUNT_ATTR).set(username); + } + + public String platformAccount(Channel channel) { + return channel.hasAttr(PLATFORM_ACCOUNT_ATTR) + ? channel.attr(PLATFORM_ACCOUNT_ATTR).get() + : null; + } + + private static String peerIp(Channel channel) { + if (channel.remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress(); + } + return null; + } +} diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java new file mode 100644 index 00000000..3fadf881 --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java @@ -0,0 +1,116 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameEncoder; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.time.Instant; + +/** + * Builds and writes GB/T 32960 response frames. + */ +public final class Gb32960AckService { + + private static final Logger log = LoggerFactory.getLogger(Gb32960AckService.class); + + public void writeResponse(ChannelHandlerContext ctx, + ProtocolVersion version, + CommandType command, + ResponseFlag responseFlag, + byte[] rawVin, + Instant eventTime, + byte[] data, + String tag) { + writeResponse(ctx.channel(), version, command, responseFlag, rawVin, eventTime, data, tag); + } + + public void writeResponse(Channel channel, + ProtocolVersion version, + CommandType command, + ResponseFlag responseFlag, + byte[] rawVin, + Instant eventTime, + byte[] data, + String tag) { + byte[] ack = Gb32960FrameEncoder.buildResponse( + version, command, responseFlag, rawVin, eventTime, data); + write(channel, ack, tag); + } + + public void writeAndClose(ChannelHandlerContext ctx, + ProtocolVersion version, + CommandType command, + ResponseFlag responseFlag, + String vin, + Instant eventTime, + byte[] data, + String tag) { + byte[] ack = Gb32960FrameEncoder.buildResponse( + version, command, responseFlag, vin, eventTime, data); + ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> ctx.close()); + } + + public void writeAndClose(ChannelHandlerContext ctx, + ProtocolVersion version, + CommandType command, + ResponseFlag responseFlag, + byte[] rawVin, + Instant eventTime, + byte[] data, + String tag) { + byte[] ack = Gb32960FrameEncoder.buildResponse( + version, command, responseFlag, rawVin, eventTime, data); + String hex = hex(ack); + ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> { + if (f.isSuccess()) { + log.info("[gb32960] {} flushed peer={} len={} hex={}", + tag, addr(ctx.channel()), ack.length, hex); + } else { + log.warn("[gb32960] {} flush FAILED peer={} len={} hex={}", + tag, addr(ctx.channel()), ack.length, hex, f.cause()); + } + ctx.close(); + }); + } + + private static void write(Channel channel, byte[] ack, String tag) { + String hex = hex(ack); + boolean highFrequency = tag.startsWith("report-ack-") || tag.startsWith("heartbeat-ack"); + channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> { + if (f.isSuccess()) { + if (highFrequency) { + if (log.isDebugEnabled()) { + log.debug("[gb32960] {} flushed peer={} len={} hex={}", + tag, addr(channel), ack.length, hex); + } + } else { + log.info("[gb32960] {} flushed peer={} len={} hex={}", + tag, addr(channel), ack.length, hex); + } + } else { + log.warn("[gb32960] {} flush FAILED peer={} len={} hex={}", + tag, addr(channel), ack.length, hex, f.cause()); + } + }); + } + + private static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + private static String addr(Channel channel) { + if (channel.remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java similarity index 70% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java index 86fbbe08..f1d7fc79 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java @@ -1,20 +1,14 @@ package com.lingniu.ingest.protocol.gb32960.inbound; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.lingniu.ingest.api.ProtocolId; import com.lingniu.ingest.api.pipeline.RawFrame; import com.lingniu.ingest.core.dispatcher.Dispatcher; import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; -import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; -import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameEncoder; import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; import com.lingniu.ingest.protocol.gb32960.model.CommandBody; import com.lingniu.ingest.protocol.gb32960.model.CommandType; import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; -import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.timeout.IdleState; @@ -27,6 +21,7 @@ import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.time.Instant; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -42,7 +37,7 @@ import java.util.Map; *

  • 其他命令:VIN 在白名单则派发;不在则 DEBUG 日志后关闭连接 * * - * 注意应答帧使用 {@link Gb32960FrameEncoder#buildResponse},由 Channel 直接写回。 + * 注意应答帧由 {@link Gb32960AckService} 构造并写回 Channel。 */ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { @@ -54,26 +49,24 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { * 普通车端直连场景(0x01 VEHICLE_LOGIN)此 attribute 始终为 null。 */ public static final AttributeKey PLATFORM_ACCOUNT_ATTR = - AttributeKey.valueOf("gb32960.platformAccount"); - - /** 仅用于调试:把解析后的 Gb32960Message 序列化成 JSON 打到日志。 */ - private static final ObjectMapper DEBUG_JSON = new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + Gb32960AccessService.PLATFORM_ACCOUNT_ATTR; private final Gb32960MessageDecoder decoder; private final Dispatcher dispatcher; - private final Gb32960VinAuthorizer authorizer; - private final Gb32960PlatformAuthorizer platformAuthorizer; + private final Gb32960AccessService accessService; + private final Gb32960AckService ackService; + private final Gb32960FrameDiagnostics diagnostics; public Gb32960ChannelHandler(Gb32960MessageDecoder decoder, Dispatcher dispatcher, - Gb32960VinAuthorizer authorizer, - Gb32960PlatformAuthorizer platformAuthorizer) { + Gb32960AccessService accessService, + Gb32960AckService ackService, + Gb32960FrameDiagnostics diagnostics) { this.decoder = decoder; this.dispatcher = dispatcher; - this.authorizer = authorizer; - this.platformAuthorizer = platformAuthorizer; + this.accessService = accessService; + this.ackService = ackService; + this.diagnostics = diagnostics; } @Override @@ -103,8 +96,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) { Gb32960Message msg; try { - String platformAccount = ctx.channel().hasAttr(PLATFORM_ACCOUNT_ATTR) - ? ctx.channel().attr(PLATFORM_ACCOUNT_ATTR).get() : null; + String platformAccount = accessService.platformAccount(ctx.channel()); msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount); } catch (Exception e) { log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e); @@ -120,7 +112,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { // 平台登入/登出(0x05/0x06)没有 VIN,不进 VIN 白名单校验 boolean isPlatformCommand = cmd == CommandType.PLATFORM_LOGIN || cmd == CommandType.PLATFORM_LOGOUT; - if (!isPlatformCommand && !authorizer.isAllowed(vin)) { + if (!isPlatformCommand && !accessService.isVinAllowed(vin)) { handleUnauthorized(ctx, msg); return; } @@ -142,6 +134,10 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { Map sourceMeta = new HashMap<>(4); sourceMeta.put("vin", vin); sourceMeta.put("peer", addr(ctx)); + String platformAccount = accessService.platformAccount(ctx.channel()); + if (platformAccount != null && !platformAccount.isBlank()) { + sourceMeta.put("platformAccount", platformAccount); + } RawFrame rf = new RawFrame( ProtocolId.GB32960, cmd.code(), @@ -155,14 +151,15 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { /** 0x01 车辆登入:按 §6.3.2 带原采集时间回成功 ACK + INFO 日志。 */ private void handleVehicleLogin(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) { - byte[] ack = Gb32960FrameEncoder.buildResponse( + ackService.writeResponse( + ctx, msg.header().protocolVersion(), CommandType.VEHICLE_LOGIN, ResponseFlag.SUCCESS, rawVin, eventOrNow(msg), - null); - writeAck(ctx, ack, "vehicle-login-ack"); + null, + "vehicle-login-ack"); log.info("[gb32960] vehicle login peer={} vin={} protocolVersion={}", addr(ctx), msg.header().vin(), msg.header().protocolVersion()); } @@ -170,13 +167,14 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { /** 0x04 车辆登出:INFO 日志 + 带原采集时间回成功 ACK。 */ private void handleVehicleLogout(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) { log.info("[gb32960] vehicle logout peer={} vin={}", addr(ctx), msg.header().vin()); - writeAck(ctx, Gb32960FrameEncoder.buildResponse( + ackService.writeResponse(ctx, msg.header().protocolVersion(), CommandType.VEHICLE_LOGOUT, ResponseFlag.SUCCESS, rawVin, eventOrNow(msg), - null), "vehicle-logout-ack"); + null, + "vehicle-logout-ack"); } /** @@ -197,82 +195,116 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { ctx.close(); return false; } - String peerIp = peerIp(ctx); - Gb32960PlatformAuthorizer.Result result = platformAuthorizer.authenticate( - pl.username(), pl.password(), peerIp); + Gb32960PlatformAuthorizer.Result result = accessService.authenticatePlatformLogin( + pl.username(), pl.password(), ctx.channel()); if (!result.accepted()) { log.warn("[gb32960] platform login REJECTED peer={} username={} reason={}", addr(ctx), pl.username(), result); - byte[] nack = Gb32960FrameEncoder.buildResponse( + ackService.writeAndClose( + ctx, msg.header().protocolVersion(), CommandType.PLATFORM_LOGIN, ResponseFlag.OTHER_ERROR, rawVin, eventOrNow(msg), - null); - ctx.writeAndFlush(Unpooled.wrappedBuffer(nack)).addListener(f -> { - log.info("[gb32960] platform login NACK flushed success={} peer={}", - f.isSuccess(), addr(ctx)); - ctx.close(); - }); + null, + "platform-login-nack"); return false; } log.info("[gb32960] platform login peer={} username={} encryptRule={} serial={} time={} policy={}", addr(ctx), pl.username(), pl.encryptRule(), pl.serialNo(), pl.loginTime(), result); // 把 username 钉到 channel attribute,供后续 0x02/0x03 帧的 vendor profile 路由 - ctx.channel().attr(PLATFORM_ACCOUNT_ATTR).set(pl.username()); - byte[] ack = Gb32960FrameEncoder.buildResponse( + accessService.bindPlatformAccount(ctx.channel(), pl.username()); + ackService.writeResponse( + ctx, msg.header().protocolVersion(), CommandType.PLATFORM_LOGIN, ResponseFlag.SUCCESS, rawVin, eventOrNow(msg), - null); - writeAck(ctx, ack, "platform-login-ack"); + null, + "platform-login-ack"); return true; } /** 0x06 平台登出:INFO 日志 + 带原采集时间回成功 ACK。 */ private void handlePlatformLogout(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) { log.info("[gb32960] platform logout peer={}", addr(ctx)); - writeAck(ctx, Gb32960FrameEncoder.buildResponse( + ackService.writeResponse(ctx, msg.header().protocolVersion(), CommandType.PLATFORM_LOGOUT, ResponseFlag.SUCCESS, rawVin, eventOrNow(msg), - null), "platform-logout-ack"); + null, + "platform-logout-ack"); } /** * 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。 * 部分车端实现严格按规范,没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。 + * + *

    日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏): + *

      + *
    • 0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。 + *
    • 0x02 / 0x03 首次见到某 (vin, rawTypes=…) → WARN 一条,列出 raw typeCode。提示对端可能没走预期 + * vendor profile(例:peer 是广东燃料电池但 platformAccount 没匹配上 + * {@code vendor-extensions.platform-accounts} → 0x30+ 落 Raw)。 + *
    • 同 (vin, signature) 后续帧 → DEBUG(默认看不到;troubleshoot 时打开 Gb32960ChannelHandler 的 DEBUG)。 + *
    • 0x07 心跳:DEBUG(量大且无信息体)。 + *
    + * 帧内字段全量 JSON 仍保留 DEBUG。 */ private void handleReportOrHeartbeat(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) { CommandType cmd = msg.header().command(); - writeAck(ctx, Gb32960FrameEncoder.buildResponse( + String platformAccount = accessService.platformAccount(ctx.channel()); + ackService.writeResponse(ctx, msg.header().protocolVersion(), cmd, ResponseFlag.SUCCESS, rawVin, eventOrNow(msg), - null), "report-ack-0x" + Integer.toHexString(cmd.code())); + null, + "report-ack-0x" + Integer.toHexString(cmd.code())); + if (cmd == CommandType.HEARTBEAT) { + if (log.isDebugEnabled()) { + log.debug("[gb32960] HEARTBEAT peer={} vin={} platformAccount={}", + addr(ctx), msg.header().vin(), platformAccount); + } + return; + } + List rawTypes = Gb32960FrameDiagnostics.collectRawTypeHex(msg.infoBlocks()); + int parsedBlocks = msg.infoBlocks().size() - rawTypes.size(); + String vin = msg.header().vin(); + boolean firstSeen = diagnostics.markFirstSeen(ctx.channel(), vin, rawTypes); + if (firstSeen) { + if (rawTypes.isEmpty()) { + log.info("[gb32960] {} (first frame for vin) peer={} vin={} platformAccount={} version={} parsedBlocks={}", + cmd.name(), addr(ctx), vin, platformAccount, + msg.header().protocolVersion(), parsedBlocks); + } else { + log.warn("[gb32960] {} peer={} vin={} platformAccount={} version={} parsedBlocks={} rawTypes={} — vendor profile likely not selected for this peer", + cmd.name(), addr(ctx), vin, platformAccount, + msg.header().protocolVersion(), parsedBlocks, rawTypes); + } + } if (log.isDebugEnabled()) { - log.debug("[gb32960] frame peer={} vin={} cmd=0x{} infoBlocks={} json={}", - addr(ctx), msg.header().vin(), Integer.toHexString(cmd.code()), - msg.infoBlocks().size(), toJson(msg)); + log.debug("[gb32960] frame peer={} vin={} cmd=0x{} parsedBlocks={} rawTypes={} json={}", + addr(ctx), vin, Integer.toHexString(cmd.code()), + parsedBlocks, rawTypes, Gb32960FrameDiagnostics.toJson(msg)); } } /** 0x08 终端校时:平台应答 data 段为平台当前时间 6B(用 Instant.now())。 */ private void handleTimeCalibration(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) { - writeAck(ctx, Gb32960FrameEncoder.buildResponse( + ackService.writeResponse(ctx, msg.header().protocolVersion(), CommandType.TIME_CALIBRATION, ResponseFlag.SUCCESS, rawVin, Instant.now(), - null), "time-calibration-ack"); + null, + "time-calibration-ack"); } /** 其它未单独处理的命令(如激活 0x09、密钥交换 0x0A):仅 DEBUG 日志,仍会进 dispatcher。 */ @@ -280,7 +312,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { if (log.isDebugEnabled()) { log.debug("[gb32960] frame peer={} vin={} cmd=0x{} infoBlocks={} json={}", addr(ctx), msg.header().vin(), Integer.toHexString(msg.header().command().code()), - msg.infoBlocks().size(), toJson(msg)); + msg.infoBlocks().size(), Gb32960FrameDiagnostics.toJson(msg)); } } @@ -289,62 +321,20 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { return msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(); } - /** - * 统一的 ack 写出 + 日志确认。 - * - *

    高频 ack(实时/补发上报 0x02/0x03、心跳 0x07)的成功事件降级为 DEBUG,避免每帧 - * 一条 INFO 淹没日志;稀疏的登入/登出/校时 ack 仍保留 INFO 以便审计。写入失败无论 - * 哪个标签都走 WARN,不会被抑制。 - */ - private static void writeAck(ChannelHandlerContext ctx, byte[] ack, String tag) { - String hex = hex(ack); - boolean highFrequency = tag.startsWith("report-ack-") || tag.startsWith("heartbeat-ack"); - ctx.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> { - if (f.isSuccess()) { - if (highFrequency) { - if (log.isDebugEnabled()) { - log.debug("[gb32960] {} flushed peer={} len={} hex={}", - tag, addr(ctx), ack.length, hex); - } - } else { - log.info("[gb32960] {} flushed peer={} len={} hex={}", - tag, addr(ctx), ack.length, hex); - } - } else { - log.warn("[gb32960] {} flush FAILED peer={} len={} hex={}", - tag, addr(ctx), ack.length, hex, f.cause()); - } - }); - } - - private static String toJson(Gb32960Message msg) { - try { - return DEBUG_JSON.writeValueAsString(msg); - } catch (Exception e) { - return ""; - } - } - - private static String hex(byte[] bytes) { - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) sb.append(String.format("%02x", b)); - return sb.toString(); - } - /** VIN 未授权:对登入命令下发 VIN_NOT_EXIST 应答;其他命令静默关闭。 */ private void handleUnauthorized(ChannelHandlerContext ctx, Gb32960Message msg) { String vin = msg.header().vin(); CommandType cmd = msg.header().command(); if (cmd == CommandType.VEHICLE_LOGIN) { - byte[] nack = Gb32960FrameEncoder.buildResponse( + ackService.writeAndClose( + ctx, msg.header().protocolVersion(), CommandType.VEHICLE_LOGIN, ResponseFlag.VIN_NOT_EXIST, vin, msg.header().eventTime() != null ? msg.header().eventTime() : Instant.now(), - null); - ctx.writeAndFlush(Unpooled.wrappedBuffer(nack)) - .addListener(f -> ctx.close()); + null, + "vehicle-login-nack"); log.info("[gb32960] reject login peer={} vin={} (not in whitelist)", addr(ctx), vin); } else { log.debug("[gb32960] drop frame peer={} vin={} cmd=0x{} (not in whitelist)", @@ -366,10 +356,4 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { return "unknown"; } - private static String peerIp(ChannelHandlerContext ctx) { - if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { - return i.getAddress().getHostAddress(); - } - return null; - } } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java new file mode 100644 index 00000000..c622a4a6 --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java @@ -0,0 +1,83 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import io.netty.channel.Channel; +import io.netty.util.AttributeKey; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Channel-scoped diagnostics for high-frequency GB/T 32960 reports. + */ +public final class Gb32960FrameDiagnostics { + + private static final AttributeKey> LOGGED_FRAME_KEYS_ATTR = + AttributeKey.valueOf("gb32960.loggedFrameKeys"); + + private static final ObjectMapper DEBUG_JSON = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private final int maxKeysPerChannel; + + public Gb32960FrameDiagnostics() { + this(128); + } + + public Gb32960FrameDiagnostics(int maxKeysPerChannel) { + this.maxKeysPerChannel = Math.max(1, maxKeysPerChannel); + } + + public boolean markFirstSeen(Channel channel, String vin, List rawTypes) { + String signature = rawTypes == null || rawTypes.isEmpty() ? "NORMAL" : "RAW:" + rawTypes; + return markFirstSeenKey(channel, vin + "|" + signature); + } + + public int seenKeyCount(Channel channel) { + Set seen = channel.attr(LOGGED_FRAME_KEYS_ATTR).get(); + return seen == null ? 0 : seen.size(); + } + + public static List collectRawTypeHex(List blocks) { + List raws = new ArrayList<>(0); + for (InfoBlock b : blocks) { + if (b instanceof InfoBlock.Raw r) { + raws.add(String.format("0x%02x", r.typeCode())); + } + } + return raws; + } + + public static String toJson(Gb32960Message msg) { + try { + return DEBUG_JSON.writeValueAsString(msg); + } catch (Exception e) { + return ""; + } + } + + private boolean markFirstSeenKey(Channel channel, String key) { + Set seen = channel.attr(LOGGED_FRAME_KEYS_ATTR).get(); + if (seen == null) { + seen = new LinkedHashSet<>(); + channel.attr(LOGGED_FRAME_KEYS_ATTR).set(seen); + } + boolean added = seen.add(key); + if (added) trimToLimit(seen); + return added; + } + + private void trimToLimit(Set seen) { + while (seen.size() > maxKeysPerChannel) { + String first = seen.iterator().next(); + seen.remove(first); + } + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java similarity index 90% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java index f5bbe720..0810ebfb 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java @@ -1,8 +1,6 @@ package com.lingniu.ingest.protocol.gb32960.inbound; import com.lingniu.ingest.core.dispatcher.Dispatcher; -import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; -import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; import com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder; import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; @@ -47,8 +45,9 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean { private final int idleReadSeconds; private final Gb32960MessageDecoder messageDecoder; private final Dispatcher dispatcher; - private final Gb32960VinAuthorizer authorizer; - private final Gb32960PlatformAuthorizer platformAuthorizer; + private final Gb32960AccessService accessService; + private final Gb32960AckService ackService; + private final Gb32960FrameDiagnostics diagnostics; private final Gb32960Properties.Tls tlsConfig; private EventLoopGroup boss; @@ -59,16 +58,18 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean { public Gb32960NettyServer(int port, int workerThreads, int idleReadSeconds, Gb32960MessageDecoder messageDecoder, Dispatcher dispatcher, - Gb32960VinAuthorizer authorizer, - Gb32960PlatformAuthorizer platformAuthorizer, + Gb32960AccessService accessService, + Gb32960AckService ackService, + Gb32960FrameDiagnostics diagnostics, Gb32960Properties.Tls tlsConfig) { this.port = port; this.workerThreads = workerThreads; this.idleReadSeconds = idleReadSeconds; this.messageDecoder = messageDecoder; this.dispatcher = dispatcher; - this.authorizer = authorizer; - this.platformAuthorizer = platformAuthorizer; + this.accessService = accessService; + this.ackService = ackService; + this.diagnostics = diagnostics; this.tlsConfig = tlsConfig; } @@ -109,15 +110,15 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean { .addLast("frame", new Gb32960FrameDecoder()) .addLast("dispatch", new Gb32960ChannelHandler(messageDecoder, dispatcher, - authorizer, platformAuthorizer)); + accessService, ackService, diagnostics)); } }); this.serverChannel = b.bind(port).sync().channel(); log.info("[gb32960] Netty server listening on :{} (tls={}, vinWhitelist={}, platformAuth={}, idleReadSeconds={})", getBoundPort(), sslContext != null, - authorizer.isEnforcing() ? authorizer.whitelistSize() : "DISABLED", - platformAuthorizer.isEnforcing() ? platformAuthorizer.size() : "DISABLED", + accessService.isVinWhitelistEnforcing() ? accessService.vinWhitelistSize() : "DISABLED", + accessService.isPlatformAuthEnforcing() ? accessService.platformPolicySize() : "DISABLED", idleReadSeconds > 0 ? idleReadSeconds : "DISABLED"); } diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java similarity index 87% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java index 9576af0f..07fcaa29 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java @@ -164,23 +164,31 @@ public final class Gb32960EventMapper implements EventMapper { header.vin(), ProtocolId.GB32960, eventTime, ingestTime, null, meta, loc)); } - if (alarm != null && alarm.maxLevel != null && alarm.maxLevel > 0) { + if (alarm != null && shouldEmitAlarm(alarm)) { List faultCodes = new ArrayList<>(); addFaultCodes(faultCodes, "BAT", alarm.batteryFaults); addFaultCodes(faultCodes, "MOT", alarm.motorFaults); addFaultCodes(faultCodes, "ENG", alarm.engineFaults); addFaultCodes(faultCodes, "OTH", alarm.otherFaults); - Set activeBits = GeneralAlarmFlagBit.parse(alarm.generalAlarmFlag).stream() + Set activeBitEnums = GeneralAlarmFlagBit.parse(alarm.generalAlarmFlag); + Set activeBits = activeBitEnums.stream() .map(Enum::name) .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + boolean hydrogenLeakDetected = activeBitEnums.contains(GeneralAlarmFlagBit.HYDROGEN_LEAK); AlarmPayload ap = new AlarmPayload( - mapAlarmLevel(alarm.maxLevel), + mapAlarmLevel(alarm.maxLevel, hydrogenLeakDetected), (int) (alarm.generalAlarmFlag & 0xFFFF), "GB32960_ALARM", faultCodes, activeBits, p != null ? p.longitude : null, - p != null ? p.latitude : null); + p != null ? p.latitude : null, + safetyCategory(activeBitEnums), + hydrogenLeakDetected, + hydrogenLeakDetected + ? AlarmPayload.HydrogenLeakLevel.CRITICAL + : AlarmPayload.HydrogenLeakLevel.NONE, + hydrogenLeakDetected); out.add(new VehicleEvent.Alarm( UUID.randomUUID().toString(), header.vin(), ProtocolId.GB32960, eventTime, ingestTime, @@ -281,7 +289,16 @@ public final class Gb32960EventMapper implements EventMapper { }; } - private static AlarmPayload.AlarmLevel mapAlarmLevel(int code) { + private static boolean shouldEmitAlarm(AlarmView alarm) { + if (alarm.maxLevel != null && alarm.maxLevel > 0) return true; + return GeneralAlarmFlagBit.HYDROGEN_LEAK.isSet(alarm.generalAlarmFlag) + || GeneralAlarmFlagBit.HYDROGEN_PRESSURE_ABNORMAL.isSet(alarm.generalAlarmFlag) + || GeneralAlarmFlagBit.HYDROGEN_TEMP_ABNORMAL.isSet(alarm.generalAlarmFlag); + } + + private static AlarmPayload.AlarmLevel mapAlarmLevel(Integer code, boolean hydrogenLeakDetected) { + if (hydrogenLeakDetected) return AlarmPayload.AlarmLevel.CRITICAL; + if (code == null) return AlarmPayload.AlarmLevel.INFO; return switch (code) { case 1 -> AlarmPayload.AlarmLevel.MINOR; case 2 -> AlarmPayload.AlarmLevel.MAJOR; @@ -290,6 +307,19 @@ public final class Gb32960EventMapper implements EventMapper { }; } + private static AlarmPayload.SafetyCategory safetyCategory(Set activeBits) { + if (activeBits.contains(GeneralAlarmFlagBit.HYDROGEN_LEAK)) { + return AlarmPayload.SafetyCategory.HYDROGEN_LEAK; + } + if (activeBits.contains(GeneralAlarmFlagBit.HYDROGEN_PRESSURE_ABNORMAL)) { + return AlarmPayload.SafetyCategory.TANK_PRESSURE; + } + if (activeBits.contains(GeneralAlarmFlagBit.HYDROGEN_TEMP_ABNORMAL)) { + return AlarmPayload.SafetyCategory.TANK_TEMPERATURE; + } + return AlarmPayload.SafetyCategory.GENERAL; + } + private static void addFaultCodes(List out, String prefix, List codes) { if (codes == null || codes.isEmpty()) return; for (Long c : codes) out.add(prefix + "-" + Long.toHexString(c)); diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandBody.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/CommandType.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/EncryptType.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Header.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/Gb32960Message.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/GeneralAlarmFlagBit.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlockType.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ProtocolVersion.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/ResponseFlag.java diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java similarity index 100% rename from protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java rename to modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/SignatureInfo.java diff --git a/protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/protocols/protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/protocols/protocol-gb32960/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderGoldenTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java similarity index 67% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java index da45e891..37609f4a 100644 --- a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java +++ b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960DecoderTest.java @@ -3,6 +3,7 @@ package com.lingniu.ingest.protocol.gb32960.codec; import com.lingniu.ingest.codec.BccChecksum; import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.CommandBody; import com.lingniu.ingest.protocol.gb32960.model.CommandType; import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; @@ -47,6 +48,24 @@ public class Gb32960DecoderTest { assertThat(p.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); } + @Test + void decodesPlatformLoginWithMd5HexPasswordLongerThanDeclaredGbField() { + byte[] frame = buildPlatformLoginFrameWithExtendedPassword( + "Hyundai", + "f2e3445d7cda409fb4f278f6fb890734"); + + Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(new Gb32960BodyParser( + new InfoBlockParserRegistry(List.of()))); + + Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame)); + + assertThat(msg.header().command()).isEqualTo(CommandType.PLATFORM_LOGIN); + assertThat(msg.commandBody()).isInstanceOf(CommandBody.PlatformLogin.class); + CommandBody.PlatformLogin login = (CommandBody.PlatformLogin) msg.commandBody(); + assertThat(login.username()).isEqualTo("Hyundai"); + assertThat(login.password()).isEqualTo("f2e3445d7cda409fb4f278f6fb890734"); + } + /** * 构造一条合法的 32960 实时上报帧: * header + 6B 时间戳(2024-01-02 03:04:05) + 0x01 整车 + 0x05 位置 + BCC @@ -107,4 +126,33 @@ public class Gb32960DecoderTest { os.write((int) ((v >> 8) & 0xFF)); os.write((int) (v & 0xFF)); } + + private static byte[] buildPlatformLoginFrameWithExtendedPassword(String username, String password) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(26); body.write(6); body.write(22); body.write(20); body.write(40); body.write(45); + writeU16(body, 1); + writePaddedAscii(body, username, 12); + writePaddedAscii(body, password, password.length()); + body.write(0x01); + + byte[] bodyBytes = body.toByteArray(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x05); + out.write(0xFE); + for (int i = 0; i < 17; i++) out.write(0); + out.write(0x01); + writeU16(out, 41); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF); + return out.toByteArray(); + } + + private static void writePaddedAscii(ByteArrayOutputStream os, String value, int len) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + int copy = Math.min(bytes.length, len); + os.write(bytes, 0, copy); + for (int i = copy; i < len; i++) os.write(0); + } } diff --git a/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoderTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoderTest.java new file mode 100644 index 00000000..70a3c0b0 --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoderTest.java @@ -0,0 +1,59 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960FrameDecoderTest { + + @Test + void keepsPlatformLoginWithMd5HexPasswordAsOneFrame() { + byte[] frame = buildPlatformLoginFrame("Hyundai", "f2e3445d7cda409fb4f278f6fb890734"); + EmbeddedChannel channel = new EmbeddedChannel(new Gb32960FrameDecoder()); + + assertThat(channel.writeInbound(frame)).isTrue(); + + byte[] decoded = channel.readInbound(); + assertThat(decoded).isEqualTo(frame); + assertThat((Object) channel.readInbound()).isNull(); + } + + private static byte[] buildPlatformLoginFrame(String username, String password) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(26); body.write(6); body.write(22); body.write(20); body.write(40); body.write(45); + writeU16(body, 1); + writePaddedAscii(body, username, 12); + writePaddedAscii(body, password, password.length()); + body.write(0x01); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x05); + out.write(0xFE); + for (int i = 0; i < 17; i++) out.write(0); + out.write(0x01); + writeU16(out, 41); + byte[] bodyBytes = body.toByteArray(); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF); + return out.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writePaddedAscii(ByteArrayOutputStream os, String value, int len) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + int copy = Math.min(bytes.length, len); + os.write(bytes, 0, copy); + for (int i = copy; i < len; i++) os.write(0); + } +} diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FullBlocksTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/GuangdongFcEndToEndTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcParserTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcParserTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcParserTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcParserTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParserTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandlerTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandlerTest.java similarity index 100% rename from protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandlerTest.java rename to modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandlerTest.java diff --git a/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessServiceTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessServiceTest.java new file mode 100644 index 00000000..d4681cd6 --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessServiceTest.java @@ -0,0 +1,52 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer; +import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer; +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960AccessServiceTest { + + @Test + void bindPlatformAccount_makesAccountAvailableForLaterFrames() { + Gb32960AccessService service = newService(false); + EmbeddedChannel channel = new EmbeddedChannel(); + + service.bindPlatformAccount(channel, "lingniu"); + + assertThat(service.platformAccount(channel)).isEqualTo("lingniu"); + } + + @Test + void authenticatePlatformLogin_rejectsUnknownPeerIpWhenPolicyRestrictsIt() { + Gb32960Properties.Auth auth = new Gb32960Properties.Auth(); + Gb32960Properties.Auth.Platform platform = new Gb32960Properties.Auth.Platform(); + platform.setUsername("lingniu"); + platform.setPassword("secret"); + platform.setAllowedIps(List.of("10.0.0.1")); + auth.setPlatforms(List.of(platform)); + + Gb32960AccessService service = new Gb32960AccessService( + new Gb32960VinAuthorizer(auth), + new Gb32960PlatformAuthorizer(auth.getPlatforms())); + EmbeddedChannel channel = new EmbeddedChannel(); + channel.connect(new InetSocketAddress("10.0.0.2", 9001)); + + assertThat(service.authenticatePlatformLogin("lingniu", "secret", channel) + .accepted()).isFalse(); + } + + private static Gb32960AccessService newService(boolean vinAuthEnabled) { + Gb32960Properties.Auth auth = new Gb32960Properties.Auth(); + auth.setEnabled(vinAuthEnabled); + return new Gb32960AccessService( + new Gb32960VinAuthorizer(auth), + new Gb32960PlatformAuthorizer(auth.getPlatforms())); + } +} diff --git a/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckServiceTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckServiceTest.java new file mode 100644 index 00000000..4f914119 --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckServiceTest.java @@ -0,0 +1,42 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960AckServiceTest { + + @Test + void writeResponse_writesGb32960AckFrameToChannel() { + Gb32960AckService service = new Gb32960AckService(); + EmbeddedChannel channel = new EmbeddedChannel(); + byte[] rawVin = "LTEST000000000001".getBytes(StandardCharsets.US_ASCII); + + service.writeResponse( + channel, + ProtocolVersion.V2016, + CommandType.VEHICLE_LOGIN, + ResponseFlag.SUCCESS, + rawVin, + Instant.parse("2026-04-20T10:00:00Z"), + null, + "vehicle-login-ack"); + + ByteBuf out = channel.readOutbound(); + assertThat(out).isNotNull(); + byte[] bytes = new byte[out.readableBytes()]; + out.readBytes(bytes); + assertThat(bytes[0]).isEqualTo((byte) 0x23); + assertThat(bytes[1]).isEqualTo((byte) 0x23); + assertThat(bytes[2]).isEqualTo((byte) CommandType.VEHICLE_LOGIN.code()); + assertThat(bytes[3]).isEqualTo((byte) ResponseFlag.SUCCESS.code()); + } +} diff --git a/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnosticsTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnosticsTest.java new file mode 100644 index 00000000..254adf4d --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnosticsTest.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.protocol.gb32960.inbound; + +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960FrameDiagnosticsTest { + + @Test + void shouldLogFirstSeenRawSignatureOnlyOncePerChannel() { + Gb32960FrameDiagnostics diagnostics = new Gb32960FrameDiagnostics(8); + EmbeddedChannel channel = new EmbeddedChannel(); + + List rawTypes = List.of("0x30", "0x83"); + + assertThat(diagnostics.markFirstSeen(channel, "VIN001", rawTypes)).isTrue(); + assertThat(diagnostics.markFirstSeen(channel, "VIN001", rawTypes)).isFalse(); + } + + @Test + void markFirstSeen_keepsBoundedKeysPerChannel() { + Gb32960FrameDiagnostics diagnostics = new Gb32960FrameDiagnostics(2); + EmbeddedChannel channel = new EmbeddedChannel(); + + assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x30"))).isTrue(); + assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x31"))).isTrue(); + assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x32"))).isTrue(); + + assertThat(diagnostics.seenKeyCount(channel)).isLessThanOrEqualTo(2); + } + + @Test + void collectRawTypeHex_returnsOnlyRawBlocks() { + List rawTypes = Gb32960FrameDiagnostics.collectRawTypeHex(List.of( + new InfoBlock.Raw(0x30, InfoBlockType.RAW, new byte[] {1}), + new InfoBlock.Raw(0x83, InfoBlockType.RAW, new byte[] {2}))); + + assertThat(rawTypes).containsExactly("0x30", "0x83"); + } +} diff --git a/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java new file mode 100644 index 00000000..949e1b7b --- /dev/null +++ b/modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java @@ -0,0 +1,82 @@ +package com.lingniu.ingest.protocol.gb32960.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.AlarmPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960DecoderTest; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.model.CommandType; +import com.lingniu.ingest.protocol.gb32960.model.EncryptType; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.GeneralAlarmFlagBit; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960EventMapperTest { + + @Test + void realtimeReportProducesRealtimeAndLocationEvents() { + byte[] frame = Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000002"); + + Gb32960BodyParser body = new Gb32960BodyParser(new InfoBlockParserRegistry( + List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser()))); + Gb32960Message msg = new Gb32960MessageDecoder(body).decode(ByteBuffer.wrap(frame)); + + List events = new Gb32960EventMapper().toEvents(msg); + + assertThat(events).hasSize(2); + assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Realtime); + assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Location); + assertThat(events).allMatch(e -> e.source() == ProtocolId.GB32960); + assertThat(events).allMatch(e -> e.vin().equals("LTEST000000000002")); + } + + @Test + void hydrogenLeakAlarmBitProducesCriticalSafetyAlarmEvent() { + long hydrogenLeakFlag = 1L << GeneralAlarmFlagBit.HYDROGEN_LEAK.bitIndex(); + Gb32960Message msg = new Gb32960Message( + new Gb32960Header( + ProtocolVersion.V2025, + CommandType.REALTIME_REPORT, + ResponseFlag.COMMAND, + "LTEST000000000003", + EncryptType.UNENCRYPTED, + 0, + Instant.parse("2026-06-22T01:00:00Z")), + List.of(new InfoBlock.Gb32960V2025.Alarm( + 0, + hydrogenLeakFlag, + List.of(), + List.of(), + List.of(), + List.of(), + List.of()))); + + List events = new Gb32960EventMapper().toEvents(msg); + + VehicleEvent.Alarm alarm = events.stream() + .filter(VehicleEvent.Alarm.class::isInstance) + .map(VehicleEvent.Alarm.class::cast) + .findFirst() + .orElseThrow(); + assertThat(alarm.payload().level()).isEqualTo(AlarmPayload.AlarmLevel.CRITICAL); + assertThat(alarm.payload().safetyCategory()).isEqualTo(AlarmPayload.SafetyCategory.HYDROGEN_LEAK); + assertThat(alarm.payload().hydrogenLeakDetected()).isTrue(); + assertThat(alarm.payload().hydrogenLeakLevel()).isEqualTo(AlarmPayload.HydrogenLeakLevel.CRITICAL); + assertThat(alarm.payload().hydrogenLeakActionRequired()).isTrue(); + assertThat(alarm.payload().activeBits()).contains("HYDROGEN_LEAK"); + } +} diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/README.md b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/README.md similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/README.md rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/README.md diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_001.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/heartbeat_002.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_002.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_003.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_010.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_020.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_100.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_200.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/resend_001.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/resend_005.hex diff --git a/protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex b/modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex similarity index 100% rename from protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex rename to modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/vehicle_login_001.hex diff --git a/protocol-jsatl12/pom.xml b/modules/protocols/protocol-jsatl12/pom.xml similarity index 75% rename from protocol-jsatl12/pom.xml rename to modules/protocols/protocol-jsatl12/pom.xml index 5e6a7baa..b84358d4 100644 --- a/protocol-jsatl12/pom.xml +++ b/modules/protocols/protocol-jsatl12/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml protocol-jsatl12 protocol-jsatl12 @@ -26,6 +27,10 @@ com.lingniu.ingest protocol-jt808 + + com.lingniu.ingest + vehicle-identity + com.lingniu.ingest sink-archive @@ -42,5 +47,15 @@ com.github.ben-manes.caffeine caffeine + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java similarity index 65% rename from protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java rename to modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java index 8afc2820..ddf7cd5a 100644 --- a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoder.java @@ -20,7 +20,16 @@ import java.util.List; public class Jsatl12FrameDecoder extends ByteToMessageDecoder { private static final byte[] DATA_MAGIC = {0x30, 0x31, 0x63, 0x64}; - private static final int MAX_FRAME = 1024 * 65; + private static final int DEFAULT_MAX_FRAME = 64 * 1024; + private final int maxFrameLength; + + public Jsatl12FrameDecoder() { + this(DEFAULT_MAX_FRAME); + } + + public Jsatl12FrameDecoder(int maxFrameLength) { + this.maxFrameLength = maxFrameLength <= 0 ? DEFAULT_MAX_FRAME : maxFrameLength; + } @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { @@ -40,7 +49,12 @@ public class Jsatl12FrameDecoder extends ByteToMessageDecoder { if (in.readableBytes() < 2) return false; int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e); if (endIdx < 0) { - if (in.readableBytes() > MAX_FRAME) in.skipBytes(in.readableBytes() - 1); + if (in.readableBytes() > maxFrameLength) { + byte[] malformed = new byte[in.readableBytes()]; + in.readBytes(malformed); + out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, malformed)); + return true; + } return false; } int len = endIdx - (in.readerIndex() + 1); @@ -64,13 +78,21 @@ public class Jsatl12FrameDecoder extends ByteToMessageDecoder { return true; } } - // 4B length (big-endian) 之后是负载 - long dataLen = in.getUnsignedInt(in.readerIndex() + DATA_MAGIC.length); - if (dataLen > MAX_FRAME) { - in.skipBytes(1); + if (in.readableBytes() < 62) return false; + // DataPacket 布局:4B magic + 50B 文件名 + 4B offset + 4B length + data + long dataLen = in.getUnsignedInt(in.readerIndex() + 58); + if (dataLen > maxFrameLength) { + int malformedLen = in.readableBytes(); + long declaredTotal = 62L + dataLen; + if (declaredTotal <= Integer.MAX_VALUE && in.readableBytes() >= declaredTotal) { + malformedLen = (int) declaredTotal; + } + byte[] malformed = new byte[malformedLen]; + in.readBytes(malformed); + out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, malformed)); return true; } - int totalFrameLen = DATA_MAGIC.length + 4 + (int) dataLen; + int totalFrameLen = 62 + (int) dataLen; if (in.readableBytes() < totalFrameLen) return false; byte[] frame = new byte[totalFrameLen]; in.readBytes(frame); diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java new file mode 100644 index 00000000..48444827 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java @@ -0,0 +1,8 @@ +package com.lingniu.ingest.protocol.jsatl12.codec; + +/** + * JSATL12 原始帧:区分信令帧、数据帧与无法继续解析但需要审计的畸形帧。 + */ +public record Jsatl12RawFrame(Type type, byte[] bytes) { + public enum Type { JT_MESSAGE, DATA_CHUNK, MALFORMED } +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java new file mode 100644 index 00000000..b1792e78 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java @@ -0,0 +1,58 @@ +package com.lingniu.ingest.protocol.jsatl12.config; + +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.protocol.jsatl12.handler.Jsatl12ArchiveEventHandler; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12AttachmentTracker; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12JtMessageBridge; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration(after = {SinkArchiveAutoConfiguration.class, Jt808AutoConfiguration.class}) +@EnableConfigurationProperties(Jsatl12Properties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.jsatl12", name = "enabled", havingValue = "true") +@ConditionalOnBean({ArchiveStore.class, Jt808MessageDecoder.class}) +public class Jsatl12AutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public Jsatl12AttachmentTracker jsatl12AttachmentTracker() { + return new Jsatl12AttachmentTracker(); + } + + @Bean + @ConditionalOnMissingBean + public Jsatl12JtMessageBridge jsatl12JtMessageBridge(Jt808MessageDecoder decoder, + VehicleIdentityResolver identityResolver, + Jsatl12AttachmentTracker attachmentTracker, + Dispatcher dispatcher) { + return new Jsatl12JtMessageBridge(decoder, identityResolver, attachmentTracker, dispatcher::dispatch); + } + + @Bean + @ConditionalOnMissingBean + public Jsatl12ArchiveEventHandler jsatl12ArchiveEventHandler() { + return new Jsatl12ArchiveEventHandler(); + } + + @Bean + @ConditionalOnMissingBean + public Jsatl12NettyServer jsatl12NettyServer(Jsatl12Properties props, + ArchiveStore archive, + Jsatl12JtMessageBridge jtMessageBridge, + Jsatl12AttachmentTracker attachmentTracker, + VehicleIdentityResolver identityResolver, + Dispatcher dispatcher) { + return new Jsatl12NettyServer(props.getPort(), props.getWorkerThreads(), + props.getMaxFrameLength(), archive, jtMessageBridge, attachmentTracker, identityResolver, dispatcher::dispatch); + } +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java similarity index 65% rename from protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java rename to modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java index 5afebbf4..b31a65de 100644 --- a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12Properties.java @@ -2,13 +2,13 @@ package com.lingniu.ingest.protocol.jsatl12.config; import org.springframework.boot.context.properties.ConfigurationProperties; -/** - * JSATL12 配置。实现尚在 TODO 状态,当前只做 Bean 占位避免空依赖。 - */ +/** JSATL12 主动安全附件接入配置。 */ @ConfigurationProperties(prefix = "lingniu.ingest.jsatl12") public class Jsatl12Properties { private boolean enabled = false; private int port = 7612; + private int workerThreads = 0; + private int maxFrameLength = 64 * 1024; /** 附件落盘根目录,支持 file:// / s3:// / oss:// 形式,由 sink-archive 解析。 */ private String fileStore = "file:///var/lingniu/alarm-files"; @@ -16,6 +16,10 @@ public class Jsatl12Properties { public void setEnabled(boolean enabled) { this.enabled = enabled; } public int getPort() { return port; } public void setPort(int port) { this.port = port; } + public int getWorkerThreads() { return workerThreads; } + public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; } + public int getMaxFrameLength() { return maxFrameLength; } + public void setMaxFrameLength(int maxFrameLength) { this.maxFrameLength = maxFrameLength; } public String getFileStore() { return fileStore; } public void setFileStore(String fileStore) { this.fileStore = fileStore; } } diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandler.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandler.java new file mode 100644 index 00000000..7f3d79e4 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandler.java @@ -0,0 +1,137 @@ +package com.lingniu.ingest.protocol.jsatl12.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchiveFailure; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchivedChunk; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedFrame; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedJtMessage; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MessageId; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@ProtocolHandler(protocol = ProtocolId.JSATL12) +public final class Jsatl12ArchiveEventHandler { + + @MessageMapping(command = Jsatl12MessageId.DATA_CHUNK_ARCHIVED, desc = "主动安全附件数据块已归档") + @EventEmit(VehicleEvent.MediaMeta.class) + public List onDataChunkArchived(Jsatl12ArchivedChunk chunk) { + Instant eventTime = chunk.archivedAt() == null ? Instant.now() : chunk.archivedAt(); + String peer = chunk.peer() == null || chunk.peer().isBlank() ? "unknown" : chunk.peer(); + return List.of(new VehicleEvent.MediaMeta( + UUID.randomUUID().toString(), + nullToUnknown(chunk.vin()), + ProtocolId.JSATL12, + eventTime, + Instant.now(), + null, + baseMeta( + "vin", nullToUnknown(chunk.vin()), + "phone", nullToEmpty(chunk.phone()), + "identityResolved", Boolean.toString(chunk.identityResolved()), + "identitySource", nullToEmpty(chunk.identitySource()), + "peer", peer, + "archiveKey", nullToEmpty(chunk.archiveKey()), + "archiveRef", nullToEmpty(chunk.archiveRef()), + "fileName", nullToEmpty(chunk.fileName()), + "fileOffset", Long.toString(Math.max(0, chunk.fileOffset())), + "declaredChunkSizeBytes", Long.toString(Math.max(0, chunk.declaredSizeBytes())), + "chunkSizeBytes", Long.toString(Math.max(0, chunk.sizeBytes()))), + "jsatl12-" + peer + "-" + eventTime.toEpochMilli(), + "jsatl12-data-chunk", + Math.max(0, chunk.sizeBytes()), + nullToEmpty(chunk.archiveRef()))); + } + + @MessageMapping(command = Jsatl12MessageId.MALFORMED_JT_MESSAGE, desc = "主动安全 JT 消息解析失败兜底") + @EventEmit(VehicleEvent.Passthrough.class) + public List onMalformedJtMessage(Jsatl12MalformedJtMessage malformed) { + Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt(); + String peer = malformed.peer() == null || malformed.peer().isBlank() ? "unknown" : malformed.peer(); + return List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + "unknown", + ProtocolId.JSATL12, + eventTime, + Instant.now(), + null, + baseMeta( + "peer", peer, + "source", "jsatl12-jt-message", + "parseError", "true", + "parseErrorMessage", nullToEmpty(malformed.errorMessage())), + Jsatl12MessageId.MALFORMED_JT_MESSAGE, + malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes())); + } + + @MessageMapping(command = Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED, desc = "主动安全附件数据块归档失败兜底") + @EventEmit(VehicleEvent.Passthrough.class) + public List onArchiveFailure(Jsatl12ArchiveFailure failure) { + Instant eventTime = failure.failedAt() == null ? Instant.now() : failure.failedAt(); + String peer = failure.peer() == null || failure.peer().isBlank() ? "unknown" : failure.peer(); + return List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + "unknown", + ProtocolId.JSATL12, + eventTime, + Instant.now(), + null, + baseMeta( + "peer", peer, + "source", "jsatl12-data-chunk", + "archiveError", "true", + "archiveErrorMessage", nullToEmpty(failure.errorMessage()), + "archiveKey", nullToEmpty(failure.archiveKey()), + "chunkSizeBytes", Long.toString(Math.max(0, failure.sizeBytes()))), + Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED, + failure.rawBytes() == null ? new byte[0] : failure.rawBytes())); + } + + @MessageMapping(command = Jsatl12MessageId.MALFORMED_FRAME, desc = "主动安全附件帧边界异常兜底") + @EventEmit(VehicleEvent.Passthrough.class) + public List onMalformedFrame(Jsatl12MalformedFrame malformed) { + Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt(); + String peer = malformed.peer() == null || malformed.peer().isBlank() ? "unknown" : malformed.peer(); + return List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + "unknown", + ProtocolId.JSATL12, + eventTime, + Instant.now(), + null, + baseMeta( + "peer", peer, + "source", "jsatl12-frame", + "frameError", "true", + "frameErrorMessage", nullToEmpty(malformed.errorMessage()), + "parseError", "true", + "parseErrorMessage", nullToEmpty(malformed.errorMessage())), + Jsatl12MessageId.MALFORMED_FRAME, + malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes())); + } + + private static Map baseMeta(String... kv) { + java.util.HashMap out = new java.util.HashMap<>(); + out.put("vin", "unknown"); + out.put("identityResolved", "false"); + out.put("identitySource", "UNKNOWN"); + for (int i = 0; i + 1 < kv.length; i += 2) { + out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]); + } + return Map.copyOf(out); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private static String nullToUnknown(String value) { + return value == null || value.isBlank() ? "unknown" : value; + } +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchiveFailure.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchiveFailure.java new file mode 100644 index 00000000..d2271739 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchiveFailure.java @@ -0,0 +1,12 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.time.Instant; + +public record Jsatl12ArchiveFailure( + byte[] rawBytes, + String archiveKey, + long sizeBytes, + String peer, + String errorMessage, + Instant failedAt +) {} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchivedChunk.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchivedChunk.java new file mode 100644 index 00000000..c3db11f2 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ArchivedChunk.java @@ -0,0 +1,45 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.time.Instant; + +public record Jsatl12ArchivedChunk( + String archiveRef, + String archiveKey, + String fileName, + long fileOffset, + long declaredSizeBytes, + long sizeBytes, + String peer, + Instant archivedAt, + String phone, + String vin, + boolean identityResolved, + String identitySource +) { + public Jsatl12ArchivedChunk { + phone = phone == null ? "" : phone.trim(); + vin = vin == null || vin.isBlank() ? "unknown" : vin.trim(); + identitySource = identitySource == null || identitySource.isBlank() ? "UNKNOWN" : identitySource.trim(); + } + + public Jsatl12ArchivedChunk(String archiveRef, + String archiveKey, + String fileName, + long fileOffset, + long declaredSizeBytes, + long sizeBytes, + String peer, + Instant archivedAt) { + this(archiveRef, archiveKey, fileName, fileOffset, declaredSizeBytes, sizeBytes, peer, archivedAt, + "", "unknown", false, "UNKNOWN"); + } + + public Jsatl12ArchivedChunk(String archiveRef, + String archiveKey, + long sizeBytes, + String peer, + Instant archivedAt) { + this(archiveRef, archiveKey, "", 0, sizeBytes, sizeBytes, peer, archivedAt, + "", "unknown", false, "UNKNOWN"); + } +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTracker.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTracker.java new file mode 100644 index 00000000..27565499 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTracker.java @@ -0,0 +1,165 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class Jsatl12AttachmentTracker { + + private final Map> filesByPhone = new HashMap<>(); + + public synchronized void registerAttachmentList(String phone, byte[] body) { + String key = normalizePhone(phone); + byte[] raw = body == null ? new byte[0] : body; + if (raw.length < 57) { + throw new IllegalArgumentException("jsatl12 T1210 body too short: " + raw.length); + } + int total = raw[56] & 0xFF; + int pos = 57; + Map files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>()); + for (int i = 0; i < total && pos < raw.length; i++) { + int nameLen = raw[pos++] & 0xFF; + if (pos + nameLen + 4 > raw.length) { + throw new IllegalArgumentException("jsatl12 T1210 item truncated at index " + i); + } + String name = new String(raw, pos, nameLen, StandardCharsets.UTF_8).trim(); + pos += nameLen; + long size = Jsatl12DataPacket.readDword(raw, pos); + pos += 4; + files.put(name, new FileState(name, 0, size)); + } + } + + public synchronized Jsatl12DataPacket recordDataPacket(String phone, byte[] rawPacket) { + Jsatl12DataPacket packet = Jsatl12DataPacket.parse(rawPacket); + String key = normalizePhone(phone); + Map files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>()); + FileState state = files.computeIfAbsent(packet.name(), + ignored -> new FileState(packet.name(), 0, packet.offset() + packet.length())); + state.ranges.add(new Range(packet.offset(), packet.offset() + packet.length())); + return packet; + } + + public synchronized Jsatl12UploadCompletionAck completeUpload(String phone, byte[] body) { + T1212 completion = parseT1212(body); + String key = normalizePhone(phone); + Map files = filesByPhone.computeIfAbsent(key, ignored -> new HashMap<>()); + FileState state = files.computeIfAbsent(completion.name(), + ignored -> new FileState(completion.name(), completion.type(), completion.size())); + mergeUnknownFileInto(key, state); + state.type = completion.type(); + state.size = completion.size(); + + List missing = missingRanges(state); + if (missing.isEmpty()) { + files.remove(completion.name()); + if (files.isEmpty()) { + filesByPhone.remove(key); + } + return new Jsatl12UploadCompletionAck(completion.name(), completion.type(), 0, List.of()); + } + return new Jsatl12UploadCompletionAck(completion.name(), completion.type(), 1, missing); + } + + private void mergeUnknownFileInto(String ownerKey, FileState target) { + if ("unknown".equals(ownerKey)) { + return; + } + Map unknownFiles = filesByPhone.get("unknown"); + if (unknownFiles == null) { + return; + } + FileState unknown = unknownFiles.remove(target.name); + if (unknown == null) { + return; + } + target.ranges.addAll(unknown.ranges); + if (unknownFiles.isEmpty()) { + filesByPhone.remove("unknown"); + } + } + + public synchronized Set knownFiles(String phone) { + Map files = filesByPhone.get(normalizePhone(phone)); + if (files == null) { + return Set.of(); + } + return Set.copyOf(new HashSet<>(files.keySet())); + } + + public synchronized String ownerPhone(String fileName) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isBlank()) { + return ""; + } + for (Map.Entry> entry : filesByPhone.entrySet()) { + if (!"unknown".equals(entry.getKey()) && entry.getValue().containsKey(name)) { + return entry.getKey(); + } + } + return ""; + } + + private static List missingRanges(FileState state) { + List ranges = new ArrayList<>(state.ranges); + ranges.sort((a, b) -> Long.compare(a.start, b.start)); + List missing = new ArrayList<>(); + long cursor = 0; + for (Range range : ranges) { + if (range.end <= cursor) { + continue; + } + if (range.start > cursor) { + missing.add(new Jsatl12UploadCompletionAck.Range(cursor, range.start - cursor)); + } + cursor = Math.max(cursor, range.end); + if (cursor >= state.size) { + break; + } + } + if (cursor < state.size) { + missing.add(new Jsatl12UploadCompletionAck.Range(cursor, state.size - cursor)); + } + return List.copyOf(missing); + } + + private static T1212 parseT1212(byte[] body) { + byte[] raw = body == null ? new byte[0] : body; + if (raw.length < 6) { + throw new IllegalArgumentException("jsatl12 T1212 body too short: " + raw.length); + } + int nameLen = raw[0] & 0xFF; + if (raw.length < 1 + nameLen + 1 + 4) { + throw new IllegalArgumentException("jsatl12 T1212 body truncated"); + } + String name = new String(raw, 1, nameLen, StandardCharsets.UTF_8).trim(); + int type = raw[1 + nameLen] & 0xFF; + long size = Jsatl12DataPacket.readDword(raw, 2 + nameLen); + return new T1212(name, type, size); + } + + private static String normalizePhone(String phone) { + return phone == null || phone.isBlank() ? "unknown" : phone; + } + + private static final class FileState { + private final String name; + private final List ranges = new ArrayList<>(); + private int type; + private long size; + + private FileState(String name, int type, long size) { + this.name = name; + this.type = type; + this.size = Math.max(0, size); + } + } + + private record Range(long start, long end) {} + + private record T1212(String name, int type, long size) {} +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java new file mode 100644 index 00000000..1768ce1b --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java @@ -0,0 +1,249 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** + * JSATL12 入站处理器。 + * + *

    当前实现:把 DATA_CHUNK 帧写入 {@link ArchiveStore} 后派发统一事件引用,文件名按日期 + 对端 IP + + * 时间戳生成;JT_MESSAGE 帧复用 JT808 decoder 后进入统一 Dispatcher。 + * + *

    路径模板:{@code yyyy/MM/dd//.bin}。 + */ +public class Jsatl12ChannelHandler extends SimpleChannelInboundHandler { + + private static final Logger log = LoggerFactory.getLogger(Jsatl12ChannelHandler.class); + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault()); + + /** 为每个 Channel 持久化一个文件 key,保证同一次传输追加到同一个文件。 */ + private static final Cache KEYS = Caffeine.newBuilder() + .expireAfterAccess(Duration.ofMinutes(30)) + .maximumSize(10_000) + .build(); + + private final ArchiveStore archive; + private final Jsatl12JtMessageBridge jtMessageBridge; + private final Jsatl12AttachmentTracker attachmentTracker; + private final VehicleIdentityResolver identityResolver; + private final Consumer dataChunkDispatcher; + + public Jsatl12ChannelHandler(ArchiveStore archive, Jsatl12JtMessageBridge jtMessageBridge) { + this(archive, jtMessageBridge, new Jsatl12AttachmentTracker(), ignored -> {}); + } + + public Jsatl12ChannelHandler(ArchiveStore archive, + Jsatl12JtMessageBridge jtMessageBridge, + Consumer dataChunkDispatcher) { + this(archive, jtMessageBridge, new Jsatl12AttachmentTracker(), null, dataChunkDispatcher); + } + + public Jsatl12ChannelHandler(ArchiveStore archive, + Jsatl12JtMessageBridge jtMessageBridge, + Jsatl12AttachmentTracker attachmentTracker, + Consumer dataChunkDispatcher) { + this(archive, jtMessageBridge, attachmentTracker, null, dataChunkDispatcher); + } + + public Jsatl12ChannelHandler(ArchiveStore archive, + Jsatl12JtMessageBridge jtMessageBridge, + Jsatl12AttachmentTracker attachmentTracker, + VehicleIdentityResolver identityResolver, + Consumer dataChunkDispatcher) { + this.archive = archive; + this.jtMessageBridge = jtMessageBridge; + this.attachmentTracker = attachmentTracker == null ? new Jsatl12AttachmentTracker() : attachmentTracker; + this.identityResolver = identityResolver; + this.dataChunkDispatcher = dataChunkDispatcher; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Jsatl12RawFrame frame) { + switch (frame.type()) { + case JT_MESSAGE -> { + jtMessageBridge.dispatch(frame.bytes(), addr(ctx)) + .ifPresent(response -> ctx.writeAndFlush(io.netty.buffer.Unpooled.wrappedBuffer(response))); + } + case MALFORMED -> { + byte[] raw = frame.bytes() == null ? new byte[0] : frame.bytes(); + Instant now = Instant.now(); + String peer = addr(ctx); + String errorMessage = "malformed jsatl12 frame"; + Jsatl12MalformedFrame malformed = new Jsatl12MalformedFrame(raw, peer, errorMessage, now); + dataChunkDispatcher.accept(new RawFrame( + ProtocolId.JSATL12, + Jsatl12MessageId.MALFORMED_FRAME, + 0, + malformed, + raw, + unresolvedMeta( + "source", "jsatl12-frame", + "peer", peer, + "frameError", "true", + "frameErrorMessage", errorMessage, + "frameSizeBytes", Integer.toString(raw.length)), + now)); + } + case DATA_CHUNK -> { + String key = KEYS.get(ctx, k -> { + String day = DAY_FMT.format(Instant.now()); + return "jsatl12/" + day + "/" + addr(ctx).replace(':', '_') + + "/" + Instant.now().toEpochMilli() + ".bin"; + }); + try { + byte[] all = frame.bytes(); + Jsatl12DataPacket packet = attachmentTracker.recordDataPacket(null, all); + byte[] payload = packet.data(); + int payloadLen = payload.length; + if (payloadLen > 0) { + String phone = attachmentTracker.ownerPhone(packet.name()); + IdentityResolution identity = resolveIdentity(phone); + String archiveRef = archive.append(key, payload); + Instant now = Instant.now(); + Jsatl12ArchivedChunk archived = new Jsatl12ArchivedChunk( + archiveRef, + key, + packet.name(), + packet.offset(), + packet.length(), + payloadLen, + addr(ctx), + now, + phone, + identity.identity().vin(), + identity.identity().resolved(), + identity.identity().source().name()); + dataChunkDispatcher.accept(new RawFrame( + ProtocolId.JSATL12, + Jsatl12MessageId.DATA_CHUNK_ARCHIVED, + 0, + archived, + null, + identityMeta(identity, + "phone", phone, + "source", "jsatl12-data-chunk", + "peer", addr(ctx), + "fileName", packet.name(), + "fileOffset", Long.toString(packet.offset()), + "declaredChunkSizeBytes", Long.toString(packet.length()), + "archiveRef", archiveRef, + "archiveKey", key, + "chunkSizeBytes", Integer.toString(payloadLen)), + now)); + } + } catch (IOException | RuntimeException e) { + log.warn("[jsatl12] write chunk failed key={}", key, e); + byte[] all = frame.bytes(); + int payloadLen = Math.max(0, all.length - 62); + Instant now = Instant.now(); + Jsatl12ArchiveFailure failure = new Jsatl12ArchiveFailure( + all, + key, + payloadLen, + addr(ctx), + e.getMessage() == null ? "" : e.getMessage(), + now); + dataChunkDispatcher.accept(new RawFrame( + ProtocolId.JSATL12, + Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED, + 0, + failure, + all, + unresolvedMeta( + "source", "jsatl12-data-chunk", + "peer", addr(ctx), + "archiveKey", key, + "archiveError", "true", + "archiveErrorMessage", failure.errorMessage(), + "chunkSizeBytes", Long.toString(payloadLen)), + now)); + } + } + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + String key = KEYS.getIfPresent(ctx); + if (key != null) { + log.info("[jsatl12] channel closed, archived key={}", key); + KEYS.invalidate(ctx); + } + } + + private static Map unresolvedMeta(String... kv) { + HashMap out = new HashMap<>(); + out.put("vin", "unknown"); + out.put("identityResolved", "false"); + out.put("identitySource", "UNKNOWN"); + for (int i = 0; i + 1 < kv.length; i += 2) { + out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]); + } + return Map.copyOf(out); + } + + private IdentityResolution resolveIdentity(String phone) { + if (identityResolver != null) { + try { + return new IdentityResolution( + identityResolver.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", phone, "", "")), + null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + if (phone != null && !phone.isBlank()) { + return new IdentityResolution(new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE), null); + } + return new IdentityResolution(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), null); + } + + private static Map identityMeta(IdentityResolution resolution, String... kv) { + VehicleIdentity identity = resolution.identity(); + HashMap out = new HashMap<>(); + out.put("vin", identity.vin()); + out.put("identityResolved", Boolean.toString(identity.resolved())); + out.put("identitySource", identity.source().name()); + if (resolution.errorMessage() != null) { + out.put("identityError", "true"); + out.put("identityErrorMessage", resolution.errorMessage()); + } + for (int i = 0; i + 1 < kv.length; i += 2) { + out.put(kv[i], kv[i + 1] == null ? "" : kv[i + 1]); + } + return Map.copyOf(out); + } + + private static String addr(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) {} +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12DataPacket.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12DataPacket.java new file mode 100644 index 00000000..ba76c156 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12DataPacket.java @@ -0,0 +1,66 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public record Jsatl12DataPacket(String name, long offset, long length, byte[] data) { + + private static final int HEADER_LENGTH = 62; + + public Jsatl12DataPacket { + name = name == null ? "" : name.trim(); + data = data == null ? new byte[0] : Arrays.copyOf(data, data.length); + if (offset < 0) { + throw new IllegalArgumentException("jsatl12 data packet offset must not be negative"); + } + if (length < 0) { + throw new IllegalArgumentException("jsatl12 data packet length must not be negative"); + } + if (length != data.length) { + throw new IllegalArgumentException("jsatl12 data packet length " + length + + " does not match data length " + data.length); + } + } + + @Override + public byte[] data() { + return Arrays.copyOf(data, data.length); + } + + public static Jsatl12DataPacket parse(byte[] bytes) { + byte[] raw = bytes == null ? new byte[0] : bytes; + if (raw.length < HEADER_LENGTH) { + throw new IllegalArgumentException("jsatl12 data packet too short: " + raw.length); + } + if (raw[0] != 0x30 || raw[1] != 0x31 || raw[2] != 0x63 || raw[3] != 0x64) { + throw new IllegalArgumentException("jsatl12 data packet magic mismatch"); + } + String name = readFixedString(raw, 4, 50); + long offset = readDword(raw, 54); + long length = readDword(raw, 58); + if (length > Integer.MAX_VALUE) { + throw new IllegalArgumentException("jsatl12 data packet length too large: " + length); + } + if (raw.length < HEADER_LENGTH + (int) length) { + throw new IllegalArgumentException("jsatl12 data packet body truncated: " + raw.length); + } + byte[] data = Arrays.copyOfRange(raw, HEADER_LENGTH, HEADER_LENGTH + (int) length); + return new Jsatl12DataPacket(name, offset, length, data); + } + + private static String readFixedString(byte[] raw, int offset, int length) { + int end = offset; + int max = Math.min(raw.length, offset + length); + while (end < max && raw[end] != 0) { + end++; + } + return new String(raw, offset, end - offset, StandardCharsets.UTF_8).trim(); + } + + static long readDword(byte[] raw, int offset) { + return ((raw[offset] & 0xFFL) << 24) + | ((raw[offset + 1] & 0xFFL) << 16) + | ((raw[offset + 2] & 0xFFL) << 8) + | (raw[offset + 3] & 0xFFL); + } +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridge.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridge.java new file mode 100644 index 00000000..60bb4797 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridge.java @@ -0,0 +1,184 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.protocol.jt808.codec.Jt808Escape; +import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +public final class Jsatl12JtMessageBridge { + + private final Jt808MessageDecoder decoder; + private final VehicleIdentityResolver identityResolver; + private final Jsatl12AttachmentTracker attachmentTracker; + private final Consumer dispatcher; + + public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder, Consumer dispatcher) { + this(decoder, (VehicleIdentityResolver) null, dispatcher); + } + + public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder, + VehicleIdentityResolver identityResolver, + Consumer dispatcher) { + this(decoder, identityResolver, new Jsatl12AttachmentTracker(), dispatcher); + } + + public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder, + Jsatl12AttachmentTracker attachmentTracker, + Consumer dispatcher) { + this(decoder, null, attachmentTracker, dispatcher); + } + + public Jsatl12JtMessageBridge(Jt808MessageDecoder decoder, + VehicleIdentityResolver identityResolver, + Jsatl12AttachmentTracker attachmentTracker, + Consumer dispatcher) { + this.decoder = decoder; + this.identityResolver = identityResolver; + this.attachmentTracker = attachmentTracker == null ? new Jsatl12AttachmentTracker() : attachmentTracker; + this.dispatcher = dispatcher; + } + + public Optional dispatch(byte[] jtMessageBytes, String peer) { + Instant receivedAt = Instant.now(); + String peerValue = peer == null ? "" : peer; + byte[] raw = jtMessageBytes == null ? new byte[0] : jtMessageBytes; + byte[] unescaped; + Jt808Message message; + try { + unescaped = Jt808Escape.unescape(raw, 0, raw.length); + message = decoder.decode(ByteBuffer.wrap(unescaped)); + } catch (RuntimeException e) { + dispatchMalformed(raw, peerValue, e, receivedAt); + return Optional.empty(); + } + try { + IdentityResolution identity = resolveIdentity(message.header().phone()); + Map meta = new HashMap<>(); + meta.put("vin", identity.identity().vin()); + meta.put("phone", message.header().phone()); + meta.put("identityResolved", Boolean.toString(identity.identity().resolved())); + meta.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + meta.put("identityError", "true"); + meta.put("identityErrorMessage", identity.errorMessage()); + } + meta.put("peer", peerValue); + meta.put("source", "jsatl12-signaling"); + dispatcher.accept(new RawFrame( + ProtocolId.JT808, + message.header().messageId(), + 0, + message, + unescaped, + meta, + receivedAt)); + return handleJsatl12Signaling(message); + } catch (RuntimeException e) { + dispatchMalformed(raw, peerValue, e, receivedAt); + return Optional.empty(); + } + } + + private Optional handleJsatl12Signaling(Jt808Message message) { + if (!(message.body() instanceof Jt808Body.Raw rawBody)) { + return Optional.empty(); + } + if (message.header().messageId() == 0x1210) { + attachmentTracker.registerAttachmentList(message.header().phone(), rawBody.bytes()); + return Optional.empty(); + } + if (message.header().messageId() == 0x1212) { + Jsatl12UploadCompletionAck ack = + attachmentTracker.completeUpload(message.header().phone(), rawBody.bytes()); + return Optional.of(Jt808FrameEncoder.encode( + 0x9212, + message.header().phone(), + message.header().serialNo(), + encodeAckBody(ack), + message.header().version())); + } + return Optional.empty(); + } + + private static byte[] encodeAckBody(Jsatl12UploadCompletionAck ack) { + byte[] name = ack.name().getBytes(StandardCharsets.UTF_8); + if (name.length > 255) { + throw new IllegalArgumentException("jsatl12 completion ack filename too long: " + ack.name()); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(4 + name.length + ack.missingRanges().size() * 8); + out.write(name.length); + out.write(name, 0, name.length); + out.write(ack.type() & 0xFF); + out.write(ack.result() & 0xFF); + out.write(ack.missingRanges().size() & 0xFF); + for (Jsatl12UploadCompletionAck.Range range : ack.missingRanges()) { + writeDword(out, range.offset()); + writeDword(out, range.length()); + } + return out.toByteArray(); + } + + private static void writeDword(ByteArrayOutputStream out, long value) { + if (value < 0 || value > 0xFFFF_FFFFL) { + throw new IllegalArgumentException("jsatl12 dword out of range: " + value); + } + out.write((int) ((value >>> 24) & 0xFF)); + out.write((int) ((value >>> 16) & 0xFF)); + out.write((int) ((value >>> 8) & 0xFF)); + out.write((int) (value & 0xFF)); + } + + private IdentityResolution resolveIdentity(String phone) { + if (identityResolver == null) { + return new IdentityResolution( + new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE), + null); + } + try { + return new IdentityResolution( + identityResolver.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", phone, "", "")), + null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private void dispatchMalformed(byte[] raw, String peer, RuntimeException error, Instant receivedAt) { + Map meta = new HashMap<>(); + meta.put("vin", "unknown"); + meta.put("identityResolved", "false"); + meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name()); + meta.put("peer", peer); + meta.put("source", "jsatl12-jt-message"); + meta.put("parseError", "true"); + meta.put("parseErrorMessage", error.getMessage() == null ? "" : error.getMessage()); + dispatcher.accept(new RawFrame( + ProtocolId.JSATL12, + Jsatl12MessageId.MALFORMED_JT_MESSAGE, + 0, + new Jsatl12MalformedJtMessage(raw, peer, error.getMessage(), receivedAt), + raw, + meta, + receivedAt)); + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) {} +} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedFrame.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedFrame.java new file mode 100644 index 00000000..fff586e3 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedFrame.java @@ -0,0 +1,10 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.time.Instant; + +public record Jsatl12MalformedFrame( + byte[] rawBytes, + String peer, + String errorMessage, + Instant receivedAt +) {} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedJtMessage.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedJtMessage.java new file mode 100644 index 00000000..3814155f --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MalformedJtMessage.java @@ -0,0 +1,10 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.time.Instant; + +public record Jsatl12MalformedJtMessage( + byte[] rawBytes, + String peer, + String errorMessage, + Instant receivedAt +) {} diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MessageId.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MessageId.java new file mode 100644 index 00000000..e170150d --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12MessageId.java @@ -0,0 +1,11 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +public final class Jsatl12MessageId { + + public static final int DATA_CHUNK_ARCHIVED = 0x30316364; + public static final int MALFORMED_JT_MESSAGE = 0x30316A65; + public static final int DATA_CHUNK_ARCHIVE_FAILED = 0x30316665; + public static final int MALFORMED_FRAME = 0x30316672; + + private Jsatl12MessageId() {} +} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java similarity index 53% rename from protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java rename to modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java index 7c2c3eae..d765de4b 100644 --- a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12NettyServer.java @@ -1,5 +1,7 @@ package com.lingniu.ingest.protocol.jsatl12.inbound; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.identity.VehicleIdentityResolver; import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12FrameDecoder; import com.lingniu.ingest.sink.archive.ArchiveStore; import io.netty.bootstrap.ServerBootstrap; @@ -16,6 +18,7 @@ import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import java.util.concurrent.ThreadFactory; +import java.util.function.Consumer; /** * 苏标报警附件 Netty 服务端。独立端口(默认 7612)。 @@ -25,15 +28,44 @@ public final class Jsatl12NettyServer implements InitializingBean, DisposableBea private static final Logger log = LoggerFactory.getLogger(Jsatl12NettyServer.class); private final int port; + private final int workerThreads; + private final int maxFrameLength; private final ArchiveStore archive; + private final Jsatl12JtMessageBridge jtMessageBridge; + private final Jsatl12AttachmentTracker attachmentTracker; + private final VehicleIdentityResolver identityResolver; + private final Consumer dataChunkDispatcher; private EventLoopGroup boss; private EventLoopGroup workers; private Channel serverChannel; - public Jsatl12NettyServer(int port, ArchiveStore archive) { + public Jsatl12NettyServer(int port, + int workerThreads, + int maxFrameLength, + ArchiveStore archive, + Jsatl12JtMessageBridge jtMessageBridge, + Jsatl12AttachmentTracker attachmentTracker, + Consumer dataChunkDispatcher) { + this(port, workerThreads, maxFrameLength, archive, jtMessageBridge, attachmentTracker, null, dataChunkDispatcher); + } + + public Jsatl12NettyServer(int port, + int workerThreads, + int maxFrameLength, + ArchiveStore archive, + Jsatl12JtMessageBridge jtMessageBridge, + Jsatl12AttachmentTracker attachmentTracker, + VehicleIdentityResolver identityResolver, + Consumer dataChunkDispatcher) { this.port = port; + this.workerThreads = workerThreads; + this.maxFrameLength = maxFrameLength; this.archive = archive; + this.jtMessageBridge = jtMessageBridge; + this.attachmentTracker = attachmentTracker == null ? new Jsatl12AttachmentTracker() : attachmentTracker; + this.identityResolver = identityResolver; + this.dataChunkDispatcher = dataChunkDispatcher; } @Override @@ -41,7 +73,8 @@ public final class Jsatl12NettyServer implements InitializingBean, DisposableBea ThreadFactory bossTf = r -> new Thread(r, "jsatl12-boss"); ThreadFactory wkTf = r -> new Thread(r, "jsatl12-worker"); this.boss = new NioEventLoopGroup(1, bossTf); - this.workers = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors(), wkTf); + int threads = workerThreads > 0 ? workerThreads : Runtime.getRuntime().availableProcessors(); + this.workers = new NioEventLoopGroup(threads, wkTf); ServerBootstrap b = new ServerBootstrap(); b.group(boss, workers) @@ -53,12 +86,14 @@ public final class Jsatl12NettyServer implements InitializingBean, DisposableBea @Override protected void initChannel(SocketChannel ch) { ch.pipeline() - .addLast("frame", new Jsatl12FrameDecoder()) - .addLast("dispatch", new Jsatl12ChannelHandler(archive)); + .addLast("frame", new Jsatl12FrameDecoder(maxFrameLength)) + .addLast("dispatch", new Jsatl12ChannelHandler( + archive, jtMessageBridge, attachmentTracker, identityResolver, dataChunkDispatcher)); } }); this.serverChannel = b.bind(port).sync().channel(); - log.info("jsatl12 Netty server listening on :{}", port); + log.info("jsatl12 Netty server listening on :{} workers={} maxFrameLength={}", + port, threads, maxFrameLength); } @Override diff --git a/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12UploadCompletionAck.java b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12UploadCompletionAck.java new file mode 100644 index 00000000..b09de61a --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12UploadCompletionAck.java @@ -0,0 +1,17 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import java.util.List; + +public record Jsatl12UploadCompletionAck( + String name, + int type, + int result, + List missingRanges +) { + public Jsatl12UploadCompletionAck { + name = name == null ? "" : name; + missingRanges = missingRanges == null ? List.of() : List.copyOf(missingRanges); + } + + public record Range(long offset, long length) {} +} diff --git a/protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/protocols/protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/protocols/protocol-jsatl12/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12DataPacketDecoderTest.java b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12DataPacketDecoderTest.java new file mode 100644 index 00000000..c46ff7e5 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12DataPacketDecoderTest.java @@ -0,0 +1,47 @@ +package com.lingniu.ingest.protocol.jsatl12.codec; + +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jsatl12DataPacketDecoderTest { + + @Test + void dataPacketUsesFilenameOffsetLengthLayoutInsteadOfMagicLengthLayout() { + EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(128)); + byte[] packet = dataPacket("ADAS_001.jpg", 3, new byte[]{0x04, 0x05, 0x06}); + + channel.writeInbound(Unpooled.wrappedBuffer(packet)); + + Jsatl12RawFrame frame = channel.readInbound(); + assertThat(frame.type()).isEqualTo(Jsatl12RawFrame.Type.DATA_CHUNK); + assertThat(frame.bytes()).containsExactly(packet); + Object next = channel.readInbound(); + assertThat(next).isNull(); + } + + private static byte[] dataPacket(String name, int offset, byte[] data) { + byte[] out = new byte[62 + data.length]; + out[0] = 0x30; + out[1] = 0x31; + out[2] = 0x63; + out[3] = 0x64; + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(nameBytes, 0, out, 4, nameBytes.length); + writeDword(out, 54, offset); + writeDword(out, 58, data.length); + System.arraycopy(data, 0, out, 62, data.length); + return out; + } + + private static void writeDword(byte[] out, int offset, long value) { + out[offset] = (byte) ((value >>> 24) & 0xFF); + out[offset + 1] = (byte) ((value >>> 16) & 0xFF); + out[offset + 2] = (byte) ((value >>> 8) & 0xFF); + out[offset + 3] = (byte) (value & 0xFF); + } +} diff --git a/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoderTest.java b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoderTest.java new file mode 100644 index 00000000..2899e1ec --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12FrameDecoderTest.java @@ -0,0 +1,72 @@ +package com.lingniu.ingest.protocol.jsatl12.codec; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jsatl12FrameDecoderTest { + + @Test + void decodesDataChunkFrame() { + EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(16)); + byte[] packet = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03}); + + channel.writeInbound(Unpooled.wrappedBuffer(packet)); + + Jsatl12RawFrame frame = channel.readInbound(); + assertThat(frame.type()).isEqualTo(Jsatl12RawFrame.Type.DATA_CHUNK); + assertThat(frame.bytes()).containsExactly(packet); + } + + @Test + void oversizedDataFrameEmitsMalformedCandidate() { + EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(2)); + byte[] packet = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03}); + + channel.writeInbound(Unpooled.wrappedBuffer(packet)); + + Jsatl12RawFrame decoded = channel.readInbound(); + assertThat(decoded.type()).isEqualTo(Jsatl12RawFrame.Type.MALFORMED); + assertThat(decoded.bytes()).containsExactly(packet); + } + + @Test + void oversizedUnclosedJtFrameEmitsMalformedCandidate() { + EmbeddedChannel channel = new EmbeddedChannel(new Jsatl12FrameDecoder(4)); + + channel.writeInbound(Unpooled.wrappedBuffer(new byte[]{ + 0x7e, 0x01, 0x02, 0x03, 0x04, 0x05 + })); + + Jsatl12RawFrame decoded = channel.readInbound(); + assertThat(decoded.type()).isEqualTo(Jsatl12RawFrame.Type.MALFORMED); + assertThat(decoded.bytes()).containsExactly( + 0x7e, 0x01, 0x02, 0x03, 0x04, 0x05); + assertThat((Object) channel.readInbound()).isNull(); + } + + private static byte[] dataPacket(String name, int offset, byte[] data) { + byte[] out = new byte[62 + data.length]; + out[0] = 0x30; + out[1] = 0x31; + out[2] = 0x63; + out[3] = 0x64; + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(nameBytes, 0, out, 4, nameBytes.length); + writeDword(out, 54, offset); + writeDword(out, 58, data.length); + System.arraycopy(data, 0, out, 62, data.length); + return out; + } + + private static void writeDword(byte[] out, int offset, long value) { + out[offset] = (byte) ((value >>> 24) & 0xFF); + out[offset + 1] = (byte) ((value >>> 16) & 0xFF); + out[offset + 2] = (byte) ((value >>> 8) & 0xFF); + out[offset + 3] = (byte) (value & 0xFF); + } +} diff --git a/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandlerTest.java b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandlerTest.java new file mode 100644 index 00000000..634b9d41 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/handler/Jsatl12ArchiveEventHandlerTest.java @@ -0,0 +1,175 @@ +package com.lingniu.ingest.protocol.jsatl12.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchiveFailure; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12ArchivedChunk; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedFrame; +import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12MalformedJtMessage; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jsatl12ArchiveEventHandlerTest { + + @Test + void archivedDataChunkProducesMediaMetaEvent() { + Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler(); + Jsatl12ArchivedChunk chunk = new Jsatl12ArchivedChunk( + "file:///archive/jsatl12/2026/06/22/peer/file.bin", + "jsatl12/2026/06/22/peer/file.bin", + "ADAS_001.jpg", + 64, + 128, + 128, + "10.0.0.8:7612", + Instant.parse("2026-06-22T01:02:03Z")); + + List events = handler.onDataChunkArchived(chunk); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.MediaMeta.class); + VehicleEvent.MediaMeta media = (VehicleEvent.MediaMeta) event; + assertThat(media.source()).isEqualTo(ProtocolId.JSATL12); + assertThat(media.vin()).isEqualTo("unknown"); + assertThat(media.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z")); + assertThat(media.archiveRef()).isEqualTo("file:///archive/jsatl12/2026/06/22/peer/file.bin"); + assertThat(media.sizeBytes()).isEqualTo(128); + assertThat(media.mediaType()).isEqualTo("jsatl12-data-chunk"); + assertThat(media.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("archiveKey", "jsatl12/2026/06/22/peer/file.bin") + .containsEntry("fileName", "ADAS_001.jpg") + .containsEntry("fileOffset", "64") + .containsEntry("declaredChunkSizeBytes", "128") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } + + @Test + void archivedDataChunkKeepsResolvedVehicleIdentity() { + Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler(); + Jsatl12ArchivedChunk chunk = new Jsatl12ArchivedChunk( + "file:///archive/jsatl12/2026/06/22/peer/file.bin", + "jsatl12/2026/06/22/peer/file.bin", + "ADAS_001.jpg", + 0, + 128, + 128, + "10.0.0.8:7612", + Instant.parse("2026-06-22T01:02:03Z"), + "123456789012", + "LNVIN00000001212", + true, + "BOUND_PHONE"); + + List events = handler.onDataChunkArchived(chunk); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.MediaMeta.class); + VehicleEvent.MediaMeta media = (VehicleEvent.MediaMeta) event; + assertThat(media.vin()).isEqualTo("LNVIN00000001212"); + assertThat(media.metadata()) + .containsEntry("vin", "LNVIN00000001212") + .containsEntry("phone", "123456789012") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE"); + }); + } + + @Test + void malformedJtMessageProducesPassthroughEvent() { + Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler(); + byte[] raw = new byte[]{0x01, 0x02, 0x03}; + Jsatl12MalformedJtMessage malformed = new Jsatl12MalformedJtMessage( + raw, + "10.0.0.8:7612", + "jt808 frame too short", + Instant.parse("2026-06-22T01:02:03Z")); + + List events = handler.onMalformedJtMessage(malformed); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JSATL12); + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z")); + assertThat(passthrough.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("peer", "10.0.0.8:7612") + .containsEntry("parseError", "true") + .containsEntry("source", "jsatl12-jt-message") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(passthrough.data()).containsExactly(raw); + }); + } + + @Test + void archiveFailureProducesPassthroughEvent() { + Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler(); + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 0, 0, 1, 0x7f}; + Jsatl12ArchiveFailure failure = new Jsatl12ArchiveFailure( + raw, + "jsatl12/2026/06/22/peer/file.bin", + 1, + "10.0.0.8:7612", + "archive backend unavailable", + Instant.parse("2026-06-22T01:02:03Z")); + + List events = handler.onArchiveFailure(failure); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JSATL12); + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z")); + assertThat(passthrough.data()).containsExactly(raw); + assertThat(passthrough.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("archiveError", "true") + .containsEntry("archiveKey", "jsatl12/2026/06/22/peer/file.bin") + .containsEntry("chunkSizeBytes", "1") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } + + @Test + void malformedFrameProducesPassthroughEvent() { + Jsatl12ArchiveEventHandler handler = new Jsatl12ArchiveEventHandler(); + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 0, 0, 3, 0x01, 0x02, 0x03}; + Jsatl12MalformedFrame malformed = new Jsatl12MalformedFrame( + raw, + "10.0.0.8:7612", + "malformed jsatl12 frame", + Instant.parse("2026-06-22T01:02:03Z")); + + List events = handler.onMalformedFrame(malformed); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JSATL12); + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T01:02:03Z")); + assertThat(passthrough.data()).containsExactly(raw); + assertThat(passthrough.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("peer", "10.0.0.8:7612") + .containsEntry("source", "jsatl12-frame") + .containsEntry("frameError", "true") + .containsEntry("frameErrorMessage", "malformed jsatl12 frame") + .containsEntry("parseError", "true") + .containsEntry("parseErrorMessage", "malformed jsatl12 frame") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } +} diff --git a/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTrackerTest.java b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTrackerTest.java new file mode 100644 index 00000000..931c6e25 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12AttachmentTrackerTest.java @@ -0,0 +1,101 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import static org.assertj.core.api.Assertions.assertThat; + +class Jsatl12AttachmentTrackerTest { + + @Test + void completionAckRequestsOnlyMissingRangesUntilFileIsComplete() { + Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker(); + String phone = "13800138000"; + String fileName = "ADAS_001.jpg"; + + tracker.registerAttachmentList(phone, t1210Body(fileName, 6)); + tracker.recordDataPacket(phone, dataPacket(fileName, 0, new byte[]{0x01, 0x02, 0x03})); + + Jsatl12UploadCompletionAck first = tracker.completeUpload(phone, t1212Body(fileName, 2, 6)); + + assertThat(first.name()).isEqualTo(fileName); + assertThat(first.type()).isEqualTo(2); + assertThat(first.result()).isEqualTo(1); + assertThat(first.missingRanges()).containsExactly(new Jsatl12UploadCompletionAck.Range(3, 3)); + + tracker.recordDataPacket(phone, dataPacket(fileName, 3, new byte[]{0x04, 0x05, 0x06})); + + Jsatl12UploadCompletionAck second = tracker.completeUpload(phone, t1212Body(fileName, 2, 6)); + + assertThat(second.result()).isZero(); + assertThat(second.missingRanges()).isEmpty(); + assertThat(tracker.knownFiles(phone)).doesNotContain(fileName); + } + + @Test + void dataPacketCanBeParsedFromProductionLayout() { + Jsatl12DataPacket packet = Jsatl12DataPacket.parse(dataPacket("BSD_002.bin", 12, new byte[]{0x01, 0x02})); + + assertThat(packet.name()).isEqualTo("BSD_002.bin"); + assertThat(packet.offset()).isEqualTo(12); + assertThat(packet.length()).isEqualTo(2); + assertThat(packet.data()).containsExactly(0x01, 0x02); + } + + private static byte[] t1210Body(String fileName, long size) { + byte[] name = fileName.getBytes(StandardCharsets.US_ASCII); + byte[] out = new byte[57 + 1 + name.length + 4]; + putAscii(out, 0, 7, "DEV0001"); + putAscii(out, 7, 7, "DEV0001"); + out[13] = 0x26; + out[14] = 0x06; + out[15] = 0x22; + out[16] = 0x10; + out[17] = 0x30; + out[18] = 0x45; + out[19] = 0x01; + out[20] = 0x01; + putAscii(out, 23, 32, "ALARM-NO-001"); + out[55] = 0; + out[56] = 1; + out[57] = (byte) name.length; + System.arraycopy(name, 0, out, 58, name.length); + writeDword(out, 58 + name.length, size); + return out; + } + + private static byte[] t1212Body(String fileName, int type, long size) { + byte[] name = fileName.getBytes(StandardCharsets.US_ASCII); + byte[] out = new byte[1 + name.length + 1 + 4]; + out[0] = (byte) name.length; + System.arraycopy(name, 0, out, 1, name.length); + out[1 + name.length] = (byte) type; + writeDword(out, 2 + name.length, size); + return out; + } + + private static byte[] dataPacket(String name, int offset, byte[] data) { + byte[] out = new byte[62 + data.length]; + out[0] = 0x30; + out[1] = 0x31; + out[2] = 0x63; + out[3] = 0x64; + putAscii(out, 4, 50, name); + writeDword(out, 54, offset); + writeDword(out, 58, data.length); + System.arraycopy(data, 0, out, 62, data.length); + return out; + } + + private static void putAscii(byte[] out, int offset, int len, String value) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(bytes, 0, out, offset, Math.min(bytes.length, len)); + } + + private static void writeDword(byte[] out, int offset, long value) { + out[offset] = (byte) ((value >>> 24) & 0xFF); + out[offset + 1] = (byte) ((value >>> 16) & 0xFF); + out[offset + 2] = (byte) ((value >>> 8) & 0xFF); + out[offset + 3] = (byte) (value & 0xFF); + } +} diff --git a/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandlerTest.java b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandlerTest.java new file mode 100644 index 00000000..27486689 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandlerTest.java @@ -0,0 +1,297 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jsatl12ChannelHandlerTest { + + @Test + void dataChunkArchivesPayloadAndDispatchesQueryableArchiveReference() { + RecordingArchiveStore archive = new RecordingArchiveStore(); + List dispatched = new ArrayList<>(); + Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(archive, null, dispatched::add); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + channel.writeInbound(new Jsatl12RawFrame( + Jsatl12RawFrame.Type.DATA_CHUNK, + dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03}))); + + assertThat(archive.lastChunk).containsExactly(0x01, 0x02, 0x03); + assertThat(dispatched).hasSize(1); + RawFrame frame = dispatched.getFirst(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12); + assertThat(frame.command()).isEqualTo(Jsatl12MessageId.DATA_CHUNK_ARCHIVED); + assertThat(frame.payload()).isInstanceOf(Jsatl12ArchivedChunk.class); + Jsatl12ArchivedChunk chunk = (Jsatl12ArchivedChunk) frame.payload(); + assertThat(chunk.archiveRef()).isEqualTo("memory://" + archive.lastKey); + assertThat(chunk.sizeBytes()).isEqualTo(3); + assertThat(chunk.peer()).isEqualTo("unknown"); + assertThat(frame.sourceMeta()) + .containsEntry("source", "jsatl12-data-chunk") + .containsEntry("fileName", "ADAS_001.jpg") + .containsEntry("fileOffset", "0") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + } + + @Test + void dataChunkUsesAttachmentListPhoneForVehicleIdentity() { + RecordingArchiveStore archive = new RecordingArchiveStore(); + Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker(); + String phone = "123456789012"; + tracker.registerAttachmentList(phone, t1210Body("ADAS_001.jpg", 3)); + InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService(); + identities.bind(new VehicleIdentityBinding( + ProtocolId.JT808, + "LNVIN00000001212", + phone, + "", + "")); + List dispatched = new ArrayList<>(); + Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler( + archive, + null, + tracker, + identities, + dispatched::add); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + channel.writeInbound(new Jsatl12RawFrame( + Jsatl12RawFrame.Type.DATA_CHUNK, + dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03}))); + + assertThat(dispatched).singleElement().satisfies(frame -> { + assertThat(frame.sourceMeta()) + .containsEntry("phone", phone) + .containsEntry("vin", "LNVIN00000001212") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE"); + assertThat(frame.payload()).isInstanceOf(Jsatl12ArchivedChunk.class); + Jsatl12ArchivedChunk chunk = (Jsatl12ArchivedChunk) frame.payload(); + assertThat(chunk.phone()).isEqualTo(phone); + assertThat(chunk.vin()).isEqualTo("LNVIN00000001212"); + assertThat(chunk.identityResolved()).isTrue(); + assertThat(chunk.identitySource()).isEqualTo("BOUND_PHONE"); + }); + } + + @Test + void dataChunkStillArchivesWhenIdentityResolverFails() { + RecordingArchiveStore archive = new RecordingArchiveStore(); + Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker(); + String phone = "123456789012"; + tracker.registerAttachmentList(phone, t1210Body("ADAS_001.jpg", 3)); + List dispatched = new ArrayList<>(); + Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler( + archive, + null, + tracker, + lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }, + dispatched::add); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] frameBytes = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03}); + + channel.writeInbound(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frameBytes)); + + assertThat(archive.lastChunk).containsExactly(0x01, 0x02, 0x03); + assertThat(dispatched).singleElement().satisfies(frame -> { + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12); + assertThat(frame.command()).isEqualTo(Jsatl12MessageId.DATA_CHUNK_ARCHIVED); + assertThat(frame.rawBytes()).isNull(); + assertThat(frame.payload()).isInstanceOf(Jsatl12ArchivedChunk.class); + Jsatl12ArchivedChunk chunk = (Jsatl12ArchivedChunk) frame.payload(); + assertThat(chunk.phone()).isEqualTo(phone); + assertThat(chunk.vin()).isEqualTo("unknown"); + assertThat(chunk.identityResolved()).isFalse(); + assertThat(chunk.identitySource()).isEqualTo("UNKNOWN"); + assertThat(frame.sourceMeta()) + .containsEntry("phone", phone) + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable") + .containsEntry("archiveRef", "memory://" + archive.lastKey); + }); + } + + @Test + void dataChunkArchiveFailureDispatchesPassthroughCandidateWithRawPayload() { + FailingArchiveStore archive = new FailingArchiveStore(); + List dispatched = new ArrayList<>(); + Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(archive, null, dispatched::add); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] frameBytes = dataPacket("ADAS_001.jpg", 0, new byte[]{0x01, 0x02, 0x03}); + + channel.writeInbound(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frameBytes)); + + assertThat(dispatched).singleElement().satisfies(frame -> { + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12); + assertThat(frame.command()).isEqualTo(Jsatl12MessageId.DATA_CHUNK_ARCHIVE_FAILED); + assertThat(frame.rawBytes()).containsExactly(frameBytes); + assertThat(frame.payload()).isInstanceOf(Jsatl12ArchiveFailure.class); + Jsatl12ArchiveFailure failure = (Jsatl12ArchiveFailure) frame.payload(); + assertThat(failure.rawBytes()).containsExactly(frameBytes); + assertThat(failure.sizeBytes()).isEqualTo(3); + assertThat(failure.peer()).isEqualTo("unknown"); + assertThat(failure.errorMessage()).contains("archive backend unavailable"); + assertThat(frame.sourceMeta()) + .containsEntry("source", "jsatl12-data-chunk") + .containsEntry("archiveError", "true") + .containsEntry("chunkSizeBytes", "3") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } + + @Test + void malformedFrameDispatchesPassthroughCandidateWithRawPayload() { + RecordingArchiveStore archive = new RecordingArchiveStore(); + List dispatched = new ArrayList<>(); + Jsatl12ChannelHandler handler = new Jsatl12ChannelHandler(archive, null, dispatched::add); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] raw = new byte[]{ + 0x30, 0x31, 0x63, 0x64, + 0x00, 0x00, 0x00, 0x03, + 0x01, 0x02, 0x03 + }; + + channel.writeInbound(new Jsatl12RawFrame(Jsatl12RawFrame.Type.MALFORMED, raw)); + + assertThat(dispatched).singleElement().satisfies(frame -> { + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12); + assertThat(frame.command()).isEqualTo(Jsatl12MessageId.MALFORMED_FRAME); + assertThat(frame.rawBytes()).containsExactly(raw); + assertThat(frame.payload()).isInstanceOf(Jsatl12MalformedFrame.class); + Jsatl12MalformedFrame malformed = (Jsatl12MalformedFrame) frame.payload(); + assertThat(malformed.rawBytes()).containsExactly(raw); + assertThat(malformed.peer()).isEqualTo("unknown"); + assertThat(malformed.errorMessage()).contains("malformed jsatl12 frame"); + assertThat(frame.sourceMeta()) + .containsEntry("source", "jsatl12-frame") + .containsEntry("frameError", "true") + .containsEntry("frameSizeBytes", Integer.toString(raw.length)) + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + } + + private static byte[] dataPacket(String name, int offset, byte[] data) { + byte[] out = new byte[62 + data.length]; + out[0] = 0x30; + out[1] = 0x31; + out[2] = 0x63; + out[3] = 0x64; + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(nameBytes, 0, out, 4, nameBytes.length); + writeDword(out, 54, offset); + writeDword(out, 58, data.length); + System.arraycopy(data, 0, out, 62, data.length); + return out; + } + + private static byte[] t1210Body(String fileName, long size) { + byte[] name = fileName.getBytes(StandardCharsets.US_ASCII); + byte[] out = new byte[57 + 1 + name.length + 4]; + putAscii(out, 0, 7, "DEV0001"); + putAscii(out, 7, 7, "DEV0001"); + putAscii(out, 23, 32, "ALARM-NO-001"); + out[55] = 0; + out[56] = 1; + out[57] = (byte) name.length; + System.arraycopy(name, 0, out, 58, name.length); + writeDword(out, 58 + name.length, size); + return out; + } + + private static void putAscii(byte[] out, int offset, int len, String value) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(bytes, 0, out, offset, Math.min(bytes.length, len)); + } + + private static void writeDword(byte[] out, int offset, long value) { + out[offset] = (byte) ((value >>> 24) & 0xFF); + out[offset + 1] = (byte) ((value >>> 16) & 0xFF); + out[offset + 2] = (byte) ((value >>> 8) & 0xFF); + out[offset + 3] = (byte) (value & 0xFF); + } + + private static final class RecordingArchiveStore implements ArchiveStore { + private String lastKey; + private byte[] lastChunk; + + @Override + public String put(String key, InputStream data, long length) { + throw new UnsupportedOperationException(); + } + + @Override + public String append(String key, byte[] chunk) { + this.lastKey = key; + this.lastChunk = chunk; + return "memory://" + key; + } + + @Override + public InputStream get(String key) { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public boolean exists(String key) { + return false; + } + + @Override + public long size(String key) throws IOException { + return lastChunk == null ? 0 : lastChunk.length; + } + } + + private static final class FailingArchiveStore implements ArchiveStore { + @Override + public String put(String key, InputStream data, long length) throws IOException { + throw new IOException("archive backend unavailable"); + } + + @Override + public String append(String key, byte[] chunk) throws IOException { + throw new IOException("archive backend unavailable"); + } + + @Override + public InputStream get(String key) { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public boolean exists(String key) { + return false; + } + + @Override + public long size(String key) throws IOException { + throw new IOException("archive backend unavailable"); + } + } +} diff --git a/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridgeTest.java b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridgeTest.java new file mode 100644 index 00000000..44045475 --- /dev/null +++ b/modules/protocols/protocol-jsatl12/src/test/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12JtMessageBridgeTest.java @@ -0,0 +1,237 @@ +package com.lingniu.ingest.protocol.jsatl12.inbound; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry; +import com.lingniu.ingest.protocol.jt808.codec.Jt808Escape; +import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jsatl12JtMessageBridgeTest { + + @Test + void decodesJtMessageAndDispatchesAsJt808RawFrame() { + List dispatched = new ArrayList<>(); + InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService(); + identities.bind(new VehicleIdentityBinding( + ProtocolId.JT808, + "LNVIN00000001212", + "123456789012", + "", + "")); + Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new HeartbeatBodyParser()))), + identities, + dispatched::add); + + bridge.dispatch(buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 3), "127.0.0.1:7612"); + + assertThat(dispatched).hasSize(1); + RawFrame frame = dispatched.get(0); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT808); + assertThat(frame.command()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT); + assertThat(frame.sourceMeta()) + .containsEntry("vin", "LNVIN00000001212") + .containsEntry("phone", "123456789012") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE"); + assertThat(frame.payload()).isInstanceOf(com.lingniu.ingest.protocol.jt808.model.Jt808Message.class); + assertThat(((com.lingniu.ingest.protocol.jt808.model.Jt808Message) frame.payload()).body()) + .isInstanceOf(Jt808Body.Heartbeat.class); + } + + @Test + void bridgeUsesExtendedJt808Parsers() { + List dispatched = new ArrayList<>(); + Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new PassthroughBodyParser()))), + dispatched::add); + byte[] body = new byte[]{0x41, 0x01, 0x02}; + + bridge.dispatch(buildFrame(Jt808MessageId.TERMINAL_PASSTHROUGH, "123456789012", 4, body), + "127.0.0.1:7612"); + + assertThat(dispatched).hasSize(1); + assertThat(dispatched.get(0).command()).isEqualTo(Jt808MessageId.TERMINAL_PASSTHROUGH); + assertThat(((com.lingniu.ingest.protocol.jt808.model.Jt808Message) dispatched.get(0).payload()).body()) + .isInstanceOf(Jt808Body.Passthrough.class); + } + + @Test + void malformedJtMessageIsDispatchedAsJsatl12PassthroughCandidate() { + List dispatched = new ArrayList<>(); + Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new HeartbeatBodyParser()))), + dispatched::add); + byte[] malformed = new byte[]{0x01, 0x02, 0x03}; + + bridge.dispatch(malformed, "127.0.0.1:7612"); + + assertThat(dispatched).hasSize(1); + RawFrame frame = dispatched.getFirst(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JSATL12); + assertThat(frame.command()).isEqualTo(Jsatl12MessageId.MALFORMED_JT_MESSAGE); + assertThat(frame.rawBytes()).containsExactly(malformed); + assertThat(frame.sourceMeta()) + .containsEntry("peer", "127.0.0.1:7612") + .containsEntry("parseError", "true") + .containsEntry("source", "jsatl12-jt-message") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(frame.payload()).isInstanceOf(Jsatl12MalformedJtMessage.class); + } + + @Test + void identityResolverFailureStillDispatchesDecodedJt808MessageWithUnknownIdentity() { + List dispatched = new ArrayList<>(); + Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new HeartbeatBodyParser()))), + lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }, + dispatched::add); + byte[] raw = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 3); + + bridge.dispatch(raw, "127.0.0.1:7612"); + + assertThat(dispatched).singleElement().satisfies(frame -> { + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT808); + assertThat(frame.command()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT); + assertThat(frame.rawBytes()).isNotNull(); + assertThat(frame.payload()).isInstanceOf(com.lingniu.ingest.protocol.jt808.model.Jt808Message.class); + assertThat(frame.sourceMeta()) + .containsEntry("source", "jsatl12-signaling") + .containsEntry("peer", "127.0.0.1:7612") + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + } + + @Test + void uploadCompletionReturnsT9212RetransmissionAck() { + List dispatched = new ArrayList<>(); + Jsatl12AttachmentTracker tracker = new Jsatl12AttachmentTracker(); + Jsatl12JtMessageBridge bridge = new Jsatl12JtMessageBridge( + new Jt808MessageDecoder(new BodyParserRegistry(List.of())), + tracker, + dispatched::add); + String phone = "123456789012"; + String fileName = "ADAS_001.jpg"; + + Optional t1210Ack = bridge.dispatch(buildFrame(0x1210, phone, 10, t1210Body(fileName, 6)), + "127.0.0.1:7612"); + tracker.recordDataPacket(phone, dataPacket(fileName, 0, new byte[]{0x01, 0x02, 0x03})); + Optional t9212 = bridge.dispatch(buildFrame(0x1212, phone, 11, t1212Body(fileName, 2, 6)), + "127.0.0.1:7612"); + + assertThat(t1210Ack).isEmpty(); + assertThat(t9212).isPresent(); + byte[] unescaped = Jt808Escape.unescape(t9212.get(), 1, t9212.get().length - 2); + var ack = new Jt808MessageDecoder(new BodyParserRegistry(List.of())) + .decode(ByteBuffer.wrap(unescaped)); + assertThat(ack.header().messageId()).isEqualTo(0x9212); + assertThat(ack.header().phone()).isEqualTo(phone); + assertThat(ack.header().serialNo()).isEqualTo(11); + Jt808Body.Raw raw = (Jt808Body.Raw) ack.body(); + byte[] body = raw.bytes(); + int nameLen = body[0] & 0xFF; + assertThat(new String(body, 1, nameLen, StandardCharsets.UTF_8)).isEqualTo(fileName); + assertThat(body[1 + nameLen] & 0xFF).isEqualTo(2); + assertThat(body[2 + nameLen] & 0xFF).isEqualTo(1); + assertThat(body[3 + nameLen] & 0xFF).isEqualTo(1); + assertThat(readDword(body, 4 + nameLen)).isEqualTo(3); + assertThat(readDword(body, 8 + nameLen)).isEqualTo(3); + } + + private static byte[] buildFrame(int msgId, String phone, int serial) { + return buildFrame(msgId, phone, serial, new byte[0]); + } + + private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) { + byte[] framed = Jt808FrameEncoder.encode(msgId, phone, serial, body); + return Jt808Escape.unescape(framed, 1, framed.length - 2); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static byte[] t1210Body(String fileName, long size) { + byte[] name = fileName.getBytes(StandardCharsets.US_ASCII); + byte[] out = new byte[57 + 1 + name.length + 4]; + putAscii(out, 0, 7, "DEV0001"); + putAscii(out, 7, 7, "DEV0001"); + putAscii(out, 23, 32, "ALARM-NO-001"); + out[55] = 0; + out[56] = 1; + out[57] = (byte) name.length; + System.arraycopy(name, 0, out, 58, name.length); + writeDword(out, 58 + name.length, size); + return out; + } + + private static byte[] t1212Body(String fileName, int type, long size) { + byte[] name = fileName.getBytes(StandardCharsets.US_ASCII); + byte[] out = new byte[1 + name.length + 1 + 4]; + out[0] = (byte) name.length; + System.arraycopy(name, 0, out, 1, name.length); + out[1 + name.length] = (byte) type; + writeDword(out, 2 + name.length, size); + return out; + } + + private static byte[] dataPacket(String name, int offset, byte[] data) { + byte[] out = new byte[62 + data.length]; + out[0] = 0x30; + out[1] = 0x31; + out[2] = 0x63; + out[3] = 0x64; + putAscii(out, 4, 50, name); + writeDword(out, 54, offset); + writeDword(out, 58, data.length); + System.arraycopy(data, 0, out, 62, data.length); + return out; + } + + private static void putAscii(byte[] out, int offset, int len, String value) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(bytes, 0, out, offset, Math.min(bytes.length, len)); + } + + private static void writeDword(byte[] out, int offset, long value) { + out[offset] = (byte) ((value >>> 24) & 0xFF); + out[offset + 1] = (byte) ((value >>> 16) & 0xFF); + out[offset + 2] = (byte) ((value >>> 8) & 0xFF); + out[offset + 3] = (byte) (value & 0xFF); + } + + private static long readDword(byte[] raw, int offset) { + return ((raw[offset] & 0xFFL) << 24) + | ((raw[offset + 1] & 0xFFL) << 16) + | ((raw[offset + 2] & 0xFFL) << 8) + | (raw[offset + 3] & 0xFFL); + } +} diff --git a/modules/protocols/protocol-jt1078/pom.xml b/modules/protocols/protocol-jt1078/pom.xml new file mode 100644 index 00000000..bc0e0643 --- /dev/null +++ b/modules/protocols/protocol-jt1078/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + ../../../pom.xml + + protocol-jt1078 + protocol-jt1078 + + JT/T 1078 音视频信令接入。复用 protocol-jt808 的 Netty server 与会话, + 只通过注册新的 BodyParser 热插拔扩展消息集(0x1003 / 0x1005 / 0x1205 / 0x1206)。 + + + + + com.lingniu.ingest + ingest-api + + + com.lingniu.ingest + ingest-core + + + com.lingniu.ingest + protocol-jt808 + + + com.lingniu.ingest + vehicle-identity + + + com.lingniu.ingest + sink-archive + + + org.springframework.boot + spring-boot-starter + + + io.netty + netty-all + + + com.github.ben-manes.caffeine + caffeine + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework.boot + spring-boot-test + test + + + diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileListBodyParser.java similarity index 55% rename from protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java rename to modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileListBodyParser.java index 34ace26d..884a045a 100644 --- a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileListBodyParser.java @@ -7,17 +7,17 @@ import com.lingniu.ingest.protocol.jt808.model.Jt808Header; import java.nio.ByteBuffer; -/** - * 0x1003 终端上传音视频属性 —— PoC 阶段先返回原始字节由下游分析。 - * 真实解析字段:音视频编码 / 音频通道数 / 采样率 / 位深 / 最大音视频通道数 / 视频编码 / 分辨率 等。 - */ -public final class MediaAttributesBodyParser implements BodyParser { - @Override public int messageId() { return Jt1078MessageId.TERMINAL_MEDIA_ATTRS_REPORT; } +/** 0x1205 终端上传文件列表,摘要字段结构化,明细原始 body 保留。 */ +public final class FileListBodyParser implements BodyParser { + @Override public int messageId() { return Jt1078MessageId.TERMINAL_FILE_LIST; } @Override public Jt808Body parse(Jt808Header header, ByteBuffer body) { byte[] raw = new byte[body.remaining()]; body.get(raw); - return new Jt808Body.Raw(messageId(), raw); + ByteBuffer b = ByteBuffer.wrap(raw); + int responseSerialNo = b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0; + long fileCount = b.remaining() >= 4 ? b.getInt() & 0xFFFFFFFFL : 0L; + return new Jt808Body.FileList(responseSerialNo, fileCount, raw); } } diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java similarity index 68% rename from protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java rename to modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java index 0d08106b..9787cd65 100644 --- a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/FileUploadCompleteBodyParser.java @@ -7,7 +7,7 @@ import com.lingniu.ingest.protocol.jt808.model.Jt808Header; import java.nio.ByteBuffer; -/** 0x1206 文件上传完成通知。PoC 只取 raw。 */ +/** 0x1206 文件上传完成通知。 */ public final class FileUploadCompleteBodyParser implements BodyParser { @Override public int messageId() { return Jt1078MessageId.TERMINAL_FILE_UPLOAD_DONE; } @@ -15,6 +15,9 @@ public final class FileUploadCompleteBodyParser implements BodyParser { public Jt808Body parse(Jt808Header header, ByteBuffer body) { byte[] raw = new byte[body.remaining()]; body.get(raw); - return new Jt808Body.Raw(messageId(), raw); + ByteBuffer b = ByteBuffer.wrap(raw); + int responseSerialNo = b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0; + int result = b.hasRemaining() ? b.get() & 0xFF : 0; + return new Jt808Body.FileUploadComplete(responseSerialNo, result, raw); } } diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java new file mode 100644 index 00000000..580fb11c --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/MediaAttributesBodyParser.java @@ -0,0 +1,39 @@ +package com.lingniu.ingest.protocol.jt1078.codec.parser; + +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; + +import java.nio.ByteBuffer; + +/** 0x1003 终端上传音视频属性。 */ +public final class MediaAttributesBodyParser implements BodyParser { + @Override public int messageId() { return Jt1078MessageId.TERMINAL_MEDIA_ATTRS_REPORT; } + + @Override + public Jt808Body parse(Jt808Header header, ByteBuffer body) { + byte[] raw = new byte[body.remaining()]; + body.get(raw); + ByteBuffer b = ByteBuffer.wrap(raw); + return new Jt808Body.MediaAttributes( + readU8(b), + readU8(b), + readU8(b), + readU8(b), + readU16(b), + readU8(b) == 1, + readU8(b), + readU8(b), + readU8(b), + raw); + } + + private static int readU8(ByteBuffer b) { + return b.hasRemaining() ? b.get() & 0xFF : 0; + } + + private static int readU16(ByteBuffer b) { + return b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0; + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/PassengerVolumeBodyParser.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/PassengerVolumeBodyParser.java new file mode 100644 index 00000000..7feedd24 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/codec/parser/PassengerVolumeBodyParser.java @@ -0,0 +1,56 @@ +package com.lingniu.ingest.protocol.jt1078.codec.parser; + +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; + +import java.nio.ByteBuffer; + +/** 0x1005 终端上传乘客流量,字段结构化后仍按可查询透传事件入库。 */ +public final class PassengerVolumeBodyParser implements BodyParser { + @Override public int messageId() { return Jt1078MessageId.TERMINAL_PASSENGER_VOLUME; } + + @Override + public Jt808Body parse(Jt808Header header, ByteBuffer body) { + byte[] raw = new byte[body.remaining()]; + body.get(raw); + ByteBuffer b = ByteBuffer.wrap(raw); + return new Jt808Body.PassengerVolume( + readU8(b), + readBcdTime(b), + readBcdTime(b), + readU16(b), + readU16(b), + raw); + } + + private static int readU8(ByteBuffer b) { + return b.hasRemaining() ? b.get() & 0xFF : 0; + } + + private static int readU16(ByteBuffer b) { + return b.remaining() >= 2 ? b.getShort() & 0xFFFF : 0; + } + + private static String readBcdTime(ByteBuffer b) { + if (b.remaining() < 6) { + byte[] tail = new byte[b.remaining()]; + b.get(tail); + return ""; + } + byte[] time = new byte[6]; + b.get(time); + String ts = BcdCodec.decode(time); + if (ts.length() != 12) { + return ""; + } + return "20" + ts.substring(0, 2) + + "-" + ts.substring(2, 4) + + "-" + ts.substring(4, 6) + + "T" + ts.substring(6, 8) + + ":" + ts.substring(8, 10) + + ":" + ts.substring(10, 12); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java new file mode 100644 index 00000000..8cbaae3a --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java @@ -0,0 +1,105 @@ +package com.lingniu.ingest.protocol.jt1078.config; + +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.protocol.jt1078.codec.parser.FileUploadCompleteBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.FileListBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.MediaAttributesBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.PassengerVolumeBodyParser; +import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MalformedRtpHandler; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpEventPublisher; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaStreamServer; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078UdpMediaStreamServer; +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * jt1078 在 jt808 之前装配它自己的 Parser Bean,Spring 会把它们注入到 jt808 的 + * {@code BodyParserRegistry} 里,实现"同一个 Netty 端口复用、消息集热插拔"。 + */ +@AutoConfiguration(before = Jt808AutoConfiguration.class, after = SinkArchiveAutoConfiguration.class) +@EnableConfigurationProperties(Jt1078Properties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.jt1078", name = "enabled", havingValue = "true") +public class Jt1078AutoConfiguration { + + @Bean + public BodyParser jt1078MediaAttributesBodyParser() { + return new MediaAttributesBodyParser(); + } + + @Bean + public BodyParser jt1078PassengerVolumeBodyParser() { + return new PassengerVolumeBodyParser(); + } + + @Bean + public BodyParser jt1078FileListBodyParser() { + return new FileListBodyParser(); + } + + @Bean + public BodyParser jt1078FileUploadCompleteBodyParser() { + return new FileUploadCompleteBodyParser(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(ArchiveStore.class) + public Jt1078MediaArchiveService jt1078MediaArchiveService(ArchiveStore archive, + VehicleIdentityResolver identityResolver, + DisruptorEventBus eventBus, + Jt1078Properties props) { + return new Jt1078MediaArchiveService( + archive, identityResolver, eventBus::publish, props.getMediaStream().getSegmentSeconds()); + } + + @Bean + @ConditionalOnMissingBean + public Jt1078MalformedRtpEventPublisher jt1078MalformedRtpEventPublisher(VehicleIdentityResolver identityResolver, + Dispatcher dispatcher) { + return new Jt1078MalformedRtpEventPublisher(identityResolver, dispatcher::dispatch); + } + + @Bean + @ConditionalOnMissingBean + public Jt1078MalformedRtpHandler jt1078MalformedRtpHandler(VehicleIdentityResolver identityResolver) { + return new Jt1078MalformedRtpHandler(identityResolver); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(Jt1078MediaArchiveService.class) + @ConditionalOnExpression("${lingniu.ingest.jt1078.media-stream.enabled:true} " + + "&& ${lingniu.ingest.jt1078.media-stream.tcp-enabled:true}") + public Jt1078MediaStreamServer jt1078MediaStreamServer(Jt1078Properties props, + Jt1078MediaArchiveService archiveService, + Jt1078MalformedRtpEventPublisher malformedPublisher) { + Jt1078Properties.MediaStream media = props.getMediaStream(); + return new Jt1078MediaStreamServer( + media.getPort(), media.getWorkerThreads(), media.getMaxPacketLength(), archiveService, malformedPublisher); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(Jt1078MediaArchiveService.class) + @ConditionalOnExpression("${lingniu.ingest.jt1078.media-stream.enabled:true} " + + "&& ${lingniu.ingest.jt1078.media-stream.udp-enabled:false}") + public Jt1078UdpMediaStreamServer jt1078UdpMediaStreamServer(Jt1078Properties props, + Jt1078MediaArchiveService archiveService, + Jt1078MalformedRtpEventPublisher malformedPublisher) { + Jt1078Properties.MediaStream media = props.getMediaStream(); + return new Jt1078UdpMediaStreamServer( + media.getUdpPort(), media.getWorkerThreads(), media.getMaxPacketLength(), archiveService, malformedPublisher); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java new file mode 100644 index 00000000..bec745d6 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java @@ -0,0 +1,43 @@ +package com.lingniu.ingest.protocol.jt1078.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.jt1078") +public class Jt1078Properties { + + private boolean enabled = false; + private MediaStream mediaStream = new MediaStream(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public MediaStream getMediaStream() { return mediaStream; } + public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; } + + public static class MediaStream { + private boolean enabled = true; + private boolean tcpEnabled = true; + private boolean udpEnabled = false; + private int port = 11078; + private int udpPort = 11078; + private int workerThreads = 0; + private int maxPacketLength = 1024 * 1024; + private int segmentSeconds = 300; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isTcpEnabled() { return tcpEnabled; } + public void setTcpEnabled(boolean tcpEnabled) { this.tcpEnabled = tcpEnabled; } + public boolean isUdpEnabled() { return udpEnabled; } + public void setUdpEnabled(boolean udpEnabled) { this.udpEnabled = udpEnabled; } + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + public int getUdpPort() { return udpPort; } + public void setUdpPort(int udpPort) { this.udpPort = udpPort; } + public int getWorkerThreads() { return workerThreads; } + public void setWorkerThreads(int workerThreads) { this.workerThreads = workerThreads; } + public int getMaxPacketLength() { return maxPacketLength; } + public void setMaxPacketLength(int maxPacketLength) { this.maxPacketLength = maxPacketLength; } + public int getSegmentSeconds() { return segmentSeconds; } + public void setSegmentSeconds(int segmentSeconds) { this.segmentSeconds = segmentSeconds; } + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078SignalAutoConfiguration.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078SignalAutoConfiguration.java new file mode 100644 index 00000000..60585cfc --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078SignalAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.protocol.jt1078.config; + +import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MediaSignalHandler; +import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +/** + * 1078 信令复用 JT808 解码和 mapper,必须在 JT808 AutoConfiguration 之后判断。 + */ +@AutoConfiguration(after = Jt808AutoConfiguration.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.jt1078", name = "enabled", havingValue = "true") +@ConditionalOnBean(Jt808EventMapper.class) +public class Jt1078SignalAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public Jt1078MediaSignalHandler jt1078MediaSignalHandler(Jt808EventMapper mapper) { + return new Jt1078MediaSignalHandler(mapper); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078Commands.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078Commands.java new file mode 100644 index 00000000..34027988 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078Commands.java @@ -0,0 +1,198 @@ +package com.lingniu.ingest.protocol.jt1078.downlink; + +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; + +/** JT/T 1078 下行信令 body 构造器,外层帧仍由 JT808 通道发送。 */ +public final class Jt1078Commands { + + private static final Charset GBK = Charset.forName("GBK"); + + private Jt1078Commands() {} + + /** 0x1003 查询终端音视频属性。 */ + public static Jt808Commands.DownlinkCommand queryMediaAttributes() { + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_QUERY_MEDIA_ATTRS, new byte[0]); + } + + /** 0x9101 实时音视频传输请求。 */ + public static Jt808Commands.DownlinkCommand realtimePlay(String ip, + int tcpPort, + int udpPort, + int channelNo, + int mediaType, + int streamType) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeStringWithU8Length(os, ip); + writeU16(os, tcpPort); + writeU16(os, udpPort); + writeU8(os, channelNo); + writeU8(os, mediaType); + writeU8(os, streamType); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_REALTIME_PLAY, os.toByteArray()); + } + + /** 0x9102 音视频实时传输控制。 */ + public static Jt808Commands.DownlinkCommand realtimeControl(int channelNo, + int command, + int closeType, + int streamType) { + ByteArrayOutputStream os = new ByteArrayOutputStream(4); + writeU8(os, channelNo); + writeU8(os, command); + writeU8(os, closeType); + writeU8(os, streamType); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_REALTIME_CONTROL, os.toByteArray()); + } + + /** 0x9201 平台下发远程录像回放请求。 */ + public static Jt808Commands.DownlinkCommand historyPlay(String ip, + int tcpPort, + int udpPort, + int channelNo, + int mediaType, + int streamType, + int storageType, + int playbackMode, + int playbackSpeed, + String startTime, + String endTime) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeStringWithU8Length(os, ip); + writeU16(os, tcpPort); + writeU16(os, udpPort); + writeU8(os, channelNo); + writeU8(os, mediaType); + writeU8(os, streamType); + writeU8(os, storageType); + writeU8(os, playbackMode); + writeU8(os, playbackSpeed); + writeBcdTime(os, startTime); + writeBcdTime(os, endTime); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_HISTORY_PLAY, os.toByteArray()); + } + + /** 0x9202 平台下发远程录像回放控制。 */ + public static Jt808Commands.DownlinkCommand historyControl(int channelNo, + int playbackMode, + int playbackSpeed, + String playbackTime) { + ByteArrayOutputStream os = new ByteArrayOutputStream(9); + writeU8(os, channelNo); + writeU8(os, playbackMode); + writeU8(os, playbackSpeed); + writeBcdTime(os, playbackTime); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_HISTORY_CONTROL, os.toByteArray()); + } + + /** 0x9205 查询资源列表。 */ + public static Jt808Commands.DownlinkCommand resourceSearch(int channelNo, + String startTime, + String endTime, + long warnBit1, + long warnBit2, + int mediaType, + int streamType, + int storageType) { + ByteArrayOutputStream os = new ByteArrayOutputStream(24); + writeU8(os, channelNo); + writeBcdTime(os, startTime); + writeBcdTime(os, endTime); + writeU32(os, warnBit1); + writeU32(os, warnBit2); + writeU8(os, mediaType); + writeU8(os, streamType); + writeU8(os, storageType); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_RESOURCE_SEARCH, os.toByteArray()); + } + + /** 0x9206 文件上传指令。 */ + public static Jt808Commands.DownlinkCommand fileUpload(String ip, + int port, + String username, + String password, + String path, + int channelNo, + String startTime, + String endTime, + long warnBit1, + long warnBit2, + int mediaType, + int streamType, + int storageType, + int condition) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeStringWithU8Length(os, ip); + writeU16(os, port); + writeStringWithU8Length(os, username); + writeStringWithU8Length(os, password); + writeStringWithU8Length(os, path); + writeU8(os, channelNo); + writeBcdTime(os, startTime); + writeBcdTime(os, endTime); + writeU32(os, warnBit1); + writeU32(os, warnBit2); + writeU8(os, mediaType); + writeU8(os, streamType); + writeU8(os, storageType); + writeU8(os, condition); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_FILE_UPLOAD, os.toByteArray()); + } + + /** 0x9207 文件上传控制。 */ + public static Jt808Commands.DownlinkCommand fileUploadControl(int responseSerialNo, int command) { + ByteArrayOutputStream os = new ByteArrayOutputStream(3); + writeU16(os, responseSerialNo); + writeU8(os, command); + return new Jt808Commands.DownlinkCommand(Jt1078MessageId.PLATFORM_FILE_UPLOAD_CONTROL, os.toByteArray()); + } + + private static void writeStringWithU8Length(ByteArrayOutputStream os, String value) { + byte[] bytes = value == null ? new byte[0] : value.getBytes(GBK); + if (bytes.length > 255) { + throw new IllegalArgumentException("jt1078 string field too long: " + bytes.length); + } + writeU8(os, bytes.length); + os.write(bytes, 0, bytes.length); + } + + private static void writeBcdTime(ByteArrayOutputStream os, String value) { + String digits = value == null ? "" : value.replaceAll("\\D", ""); + if (digits.length() == 14 && digits.startsWith("20")) { + digits = digits.substring(2); + } + if (digits.isBlank()) { + digits = "000000000000"; + } + if (digits.length() != 12) { + throw new IllegalArgumentException("jt1078 time must be YYMMDDHHMMSS or yyyy-MM-dd HH:mm:ss: " + value); + } + for (int i = 0; i < digits.length(); i += 2) { + int high = Character.digit(digits.charAt(i), 10); + int low = Character.digit(digits.charAt(i + 1), 10); + if (high < 0 || low < 0) { + throw new IllegalArgumentException("jt1078 time contains non-decimal digit: " + value); + } + os.write((high << 4) | low); + } + } + + private static void writeU8(ByteArrayOutputStream os, int v) { + os.write(v & 0xFF); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MalformedRtpHandler.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MalformedRtpHandler.java new file mode 100644 index 00000000..d6f81fef --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MalformedRtpHandler.java @@ -0,0 +1,88 @@ +package com.lingniu.ingest.protocol.jt1078.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaMessageId; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@ProtocolHandler(protocol = ProtocolId.JT1078) +public final class Jt1078MalformedRtpHandler { + + private final VehicleIdentityResolver identityResolver; + + public Jt1078MalformedRtpHandler() { + this(new InMemoryVehicleIdentityService()); + } + + public Jt1078MalformedRtpHandler(VehicleIdentityResolver identityResolver) { + this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver; + } + + @MessageMapping(command = Jt1078MediaMessageId.MALFORMED_RTP, desc = "JT1078 RTP 入口坏包兜底") + @EventEmit(VehicleEvent.Passthrough.class) + public List onMalformedRtp(Jt1078MalformedRtpPacket malformed) { + if (malformed == null) { + return List.of(); + } + Instant eventTime = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt(); + IdentityResolution identity = resolveIdentity(malformed); + Map metadata = new HashMap<>(); + metadata.put("transport", safe(malformed.transport())); + metadata.put("peer", safe(malformed.peer())); + metadata.put("sim", safe(malformed.sim())); + metadata.put("vin", identity.identity().vin()); + metadata.put("identityResolved", Boolean.toString(identity.identity().resolved())); + metadata.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + metadata.put("identityError", "true"); + metadata.put("identityErrorMessage", identity.errorMessage()); + } + metadata.put("malformedRtp", "true"); + metadata.put("parseError", "true"); + metadata.put("parseErrorMessage", safe(malformed.reason())); + metadata.put("packetLength", Integer.toString(malformed.packetLength())); + metadata.put("reason", safe(malformed.reason())); + return List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + identity.identity().vin(), + ProtocolId.JT1078, + eventTime, + Instant.now(), + null, + Map.copyOf(metadata), + Jt1078MediaMessageId.MALFORMED_RTP, + malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes())); + } + + private IdentityResolution resolveIdentity(Jt1078MalformedRtpPacket malformed) { + try { + return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.JT1078, "", safe(malformed.sim()), "", "")), null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static String safe(String value) { + return value == null ? "" : value; + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) { + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MediaSignalHandler.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MediaSignalHandler.java new file mode 100644 index 00000000..9d7b56bd --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/handler/Jt1078MediaSignalHandler.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.protocol.jt1078.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.annotation.EventEmit; +import com.lingniu.ingest.api.annotation.MessageMapping; +import com.lingniu.ingest.api.annotation.ProtocolHandler; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; + +import java.util.List; + +/** + * JT/T 1078 信令复用 JT808 包头、连接与 dispatcher,但路由独立放在 1078 模块内。 + */ +@ProtocolHandler(protocol = ProtocolId.JT808) +public final class Jt1078MediaSignalHandler { + + private final Jt808EventMapper mapper; + + public Jt1078MediaSignalHandler(Jt808EventMapper mapper) { + this.mapper = mapper; + } + + @MessageMapping(command = { + Jt1078MessageId.TERMINAL_MEDIA_ATTRS_REPORT, + Jt1078MessageId.TERMINAL_PASSENGER_VOLUME, + Jt1078MessageId.TERMINAL_FILE_LIST, + Jt1078MessageId.TERMINAL_FILE_UPLOAD_DONE + }, desc = "JT/T 1078 音视频信令") + @EventEmit({VehicleEvent.MediaMeta.class, VehicleEvent.Passthrough.class}) + public List onMediaSignal(Jt808Message msg) { + return mapper.toEvents(msg); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpEventPublisher.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpEventPublisher.java new file mode 100644 index 00000000..8300f99d --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpEventPublisher.java @@ -0,0 +1,80 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +public final class Jt1078MalformedRtpEventPublisher { + + private final VehicleIdentityResolver identityResolver; + private final Consumer dispatcher; + + public Jt1078MalformedRtpEventPublisher(Consumer dispatcher) { + this(new InMemoryVehicleIdentityService(), dispatcher); + } + + public Jt1078MalformedRtpEventPublisher(VehicleIdentityResolver identityResolver, + Consumer dispatcher) { + this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver; + this.dispatcher = dispatcher; + } + + public void publish(Jt1078MalformedRtpPacket malformed) { + if (malformed == null) { + return; + } + Instant now = malformed.receivedAt() == null ? Instant.now() : malformed.receivedAt(); + IdentityResolution identity = resolveIdentity(malformed); + Map metadata = new HashMap<>(); + metadata.put("transport", safe(malformed.transport())); + metadata.put("peer", safe(malformed.peer())); + metadata.put("sim", safe(malformed.sim())); + metadata.put("identityResolved", Boolean.toString(identity.identity().resolved())); + metadata.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + metadata.put("identityError", "true"); + metadata.put("identityErrorMessage", identity.errorMessage()); + } + metadata.put("malformedRtp", "true"); + metadata.put("parseError", "true"); + metadata.put("parseErrorMessage", safe(malformed.reason())); + metadata.put("packetLength", Integer.toString(malformed.packetLength())); + metadata.put("reason", safe(malformed.reason())); + metadata.put("vin", identity.identity().vin()); + dispatcher.accept(new RawFrame( + ProtocolId.JT1078, + Jt1078MediaMessageId.MALFORMED_RTP, + 0, + malformed, + malformed.rawBytes() == null ? new byte[0] : malformed.rawBytes(), + Map.copyOf(metadata), + now + )); + } + + private IdentityResolution resolveIdentity(Jt1078MalformedRtpPacket malformed) { + try { + return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.JT1078, "", malformed.sim(), "", "")), null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static String safe(String value) { + return value == null ? "" : value; + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) {} +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpPacket.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpPacket.java new file mode 100644 index 00000000..c31e0a72 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MalformedRtpPacket.java @@ -0,0 +1,22 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import java.time.Instant; + +public record Jt1078MalformedRtpPacket( + String transport, + String peer, + String sim, + byte[] rawBytes, + int packetLength, + String reason, + Instant receivedAt +) { + public Jt1078MalformedRtpPacket(String transport, + String peer, + byte[] rawBytes, + int packetLength, + String reason, + Instant receivedAt) { + this(transport, peer, "", rawBytes, packetLength, reason, receivedAt); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java new file mode 100644 index 00000000..541387d4 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java @@ -0,0 +1,194 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +public final class Jt1078MediaArchiveService { + + private static final Logger log = LoggerFactory.getLogger(Jt1078MediaArchiveService.class); + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault()); + + private final ArchiveStore archive; + private final VehicleIdentityResolver identityResolver; + private final Consumer eventPublisher; + private final int segmentSeconds; + private final Cache publishedSegmentRefs = Caffeine.newBuilder() + .expireAfterAccess(Duration.ofHours(2)) + .maximumSize(200_000) + .build(); + + public Jt1078MediaArchiveService(ArchiveStore archive, + VehicleIdentityResolver identityResolver, + Consumer eventPublisher, + int segmentSeconds) { + this.archive = archive; + this.identityResolver = identityResolver; + this.eventPublisher = eventPublisher; + this.segmentSeconds = Math.max(60, segmentSeconds); + } + + public void archive(Jt1078RtpPacket packet, String peer) { + if (packet == null || packet.payload() == null || packet.payload().length == 0) { + return; + } + IdentityResolution identity = resolveIdentity(packet); + String vin = identity.identity().vin(); + long segment = segment(packet.timestamp()); + String key = key(vin, packet, segment); + try { + String uri = archive.append(key, packet.payload()); + long segmentSizeBytes = archiveSize(key, packet.payload().length); + publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes); + } catch (Exception e) { + log.warn("[jt1078] media archive failed sim={} channel={} sequence={} peer={}", + packet.sim(), packet.channelId(), packet.sequence(), peer, e); + publishArchiveFailure(packet, peer, identity, vin, segment, key, e); + } + } + + private void publishArchiveFailure(Jt1078RtpPacket packet, + String peer, + IdentityResolution identity, + String vin, + long segment, + String archiveKey, + Exception error) { + Instant now = Instant.now(); + Map metadata = baseMetadata(packet, peer, identity); + metadata.put("sequence", Integer.toString(packet.sequence())); + metadata.put("segment", Long.toString(segment)); + metadata.put("archiveKey", archiveKey == null ? "" : archiveKey); + metadata.put("archiveError", "true"); + metadata.put("archiveErrorMessage", error.getMessage() == null ? "" : error.getMessage()); + eventPublisher.accept(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, + ProtocolId.JT1078, + now, + now, + null, + Map.copyOf(metadata), + packet.dataType(), + packet.payload())); + } + + private void publishOnce(Jt1078RtpPacket packet, + String peer, + IdentityResolution identity, + String vin, + long segment, + String archiveKey, + String uri, + long segmentSizeBytes) { + String segmentKey = archiveKey == null || archiveKey.isBlank() + ? packet.sim() + ':' + packet.channelId() + ':' + packet.dataType() + ':' + segment + : archiveKey; + String old = publishedSegmentRefs.getIfPresent(segmentKey); + if (old != null) { + return; + } + publishedSegmentRefs.put(segmentKey, uri); + Instant now = Instant.now(); + Map metadata = baseMetadata(packet, peer, identity); + metadata.put("segment", Long.toString(segment)); + metadata.put("sequence", Integer.toString(packet.sequence())); + metadata.put("segmentSizeBytes", Long.toString(segmentSizeBytes)); + metadata.put("archiveKey", archiveKey == null ? "" : archiveKey); + eventPublisher.accept(new VehicleEvent.MediaMeta( + UUID.randomUUID().toString(), + vin, + ProtocolId.JT1078, + now, + now, + null, + Map.copyOf(metadata), + packet.sim() + "-" + packet.channelId() + "-" + segment, + mediaType(packet), + segmentSizeBytes, + uri)); + } + + private long archiveSize(String key, int fallbackBytes) { + try { + return archive.size(key); + } catch (Exception e) { + return Math.max(0, fallbackBytes); + } + } + + private IdentityResolution resolveIdentity(Jt1078RtpPacket packet) { + try { + return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.JT1078, "", packet.sim(), "", "")), null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static Map baseMetadata(Jt1078RtpPacket packet, + String peer, + IdentityResolution identity) { + Map metadata = new LinkedHashMap<>(); + metadata.put("sim", packet.sim()); + metadata.put("channelId", Integer.toString(packet.channelId())); + metadata.put("dataType", Integer.toString(packet.dataType())); + metadata.put("packetType", Integer.toString(packet.packetType())); + metadata.put("peer", peer == null ? "" : peer); + metadata.put("vin", identity.identity().vin()); + metadata.put("identityResolved", Boolean.toString(identity.identity().resolved())); + metadata.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + metadata.put("identityError", "true"); + metadata.put("identityErrorMessage", identity.errorMessage()); + } + return metadata; + } + + private long segment(long timestamp) { + long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp; + return seconds - Math.floorMod(seconds, segmentSeconds); + } + + private static String key(String vin, Jt1078RtpPacket packet, long segment) { + String day = DAY_FMT.format(Instant.ofEpochSecond(Math.max(0, segment))); + String safeVin = safe(vin); + return "jt1078/" + day + "/" + safeVin + "/ch-" + packet.channelId() + + "/dt-" + packet.dataType() + "/" + segment + ".media"; + } + + private static String mediaType(Jt1078RtpPacket packet) { + return "rtp,dataType=" + packet.dataType() + + ",packetType=" + packet.packetType() + + ",channel=" + packet.channelId(); + } + + private static String safe(String raw) { + if (raw == null || raw.isBlank()) { + return "unknown"; + } + return raw.replaceAll("[^A-Za-z0-9_.-]", "_"); + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) { + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaMessageId.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaMessageId.java new file mode 100644 index 00000000..5ee98aeb --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaMessageId.java @@ -0,0 +1,9 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +public final class Jt1078MediaMessageId { + + /** 内部消息:JT1078 RTP 入口坏包,走 Dispatcher 统一 RawArchive + Passthrough 链路。 */ + public static final int MALFORMED_RTP = 0x72747065; // "rtpe" + + private Jt1078MediaMessageId() {} +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamHandler.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamHandler.java new file mode 100644 index 00000000..d4bd4e66 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamHandler.java @@ -0,0 +1,36 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; + +import java.net.InetSocketAddress; + +public final class Jt1078MediaStreamHandler extends SimpleChannelInboundHandler { + + private final Jt1078MediaArchiveService archiveService; + private final Jt1078MalformedRtpEventPublisher malformedPublisher; + + public Jt1078MediaStreamHandler(Jt1078MediaArchiveService archiveService, + Jt1078MalformedRtpEventPublisher malformedPublisher) { + this.archiveService = archiveService; + this.malformedPublisher = malformedPublisher; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof Jt1078RtpPacket packet) { + archiveService.archive(packet, addr(ctx)); + } else if (msg instanceof Jt1078MalformedRtpPacket malformed) { + malformedPublisher.publish(malformed); + } else { + ctx.fireChannelRead(msg); + } + } + + private static String addr(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamServer.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamServer.java new file mode 100644 index 00000000..476f0863 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaStreamServer.java @@ -0,0 +1,83 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +import java.util.concurrent.ThreadFactory; + +public final class Jt1078MediaStreamServer implements InitializingBean, DisposableBean { + + private static final Logger log = LoggerFactory.getLogger(Jt1078MediaStreamServer.class); + + private final int port; + private final int workerThreads; + private final int maxPacketLength; + private final Jt1078MediaArchiveService archiveService; + private final Jt1078MalformedRtpEventPublisher malformedPublisher; + + private EventLoopGroup boss; + private EventLoopGroup workers; + private Channel serverChannel; + + public Jt1078MediaStreamServer(int port, + int workerThreads, + int maxPacketLength, + Jt1078MediaArchiveService archiveService, + Jt1078MalformedRtpEventPublisher malformedPublisher) { + this.port = port; + this.workerThreads = workerThreads; + this.maxPacketLength = maxPacketLength; + this.archiveService = archiveService; + this.malformedPublisher = malformedPublisher; + } + + @Override + public void afterPropertiesSet() throws Exception { + ThreadFactory bossTf = r -> new Thread(r, "jt1078-media-boss"); + ThreadFactory wkTf = r -> new Thread(r, "jt1078-media-worker"); + this.boss = new NioEventLoopGroup(1, bossTf); + this.workers = new NioEventLoopGroup( + workerThreads == 0 ? Runtime.getRuntime().availableProcessors() : workerThreads, + wkTf); + + ServerBootstrap b = new ServerBootstrap(); + b.group(boss, workers) + .channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 1024) + .childOption(ChannelOption.TCP_NODELAY, true) + .childOption(ChannelOption.SO_KEEPALIVE, true) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline() + .addLast("rtp", new Jt1078RtpPacketDecoder(maxPacketLength)) + .addLast("archive", new Jt1078MediaStreamHandler(archiveService, malformedPublisher)); + } + }); + this.serverChannel = b.bind(port).sync().channel(); + log.info("[jt1078] media stream server listening on :{}", port); + } + + @Override + public void destroy() { + try { + if (serverChannel != null) serverChannel.close().sync(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (boss != null) boss.shutdownGracefully(); + if (workers != null) workers.shutdownGracefully(); + log.info("[jt1078] media stream server stopped"); + } + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedMediaStreamHandler.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedMediaStreamHandler.java new file mode 100644 index 00000000..3ebf6ab9 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedMediaStreamHandler.java @@ -0,0 +1,27 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.ChannelHandlerContext; + +public final class Jt1078ReceivedMediaStreamHandler extends SimpleChannelInboundHandler { + + private final Jt1078MediaArchiveService archiveService; + private final Jt1078MalformedRtpEventPublisher malformedPublisher; + + public Jt1078ReceivedMediaStreamHandler(Jt1078MediaArchiveService archiveService, + Jt1078MalformedRtpEventPublisher malformedPublisher) { + this.archiveService = archiveService; + this.malformedPublisher = malformedPublisher; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof Jt1078ReceivedRtpPacket received) { + archiveService.archive(received.packet(), received.peer()); + } else if (msg instanceof Jt1078MalformedRtpPacket malformed) { + malformedPublisher.publish(malformed); + } else { + ctx.fireChannelRead(msg); + } + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedRtpPacket.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedRtpPacket.java new file mode 100644 index 00000000..7405bc5e --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078ReceivedRtpPacket.java @@ -0,0 +1,10 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +public record Jt1078ReceivedRtpPacket( + Jt1078RtpPacket packet, + String peer +) { + public Jt1078ReceivedRtpPacket { + peer = peer == null ? "" : peer; + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacket.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacket.java new file mode 100644 index 00000000..92d53e46 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacket.java @@ -0,0 +1,11 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +public record Jt1078RtpPacket( + int sequence, + String sim, + int channelId, + int dataType, + int packetType, + long timestamp, + byte[] payload +) {} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketDecoder.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketDecoder.java new file mode 100644 index 00000000..953743ae --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketDecoder.java @@ -0,0 +1,83 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; + +import java.time.Instant; +import java.util.List; + +/** + * JT/T 1078 TCP RTP 包解码器。 + * + *

    包头固定魔数 0x30316364,header 固定 28 字节,随后是 bodyLength 指定的媒体负载。 + */ +public final class Jt1078RtpPacketDecoder extends ByteToMessageDecoder { + + private final int maxPacketLength; + + public Jt1078RtpPacketDecoder(int maxPacketLength) { + this.maxPacketLength = maxPacketLength; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { + while (true) { + if (in.readableBytes() < Jt1078RtpPacketParser.HEADER_LEN) { + return; + } + int frameStart = findMagic(in); + if (frameStart < 0) { + in.skipBytes(Math.max(0, in.readableBytes() - 3)); + return; + } + if (frameStart > in.readerIndex()) { + in.skipBytes(frameStart - in.readerIndex()); + } + if (in.readableBytes() < Jt1078RtpPacketParser.HEADER_LEN) { + return; + } + int length = in.getUnsignedShort(in.readerIndex() + 26); + int packetLength = Jt1078RtpPacketParser.HEADER_LEN + length; + if (packetLength > maxPacketLength) { + String sim = Jt1078RtpPacketParser.peekSim(in); + int available = in.readableBytes(); + int rawLength = Math.min(available, packetLength); + byte[] raw = new byte[rawLength]; + in.readBytes(raw); + out.add(new Jt1078MalformedRtpPacket( + "tcp", + peer(ctx), + sim, + raw, + packetLength, + "jt1078 rtp packet too large: " + packetLength, + Instant.now())); + return; + } + if (in.readableBytes() < packetLength) { + return; + } + ByteBuf packet = in.readSlice(packetLength); + out.add(Jt1078RtpPacketParser.parse(packet)); + } + } + + private static String peer(ChannelHandlerContext ctx) { + if (ctx == null || ctx.channel() == null || ctx.channel().remoteAddress() == null) { + return ""; + } + return ctx.channel().remoteAddress().toString(); + } + + private static int findMagic(ByteBuf in) { + int start = in.readerIndex(); + int end = in.writerIndex() - 3; + for (int i = start; i < end; i++) { + if (in.getInt(i) == Jt1078RtpPacketParser.MAGIC) { + return i; + } + } + return -1; + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java new file mode 100644 index 00000000..048480c6 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java @@ -0,0 +1,59 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.CorruptedFrameException; + +final class Jt1078RtpPacketParser { + + static final int MAGIC = 0x30316364; + static final int HEADER_LEN = 28; + + private Jt1078RtpPacketParser() {} + + static Jt1078RtpPacket parse(ByteBuf in) { + int magic = in.readInt(); + if (magic != MAGIC) { + throw new CorruptedFrameException("jt1078 invalid magic: 0x" + Integer.toHexString(magic)); + } + int sequence = in.readUnsignedShort(); + String sim = readBcd(in, 6); + int channelId = in.readUnsignedByte(); + int dataAndPacketType = in.readUnsignedByte(); + int dataType = (dataAndPacketType >>> 4) & 0x0F; + int packetType = dataAndPacketType & 0x0F; + long timestamp = in.readLong(); + in.skipBytes(4); + int bodyLength = in.readUnsignedShort(); + if (bodyLength > in.readableBytes()) { + throw new CorruptedFrameException("jt1078 body length exceeds readable bytes: " + + bodyLength + " > " + in.readableBytes()); + } + byte[] payload = new byte[bodyLength]; + in.readBytes(payload); + return new Jt1078RtpPacket(sequence, sim, channelId, dataType, packetType, timestamp, payload); + } + + static String peekSim(ByteBuf in) { + if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) { + return ""; + } + StringBuilder sb = new StringBuilder(12); + int simOffset = in.readerIndex() + 6; + for (int i = 0; i < 6; i++) { + int b = in.getUnsignedByte(simOffset + i); + sb.append((b >>> 4) & 0x0F); + sb.append(b & 0x0F); + } + return sb.toString(); + } + + private static String readBcd(ByteBuf in, int len) { + StringBuilder sb = new StringBuilder(len * 2); + for (int i = 0; i < len; i++) { + int b = in.readUnsignedByte(); + sb.append((b >>> 4) & 0x0F); + sb.append(b & 0x0F); + } + return sb.toString(); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpMediaStreamServer.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpMediaStreamServer.java new file mode 100644 index 00000000..ffdd886e --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpMediaStreamServer.java @@ -0,0 +1,78 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramChannel; +import io.netty.channel.socket.nio.NioDatagramChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +import java.util.concurrent.ThreadFactory; + +public final class Jt1078UdpMediaStreamServer implements InitializingBean, DisposableBean { + + private static final Logger log = LoggerFactory.getLogger(Jt1078UdpMediaStreamServer.class); + + private final int port; + private final int workerThreads; + private final int maxPacketLength; + private final Jt1078MediaArchiveService archiveService; + private final Jt1078MalformedRtpEventPublisher malformedPublisher; + + private EventLoopGroup workers; + private Channel serverChannel; + + public Jt1078UdpMediaStreamServer(int port, + int workerThreads, + int maxPacketLength, + Jt1078MediaArchiveService archiveService, + Jt1078MalformedRtpEventPublisher malformedPublisher) { + this.port = port; + this.workerThreads = workerThreads; + this.maxPacketLength = maxPacketLength; + this.archiveService = archiveService; + this.malformedPublisher = malformedPublisher; + } + + @Override + public void afterPropertiesSet() throws Exception { + ThreadFactory wkTf = r -> new Thread(r, "jt1078-media-udp-worker"); + int threads = workerThreads == 0 ? Runtime.getRuntime().availableProcessors() : workerThreads; + this.workers = new NioEventLoopGroup(threads, wkTf); + + Bootstrap b = new Bootstrap(); + b.group(workers) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, false) + .option(ChannelOption.SO_RCVBUF, Math.max(1024 * 1024, maxPacketLength * 2)) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(DatagramChannel ch) { + ch.pipeline() + .addLast("rtp", new Jt1078UdpRtpPacketDecoder(maxPacketLength)) + .addLast("archive", new Jt1078ReceivedMediaStreamHandler(archiveService, malformedPublisher)); + } + }); + this.serverChannel = b.bind(port).sync().channel(); + log.info("[jt1078] udp media stream server listening on :{} workers={} maxPacketLength={}", + port, threads, maxPacketLength); + } + + @Override + public void destroy() { + try { + if (serverChannel != null) serverChannel.close().sync(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (workers != null) workers.shutdownGracefully(); + log.info("[jt1078] udp media stream server stopped"); + } + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpRtpPacketDecoder.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpRtpPacketDecoder.java new file mode 100644 index 00000000..f9bd4b6c --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078UdpRtpPacketDecoder.java @@ -0,0 +1,59 @@ +package com.lingniu.ingest.protocol.jt1078.media; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.socket.DatagramPacket; +import io.netty.handler.codec.CorruptedFrameException; +import io.netty.handler.codec.MessageToMessageDecoder; + +import java.net.InetSocketAddress; +import java.time.Instant; +import java.util.List; + +public final class Jt1078UdpRtpPacketDecoder extends MessageToMessageDecoder { + + private final int maxPacketLength; + + public Jt1078UdpRtpPacketDecoder(int maxPacketLength) { + this.maxPacketLength = maxPacketLength; + } + + @Override + protected void decode(ChannelHandlerContext ctx, DatagramPacket msg, List out) throws Exception { + int packetLength = msg.content().readableBytes(); + if (packetLength > maxPacketLength) { + out.add(malformed(msg, packetLength, "jt1078 udp rtp packet too large: " + packetLength)); + return; + } + if (packetLength < Jt1078RtpPacketParser.HEADER_LEN) { + out.add(malformed(msg, packetLength, "jt1078 udp rtp packet too short: " + packetLength)); + return; + } + try { + out.add(new Jt1078ReceivedRtpPacket( + Jt1078RtpPacketParser.parse(msg.content().slice()), + peer(msg.sender()))); + } catch (CorruptedFrameException | IndexOutOfBoundsException e) { + out.add(malformed(msg, packetLength, e.getMessage())); + } + } + + private static Jt1078MalformedRtpPacket malformed(DatagramPacket msg, int packetLength, String reason) { + byte[] raw = new byte[Math.max(0, msg.content().readableBytes())]; + msg.content().getBytes(msg.content().readerIndex(), raw); + return new Jt1078MalformedRtpPacket( + "udp", + peer(msg.sender()), + Jt1078RtpPacketParser.peekSim(msg.content().slice()), + raw, + packetLength, + reason == null ? "" : reason, + Instant.now()); + } + + private static String peer(InetSocketAddress sender) { + if (sender == null) { + return ""; + } + return sender.getAddress().getHostAddress() + ":" + sender.getPort(); + } +} diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java new file mode 100644 index 00000000..de31a886 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java @@ -0,0 +1,21 @@ +package com.lingniu.ingest.protocol.jt1078.model; + +/** JT/T 1078 消息 ID 常量(核心信令子集)。 */ +public final class Jt1078MessageId { + + private Jt1078MessageId() {} + + public static final int TERMINAL_MEDIA_ATTRS_REPORT = 0x1003; + public static final int TERMINAL_PASSENGER_VOLUME = 0x1005; + public static final int TERMINAL_FILE_LIST = 0x1205; + public static final int TERMINAL_FILE_UPLOAD_DONE = 0x1206; + + public static final int PLATFORM_QUERY_MEDIA_ATTRS = 0x1003; + public static final int PLATFORM_REALTIME_PLAY = 0x9101; + public static final int PLATFORM_REALTIME_CONTROL = 0x9102; + public static final int PLATFORM_HISTORY_PLAY = 0x9201; + public static final int PLATFORM_HISTORY_CONTROL = 0x9202; + public static final int PLATFORM_RESOURCE_SEARCH = 0x9205; + public static final int PLATFORM_FILE_UPLOAD = 0x9206; + public static final int PLATFORM_FILE_UPLOAD_CONTROL = 0x9207; +} diff --git a/modules/protocols/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/protocols/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..8539eae3 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration +com.lingniu.ingest.protocol.jt1078.config.Jt1078SignalAutoConfiguration diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078EventMappingTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078EventMappingTest.java new file mode 100644 index 00000000..522616ea --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078EventMappingTest.java @@ -0,0 +1,93 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.protocol.jt1078.model.Jt1078MessageId; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078EventMappingTest { + + private final Jt808EventMapper mapper = new Jt808EventMapper(new InMemoryVehicleIdentityService()); + private final Jt808Header header = new Jt808Header( + 0x1003, 0, 0, false, Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0); + + @Test + void mediaAttributesProduceQueryableMediaMetaEvent() { + Jt808Body.MediaAttributes attrs = new Jt808Body.MediaAttributes(1, 2, 3, 4, 320, true, 98, 4, 8, new byte[]{1, 2}); + + List events = mapper.toEvents(new Jt808Message(header, attrs)); + + assertThat(events).hasSize(1); + assertThat(events.getFirst()).isInstanceOf(VehicleEvent.MediaMeta.class); + VehicleEvent.MediaMeta event = (VehicleEvent.MediaMeta) events.getFirst(); + assertThat(event.mediaType()).contains("videoEncoding=98"); + assertThat(event.metadata()).containsEntry("jt1078Message", "mediaAttributes"); + } + + @Test + void fileUploadCompleteProducesPassthroughEventWithResultMetadata() { + Jt808Body.FileUploadComplete done = new Jt808Body.FileUploadComplete(0x1234, 0, new byte[]{0x12, 0x34, 0}); + + List events = mapper.toEvents(new Jt808Message(header, done)); + + assertThat(events).hasSize(1); + assertThat(events.getFirst()).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) events.getFirst(); + assertThat(event.metadata()).containsEntry("jt1078Message", "fileUploadComplete"); + assertThat(event.metadata()).containsEntry("uploadResult", "0"); + } + + @Test + void fileListProducesPassthroughEventWithQueryableSummaryMetadata() { + Jt808Header fileListHeader = new Jt808Header( + Jt1078MessageId.TERMINAL_FILE_LIST, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0); + byte[] raw = new byte[]{0x12, 0x34, 0, 0, 0, 2}; + Jt808Body.FileList fileList = new Jt808Body.FileList(0x1234, 2, raw); + + List events = mapper.toEvents(new Jt808Message(fileListHeader, fileList)); + + assertThat(events).hasSize(1); + assertThat(events.getFirst()).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) events.getFirst(); + assertThat(event.passthroughType()).isEqualTo(Jt1078MessageId.TERMINAL_FILE_LIST); + assertThat(event.data()).containsExactly(raw); + assertThat(event.metadata()) + .containsEntry("jt1078Message", "fileList") + .containsEntry("responseSerialNo", "4660") + .containsEntry("fileCount", "2"); + } + + @Test + void passengerVolumeProducesPassthroughEventWithQueryableMetadata() { + Jt808Header passengerHeader = new Jt808Header( + Jt1078MessageId.TERMINAL_PASSENGER_VOLUME, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0); + byte[] raw = new byte[]{1, 2, 3}; + Jt808Body.PassengerVolume passengerVolume = new Jt808Body.PassengerVolume( + 2, "2026-06-22T15:30:00", "2026-06-22T16:00:00", 21, 3, raw); + + List events = mapper.toEvents(new Jt808Message(passengerHeader, passengerVolume)); + + assertThat(events).hasSize(1); + assertThat(events.getFirst()).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) events.getFirst(); + assertThat(event.passthroughType()).isEqualTo(Jt1078MessageId.TERMINAL_PASSENGER_VOLUME); + assertThat(event.data()).containsExactly(raw); + assertThat(event.metadata()) + .containsEntry("jt1078Message", "passengerVolume") + .containsEntry("channelId", "2") + .containsEntry("startTime", "2026-06-22T15:30:00") + .containsEntry("endTime", "2026-06-22T16:00:00") + .containsEntry("passengerGetOn", "21") + .containsEntry("passengerGetOff", "3"); + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpEventPublisherTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpEventPublisherTest.java new file mode 100644 index 00000000..ef064dc1 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpEventPublisherTest.java @@ -0,0 +1,108 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpEventPublisher; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaMessageId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078MalformedRtpEventPublisherTest { + + @Test + void malformedRtpPacketProducesRawFrameForUnifiedArchiveAndPassthrough() { + ArrayList frames = new ArrayList<>(); + Jt1078MalformedRtpEventPublisher publisher = new Jt1078MalformedRtpEventPublisher(frames::add); + Instant receivedAt = Instant.parse("2026-06-22T08:30:00Z"); + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64}; + + publisher.publish(new Jt1078MalformedRtpPacket( + "udp", "10.0.0.8:1078", raw, raw.length, "jt1078 udp rtp packet too short: 4", receivedAt)); + + assertThat(frames).hasSize(1); + RawFrame frame = frames.getFirst(); + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT1078); + assertThat(frame.command()).isEqualTo(Jt1078MediaMessageId.MALFORMED_RTP); + assertThat(frame.payload()).isInstanceOf(Jt1078MalformedRtpPacket.class); + assertThat(frame.rawBytes()).containsExactly(raw); + assertThat(frame.receivedAt()).isEqualTo(receivedAt); + assertThat(frame.sourceMeta()).containsEntry("transport", "udp") + .containsEntry("peer", "10.0.0.8:1078") + .containsEntry("malformedRtp", "true") + .containsEntry("parseError", "true") + .containsEntry("parseErrorMessage", "jt1078 udp rtp packet too short: 4") + .containsEntry("packetLength", "4") + .containsEntry("reason", "jt1078 udp rtp packet too short: 4"); + } + + @Test + void malformedRtpPacketUsesSharedIdentityWhenSimIsAvailable() { + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT1078, "LNVIN107800000001", "013812345678", "", "")); + ArrayList frames = new ArrayList<>(); + Jt1078MalformedRtpEventPublisher publisher = new Jt1078MalformedRtpEventPublisher(identity, frames::add); + Instant receivedAt = Instant.parse("2026-06-22T08:30:00Z"); + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 1}; + + publisher.publish(new Jt1078MalformedRtpPacket( + "tcp", + "10.0.0.8:1078", + "013812345678", + raw, + 128, + "jt1078 rtp packet too large: 128", + receivedAt)); + + assertThat(frames).hasSize(1); + RawFrame frame = frames.getFirst(); + assertThat(frame.sourceMeta()) + .containsEntry("vin", "LNVIN107800000001") + .containsEntry("sim", "013812345678") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE"); + } + + @Test + void identityResolverFailureStillPublishesMalformedRtpWithOriginalPayload() { + ArrayList frames = new ArrayList<>(); + Jt1078MalformedRtpEventPublisher publisher = new Jt1078MalformedRtpEventPublisher( + lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }, + frames::add); + Instant receivedAt = Instant.parse("2026-06-22T08:30:00Z"); + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64, 0, 1}; + + publisher.publish(new Jt1078MalformedRtpPacket( + "tcp", + "10.0.0.8:1078", + "013812345678", + raw, + raw.length, + "jt1078 rtp packet too short: 6", + receivedAt)); + + assertThat(frames).singleElement().satisfies(frame -> { + assertThat(frame.protocolId()).isEqualTo(ProtocolId.JT1078); + assertThat(frame.command()).isEqualTo(Jt1078MediaMessageId.MALFORMED_RTP); + assertThat(frame.rawBytes()).containsExactly(raw); + assertThat(frame.sourceMeta()) + .containsEntry("vin", "unknown") + .containsEntry("sim", "013812345678") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable") + .containsEntry("parseError", "true") + .containsEntry("parseErrorMessage", "jt1078 rtp packet too short: 6"); + }); + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpHandlerTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpHandlerTest.java new file mode 100644 index 00000000..f876e6e2 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MalformedRtpHandlerTest.java @@ -0,0 +1,120 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.RawArchiveKeys; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MalformedRtpHandler; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078MalformedRtpHandlerTest { + + @Test + void malformedRtpRawFramePayloadMapsToPassthroughWithDiagnostics() { + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64}; + Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(new InMemoryVehicleIdentityService()); + + List events = handler.onMalformedRtp(new Jt1078MalformedRtpPacket( + "udp", + "10.0.0.8:1078", + raw, + raw.length, + "jt1078 udp rtp packet too short: 4", + Instant.parse("2026-06-22T08:30:00Z"))); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JT1078); + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.eventTime()).isEqualTo(Instant.parse("2026-06-22T08:30:00Z")); + assertThat(passthrough.data()).containsExactly(raw); + assertThat(passthrough.metadata()) + .containsEntry("transport", "udp") + .containsEntry("peer", "10.0.0.8:1078") + .containsEntry("vin", "unknown") + .containsEntry("malformedRtp", "true") + .containsEntry("parseError", "true") + .containsEntry("parseErrorMessage", "jt1078 udp rtp packet too short: 4") + .containsEntry("packetLength", "4"); + }); + } + + @Test + void malformedRtpHandlerResolvesVinBySim() { + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT1078, "LNVIN107800000001", "013812345678", "", "")); + Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(identity); + + List events = handler.onMalformedRtp(new Jt1078MalformedRtpPacket( + "tcp", + "10.0.0.8:1078", + "013812345678", + new byte[]{0x30, 0x31, 0x63, 0x64}, + 128, + "jt1078 rtp packet too large: 128", + Instant.parse("2026-06-22T08:30:00Z"))); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event.vin()).isEqualTo("LNVIN107800000001"); + assertThat(event.metadata()) + .containsEntry("vin", "LNVIN107800000001") + .containsEntry("sim", "013812345678") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE"); + }); + } + + @Test + void identityResolverFailureStillProducesPassthroughWithRawPayload() { + Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }); + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64}; + + List events = handler.onMalformedRtp(new Jt1078MalformedRtpPacket( + "udp", + "10.0.0.8:1078", + "013812345678", + raw, + raw.length, + "jt1078 udp rtp packet too short: 4", + Instant.parse("2026-06-22T08:30:00Z"))); + + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.data()).containsExactly(raw); + assertThat(passthrough.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("sim", "013812345678") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable") + .containsEntry("parseError", "true"); + }); + } + + @Test + void dispatcherEnrichmentCanAttachRawArchiveReferenceToMalformedRtpPassthrough() { + byte[] raw = new byte[]{0x30, 0x31, 0x63, 0x64}; + Jt1078MalformedRtpHandler handler = new Jt1078MalformedRtpHandler(new InMemoryVehicleIdentityService()); + + VehicleEvent.Passthrough event = (VehicleEvent.Passthrough) handler.onMalformedRtp(new Jt1078MalformedRtpPacket( + "udp", "10.0.0.8:1078", raw, raw.length, "bad", Instant.parse("2026-06-22T08:30:00Z"))) + .getFirst(); + + assertThat(event.metadata()).doesNotContainKey(RawArchiveKeys.META_URI); + assertThat(event.data()).containsExactly(raw); + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MediaArchiveServiceTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MediaArchiveServiceTest.java new file mode 100644 index 00000000..f3b7916a --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078MediaArchiveServiceTest.java @@ -0,0 +1,198 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacket; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import com.lingniu.ingest.sink.archive.LocalArchiveStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078MediaArchiveServiceTest { + + @TempDir + Path tempDir; + + @Test + void archivesPayloadBySegmentAndPublishesMediaMetaOncePerSegment() throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tempDir.toString()); + List published = new ArrayList<>(); + Jt1078MediaArchiveService service = new Jt1078MediaArchiveService( + store, new InMemoryVehicleIdentityService(), published::add, 300); + + Jt1078RtpPacket p1 = new Jt1078RtpPacket(1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}); + Jt1078RtpPacket p2 = new Jt1078RtpPacket(2, "013800138000", 3, 0, 1, 1_100L, new byte[]{3, 4, 5}); + service.archive(p1, "127.0.0.1:12345"); + service.archive(p2, "127.0.0.1:12345"); + + assertThat(published).hasSize(1); + VehicleEvent.MediaMeta meta = (VehicleEvent.MediaMeta) published.getFirst(); + assertThat(meta.source()).isEqualTo(ProtocolId.JT1078); + assertThat(meta.vin()).isEqualTo("013800138000"); + assertThat(meta.sizeBytes()).isEqualTo(2); + assertThat(meta.archiveRef()).startsWith("file://"); + assertThat(meta.metadata()) + .containsEntry("vin", "013800138000") + .containsEntry("channelId", "3") + .containsEntry("segment", "900") + .containsEntry("segmentSizeBytes", "2") + .containsEntry("archiveKey", "jt1078/1970/01/01/013800138000/ch-3/dt-0/900.media"); + + Path archived = Path.of(meta.archiveRef().substring("file://".length())); + assertThat(Files.readAllBytes(archived)).containsExactly(1, 2, 3, 4, 5); + } + + @Test + void archiveMediaMetaUsesResolvedInternalVinInMetadataAndArchiveKey() throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tempDir.toString()); + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT1078, "LNVIN107800000009", "013800138000", "", "")); + List published = new ArrayList<>(); + Jt1078MediaArchiveService service = new Jt1078MediaArchiveService( + store, identity, published::add, 300); + + service.archive(new Jt1078RtpPacket( + 1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}), "127.0.0.1:12345"); + + assertThat(published).singleElement() + .isInstanceOf(VehicleEvent.MediaMeta.class) + .satisfies(event -> { + assertThat(event.vin()).isEqualTo("LNVIN107800000009"); + assertThat(event.metadata()) + .containsEntry("vin", "LNVIN107800000009") + .containsEntry("sim", "013800138000") + .containsEntry("identityResolved", "true") + .containsEntry("archiveKey", "jt1078/1970/01/01/LNVIN107800000009/ch-3/dt-0/900.media"); + }); + } + + @Test + void identityChangeWithinSameSegmentPublishesMediaMetaForNewArchiveKey() throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tempDir.toString()); + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + List published = new ArrayList<>(); + Jt1078MediaArchiveService service = new Jt1078MediaArchiveService( + store, identity, published::add, 300); + + service.archive(new Jt1078RtpPacket( + 1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}), "127.0.0.1:12345"); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT1078, "LNVIN107800000009", "013800138000", "", "")); + service.archive(new Jt1078RtpPacket( + 2, "013800138000", 3, 0, 1, 1_100L, new byte[]{3, 4}), "127.0.0.1:12345"); + + assertThat(published) + .extracting(event -> event.metadata().get("archiveKey")) + .containsExactly( + "jt1078/1970/01/01/013800138000/ch-3/dt-0/900.media", + "jt1078/1970/01/01/LNVIN107800000009/ch-3/dt-0/900.media"); + assertThat(published) + .extracting(VehicleEvent::vin) + .containsExactly("013800138000", "LNVIN107800000009"); + } + + @Test + void identityResolverFailureStillArchivesPayloadAndPublishesMediaMeta() throws Exception { + LocalArchiveStore store = new LocalArchiveStore(tempDir.toString()); + List published = new ArrayList<>(); + Jt1078MediaArchiveService service = new Jt1078MediaArchiveService( + store, + lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }, + published::add, + 300); + + service.archive(new Jt1078RtpPacket( + 1, "013800138000", 3, 0, 0, 1_000L, new byte[]{1, 2}), "127.0.0.1:12345"); + + assertThat(published).singleElement() + .isInstanceOf(VehicleEvent.MediaMeta.class) + .satisfies(event -> { + VehicleEvent.MediaMeta media = (VehicleEvent.MediaMeta) event; + assertThat(media.vin()).isEqualTo("unknown"); + assertThat(media.archiveRef()).startsWith("file://"); + assertThat(media.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("sim", "013800138000") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable") + .containsEntry("archiveKey", "jt1078/1970/01/01/unknown/ch-3/dt-0/900.media"); + Path archived = Path.of(media.archiveRef().substring("file://".length())); + assertThat(Files.readAllBytes(archived)).containsExactly(1, 2); + }); + } + + @Test + void archiveFailurePublishesPassthroughErrorEventWithOriginalPayload() { + List published = new ArrayList<>(); + Jt1078MediaArchiveService service = new Jt1078MediaArchiveService( + new FailingArchiveStore(), new InMemoryVehicleIdentityService(), published::add, 300); + + Jt1078RtpPacket packet = new Jt1078RtpPacket( + 9, "013800138000", 4, 2, 1, 1_000L, new byte[]{9, 8, 7}); + + service.archive(packet, "127.0.0.1:9000"); + + assertThat(published).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JT1078); + assertThat(passthrough.vin()).isEqualTo("013800138000"); + assertThat(passthrough.passthroughType()).isEqualTo(2); + assertThat(passthrough.data()).containsExactly(9, 8, 7); + assertThat(passthrough.metadata()) + .containsEntry("archiveError", "true") + .containsEntry("vin", "013800138000") + .containsEntry("sim", "013800138000") + .containsEntry("channelId", "4") + .containsEntry("sequence", "9") + .containsEntry("peer", "127.0.0.1:9000") + .containsEntry("segment", "900") + .containsEntry("archiveKey", "jt1078/1970/01/01/013800138000/ch-4/dt-2/900.media"); + }); + } + + private static final class FailingArchiveStore implements ArchiveStore { + @Override + public String put(String key, InputStream data, long length) throws IOException { + throw new IOException("archive backend unavailable"); + } + + @Override + public String append(String key, byte[] chunk) throws IOException { + throw new IOException("archive backend unavailable"); + } + + @Override + public InputStream get(String key) throws IOException { + throw new IOException("archive backend unavailable"); + } + + @Override + public boolean exists(String key) { + return false; + } + + @Override + public long size(String key) throws IOException { + throw new IOException("archive backend unavailable"); + } + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078ParserTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078ParserTest.java new file mode 100644 index 00000000..5770b162 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078ParserTest.java @@ -0,0 +1,87 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.protocol.jt1078.codec.parser.FileUploadCompleteBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.FileListBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.MediaAttributesBodyParser; +import com.lingniu.ingest.protocol.jt1078.codec.parser.PassengerVolumeBodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078ParserTest { + + private final Jt808Header header = new Jt808Header( + 0, 0, 0, false, Jt808Header.ProtocolVersion.V2013, "13800138000", 1, 0, 0); + + @Test + void parsesMediaAttributesAsSemanticBody() { + byte[] raw = new byte[]{1, 2, 3, 4, 0x01, 0x40, 1, 98, 4, 8}; + + Jt808Body body = new MediaAttributesBodyParser().parse(header, ByteBuffer.wrap(raw)); + + assertThat(body).isInstanceOf(Jt808Body.MediaAttributes.class); + Jt808Body.MediaAttributes attrs = (Jt808Body.MediaAttributes) body; + assertThat(attrs.inputAudioEncoding()).isEqualTo(1); + assertThat(attrs.inputAudioChannels()).isEqualTo(2); + assertThat(attrs.inputAudioSampleRate()).isEqualTo(3); + assertThat(attrs.inputAudioBitDepth()).isEqualTo(4); + assertThat(attrs.audioFrameLength()).isEqualTo(320); + assertThat(attrs.audioOutputSupported()).isTrue(); + assertThat(attrs.videoEncoding()).isEqualTo(98); + assertThat(attrs.maxAudioChannels()).isEqualTo(4); + assertThat(attrs.maxVideoChannels()).isEqualTo(8); + assertThat(attrs.raw()).containsExactly(raw); + } + + @Test + void parsesFileUploadCompleteAsSemanticBody() { + byte[] raw = new byte[]{0x12, 0x34, 1, 2, 3, 4}; + + Jt808Body body = new FileUploadCompleteBodyParser().parse(header, ByteBuffer.wrap(raw)); + + assertThat(body).isInstanceOf(Jt808Body.FileUploadComplete.class); + Jt808Body.FileUploadComplete done = (Jt808Body.FileUploadComplete) body; + assertThat(done.responseSerialNo()).isEqualTo(0x1234); + assertThat(done.result()).isEqualTo(1); + assertThat(done.raw()).containsExactly(raw); + } + + @Test + void parsesFileListSummaryAsSemanticBody() { + byte[] raw = new byte[]{0x12, 0x34, 0x00, 0x00, 0x00, 0x02, 9, 8, 7}; + + Jt808Body body = new FileListBodyParser().parse(header, ByteBuffer.wrap(raw)); + + assertThat(body).isInstanceOf(Jt808Body.FileList.class); + Jt808Body.FileList list = (Jt808Body.FileList) body; + assertThat(list.responseSerialNo()).isEqualTo(0x1234); + assertThat(list.fileCount()).isEqualTo(2); + assertThat(list.raw()).containsExactly(raw); + } + + @Test + void parsesPassengerVolumeAsSemanticBody() { + byte[] raw = new byte[]{ + 0x02, + 0x26, 0x06, 0x22, 0x15, 0x30, 0x00, + 0x26, 0x06, 0x22, 0x16, 0x00, 0x00, + 0x00, 0x15, + 0x00, 0x03 + }; + + Jt808Body body = new PassengerVolumeBodyParser().parse(header, ByteBuffer.wrap(raw)); + + assertThat(body).isInstanceOf(Jt808Body.PassengerVolume.class); + Jt808Body.PassengerVolume passengerVolume = (Jt808Body.PassengerVolume) body; + assertThat(passengerVolume.channelId()).isEqualTo(2); + assertThat(passengerVolume.startTime()).isEqualTo("2026-06-22T15:30:00"); + assertThat(passengerVolume.endTime()).isEqualTo("2026-06-22T16:00:00"); + assertThat(passengerVolume.passengerGetOn()).isEqualTo(21); + assertThat(passengerVolume.passengerGetOff()).isEqualTo(3); + assertThat(passengerVolume.raw()).containsExactly(raw); + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078RtpPacketDecoderTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078RtpPacketDecoderTest.java new file mode 100644 index 00000000..6743e8cd --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078RtpPacketDecoderTest.java @@ -0,0 +1,91 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacketDecoder; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078RtpPacketDecoderTest { + + @Test + void decodesCompleteTcpRtpPacketAndKeepsPayloadOnly() { + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078RtpPacketDecoder(1024)); + + channel.writeInbound(Unpooled.wrappedBuffer(packet(7, "013800138000", 2, 3, 1, 123456789L, + new byte[]{1, 2, 3, 4}))); + + Jt1078RtpPacket packet = channel.readInbound(); + assertThat(packet.sequence()).isEqualTo(7); + assertThat(packet.sim()).isEqualTo("013800138000"); + assertThat(packet.channelId()).isEqualTo(2); + assertThat(packet.dataType()).isEqualTo(3); + assertThat(packet.packetType()).isEqualTo(1); + assertThat(packet.timestamp()).isEqualTo(123456789L); + assertThat(packet.payload()).containsExactly(1, 2, 3, 4); + } + + @Test + void waitsForFragmentedPayloadBeforeEmittingPacket() { + byte[] packet = packet(8, "013800138000", 1, 0, 0, 1L, new byte[]{9, 8, 7}); + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078RtpPacketDecoder(1024)); + + channel.writeInbound(Unpooled.wrappedBuffer(packet, 0, packet.length - 1)); + assertThat((Object) channel.readInbound()).isNull(); + channel.writeInbound(Unpooled.wrappedBuffer(packet, packet.length - 1, 1)); + + Jt1078RtpPacket decoded = channel.readInbound(); + assertThat(decoded.payload()).containsExactly(9, 8, 7); + } + + @Test + void oversizedTcpRtpPacketEmitsMalformedCandidateInsteadOfClosingPipeline() { + byte[] packet = packet(9, "013800138000", 1, 0, 0, 1L, new byte[]{1, 2, 3, 4}); + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078RtpPacketDecoder(30)); + + channel.writeInbound(Unpooled.wrappedBuffer(packet)); + + Jt1078MalformedRtpPacket malformed = channel.readInbound(); + assertThat(malformed.transport()).isEqualTo("tcp"); + assertThat(malformed.sim()).isEqualTo("013800138000"); + assertThat(malformed.reason()).contains("too large"); + assertThat(malformed.rawBytes()).containsExactly(packet); + assertThat(malformed.packetLength()).isEqualTo(packet.length); + assertThat((Object) channel.readInbound()).isNull(); + } + + static byte[] packet(int sequence, String sim, int channelId, int dataType, int packetType, + long timestamp, byte[] payload) { + byte[] out = new byte[28 + payload.length]; + out[0] = 0x30; + out[1] = 0x31; + out[2] = 0x63; + out[3] = 0x64; + out[4] = (byte) (sequence >>> 8); + out[5] = (byte) sequence; + byte[] bcd = bcd(sim); + System.arraycopy(bcd, 0, out, 6, bcd.length); + out[12] = (byte) channelId; + out[13] = (byte) ((dataType << 4) | (packetType & 0x0F)); + for (int i = 0; i < 8; i++) { + out[14 + i] = (byte) (timestamp >>> (56 - i * 8)); + } + out[26] = (byte) (payload.length >>> 8); + out[27] = (byte) payload.length; + System.arraycopy(payload, 0, out, 28, payload.length); + return out; + } + + private static byte[] bcd(String digits) { + byte[] out = new byte[6]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(digits.charAt(i * 2), 10); + int lo = Character.digit(digits.charAt(i * 2 + 1), 10); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078UdpRtpPacketDecoderTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078UdpRtpPacketDecoderTest.java new file mode 100644 index 00000000..0e22d695 --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/Jt1078UdpRtpPacketDecoderTest.java @@ -0,0 +1,89 @@ +package com.lingniu.ingest.protocol.jt1078; + +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078RtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078ReceivedRtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078UdpRtpPacketDecoder; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.channel.socket.DatagramPacket; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078UdpRtpPacketDecoderTest { + + @Test + void decodesSingleUdpDatagramRtpPacket() { + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(1024)); + InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000); + InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780); + + channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(Jt1078RtpPacketDecoderTest.packet( + 9, "013800138000", 4, 0, 2, 123456789L, new byte[]{5, 6, 7})), recipient, sender)); + + Jt1078ReceivedRtpPacket received = channel.readInbound(); + Jt1078RtpPacket decoded = received.packet(); + assertThat(received.peer()).isEqualTo("127.0.0.1:19000"); + assertThat(decoded.sequence()).isEqualTo(9); + assertThat(decoded.sim()).isEqualTo("013800138000"); + assertThat(decoded.channelId()).isEqualTo(4); + assertThat(decoded.packetType()).isEqualTo(2); + assertThat(decoded.payload()).containsExactly(5, 6, 7); + } + + @Test + void shortUdpDatagramEmitsMalformedCandidateWithPeerAndRawBytes() { + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(1024)); + InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000); + InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780); + + channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(new byte[]{1, 2, 3}), recipient, sender)); + + Jt1078MalformedRtpPacket malformed = channel.readInbound(); + assertThat(malformed.transport()).isEqualTo("udp"); + assertThat(malformed.peer()).isEqualTo("127.0.0.1:19000"); + assertThat(malformed.reason()).contains("too short"); + assertThat(malformed.rawBytes()).containsExactly(1, 2, 3); + assertThat(malformed.packetLength()).isEqualTo(3); + } + + @Test + void invalidUdpDatagramEmitsMalformedCandidateInsteadOfThrowing() { + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(1024)); + InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000); + InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780); + byte[] invalid = Jt1078RtpPacketDecoderTest.packet( + 9, "013800138000", 4, 0, 2, 123456789L, new byte[]{5, 6, 7}); + invalid[0] = 0x12; + + channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(invalid), recipient, sender)); + + Jt1078MalformedRtpPacket malformed = channel.readInbound(); + assertThat(malformed.transport()).isEqualTo("udp"); + assertThat(malformed.peer()).isEqualTo("127.0.0.1:19000"); + assertThat(malformed.reason()).contains("invalid magic"); + assertThat(malformed.rawBytes()).containsExactly(invalid); + assertThat(malformed.packetLength()).isEqualTo(invalid.length); + } + + @Test + void oversizedUdpDatagramKeepsSimForIdentityResolution() { + EmbeddedChannel channel = new EmbeddedChannel(new Jt1078UdpRtpPacketDecoder(30)); + InetSocketAddress sender = new InetSocketAddress("127.0.0.1", 19000); + InetSocketAddress recipient = new InetSocketAddress("127.0.0.1", 10780); + byte[] oversized = Jt1078RtpPacketDecoderTest.packet( + 10, "013800138000", 4, 0, 2, 123456789L, new byte[]{5, 6, 7, 8}); + + channel.writeInbound(new DatagramPacket(Unpooled.wrappedBuffer(oversized), recipient, sender)); + + Jt1078MalformedRtpPacket malformed = channel.readInbound(); + assertThat(malformed.transport()).isEqualTo("udp"); + assertThat(malformed.sim()).isEqualTo("013800138000"); + assertThat(malformed.reason()).contains("too large"); + assertThat(malformed.rawBytes()).containsExactly(oversized); + assertThat(malformed.packetLength()).isEqualTo(oversized.length); + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfigurationTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfigurationTest.java new file mode 100644 index 00000000..0fbfde9b --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfigurationTest.java @@ -0,0 +1,218 @@ +package com.lingniu.ingest.protocol.jt1078.config; + +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.config.IngestCoreAutoConfiguration; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration; +import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MediaSignalHandler; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpEventPublisher; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MalformedRtpPacket; +import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService; +import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration; +import com.lingniu.ingest.sink.archive.ArchiveStore; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078AutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + IngestCoreAutoConfiguration.class, + Jt1078AutoConfiguration.class, + Jt1078SignalAutoConfiguration.class)) + .withUserConfiguration(SupportBeans.class) + .withPropertyValues( + "lingniu.ingest.jt1078.enabled=true", + "lingniu.ingest.jt1078.media-stream.enabled=false"); + + @Test + void mediaStreamDefaultsToLegacyProductionPort() { + Jt1078Properties props = new Jt1078Properties(); + + assertThat(props.getMediaStream().getPort()).isEqualTo(11078); + assertThat(props.getMediaStream().getUdpPort()).isEqualTo(11078); + } + + @Test + void mediaArchiveStartsWithoutJt808Mapper() { + runner.run(context -> { + assertThat(context).hasSingleBean(Jt1078MediaArchiveService.class); + assertThat(context).doesNotHaveBean(Jt808EventMapper.class); + assertThat(context).doesNotHaveBean(Jt1078MediaSignalHandler.class); + }); + } + + @Test + void mediaSignalHandlerStartsWhenJt808IsEnabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + Jt1078AutoConfiguration.class, + Jt1078SignalAutoConfiguration.class, + Jt808AutoConfiguration.class, + IngestCoreAutoConfiguration.class, + SessionCoreAutoConfiguration.class, + VehicleIdentityAutoConfiguration.class)) + .withPropertyValues( + "lingniu.ingest.jt1078.enabled=true", + "lingniu.ingest.jt1078.media-stream.enabled=false", + "lingniu.ingest.jt808.enabled=true", + "lingniu.ingest.jt808.port=0") + .run(context -> { + assertThat(context).hasSingleBean(Jt808EventMapper.class); + assertThat(context).hasSingleBean(Jt1078MediaSignalHandler.class); + }); + } + + @Test + void malformedRtpPublisherUsesConfiguredIdentityResolver() { + runner.run(context -> { + Jt1078MalformedRtpEventPublisher publisher = context.getBean(Jt1078MalformedRtpEventPublisher.class); + CapturingSink sink = context.getBean(CapturingSink.class); + + publisher.publish(new Jt1078MalformedRtpPacket( + "tcp", + "10.0.0.8:1078", + "013812345678", + new byte[]{0x30, 0x31, 0x63, 0x64}, + 128, + "jt1078 rtp packet too large: 128", + java.time.Instant.parse("2026-06-22T08:30:00Z"))); + + List events = sink.awaitCount(2); + assertThat(events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + assertThat(event.vin()).isEqualTo("LNVIN107800000001"); + assertThat(event.metadata()) + .containsEntry("sim", "013812345678") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE") + .containsEntry("parseError", "true"); + }); + assertThat(events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + assertThat(event.vin()).isEqualTo("LNVIN107800000001"); + assertThat(event.metadata()) + .containsEntry("sim", "013812345678") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE") + .containsEntry("parseError", "true") + .containsKey("rawArchiveUri"); + }); + }); + } + + @Configuration(proxyBeanMethods = false) + static class SupportBeans { + + @Bean + ArchiveStore archiveStore() { + return new InMemoryArchiveStore(); + } + + @Bean + VehicleIdentityResolver vehicleIdentityResolver() { + return lookup -> { + if (!lookup.vin().isBlank()) { + return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN); + } + if ("013812345678".equals(lookup.phone())) { + return new VehicleIdentity("LNVIN107800000001", true, VehicleIdentitySource.BOUND_PHONE); + } + return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN); + }; + } + + @Bean(destroyMethod = "close") + DisruptorEventBus disruptorEventBus(CapturingSink sink) { + return new DisruptorEventBus(1024, "blocking", List.of(sink)); + } + + @Bean + CapturingSink capturingSink() { + return new CapturingSink(); + } + } + + private static final class CapturingSink implements EventSink { + private final CopyOnWriteArrayList events = new CopyOnWriteArrayList<>(); + + @Override + public String name() { + return "capturing"; + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + events.add(event); + return CompletableFuture.completedFuture(null); + } + + VehicleEvent awaitOne() throws InterruptedException { + for (int i = 0; i < 50; i++) { + if (!events.isEmpty()) { + return events.getFirst(); + } + Thread.sleep(20); + } + throw new AssertionError("expected one event"); + } + + List awaitCount(int count) throws InterruptedException { + for (int i = 0; i < 50; i++) { + if (events.size() >= count) { + return List.copyOf(events); + } + Thread.sleep(20); + } + throw new AssertionError("expected " + count + " events, got " + events.size()); + } + } + + private static final class InMemoryArchiveStore implements ArchiveStore { + + @Override + public String put(String key, InputStream data, long length) throws IOException { + data.transferTo(OutputStream.nullOutputStream()); + return "memory://" + key; + } + + @Override + public String append(String key, byte[] chunk) { + return "memory://" + key; + } + + @Override + public InputStream get(String key) { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public boolean exists(String key) { + return true; + } + + @Override + public long size(String key) { + return 0; + } + } +} diff --git a/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078CommandsTest.java b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078CommandsTest.java new file mode 100644 index 00000000..98289f0e --- /dev/null +++ b/modules/protocols/protocol-jt1078/src/test/java/com/lingniu/ingest/protocol/jt1078/downlink/Jt1078CommandsTest.java @@ -0,0 +1,127 @@ +package com.lingniu.ingest.protocol.jt1078.downlink; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt1078CommandsTest { + + @Test + void realtimePlayEncodesServerPortsChannelAndStreamOptions() { + var command = Jt1078Commands.realtimePlay("10.1.2.3", 11078, 11079, 2, 1, 0); + + assertThat(command.messageId()).isEqualTo(0x9101); + assertThat(command.body()).containsExactly( + 0x08, '1', '0', '.', '1', '.', '2', '.', '3', + 0x2B, 0x46, + 0x2B, 0x47, + 0x02, + 0x01, + 0x00); + } + + @Test + void resourceSearchEncodesBcdTimeAndQueryOptions() { + var command = Jt1078Commands.resourceSearch( + 3, + "2026-06-22 08:09:10", + "260622180000", + 0x01020304L, + 0x05060708L, + 3, + 2, + 1); + + assertThat(command.messageId()).isEqualTo(0x9205); + assertThat(command.body()).containsExactly( + 0x03, + 0x26, 0x06, 0x22, 0x08, 0x09, 0x10, + 0x26, 0x06, 0x22, 0x18, 0x00, 0x00, + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x03, + 0x02, + 0x01); + } + + @Test + void queryMediaAttributesUses1003RequestMessageIdWithEmptyBody() { + var command = Jt1078Commands.queryMediaAttributes(); + + assertThat(command.messageId()).isEqualTo(0x1003); + assertThat(command.body()).isEmpty(); + } + + @Test + void realtimeControlEncodesChannelCommandCloseTypeAndStreamType() { + var command = Jt1078Commands.realtimeControl(2, 1, 0, 1); + + assertThat(command.messageId()).isEqualTo(0x9102); + assertThat(command.body()).containsExactly(0x02, 0x01, 0x00, 0x01); + } + + @Test + void historyPlayEncodesPlaybackOptionsAndBcdTimes() { + var command = Jt1078Commands.historyPlay( + "media.example.cn", 11078, 0, 4, + 3, 2, 1, 0, 0, + "260622080910", "260622180000"); + + assertThat(command.messageId()).isEqualTo(0x9201); + assertThat(command.body()).containsExactly( + 0x10, 'm', 'e', 'd', 'i', 'a', '.', 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'n', + 0x2B, 0x46, + 0x00, 0x00, + 0x04, + 0x03, + 0x02, + 0x01, + 0x00, + 0x00, + 0x26, 0x06, 0x22, 0x08, 0x09, 0x10, + 0x26, 0x06, 0x22, 0x18, 0x00, 0x00); + } + + @Test + void historyControlEncodesPlaybackTime() { + var command = Jt1078Commands.historyControl(4, 5, 0, "260622090000"); + + assertThat(command.messageId()).isEqualTo(0x9202); + assertThat(command.body()).containsExactly( + 0x04, 0x05, 0x00, + 0x26, 0x06, 0x22, 0x09, 0x00, 0x00); + } + + @Test + void fileUploadEncodesServerAccountPathAndQueryOptions() { + var command = Jt1078Commands.fileUpload( + "10.1.2.3", 11078, "user", "pass", "/data/upload", + 2, "260622080910", "260622180000", + 1, 2, 3, 2, 1, 0x07); + + assertThat(command.messageId()).isEqualTo(0x9206); + assertThat(command.body()).containsExactly( + 0x08, '1', '0', '.', '1', '.', '2', '.', '3', + 0x2B, 0x46, + 0x04, 'u', 's', 'e', 'r', + 0x04, 'p', 'a', 's', 's', + 0x0C, '/', 'd', 'a', 't', 'a', '/', 'u', 'p', 'l', 'o', 'a', 'd', + 0x02, + 0x26, 0x06, 0x22, 0x08, 0x09, 0x10, + 0x26, 0x06, 0x22, 0x18, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x02, + 0x03, + 0x02, + 0x01, + 0x07); + } + + @Test + void fileUploadControlEncodesResponseSerialAndCommand() { + var command = Jt1078Commands.fileUploadControl(0x1234, 2); + + assertThat(command.messageId()).isEqualTo(0x9207); + assertThat(command.body()).containsExactly(0x12, 0x34, 0x02); + } +} diff --git a/protocol-jt808/pom.xml b/modules/protocols/protocol-jt808/pom.xml similarity index 90% rename from protocol-jt808/pom.xml rename to modules/protocols/protocol-jt808/pom.xml index 6f4f1eee..77bfd010 100644 --- a/protocol-jt808/pom.xml +++ b/modules/protocols/protocol-jt808/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml protocol-jt808 protocol-jt808 @@ -27,6 +28,10 @@ com.lingniu.ingest session-core + + com.lingniu.ingest + vehicle-identity + org.springframework.boot spring-boot-starter diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParser.java diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/BodyParserRegistry.java diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808Escape.java diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java similarity index 63% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java index 47c1186b..6bcd5fc9 100644 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoder.java @@ -21,16 +21,25 @@ public class Jt808FrameDecoder extends ByteToMessageDecoder { while (in.readableBytes() >= 2) { int startIdx = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) 0x7e); if (startIdx < 0) { - in.skipBytes(in.readableBytes()); + int len = in.readableBytes(); + byte[] raw = new byte[len]; + in.readBytes(raw); + out.add(new Jt808MalformedFrame(raw, peer(ctx), "jt808 missing frame boundary")); return; } if (startIdx != in.readerIndex()) { - in.skipBytes(startIdx - in.readerIndex()); + int len = startIdx - in.readerIndex(); + byte[] raw = new byte[len]; + in.readBytes(raw); + out.add(new Jt808MalformedFrame(raw, peer(ctx), "jt808 leading bytes before frame boundary")); } int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e); if (endIdx < 0) { if (in.readableBytes() > MAX_FRAME) { - in.skipBytes(in.readableBytes() - 1); + int len = in.readableBytes(); + byte[] raw = new byte[len]; + in.readBytes(raw); + out.add(new Jt808MalformedFrame(raw, peer(ctx), "jt808 unclosed frame too large: " + len)); } return; } @@ -48,4 +57,11 @@ public class Jt808FrameDecoder extends ByteToMessageDecoder { out.add(unescaped); } } + + private static String peer(ChannelHandlerContext ctx) { + if (ctx == null || ctx.channel() == null || ctx.channel().remoteAddress() == null) { + return ""; + } + return ctx.channel().remoteAddress().toString(); + } } diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java similarity index 52% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java index af8fe0c3..3b052690 100644 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoder.java @@ -5,14 +5,19 @@ import com.lingniu.ingest.codec.BcdCodec; import com.lingniu.ingest.protocol.jt808.model.Jt808Header; import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; /** * JT/T 808 下行帧编码器:把 header + body 字节拼装 → 加 XOR 校验 → 转义 → 外包 0x7e。 * - *

    只支持 2013 版(6 字节 BCD phone)。分包暂不支持(PoC 阶段下行多数是短命令)。 + *

    支持 2013 与 2019 版基础头;短命令可直接编码,长命令通过 {@link #encodeSubpackages} + * 拆分为 JT/T 808 分包帧。 */ public final class Jt808FrameEncoder { + private static final int MAX_BODY_LENGTH = 0x03FF; + private Jt808FrameEncoder() {} public static byte[] encode(int messageId, String phone, int serialNo, byte[] body) { @@ -21,7 +26,41 @@ public final class Jt808FrameEncoder { public static byte[] encode(int messageId, String phone, int serialNo, byte[] body, Jt808Header.ProtocolVersion version) { - byte[] raw = buildRaw(messageId, phone, serialNo, body, version); + byte[] raw = buildRaw(messageId, phone, serialNo, body, version, 0, 0); + return wrap(raw); + } + + public static List encodeSubpackages(int messageId, String phone, int serialNo, byte[] body) { + return encodeSubpackages(messageId, phone, serialNo, body, Jt808Header.ProtocolVersion.V2013); + } + + public static List encodeSubpackages(int messageId, + String phone, + int serialNo, + byte[] body, + Jt808Header.ProtocolVersion version) { + if (body == null) { + body = new byte[0]; + } + if (body.length <= MAX_BODY_LENGTH) { + return List.of(encode(messageId, phone, serialNo, body, version)); + } + int totalPackets = (body.length + MAX_BODY_LENGTH - 1) / MAX_BODY_LENGTH; + if (totalPackets > 0xFFFF) { + throw new IllegalArgumentException("jt808 subpackage count too large: " + totalPackets); + } + List frames = new ArrayList<>(totalPackets); + for (int i = 0; i < totalPackets; i++) { + int offset = i * MAX_BODY_LENGTH; + int len = Math.min(MAX_BODY_LENGTH, body.length - offset); + byte[] chunk = new byte[len]; + System.arraycopy(body, offset, chunk, 0, len); + frames.add(wrap(buildRaw(messageId, phone, serialNo, chunk, version, totalPackets, i + 1))); + } + return List.copyOf(frames); + } + + private static byte[] wrap(byte[] raw) { byte bcc = BccChecksum.compute(raw, 0, raw.length); ByteArrayOutputStream almost = new ByteArrayOutputStream(raw.length + 1); @@ -37,10 +76,19 @@ public final class Jt808FrameEncoder { } private static byte[] buildRaw(int messageId, String phone, int serialNo, byte[] body, - Jt808Header.ProtocolVersion version) { + Jt808Header.ProtocolVersion version, int totalPackets, int packetSeq) { + if (body == null) { + body = new byte[0]; + } + if (body.length > MAX_BODY_LENGTH) { + throw new IllegalArgumentException("jt808 body too large without subpackage: " + + body.length + " > " + MAX_BODY_LENGTH); + } + boolean subpacket = totalPackets > 0; boolean v2019 = version == Jt808Header.ProtocolVersion.V2019; int phoneLen = v2019 ? 10 : 6; - int len = 2 /* msgId */ + 2 /* attrs */ + (v2019 ? 1 : 0) + phoneLen + 2 /* serial */ + body.length; + int len = 2 /* msgId */ + 2 /* attrs */ + (v2019 ? 1 : 0) + + phoneLen + 2 /* serial */ + (subpacket ? 4 : 0) + body.length; ByteArrayOutputStream os = new ByteArrayOutputStream(len); // msgId @@ -49,6 +97,7 @@ public final class Jt808FrameEncoder { // attrs: 2019 版 bit14 置 1 int attrs = body.length & 0x03FF; + if (subpacket) attrs |= 0x2000; if (v2019) attrs |= 0x4000; os.write((attrs >> 8) & 0xFF); os.write(attrs & 0xFF); @@ -65,6 +114,13 @@ public final class Jt808FrameEncoder { os.write((serialNo >> 8) & 0xFF); os.write(serialNo & 0xFF); + if (subpacket) { + os.write((totalPackets >> 8) & 0xFF); + os.write(totalPackets & 0xFF); + os.write((packetSeq >> 8) & 0xFF); + os.write(packetSeq & 0xFF); + } + // body os.write(body, 0, body.length); return os.toByteArray(); diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MalformedFrame.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MalformedFrame.java new file mode 100644 index 00000000..dfe62dd2 --- /dev/null +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MalformedFrame.java @@ -0,0 +1,7 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +public record Jt808MalformedFrame( + byte[] rawBytes, + String peer, + String reason +) {} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/AuthBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/AuthBodyParser.java new file mode 100644 index 00000000..4ca31857 --- /dev/null +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/AuthBodyParser.java @@ -0,0 +1,49 @@ +package com.lingniu.ingest.protocol.jt808.codec.parser; + +import com.lingniu.ingest.protocol.jt808.codec.BodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class AuthBodyParser implements BodyParser { + + private static final Pattern IMEI = Pattern.compile("(?= 2 ? body.getShort() & 0xFFFF : 0; + int batchType = body.remaining() >= 1 ? body.get() & 0xFF : 0; + List locations = new ArrayList<>(count); + for (int i = 0; i < count && body.remaining() >= 2; i++) { + int len = body.getShort() & 0xFFFF; + if (len > body.remaining()) { + len = body.remaining(); + } + ByteBuffer slice = body.slice(body.position(), len); + Jt808Body parsed = locationParser.parse(header, slice); + if (parsed instanceof Jt808Body.Location location) { + locations.add(location); + } + body.position(body.position() + len); + } + return new Jt808Body.LocationBatch(batchType, List.copyOf(locations)); + } +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java similarity index 63% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java index df0ee8fa..de5da2b3 100644 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/LocationBodyParser.java @@ -9,11 +9,14 @@ import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; import java.nio.ByteBuffer; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.Map; /** * 0x0200 位置信息汇报。 * - *

    固定 28 字节 + 可选附加项(本 PoC 先忽略附加项)。 + *

    固定 28 字节 + 可选附加项。附加项按 JT/T 808 的 ID/长度/值 TLV 保留原始字节, + * 由上层统计或导出服务按配置解释。 *

      *   alarmFlag(4)
      *   statusFlag(4)
    @@ -54,13 +57,39 @@ public final class LocationBodyParser implements BodyParser {
                     Integer.parseInt(ts.substring(8, 10)),
                     Integer.parseInt(ts.substring(10, 12)));
     
    -        // 跳过剩余附加项
    -        if (buf.hasRemaining()) buf.position(buf.limit());
    +        Map extensions = parseExtensions(buf);
     
             return new Jt808Body.Location(
                     alarmFlag, statusFlag,
                     lonRaw / 1_000_000.0, latRaw / 1_000_000.0,
                     altitudeM, speed, direction,
    -                ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant());
    +                ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant(),
    +                extensions);
    +    }
    +
    +    private static Map parseExtensions(ByteBuffer buf) {
    +        if (!buf.hasRemaining()) {
    +            return Map.of();
    +        }
    +        Map out = new LinkedHashMap<>();
    +        while (buf.remaining() >= 2) {
    +            int id = buf.get() & 0xFF;
    +            int len = buf.get() & 0xFF;
    +            if (len > buf.remaining()) {
    +                byte[] truncated = new byte[buf.remaining()];
    +                buf.get(truncated);
    +                out.put(id, truncated);
    +                break;
    +            }
    +            byte[] value = new byte[len];
    +            buf.get(value);
    +            out.put(id, value);
    +        }
    +        if (buf.hasRemaining()) {
    +            byte[] tail = new byte[buf.remaining()];
    +            buf.get(tail);
    +            out.put(0xFFFF, tail);
    +        }
    +        return Map.copyOf(out);
         }
     }
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaEventBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaEventBodyParser.java
    new file mode 100644
    index 00000000..182d2fe8
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaEventBodyParser.java
    @@ -0,0 +1,26 @@
    +package com.lingniu.ingest.protocol.jt808.codec.parser;
    +
    +import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +
    +import java.nio.ByteBuffer;
    +
    +public final class MediaEventBodyParser implements BodyParser {
    +
    +    @Override
    +    public int messageId() {
    +        return Jt808MessageId.TERMINAL_MEDIA_EVENT;
    +    }
    +
    +    @Override
    +    public Jt808Body parse(Jt808Header header, ByteBuffer body) {
    +        long mediaId = body.remaining() >= 4 ? body.getInt() & 0xFFFFFFFFL : 0;
    +        int mediaType = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int mediaFormat = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int eventCode = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int channelId = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        return new Jt808Body.MediaEvent(mediaId, mediaType, mediaFormat, eventCode, channelId);
    +    }
    +}
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaUploadBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaUploadBodyParser.java
    new file mode 100644
    index 00000000..8a0c3869
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/MediaUploadBodyParser.java
    @@ -0,0 +1,38 @@
    +package com.lingniu.ingest.protocol.jt808.codec.parser;
    +
    +import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +
    +import java.nio.ByteBuffer;
    +
    +public final class MediaUploadBodyParser implements BodyParser {
    +
    +    private final LocationBodyParser locationParser = new LocationBodyParser();
    +
    +    @Override
    +    public int messageId() {
    +        return Jt808MessageId.TERMINAL_MEDIA_UPLOAD;
    +    }
    +
    +    @Override
    +    public Jt808Body parse(Jt808Header header, ByteBuffer body) {
    +        long mediaId = body.remaining() >= 4 ? body.getInt() & 0xFFFFFFFFL : 0;
    +        int mediaType = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int mediaFormat = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int eventCode = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int channelId = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        Jt808Body.Location location = null;
    +        if (body.remaining() >= 28) {
    +            ByteBuffer loc = body.slice(body.position(), 28);
    +            if (locationParser.parse(header, loc) instanceof Jt808Body.Location parsed) {
    +                location = parsed;
    +            }
    +            body.position(body.position() + 28);
    +        }
    +        byte[] data = new byte[body.remaining()];
    +        body.get(data);
    +        return new Jt808Body.MediaUpload(mediaId, mediaType, mediaFormat, eventCode, channelId, location, data);
    +    }
    +}
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/PassthroughBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/PassthroughBodyParser.java
    new file mode 100644
    index 00000000..83c47848
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/PassthroughBodyParser.java
    @@ -0,0 +1,24 @@
    +package com.lingniu.ingest.protocol.jt808.codec.parser;
    +
    +import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +
    +import java.nio.ByteBuffer;
    +
    +public final class PassthroughBodyParser implements BodyParser {
    +
    +    @Override
    +    public int messageId() {
    +        return Jt808MessageId.TERMINAL_PASSTHROUGH;
    +    }
    +
    +    @Override
    +    public Jt808Body parse(Jt808Header header, ByteBuffer body) {
    +        int type = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        byte[] data = new byte[body.remaining()];
    +        body.get(data);
    +        return new Jt808Body.Passthrough(type, data);
    +    }
    +}
    diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java
    similarity index 100%
    rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java
    rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/RegisterBodyParser.java
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalAttrsBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalAttrsBodyParser.java
    new file mode 100644
    index 00000000..b30c24ff
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalAttrsBodyParser.java
    @@ -0,0 +1,58 @@
    +package com.lingniu.ingest.protocol.jt808.codec.parser;
    +
    +import com.lingniu.ingest.codec.BcdCodec;
    +import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +
    +import java.nio.ByteBuffer;
    +import java.nio.charset.StandardCharsets;
    +
    +public final class TerminalAttrsBodyParser implements BodyParser {
    +
    +    @Override
    +    public int messageId() {
    +        return Jt808MessageId.TERMINAL_ATTRS_REPORT;
    +    }
    +
    +    @Override
    +    public Jt808Body parse(Jt808Header header, ByteBuffer body) {
    +        int terminalType = readU16(body);
    +        String maker = readAscii(body, 5);
    +        String model = readAscii(body, 20);
    +        String terminalId = readAscii(body, 7);
    +        String iccid = readBcd(body, 10);
    +        String hardwareVersion = readAscii(body, 10);
    +        String firmwareVersion = readAscii(body, 10);
    +        int gnssProperty = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        int commProperty = body.hasRemaining() ? body.get() & 0xFF : 0;
    +        if (body.hasRemaining()) {
    +            body.position(body.limit());
    +        }
    +        return new Jt808Body.TerminalAttrs(
    +                terminalType, maker, model, terminalId, iccid, hardwareVersion, firmwareVersion,
    +                gnssProperty, commProperty);
    +    }
    +
    +    private static int readU16(ByteBuffer body) {
    +        return body.remaining() >= 2 ? body.getShort() & 0xFFFF : 0;
    +    }
    +
    +    private static String readAscii(ByteBuffer body, int len) {
    +        int n = Math.min(len, body.remaining());
    +        byte[] bytes = new byte[n];
    +        body.get(bytes);
    +        if (n < len && body.hasRemaining()) {
    +            body.position(Math.min(body.limit(), body.position() + (len - n)));
    +        }
    +        return new String(bytes, StandardCharsets.US_ASCII).trim();
    +    }
    +
    +    private static String readBcd(ByteBuffer body, int len) {
    +        int n = Math.min(len, body.remaining());
    +        byte[] bytes = new byte[n];
    +        body.get(bytes);
    +        return BcdCodec.decode(bytes).trim();
    +    }
    +}
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalParamsReportBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalParamsReportBodyParser.java
    new file mode 100644
    index 00000000..a88e50e5
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/TerminalParamsReportBodyParser.java
    @@ -0,0 +1,36 @@
    +package com.lingniu.ingest.protocol.jt808.codec.parser;
    +
    +import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +
    +import java.nio.ByteBuffer;
    +import java.util.LinkedHashMap;
    +import java.util.Map;
    +
    +public final class TerminalParamsReportBodyParser implements BodyParser {
    +
    +    @Override
    +    public int messageId() {
    +        return Jt808MessageId.TERMINAL_PARAMS_REPORT;
    +    }
    +
    +    @Override
    +    public Jt808Body parse(Jt808Header header, ByteBuffer body) {
    +        int responseSerialNo = body.remaining() >= 2 ? body.getShort() & 0xFFFF : 0;
    +        int count = body.remaining() >= 1 ? body.get() & 0xFF : 0;
    +        Map parameters = new LinkedHashMap<>();
    +        for (int i = 0; i < count && body.remaining() >= 5; i++) {
    +            long paramId = body.getInt() & 0xFFFFFFFFL;
    +            int len = body.get() & 0xFF;
    +            if (len > body.remaining()) {
    +                len = body.remaining();
    +            }
    +            byte[] value = new byte[len];
    +            body.get(value);
    +            parameters.put(paramId, value);
    +        }
    +        return new Jt808Body.ParamsReport(responseSerialNo, Map.copyOf(parameters));
    +    }
    +}
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/UnregisterBodyParser.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/UnregisterBodyParser.java
    new file mode 100644
    index 00000000..7b2d3a5f
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/parser/UnregisterBodyParser.java
    @@ -0,0 +1,22 @@
    +package com.lingniu.ingest.protocol.jt808.codec.parser;
    +
    +import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +
    +import java.nio.ByteBuffer;
    +
    +public final class UnregisterBodyParser implements BodyParser {
    +
    +    @Override
    +    public int messageId() {
    +        return Jt808MessageId.TERMINAL_UNREGISTER;
    +    }
    +
    +    @Override
    +    public Jt808Body parse(Jt808Header header, ByteBuffer body) {
    +        body.position(body.limit());
    +        return new Jt808Body.Unregister();
    +    }
    +}
    diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java
    similarity index 67%
    rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java
    rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java
    index cdae3b4c..d19fef39 100644
    --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808AutoConfiguration.java
    @@ -1,12 +1,22 @@
     package com.lingniu.ingest.protocol.jt808.config;
     
     import com.lingniu.ingest.core.dispatcher.Dispatcher;
    +import com.lingniu.ingest.identity.VehicleIdentityRegistry;
    +import com.lingniu.ingest.identity.VehicleIdentityResolver;
     import com.lingniu.ingest.protocol.jt808.codec.BodyParser;
     import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry;
     import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser;
     import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser;
     import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBatchBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.MediaEventBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.MediaUploadBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser;
     import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalAttrsBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalParamsReportBodyParser;
    +import com.lingniu.ingest.protocol.jt808.codec.parser.UnregisterBodyParser;
     import com.lingniu.ingest.protocol.jt808.downlink.Jt808CommandDispatcher;
     import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler;
     import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer;
    @@ -30,8 +40,16 @@ import java.util.List;
     public class Jt808AutoConfiguration {
     
         @Bean public BodyParser jt808RegisterBodyParser() { return new RegisterBodyParser(); }
    +    @Bean public BodyParser jt808AuthBodyParser() { return new AuthBodyParser(); }
         @Bean public BodyParser jt808LocationBodyParser() { return new LocationBodyParser(); }
         @Bean public BodyParser jt808HeartbeatBodyParser() { return new HeartbeatBodyParser(); }
    +    @Bean public BodyParser jt808UnregisterBodyParser() { return new UnregisterBodyParser(); }
    +    @Bean public BodyParser jt808TerminalParamsReportBodyParser() { return new TerminalParamsReportBodyParser(); }
    +    @Bean public BodyParser jt808TerminalAttrsBodyParser() { return new TerminalAttrsBodyParser(); }
    +    @Bean public BodyParser jt808LocationBatchBodyParser() { return new LocationBatchBodyParser(); }
    +    @Bean public BodyParser jt808MediaEventBodyParser() { return new MediaEventBodyParser(); }
    +    @Bean public BodyParser jt808MediaUploadBodyParser() { return new MediaUploadBodyParser(); }
    +    @Bean public BodyParser jt808PassthroughBodyParser() { return new PassthroughBodyParser(); }
     
         @Bean
         @ConditionalOnMissingBean
    @@ -47,8 +65,8 @@ public class Jt808AutoConfiguration {
     
         @Bean
         @ConditionalOnMissingBean
    -    public Jt808EventMapper jt808EventMapper() {
    -        return new Jt808EventMapper();
    +    public Jt808EventMapper jt808EventMapper(VehicleIdentityResolver identityResolver) {
    +        return new Jt808EventMapper(identityResolver);
         }
     
         @Bean
    @@ -88,9 +106,12 @@ public class Jt808AutoConfiguration {
                                                  Jt808MessageDecoder decoder,
                                                  Dispatcher dispatcher,
                                                  SessionStore sessions,
    +                                             VehicleIdentityRegistry identityRegistry,
    +                                             VehicleIdentityResolver identityResolver,
                                                  Jt808ChannelRegistry channelRegistry,
                                                  Jt808PendingRequests pendingRequests) {
             return new Jt808NettyServer(props.getPort(), props.getWorkerThreads(),
    -                decoder, dispatcher, sessions, channelRegistry, pendingRequests);
    +                decoder, dispatcher, sessions, identityRegistry, identityResolver,
    +                channelRegistry, pendingRequests);
         }
     }
    diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java
    similarity index 100%
    rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java
    rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java
    diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java
    similarity index 81%
    rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java
    rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java
    index b203db56..c43bb434 100644
    --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java
    @@ -17,6 +17,7 @@ import java.util.Optional;
     import java.util.concurrent.CompletableFuture;
     import java.util.concurrent.CompletionException;
     import java.util.concurrent.TimeUnit;
    +import java.util.concurrent.atomic.AtomicInteger;
     
     /**
      * JT808 侧的 {@link CommandDispatcher} 实现:把 session-core 的调用桥接到具体 Netty Channel。
    @@ -50,13 +51,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
             } catch (IllegalArgumentException e) {
                 return CompletableFuture.failedFuture(e);
             }
    -        byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
    -        CompletableFuture cf = new CompletableFuture<>();
    -        ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
    -            if (f.isSuccess()) cf.complete(null);
    -            else cf.completeExceptionally(f.cause());
    -        });
    -        return cf;
    +        return writeFrames(ctx, false);
         }
     
         @Override
    @@ -70,11 +65,10 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
             }
     
             CompletableFuture pending = pendingRequests.await(ctx.phone, ctx.serial);
    -        byte[] frame = Jt808FrameEncoder.encode(ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
    -        ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
    -            if (!f.isSuccess()) {
    +        writeFrames(ctx, true).whenComplete((unused, ex) -> {
    +            if (ex != null) {
                     pendingRequests.cancel(ctx.phone, ctx.serial);
    -                pending.completeExceptionally(f.cause());
    +                pending.completeExceptionally(ex);
                 }
             });
     
    @@ -90,6 +84,28 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
                     });
         }
     
    +    private CompletableFuture writeFrames(CommandContext ctx, boolean request) {
    +        var frames = Jt808FrameEncoder.encodeSubpackages(
    +                ctx.cmd.messageId(), ctx.phone, ctx.serial, ctx.cmd.body());
    +        CompletableFuture cf = new CompletableFuture<>();
    +        AtomicInteger remaining = new AtomicInteger(frames.size());
    +        for (byte[] frame : frames) {
    +            ctx.channel.writeAndFlush(Unpooled.wrappedBuffer(frame)).addListener(f -> {
    +                if (!f.isSuccess()) {
    +                    if (request) {
    +                        pendingRequests.cancel(ctx.phone, ctx.serial);
    +                    }
    +                    cf.completeExceptionally(f.cause());
    +                    return;
    +                }
    +                if (remaining.decrementAndGet() == 0) {
    +                    cf.complete(null);
    +                }
    +            });
    +        }
    +        return cf;
    +    }
    +
         private CommandContext resolve(String sessionId, Object command) {
             if (!(command instanceof Jt808Commands.DownlinkCommand cmd)) {
                 throw new IllegalArgumentException(
    diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java
    similarity index 74%
    rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java
    rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java
    index 41e12d13..d62b7a62 100644
    --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808Commands.java
    @@ -5,6 +5,7 @@ import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
     import java.io.ByteArrayOutputStream;
     import java.nio.charset.Charset;
     import java.nio.charset.StandardCharsets;
    +import java.util.List;
     
     /**
      * 常用下行命令 body 构造器。返回的是 body 字节,外层由 {@link com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder} 包装。
    @@ -46,6 +47,11 @@ public final class Jt808Commands {
             return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_PARAMS, new byte[0]);
         }
     
    +    /** 0x8107 查询终端属性。 */
    +    public static DownlinkCommand queryAttributes() {
    +        return new DownlinkCommand(Jt808MessageId.PLATFORM_QUERY_ATTRS, new byte[0]);
    +    }
    +
         /**
          * 0x8103 设置终端参数:参数项列表格式。
          *
    @@ -73,6 +79,27 @@ public final class Jt808Commands {
             return new DownlinkCommand(Jt808MessageId.PLATFORM_TERMINAL_CONTROL, os.toByteArray());
         }
     
    +    /** 0x8203 人工确认报警:报警消息流水号(WORD) + 报警类型(DWORD)。 */
    +    public static DownlinkCommand alarmAck(int responseSerialNo, long alarmType) {
    +        ByteArrayOutputStream os = new ByteArrayOutputStream(6);
    +        writeU16(os, responseSerialNo);
    +        writeU32(os, alarmType);
    +        return new DownlinkCommand(Jt808MessageId.PLATFORM_ALARM_ACK, os.toByteArray());
    +    }
    +
    +    /** 0x8601 删除圆形区域:区域总数(BYTE) + N * 区域ID(DWORD)。 */
    +    public static DownlinkCommand deleteArea(List areaIds) {
    +        if (areaIds.size() > 255) {
    +            throw new IllegalArgumentException("jt808 delete-area supports at most 255 area ids");
    +        }
    +        ByteArrayOutputStream os = new ByteArrayOutputStream(1 + areaIds.size() * 4);
    +        os.write(areaIds.size() & 0xFF);
    +        for (Long areaId : areaIds) {
    +            writeU32(os, areaId == null ? 0 : areaId);
    +        }
    +        return new DownlinkCommand(Jt808MessageId.PLATFORM_DELETE_CIRCLE_AREA, os.toByteArray());
    +    }
    +
         public record DownlinkCommand(int messageId, byte[] body) {}
     
         public record ParamItem(long paramId, byte[] value) {}
    diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java
    similarity index 62%
    rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java
    rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java
    index d348b48b..83750860 100644
    --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandler.java
    @@ -8,6 +8,7 @@ import com.lingniu.ingest.api.annotation.ProtocolHandler;
     import com.lingniu.ingest.api.annotation.RateLimited;
     import com.lingniu.ingest.api.event.VehicleEvent;
     import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
     import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
     import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
     
    @@ -29,7 +30,10 @@ public class Jt808LocationHandler {
             this.mapper = mapper;
         }
     
    -    @MessageMapping(command = Jt808MessageId.TERMINAL_LOCATION, desc = "位置信息汇报(批量)")
    +    @MessageMapping(command = {
    +            Jt808MessageId.TERMINAL_LOCATION,
    +            Jt808MessageId.TERMINAL_LOCATION_BATCH
    +    }, desc = "位置信息汇报(批量)")
         @RateLimited(perVin = 20)
         @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
         @EventEmit(VehicleEvent.Location.class)
    @@ -47,9 +51,31 @@ public class Jt808LocationHandler {
             return mapper.toEvents(msg);
         }
     
    +    @MessageMapping(command = {
    +            Jt808MessageId.TERMINAL_UNREGISTER,
    +            Jt808MessageId.TERMINAL_PARAMS_REPORT,
    +            Jt808MessageId.TERMINAL_ATTRS_REPORT,
    +            Jt808MessageId.TERMINAL_MEDIA_EVENT,
    +            Jt808MessageId.TERMINAL_MEDIA_UPLOAD,
    +            Jt808MessageId.TERMINAL_PASSTHROUGH
    +    }, desc = "终端状态/属性/多媒体/透传")
    +    @EventEmit({VehicleEvent.Logout.class, VehicleEvent.Login.class, VehicleEvent.MediaMeta.class, VehicleEvent.Passthrough.class})
    +    public List onExtended(Jt808Message msg) {
    +        return mapper.toEvents(msg);
    +    }
    +
         @MessageMapping(command = Jt808MessageId.TERMINAL_HEARTBEAT, desc = "心跳")
         @EventEmit(VehicleEvent.Heartbeat.class)
         public List onHeartbeat(Jt808Message msg) {
             return mapper.toEvents(msg);
         }
    +
    +    @MessageMapping(desc = "未解析 JT808 上行兜底透传")
    +    @EventEmit(VehicleEvent.Passthrough.class)
    +    public List onRaw(Jt808Message msg) {
    +        if (!(msg.body() instanceof Jt808Body.Raw) && !(msg.body() instanceof Jt808Body.Malformed)) {
    +            return List.of();
    +        }
    +        return mapper.toEvents(msg);
    +    }
     }
    diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java
    new file mode 100644
    index 00000000..eeafbdbf
    --- /dev/null
    +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java
    @@ -0,0 +1,327 @@
    +package com.lingniu.ingest.protocol.jt808.inbound;
    +
    +import com.lingniu.ingest.api.ProtocolId;
    +import com.lingniu.ingest.api.pipeline.RawFrame;
    +import com.lingniu.ingest.core.dispatcher.Dispatcher;
    +import com.lingniu.ingest.identity.VehicleIdentity;
    +import com.lingniu.ingest.identity.VehicleIdentityBinding;
    +import com.lingniu.ingest.identity.VehicleIdentityLookup;
    +import com.lingniu.ingest.identity.VehicleIdentityRegistry;
    +import com.lingniu.ingest.identity.VehicleIdentityResolver;
    +import com.lingniu.ingest.identity.VehicleIdentitySource;
    +import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder;
    +import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame;
    +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
    +import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Body;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
    +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
    +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
    +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
    +import com.lingniu.ingest.session.DeviceSession;
    +import com.lingniu.ingest.session.SessionStore;
    +import io.netty.buffer.Unpooled;
    +import io.netty.channel.ChannelHandlerContext;
    +import io.netty.channel.SimpleChannelInboundHandler;
    +import org.slf4j.Logger;
    +import org.slf4j.LoggerFactory;
    +
    +import java.net.InetSocketAddress;
    +import java.nio.ByteBuffer;
    +import java.time.Instant;
    +import java.util.HashMap;
    +import java.util.Map;
    +import java.util.UUID;
    +
    +/**
    + * JT808 Netty 入站处理器。职责:
    + * 
      + *
    1. 解码 → 构造 RawFrame → 交给 {@link Dispatcher} + *
    2. 对 0x0100 注册 / 0x0102 鉴权 → 创建 / 更新 {@link DeviceSession} 并绑定 Channel + *
    3. 对 0x0001 终端通用应答 → 唤醒 {@link Jt808PendingRequests} 里等待的 future + *
    4. 对 0x0002 心跳 → 下发 0x8001 通用应答(若平台要求) + *
    5. 连接断开 → 清理 Channel 绑定 + *
    + */ +public class Jt808ChannelHandler extends SimpleChannelInboundHandler { + + private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class); + private static final String PROCESSING_ERROR_PREFIX = "processing failed: "; + + private final Jt808MessageDecoder decoder; + private final Dispatcher dispatcher; + private final SessionStore sessions; + private final VehicleIdentityRegistry identityRegistry; + private final VehicleIdentityResolver identityResolver; + private final Jt808ChannelRegistry channelRegistry; + private final Jt808PendingRequests pendingRequests; + + public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher, + SessionStore sessions, VehicleIdentityRegistry identityRegistry, + Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + this(decoder, dispatcher, sessions, identityRegistry, + identityRegistry instanceof VehicleIdentityResolver resolver ? resolver : null, + channelRegistry, pendingRequests); + } + + public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher, + SessionStore sessions, VehicleIdentityRegistry identityRegistry, + VehicleIdentityResolver identityResolver, + Jt808ChannelRegistry channelRegistry, + Jt808PendingRequests pendingRequests) { + this.decoder = decoder; + this.dispatcher = dispatcher; + this.sessions = sessions; + this.identityRegistry = identityRegistry; + this.identityResolver = identityResolver; + this.channelRegistry = channelRegistry; + this.pendingRequests = pendingRequests; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof byte[] frame) { + handleDecodedFrame(ctx, frame); + } else if (msg instanceof Jt808MalformedFrame malformed) { + dispatchFrameError(ctx, malformed); + } else { + ctx.fireChannelRead(msg); + } + } + + private void handleDecodedFrame(ChannelHandlerContext ctx, byte[] frame) { + Jt808Message msg; + try { + msg = decoder.decode(ByteBuffer.wrap(frame)); + } catch (Exception e) { + log.warn("[jt808] decode failed peer={} len={} error={}", addr(ctx), frame.length, e.getMessage()); + log.debug("[jt808] decode failed stack peer={}", addr(ctx), e); + dispatchMalformed(ctx, frame, e.getMessage(), false); + return; + } + + try { + processDecodedFrame(ctx, frame, msg); + } catch (RuntimeException e) { + log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}", + addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage()); + log.debug("[jt808] processing failed stack peer={}", addr(ctx), e); + dispatchProcessingError(ctx, frame, msg, e.getMessage()); + } + } + + private void processDecodedFrame(ChannelHandlerContext ctx, byte[] frame, Jt808Message msg) { + // ====== 会话维护 & 同步应答匹配 ====== + String phone = msg.header().phone(); + IdentityResolution identity = resolveIdentity(msg); + switch (msg.header().messageId()) { + case Jt808MessageId.TERMINAL_REGISTER -> { + DeviceSession session = upsertSession(ctx, msg, identity.identity()); + channelRegistry.bind(phone, ctx.channel()); + byte[] registerAck = Jt808FrameEncoder.encode( + Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).messageId(), + phone, + channelRegistry.nextSerial(phone), + Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).body()); + ctx.writeAndFlush(Unpooled.wrappedBuffer(registerAck)); + } + case Jt808MessageId.TERMINAL_AUTH -> { + upsertSession(ctx, msg, identity.identity()); + channelRegistry.bind(phone, ctx.channel()); + ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0); + } + case Jt808MessageId.TERMINAL_HEARTBEAT -> { + channelRegistry.bind(phone, ctx.channel()); + ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0); + } + case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> { + // body: ackSerial(2) + ackMsgId(2) + result(1) + if (msg.body() instanceof Jt808Body.Raw raw && raw.bytes().length >= 5) { + int ackSerial = ((raw.bytes()[0] & 0xFF) << 8) | (raw.bytes()[1] & 0xFF); + pendingRequests.complete(phone, ackSerial, msg); + } + } + default -> { + // 定位/其他上行:也可能是对同步命令的应答 + pendingRequests.complete(phone, msg.header().serialNo(), msg); + } + } + + // ====== 派发到 Dispatcher ====== + Map meta = new HashMap<>(6); + meta.put("vin", identity.identity().vin()); + meta.put("phone", phone); + meta.put("identityResolved", Boolean.toString(identity.identity().resolved())); + meta.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + meta.put("identityError", "true"); + meta.put("identityErrorMessage", identity.errorMessage()); + } + meta.put("seq", Integer.toString(msg.header().serialNo())); + meta.put("peer", addr(ctx)); + RawFrame rf = new RawFrame( + ProtocolId.JT808, + msg.header().messageId(), + 0, + msg, + frame, + meta, + Instant.now()); + dispatcher.dispatch(rf); + } + + private IdentityResolution resolveIdentity(Jt808Message msg) { + String phone = msg.header().phone(); + if (identityResolver == null) { + return new IdentityResolution( + new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE), + null); + } + try { + return new IdentityResolution( + identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, + "", + phone, + deviceId(msg.body()), + plate(msg.body()))), + null); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private void dispatchFrameError(ChannelHandlerContext ctx, Jt808MalformedFrame malformed) { + String peer = malformed.peer() == null || malformed.peer().isBlank() ? addr(ctx) : malformed.peer(); + dispatchMalformed(peer, malformed.rawBytes(), malformed.reason(), true); + } + + private void dispatchMalformed(ChannelHandlerContext ctx, byte[] frame, String errorMessage, boolean frameError) { + dispatchMalformed(addr(ctx), frame, errorMessage, frameError); + } + + private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) { + String message = errorMessage == null ? "" : errorMessage; + Jt808Message malformed = new Jt808Message( + new Jt808Header(0, frame == null ? 0 : frame.length, 0, false, + Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0), + new Jt808Body.Malformed(frame == null ? new byte[0] : frame, peer, message)); + Map meta = new HashMap<>(4); + meta.put("vin", "unknown"); + meta.put("peer", peer); + meta.put("identityResolved", "false"); + meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name()); + if (frameError) { + meta.put("frameError", "true"); + meta.put("frameErrorMessage", message); + } else { + meta.put("parseError", "true"); + meta.put("parseErrorMessage", message); + } + dispatcher.dispatch(new RawFrame( + ProtocolId.JT808, + 0, + 0, + malformed, + frame == null ? new byte[0] : frame, + meta, + Instant.now())); + } + + private void dispatchProcessingError(ChannelHandlerContext ctx, byte[] frame, Jt808Message msg, String errorMessage) { + String message = errorMessage == null ? "" : errorMessage; + Jt808Message failed = new Jt808Message( + new Jt808Header(0, frame == null ? 0 : frame.length, 0, false, + msg.header().version(), msg.header().phone(), msg.header().serialNo(), 0, 0), + new Jt808Body.Malformed(frame == null ? new byte[0] : frame, addr(ctx), + PROCESSING_ERROR_PREFIX + message)); + Map meta = new HashMap<>(8); + meta.put("vin", "unknown"); + meta.put("phone", msg.header().phone()); + meta.put("peer", addr(ctx)); + meta.put("seq", Integer.toString(msg.header().serialNo())); + meta.put("identityResolved", "false"); + meta.put("identitySource", VehicleIdentitySource.UNKNOWN.name()); + meta.put("processingError", "true"); + meta.put("processingErrorMessage", message); + dispatcher.dispatch(new RawFrame( + ProtocolId.JT808, + 0, + 0, + failed, + frame == null ? new byte[0] : frame, + meta, + Instant.now())); + } + + private DeviceSession upsertSession(ChannelHandlerContext ctx, Jt808Message msg, VehicleIdentity identity) { + String phone = msg.header().phone(); + String sessionId = "jt808-" + phone; + DeviceSession session = sessions.findBySessionId(sessionId) + .map(s -> s.touch(Instant.now())) + .orElseGet(() -> new DeviceSession( + sessionId, ProtocolId.JT808, identity.vin(), phone, + generateToken(phone), + addr(ctx), + Instant.now(), Instant.now(), + Map.of("protocolVersion", msg.header().version().name()))); + session = session.withVin(identity.vin()); + if (msg.body() instanceof Jt808Body.Register reg && reg.plate() != null) { + if (session.vin() != null && !session.vin().isBlank()) { + identityRegistry.bind(new VehicleIdentityBinding( + ProtocolId.JT808, session.vin(), phone, reg.deviceId(), reg.plate())); + } + } + sessions.put(session); + return session; + } + + private static String deviceId(Jt808Body body) { + if (body instanceof Jt808Body.Register reg) { + return reg.deviceId(); + } + if (body instanceof Jt808Body.Auth auth) { + return auth.imei(); + } + return ""; + } + + private static String plate(Jt808Body body) { + return body instanceof Jt808Body.Register reg ? reg.plate() : ""; + } + + private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) { + var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result); + byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body()); + ctx.writeAndFlush(Unpooled.wrappedBuffer(frame)); + } + + private static String generateToken(String phone) { + return UUID.nameUUIDFromBytes(("jt808:" + phone).getBytes()).toString().replace("-", "").substring(0, 16); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + channelRegistry.unbind(ctx.channel()) + .ifPresent(phone -> sessions.remove("jt808-" + phone)); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + log.warn("[jt808] channel error peer={}", addr(ctx), cause); + ctx.close(); + } + + private static String addr(ChannelHandlerContext ctx) { + if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { + return i.getAddress().getHostAddress() + ":" + i.getPort(); + } + return "unknown"; + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) {} +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java similarity index 87% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java index 0c456f54..f7d63a1a 100644 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808NettyServer.java @@ -1,6 +1,8 @@ package com.lingniu.ingest.protocol.jt808.inbound; import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.identity.VehicleIdentityRegistry; +import com.lingniu.ingest.identity.VehicleIdentityResolver; import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameDecoder; import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; @@ -30,6 +32,8 @@ public class Jt808NettyServer implements InitializingBean, DisposableBean { private final Jt808MessageDecoder messageDecoder; private final Dispatcher dispatcher; private final SessionStore sessions; + private final VehicleIdentityRegistry identityRegistry; + private final VehicleIdentityResolver identityResolver; private final Jt808ChannelRegistry channelRegistry; private final Jt808PendingRequests pendingRequests; @@ -40,6 +44,8 @@ public class Jt808NettyServer implements InitializingBean, DisposableBean { public Jt808NettyServer(int port, int workerThreads, Jt808MessageDecoder messageDecoder, Dispatcher dispatcher, SessionStore sessions, + VehicleIdentityRegistry identityRegistry, + VehicleIdentityResolver identityResolver, Jt808ChannelRegistry channelRegistry, Jt808PendingRequests pendingRequests) { this.port = port; @@ -47,6 +53,8 @@ public class Jt808NettyServer implements InitializingBean, DisposableBean { this.messageDecoder = messageDecoder; this.dispatcher = dispatcher; this.sessions = sessions; + this.identityRegistry = identityRegistry; + this.identityResolver = identityResolver; this.channelRegistry = channelRegistry; this.pendingRequests = pendingRequests; } @@ -73,6 +81,7 @@ public class Jt808NettyServer implements InitializingBean, DisposableBean { .addLast("frame", new Jt808FrameDecoder()) .addLast("dispatch", new Jt808ChannelHandler( messageDecoder, dispatcher, sessions, + identityRegistry, identityResolver, channelRegistry, pendingRequests)); } }); diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java new file mode 100644 index 00000000..a767233c --- /dev/null +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java @@ -0,0 +1,349 @@ +package com.lingniu.ingest.protocol.jt808.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.spi.EventMapper; +import com.lingniu.ingest.identity.VehicleIdentity; +import com.lingniu.ingest.identity.VehicleIdentityLookup; +import com.lingniu.ingest.identity.VehicleIdentityResolver; +import com.lingniu.ingest.identity.VehicleIdentitySource; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * JT808 消息 → 领域事件映射。sealed body 允许 switch 穷尽处理。 + * + *

    备注:JT808 里没有强制的 VIN 字段,事件 VIN 统一通过 {@link VehicleIdentityResolver} + * 解析;未绑定时才降级为终端手机号,metadata 同步保留内部 vin 与外部 phone。 + */ +public final class Jt808EventMapper implements EventMapper { + + private static final String PROCESSING_ERROR_PREFIX = "processing failed: "; + + private final VehicleIdentityResolver identityResolver; + + public Jt808EventMapper(VehicleIdentityResolver identityResolver) { + this.identityResolver = identityResolver; + } + + @Override + public List toEvents(Jt808Message message) { + var header = message.header(); + Instant ingestTime = Instant.now(); + IdentityResolution identity = resolveIdentity(message); + Map meta = baseMeta(header, identity); + String vin = identity.vin(); + + return switch (message.body()) { + case Jt808Body.Location loc -> List.of(new VehicleEvent.Location( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, locationMeta(meta, loc), + new LocationPayload(loc.longitude(), loc.latitude(), + loc.altitudeM(), loc.speedKmh(), loc.directionDeg(), + loc.alarmFlag(), loc.statusFlag(), totalMileageKm(loc)))); + + case Jt808Body.Register reg -> List.of(new VehicleEvent.Login( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta, + reg.deviceId(), header.version().name())); + + case Jt808Body.Auth auth -> List.of(new VehicleEvent.Login( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta, + auth.imei() == null ? "" : auth.imei(), header.version().name())); + + case Jt808Body.Heartbeat _ -> List.of(new VehicleEvent.Heartbeat( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta)); + + case Jt808Body.Unregister _ -> List.of(new VehicleEvent.Logout( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta)); + + case Jt808Body.LocationBatch batch -> locationBatchEvents(vin, meta, batch, ingestTime); + + case Jt808Body.ParamsReport params -> List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(meta, "paramsCount", Integer.toString(params.parameters().size())), + header.messageId(), + encodeParamsSummary(params))); + + case Jt808Body.TerminalAttrs attrs -> List.of(new VehicleEvent.Login( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(meta, "terminalId", attrs.terminalId()), + attrs.iccid(), header.version().name())); + + case Jt808Body.MediaEvent media -> List.of(new VehicleEvent.MediaMeta( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(meta, "mediaEventCode", Integer.toString(media.eventCode())), + Long.toString(media.mediaId()), + mediaType(media.mediaType(), media.mediaFormat(), media.channelId()), + 0, + "")); + + case Jt808Body.MediaUpload media -> mediaUploadEvents(vin, meta, media, ingestTime); + + case Jt808Body.MediaAttributes attrs -> List.of(new VehicleEvent.MediaMeta( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(withMeta(meta, "jt1078Message", "mediaAttributes"), + "maxVideoChannels", Integer.toString(attrs.maxVideoChannels())), + "jt1078-attrs-" + header.phone(), + mediaAttributesType(attrs), + attrs.raw() == null ? 0 : attrs.raw().length, + "")); + + case Jt808Body.FileUploadComplete done -> List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(withMeta(withMeta(meta, "jt1078Message", "fileUploadComplete"), + "responseSerialNo", Integer.toString(done.responseSerialNo())), + "uploadResult", Integer.toString(done.result())), + header.messageId(), + done.raw() == null ? new byte[0] : done.raw())); + + case Jt808Body.FileList list -> List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(withMeta(withMeta(meta, "jt1078Message", "fileList"), + "responseSerialNo", Integer.toString(list.responseSerialNo())), + "fileCount", Long.toString(list.fileCount())), + header.messageId(), + list.raw() == null ? new byte[0] : list.raw())); + + case Jt808Body.PassengerVolume volume -> List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + passengerVolumeMeta(meta, volume), + header.messageId(), + volume.raw() == null ? new byte[0] : volume.raw())); + + case Jt808Body.Passthrough passthrough -> List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(meta, "passthroughType", "0x" + Integer.toHexString(passthrough.passthroughType())), + header.messageId(), + passthrough.data())); + + case Jt808Body.Raw raw -> List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(meta, "rawBody", "true"), + raw.messageId(), + raw.bytes())); + + case Jt808Body.Malformed malformed -> { + String errorMessage = malformed.errorMessage() == null ? "" : malformed.errorMessage(); + boolean processingError = isProcessingError(errorMessage); + boolean frameError = isFrameError(errorMessage); + String publicErrorMessage = processingError + ? errorMessage.substring(PROCESSING_ERROR_PREFIX.length()) + : errorMessage; + Map errorMeta = withMeta(meta, + processingError ? "processingError" : frameError ? "frameError" : "parseError", "true"); + errorMeta = withMeta(errorMeta, "peer", malformed.peer() == null ? "" : malformed.peer()); + errorMeta = withMeta(errorMeta, + processingError ? "processingErrorMessage" : frameError ? "frameErrorMessage" : "parseErrorMessage", + publicErrorMessage); + yield List.of(new VehicleEvent.Passthrough( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + errorMeta, + header.messageId(), + malformed.bytes() == null ? new byte[0] : malformed.bytes())); + } + }; + } + + private IdentityResolution resolveIdentity(Jt808Message message) { + if (message.body() instanceof Jt808Body.Malformed) { + return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN)); + } + var header = message.header(); + try { + return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup( + ProtocolId.JT808, "", header.phone(), deviceId(message.body()), plate(message.body())))); + } catch (RuntimeException e) { + return new IdentityResolution( + new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN), + e.getMessage() == null ? "" : e.getMessage()); + } + } + + private static Map baseMeta(Jt808Header header, IdentityResolution identity) { + java.util.HashMap meta = new java.util.HashMap<>(); + meta.put("messageId", "0x" + Integer.toHexString(header.messageId())); + meta.put("protocolVersion", header.version().name()); + meta.put("vin", identity.vin()); + meta.put("phone", header.phone()); + meta.put("identityResolved", Boolean.toString(identity.identity().resolved())); + meta.put("identitySource", identity.identity().source().name()); + if (identity.errorMessage() != null) { + meta.put("identityError", "true"); + meta.put("identityErrorMessage", identity.errorMessage()); + } + return Map.copyOf(meta); + } + + private static boolean isFrameError(String errorMessage) { + return errorMessage != null && (errorMessage.startsWith("jt808 missing frame boundary") + || errorMessage.startsWith("jt808 leading bytes before frame boundary") + || errorMessage.startsWith("jt808 unclosed frame too large")); + } + + private static boolean isProcessingError(String errorMessage) { + return errorMessage != null && errorMessage.startsWith(PROCESSING_ERROR_PREFIX); + } + + private static List locationBatchEvents(String vin, + Map meta, + Jt808Body.LocationBatch batch, + Instant ingestTime) { + List out = new ArrayList<>(batch.locations().size()); + Map batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType())); + for (Jt808Body.Location loc : batch.locations()) { + out.add(locationEvent(vin, batchMeta, loc, ingestTime)); + } + return out; + } + + private static List mediaUploadEvents(String vin, + Map meta, + Jt808Body.MediaUpload media, + Instant ingestTime) { + List out = new ArrayList<>(2); + if (media.location() != null) { + out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())), + media.location(), ingestTime)); + } + out.add(new VehicleEvent.MediaMeta( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, ingestTime, ingestTime, null, + withMeta(meta, "mediaEventCode", Integer.toString(media.eventCode())), + Long.toString(media.mediaId()), + mediaType(media.mediaType(), media.mediaFormat(), media.channelId()), + media.data() == null ? 0 : media.data().length, + "")); + return out; + } + + private static Map passengerVolumeMeta(Map meta, + Jt808Body.PassengerVolume volume) { + java.util.HashMap copy = new java.util.HashMap<>(meta); + copy.put("jt1078Message", "passengerVolume"); + copy.put("channelId", Integer.toString(volume.channelId())); + copy.put("startTime", volume.startTime() == null ? "" : volume.startTime()); + copy.put("endTime", volume.endTime() == null ? "" : volume.endTime()); + copy.put("passengerGetOn", Integer.toString(volume.passengerGetOn())); + copy.put("passengerGetOff", Integer.toString(volume.passengerGetOff())); + return Map.copyOf(copy); + } + + private static VehicleEvent.Location locationEvent(String vin, + Map meta, + Jt808Body.Location loc, + Instant ingestTime) { + return new VehicleEvent.Location( + UUID.randomUUID().toString(), + vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, locationMeta(meta, loc), + new LocationPayload(loc.longitude(), loc.latitude(), + loc.altitudeM(), loc.speedKmh(), loc.directionDeg(), + loc.alarmFlag(), loc.statusFlag(), totalMileageKm(loc))); + } + + private static Map locationMeta(Map meta, Jt808Body.Location loc) { + if (loc.extensionItems().isEmpty()) { + return meta; + } + java.util.HashMap copy = new java.util.HashMap<>(meta); + loc.extensionItems().forEach((id, bytes) -> + copy.put("jt808.extra.0x" + String.format("%02X", id), hex(bytes))); + return Map.copyOf(copy); + } + + private static Map withMeta(Map meta, String key, String value) { + java.util.HashMap copy = new java.util.HashMap<>(meta); + copy.put(key, value == null ? "" : value); + return Map.copyOf(copy); + } + + private static Double totalMileageKm(Jt808Body.Location loc) { + byte[] bytes = loc.extensionItems().get(0x01); + if (bytes == null || bytes.length != 4) { + return null; + } + long raw = ((long) bytes[0] & 0xFF) << 24 + | ((long) bytes[1] & 0xFF) << 16 + | ((long) bytes[2] & 0xFF) << 8 + | ((long) bytes[3] & 0xFF); + return raw * 0.1; + } + + private static String hex(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0x0F, 16)); + sb.append(Character.forDigit(b & 0x0F, 16)); + } + return sb.toString().toUpperCase(java.util.Locale.ROOT); + } + + private static byte[] encodeParamsSummary(Jt808Body.ParamsReport params) { + return ("responseSerialNo=" + params.responseSerialNo() + + ",parameters=" + params.parameters().keySet()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static String mediaType(int mediaType, int mediaFormat, int channelId) { + return "type=" + mediaType + ",format=" + mediaFormat + ",channel=" + channelId; + } + + private static String mediaAttributesType(Jt808Body.MediaAttributes attrs) { + return "audioEncoding=" + attrs.inputAudioEncoding() + + ",audioChannels=" + attrs.inputAudioChannels() + + ",audioSampleRate=" + attrs.inputAudioSampleRate() + + ",audioBitDepth=" + attrs.inputAudioBitDepth() + + ",audioFrameLength=" + attrs.audioFrameLength() + + ",audioOutput=" + attrs.audioOutputSupported() + + ",videoEncoding=" + attrs.videoEncoding() + + ",maxAudioChannels=" + attrs.maxAudioChannels() + + ",maxVideoChannels=" + attrs.maxVideoChannels(); + } + + private static String deviceId(Jt808Body body) { + if (body instanceof Jt808Body.Register reg) { + return reg.deviceId(); + } + if (body instanceof Jt808Body.Auth auth) { + return auth.imei(); + } + return ""; + } + + private static String plate(Jt808Body body) { + return body instanceof Jt808Body.Register reg ? reg.plate() : ""; + } + + private record IdentityResolution(VehicleIdentity identity, String errorMessage) { + private static IdentityResolution ok(VehicleIdentity identity) { + return new IdentityResolution(identity, null); + } + + private String vin() { + return identity.vin(); + } + } +} diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java new file mode 100644 index 00000000..e651c375 --- /dev/null +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java @@ -0,0 +1,163 @@ +package com.lingniu.ingest.protocol.jt808.model; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * 已解析的消息体。sealed,新增消息体时必须同步新增子类型与 Parser。 + */ +public sealed interface Jt808Body + permits Jt808Body.Register, Jt808Body.Auth, Jt808Body.Location, + Jt808Body.LocationBatch, Jt808Body.Unregister, + Jt808Body.ParamsReport, Jt808Body.TerminalAttrs, + Jt808Body.MediaEvent, Jt808Body.MediaUpload, + Jt808Body.MediaAttributes, Jt808Body.FileUploadComplete, + Jt808Body.FileList, Jt808Body.PassengerVolume, + Jt808Body.Passthrough, Jt808Body.Heartbeat, Jt808Body.Raw, + Jt808Body.Malformed { + + /** 终端注册 0x0100。 */ + record Register( + int province, + int city, + String maker, + String deviceType, + String deviceId, + int plateColor, + String plate + ) implements Jt808Body {} + + /** 终端鉴权 0x0102。 */ + record Auth(String token, String imei, String softwareVersion) implements Jt808Body {} + + /** 位置信息汇报 0x0200。 */ + record Location( + long alarmFlag, + long statusFlag, + double longitude, + double latitude, + int altitudeM, + double speedKmh, + int directionDeg, + Instant deviceTime, + Map extensionItems + ) implements Jt808Body { + public Location(long alarmFlag, + long statusFlag, + double longitude, + double latitude, + int altitudeM, + double speedKmh, + int directionDeg, + Instant deviceTime) { + this(alarmFlag, statusFlag, longitude, latitude, altitudeM, speedKmh, + directionDeg, deviceTime, Map.of()); + } + + public Location { + extensionItems = extensionItems == null ? Map.of() : Map.copyOf(extensionItems); + } + } + + /** 位置信息批量上传 0x0704。 */ + record LocationBatch( + int batchType, + List locations + ) implements Jt808Body {} + + /** 终端注销 0x0003。 */ + record Unregister() implements Jt808Body {} + + /** 查询终端参数应答 0x0104。 */ + record ParamsReport( + int responseSerialNo, + Map parameters + ) implements Jt808Body {} + + /** 查询终端属性应答 0x0107。 */ + record TerminalAttrs( + int terminalType, + String maker, + String terminalModel, + String terminalId, + String iccid, + String hardwareVersion, + String firmwareVersion, + int gnssProperty, + int commProperty + ) implements Jt808Body {} + + /** 多媒体事件信息上传 0x0800。 */ + record MediaEvent( + long mediaId, + int mediaType, + int mediaFormat, + int eventCode, + int channelId + ) implements Jt808Body {} + + /** 多媒体数据上传 0x0801。 */ + record MediaUpload( + long mediaId, + int mediaType, + int mediaFormat, + int eventCode, + int channelId, + Location location, + byte[] data + ) implements Jt808Body {} + + /** JT/T 1078 终端上传音视频属性 0x1003。 */ + record MediaAttributes( + int inputAudioEncoding, + int inputAudioChannels, + int inputAudioSampleRate, + int inputAudioBitDepth, + int audioFrameLength, + boolean audioOutputSupported, + int videoEncoding, + int maxAudioChannels, + int maxVideoChannels, + byte[] raw + ) implements Jt808Body {} + + /** JT/T 1078 文件上传完成通知 0x1206。 */ + record FileUploadComplete( + int responseSerialNo, + int result, + byte[] raw + ) implements Jt808Body {} + + /** JT/T 1078 终端上传文件列表 0x1205。 */ + record FileList( + int responseSerialNo, + long fileCount, + byte[] raw + ) implements Jt808Body {} + + /** JT/T 1078 终端上传乘客流量 0x1005。 */ + record PassengerVolume( + int channelId, + String startTime, + String endTime, + int passengerGetOn, + int passengerGetOff, + byte[] raw + ) implements Jt808Body {} + + /** 数据上行透传 0x0900。 */ + record Passthrough( + int passthroughType, + byte[] data + ) implements Jt808Body {} + + /** 心跳 0x0002(无 body)。 */ + record Heartbeat() implements Jt808Body {} + + /** 未实现 Parser 的消息体,透传原始字节。 */ + record Raw(int messageId, byte[] bytes) implements Jt808Body {} + + /** 入站帧整体解析失败,保留原始字节便于冷存、查询和排障。 */ + record Malformed(byte[] bytes, String peer, String errorMessage) implements Jt808Body {} +} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Header.java diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Message.java diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java similarity index 90% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java index 90b45866..edc9c3ef 100644 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808MessageId.java @@ -31,6 +31,9 @@ public final class Jt808MessageId { public static final int PLATFORM_SET_PARAMS = 0x8103; public static final int PLATFORM_QUERY_PARAMS = 0x8104; public static final int PLATFORM_TERMINAL_CONTROL = 0x8105; + public static final int PLATFORM_QUERY_ATTRS = 0x8107; public static final int PLATFORM_QUERY_LOCATION = 0x8201; public static final int PLATFORM_TRACK_CONTROL = 0x8202; + public static final int PLATFORM_ALARM_ACK = 0x8203; + public static final int PLATFORM_DELETE_CIRCLE_AREA = 0x8601; } diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java similarity index 92% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java index 6b38c2d9..a7d62d55 100644 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java @@ -46,9 +46,12 @@ public final class Jt808ChannelRegistry { return Optional.of(c); } - public void unbind(Channel channel) { + public Optional unbind(Channel channel) { String phone = reverse.remove(channel); - if (phone != null) byPhone.remove(phone, channel); + if (phone != null) { + byPhone.remove(phone, channel); + } + return Optional.ofNullable(phone); } public int nextSerial(String phone) { diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java similarity index 100% rename from protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java rename to modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java diff --git a/protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/protocols/protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/protocols/protocol-jt808/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java similarity index 100% rename from protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java rename to modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderGoldenTest.java diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java new file mode 100644 index 00000000..e1cb5a3d --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java @@ -0,0 +1,239 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBatchBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.MediaEventBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.MediaUploadBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.PassthroughBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalAttrsBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.TerminalParamsReportBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.UnregisterBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808DecoderTest { + + private final Jt808MessageDecoder decoder = new Jt808MessageDecoder( + new BodyParserRegistry(List.of( + new RegisterBodyParser(), + new AuthBodyParser(), + new LocationBodyParser(), + new LocationBatchBodyParser(), + new UnregisterBodyParser(), + new TerminalParamsReportBodyParser(), + new TerminalAttrsBodyParser(), + new MediaEventBodyParser(), + new MediaUploadBodyParser(), + new PassthroughBodyParser(), + new HeartbeatBodyParser()))); + + @Test + void decodesSyntheticLocationFrame() { + byte[] body = buildLocationBody(); + byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 0x0001, body); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame)); + + assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION); + assertThat(msg.header().phone()).isEqualTo("123456789012"); + assertThat(msg.header().serialNo()).isEqualTo(1); + + assertThat(msg.body()).isInstanceOf(Jt808Body.Location.class); + Jt808Body.Location loc = (Jt808Body.Location) msg.body(); + assertThat(loc.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(loc.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); + assertThat(loc.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01)); + } + + @Test + void decodesLocationExtensionItems() { + byte[] body = bytes(os -> { + os.write(buildLocationBody(), 0, buildLocationBody().length); + os.write(0x01); // mileage, DWORD, 0.1 km + os.write(4); + writeU32(os, 12345); + os.write(0x30); // wireless signal strength + os.write(1); + os.write(88); + }); + + Jt808Body.Location loc = (Jt808Body.Location) decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_LOCATION, "123456789012", 9, body))).body(); + + assertThat(loc.extensionItems()).containsOnlyKeys(0x01, 0x30); + assertThat(loc.extensionItems().get(0x01)).containsExactly(0x00, 0x00, 0x30, 0x39); + assertThat(loc.extensionItems().get(0x30)).containsExactly(88); + } + + @Test + void decodesSyntheticHeartbeatFrame() { + byte[] frame = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 0x0002, new byte[0]); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame)); + assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT); + assertThat(msg.body()).isInstanceOf(Jt808Body.Heartbeat.class); + } + + @Test + void decodesAuthFrameAndExtractsImeiWhenPresent() { + byte[] authBody = "AUTH-CODE,123456789012345,SW-1".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + + Jt808Body.Auth auth = (Jt808Body.Auth) decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_AUTH, "123456789012", 10, authBody))).body(); + + assertThat(auth.token()).isEqualTo("AUTH-CODE,123456789012345,SW-1"); + assertThat(auth.imei()).isEqualTo("123456789012345"); + assertThat(auth.softwareVersion()).isEqualTo("SW-1"); + } + + @Test + void decodesLogoutParamsAttrsMediaAndPassthroughFrames() { + assertThat(decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_UNREGISTER, "123456789012", 3, new byte[0]))).body()) + .isInstanceOf(Jt808Body.Unregister.class); + + byte[] paramsBody = bytes(os -> { + writeU16(os, 7); + os.write(1); + writeU32(os, 0x00000080L); + os.write(1); + os.write(5); + }); + Jt808Body.ParamsReport params = (Jt808Body.ParamsReport) decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_PARAMS_REPORT, "123456789012", 4, paramsBody))).body(); + assertThat(params.responseSerialNo()).isEqualTo(7); + assertThat(params.parameters()).containsKey(0x00000080L); + + byte[] attrsBody = bytes(os -> { + writeU16(os, 0x1234); + writeAscii(os, "MAKER", 5); + writeAscii(os, "MODEL-X", 20); + writeAscii(os, "DEV1234", 7); + os.write(BcdCodec.encode("89860012345678901234"), 0, 10); + writeAscii(os, "HW1", 10); + writeAscii(os, "SW1", 10); + os.write(1); + os.write(2); + }); + Jt808Body.TerminalAttrs attrs = (Jt808Body.TerminalAttrs) decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_ATTRS_REPORT, "123456789012", 5, attrsBody))).body(); + assertThat(attrs.maker()).isEqualTo("MAKER"); + assertThat(attrs.terminalId()).isEqualTo("DEV1234"); + + byte[] mediaEventBody = bytes(os -> { + writeU32(os, 42); + os.write(0); + os.write(1); + os.write(2); + os.write(3); + }); + assertThat(decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_MEDIA_EVENT, "123456789012", 6, mediaEventBody))).body()) + .isInstanceOf(Jt808Body.MediaEvent.class); + + byte[] passthroughBody = bytes(os -> { + os.write(0x41); + os.write(new byte[]{0x01, 0x02, 0x03}, 0, 3); + }); + Jt808Body.Passthrough passthrough = (Jt808Body.Passthrough) decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_PASSTHROUGH, "123456789012", 7, passthroughBody))).body(); + assertThat(passthrough.passthroughType()).isEqualTo(0x41); + assertThat(passthrough.data()).containsExactly(0x01, 0x02, 0x03); + } + + @Test + void decodesLocationBatchFrame() { + byte[] location = buildLocationBody(); + byte[] batchBody = bytes(os -> { + writeU16(os, 2); + os.write(0); + writeU16(os, location.length); + os.write(location, 0, location.length); + writeU16(os, location.length); + os.write(location, 0, location.length); + }); + + Jt808Body.LocationBatch batch = (Jt808Body.LocationBatch) decoder.decode(ByteBuffer.wrap(buildFrame( + Jt808MessageId.TERMINAL_LOCATION_BATCH, "123456789012", 8, batchBody))).body(); + + assertThat(batch.batchType()).isEqualTo(0); + assertThat(batch.locations()).hasSize(2); + assertThat(batch.locations().getFirst().longitude()).isEqualTo(116.397128); + } + + // ===== helpers ===== + + private static byte[] buildLocationBody() { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU32(os, 0); // alarmFlag + writeU32(os, 0x00030000); // statusFlag + writeU32(os, 39_916_527L); // lat + writeU32(os, 116_397_128L); // lon + writeU16(os, 50); // altitude + writeU16(os, 523); // speed 52.3 + writeU16(os, 90); // direction + byte[] bcd = BcdCodec.encode("240102030405"); + os.write(bcd, 0, 6); + return os.toByteArray(); + } + + private static byte[] bytes(IoConsumer writer) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writer.accept(os); + return os.toByteArray(); + } + + private static void writeAscii(ByteArrayOutputStream os, String value, int len) { + byte[] out = new byte[len]; + byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + System.arraycopy(raw, 0, out, 0, Math.min(raw.length, out.length)); + os.write(out, 0, out.length); + } + + @FunctionalInterface + private interface IoConsumer { + void accept(T value); + } + + private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) { + // header: msgId(2) attrs(2) phone(6 BCD) serial(2) + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU16(os, msgId); + int attrs = body.length & 0x03FF; // 2013 版,无子包、无加密 + writeU16(os, attrs); + byte[] phoneBcd = BcdCodec.encode(phone); + os.write(phoneBcd, 0, 6); + writeU16(os, serial); + os.write(body, 0, body.length); + byte[] raw = os.toByteArray(); + byte bcc = BccChecksum.compute(raw, 0, raw.length); + + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + framed.write(raw, 0, raw.length); + framed.write(bcc & 0xFF); + return framed.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } +} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java similarity index 100% rename from protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java rename to modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808EscapeTest.java diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoderTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoderTest.java new file mode 100644 index 00000000..8fcc2d0c --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameDecoderTest.java @@ -0,0 +1,39 @@ +package com.lingniu.ingest.protocol.jt808.codec; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808FrameDecoderTest { + + @Test + void garbageWithoutFrameBoundaryEmitsMalformedCandidate() { + EmbeddedChannel channel = new EmbeddedChannel(new Jt808FrameDecoder()); + + channel.writeInbound(Unpooled.wrappedBuffer(new byte[]{0x01, 0x02, 0x03})); + + Jt808MalformedFrame malformed = channel.readInbound(); + assertThat(malformed.rawBytes()).containsExactly(0x01, 0x02, 0x03); + assertThat(malformed.reason()).contains("missing frame boundary"); + assertThat(malformed.peer()).isNotNull(); + assertThat((Object) channel.readInbound()).isNull(); + } + + @Test + void oversizedUnclosedFrameEmitsMalformedCandidate() { + byte[] bytes = new byte[16 * 1024 + 2]; + bytes[0] = 0x7e; + bytes[1] = 0x01; + EmbeddedChannel channel = new EmbeddedChannel(new Jt808FrameDecoder()); + + channel.writeInbound(Unpooled.wrappedBuffer(bytes)); + + Jt808MalformedFrame malformed = channel.readInbound(); + assertThat(malformed.rawBytes()).containsExactly(bytes); + assertThat(malformed.reason()).contains("too large"); + assertThat(malformed.peer()).isNotNull(); + assertThat((Object) channel.readInbound()).isNull(); + } +} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java similarity index 60% rename from protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java rename to modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java index e6e93933..a29d9e46 100644 --- a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808FrameEncoderTest.java @@ -13,6 +13,7 @@ import java.nio.ByteBuffer; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * 端到端验证:encoder 生成的下行帧可以被 decoder 反向解析,且字段对称。 @@ -54,4 +55,37 @@ class Jt808FrameEncoderTest { assertThat(framed[i]).isNotEqualTo((byte) 0x7e); } } + + @Test + void rejectsOversizedBodyWhenSubpackageIsNotUsed() { + assertThatThrownBy(() -> Jt808FrameEncoder.encode( + Jt808MessageId.PLATFORM_SET_PARAMS, "123456789012", 1, new byte[1024])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("jt808 body too large"); + } + + @Test + void encodesOversizedBodyAsSubpackages() { + byte[] body = new byte[2048]; + for (int i = 0; i < body.length; i++) { + body[i] = (byte) i; + } + + List frames = Jt808FrameEncoder.encodeSubpackages( + Jt808MessageId.PLATFORM_SET_PARAMS, "123456789012", 9, body); + + assertThat(frames).hasSize(3); + java.io.ByteArrayOutputStream joined = new java.io.ByteArrayOutputStream(); + for (int i = 0; i < frames.size(); i++) { + byte[] unescaped = Jt808Escape.unescape(frames.get(i), 1, frames.get(i).length - 2); + Jt808Message msg = decoder.decode(ByteBuffer.wrap(unescaped)); + assertThat(msg.header().subpacket()).isTrue(); + assertThat(msg.header().totalPackets()).isEqualTo(3); + assertThat(msg.header().packetSeq()).isEqualTo(i + 1); + assertThat(msg.header().serialNo()).isEqualTo(9); + assertThat(msg.body()).isInstanceOf(Jt808Body.Raw.class); + joined.writeBytes(((Jt808Body.Raw) msg.body()).bytes()); + } + assertThat(joined.toByteArray()).containsExactly(body); + } } diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcherTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcherTest.java new file mode 100644 index 00000000..f8a3413c --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcherTest.java @@ -0,0 +1,56 @@ +package com.lingniu.ingest.protocol.jt808.downlink; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry; +import com.lingniu.ingest.protocol.jt808.codec.Jt808Escape; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; +import com.lingniu.ingest.session.DeviceSession; +import com.lingniu.ingest.session.InMemorySessionStore; +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808CommandDispatcherTest { + + @Test + void notifyWritesSubpackageFramesForLargeBody() { + InMemorySessionStore sessions = new InMemorySessionStore(); + sessions.put(new DeviceSession("sid-1", ProtocolId.JT808, "LNVIN000000000303", + "123456789012", "", "127.0.0.1:10000", Instant.now(), Instant.now(), Map.of())); + Jt808ChannelRegistry channels = new Jt808ChannelRegistry(); + EmbeddedChannel channel = new EmbeddedChannel(); + channels.bind("123456789012", channel); + Jt808CommandDispatcher dispatcher = new Jt808CommandDispatcher( + sessions, channels, new Jt808PendingRequests()); + byte[] body = new byte[2048]; + + dispatcher.notify("sid-1", new Jt808Commands.DownlinkCommand( + Jt808MessageId.PLATFORM_SET_PARAMS, body)).join(); + + Jt808MessageDecoder decoder = new Jt808MessageDecoder(new BodyParserRegistry(List.of())); + for (int i = 1; i <= 3; i++) { + ByteBuf outbound = channel.readOutbound(); + byte[] frame = new byte[outbound.readableBytes()]; + outbound.readBytes(frame); + byte[] unescaped = Jt808Escape.unescape(frame, 1, frame.length - 2); + Jt808Message decoded = decoder.decode(ByteBuffer.wrap(unescaped)); + assertThat(decoded.header().subpacket()).isTrue(); + assertThat(decoded.header().totalPackets()).isEqualTo(3); + assertThat(decoded.header().packetSeq()).isEqualTo(i); + assertThat(decoded.body()).isInstanceOf(Jt808Body.Raw.class); + } + assertThat((Object) channel.readOutbound()).isNull(); + } +} diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandsTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandsTest.java new file mode 100644 index 00000000..f5a5dc6f --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandsTest.java @@ -0,0 +1,21 @@ +package com.lingniu.ingest.protocol.jt808.downlink; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808CommandsTest { + + @Test + void deleteAreaEncodesCountAndDwordAreaIds() { + var command = Jt808Commands.deleteArea(List.of(1L, 0x01020304L)); + + assertThat(command.messageId()).isEqualTo(0x8601); + assertThat(command.body()).containsExactly( + 0x02, + 0x00, 0x00, 0x00, 0x01, + 0x01, 0x02, 0x03, 0x04); + } +} diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandlerTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandlerTest.java new file mode 100644 index 00000000..03c05fd9 --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/handler/Jt808LocationHandlerTest.java @@ -0,0 +1,48 @@ +package com.lingniu.ingest.protocol.jt808.handler; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.pipeline.IngestContext; +import com.lingniu.ingest.api.pipeline.RawFrame; +import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor; +import com.lingniu.ingest.core.dispatcher.HandlerInvoker; +import com.lingniu.ingest.core.dispatcher.HandlerRegistry; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808LocationHandlerTest { + + @Test + void unknownRawMessageRoutesToPassthroughHandler() { + Jt808LocationHandler handler = new Jt808LocationHandler( + new Jt808EventMapper(new InMemoryVehicleIdentityService())); + HandlerRegistry registry = new HandlerRegistry(); + new AnnotationHandlerBeanPostProcessor(registry).postProcessAfterInitialization(handler, "jt808Handler"); + Jt808Message message = new Jt808Message( + new Jt808Header(0x0F01, 3, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 7, 0, 0), + new Jt808Body.Raw(0x0F01, new byte[]{0x01, 0x02, 0x03})); + + var definitions = registry.resolve(ProtocolId.JT808, 0x0F01, 0); + + assertThat(definitions).hasSize(1); + var events = new HandlerInvoker().invoke(definitions.getFirst(), new RawFrame( + ProtocolId.JT808, 0x0F01, 0, message, new byte[]{0x7e}, + Map.of("phone", "123456789012"), Instant.now()), new IngestContext("trace-1")); + assertThat(events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.passthroughType()).isEqualTo(0x0F01); + assertThat(passthrough.metadata()).containsEntry("rawBody", "true"); + }); + } +} diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandlerTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandlerTest.java new file mode 100644 index 00000000..e4b8505e --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandlerTest.java @@ -0,0 +1,495 @@ +package com.lingniu.ingest.protocol.jt808.inbound; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.codec.BcdCodec; +import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor; +import com.lingniu.ingest.core.concurrency.DisruptorEventBus; +import com.lingniu.ingest.core.dispatcher.Dispatcher; +import com.lingniu.ingest.core.dispatcher.HandlerDefinition; +import com.lingniu.ingest.core.dispatcher.HandlerInvoker; +import com.lingniu.ingest.core.dispatcher.HandlerRegistry; +import com.lingniu.ingest.core.pipeline.InterceptorChain; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jt808.codec.BodyParserRegistry; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MalformedFrame; +import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; +import com.lingniu.ingest.protocol.jt808.codec.parser.AuthBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; +import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; +import com.lingniu.ingest.protocol.jt808.handler.Jt808LocationHandler; +import com.lingniu.ingest.protocol.jt808.mapper.Jt808EventMapper; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; +import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; +import com.lingniu.ingest.session.InMemorySessionStore; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808ChannelHandlerTest { + + @Test + void registerSessionUsesResolvedInternalVinInsteadOfDeviceIdPlaceholder() { + InMemorySessionStore sessions = new InMemorySessionStore(); + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "B80808")); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of()); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + new HandlerRegistry(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))), + dispatcher, + sessions, + identity, + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + channel.writeInbound(buildFrame( + Jt808MessageId.TERMINAL_REGISTER, + "123456789012", + 1, + buildRegisterBody("DEV808", "B80808"))); + + assertThat(sessions.findByPhone("123456789012")) + .get() + .extracting(session -> session.vin()) + .isEqualTo("LNVIN000000000808"); + assertThat(identity.resolve(new com.lingniu.ingest.identity.VehicleIdentityLookup( + ProtocolId.JT808, "", "123456789012", "", "")).vin()) + .isEqualTo("LNVIN000000000808"); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void authSessionUsesResolvedInternalVinFromImei() { + InMemorySessionStore sessions = new InMemorySessionStore(); + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT808, "LNVIN000000AUTH01", "123456789012", "123456789012345", "")); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of()); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + new HandlerRegistry(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))), + dispatcher, + sessions, + identity, + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + channel.writeInbound(buildFrame( + Jt808MessageId.TERMINAL_AUTH, + "123456789012", + 2, + "TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + + assertThat(sessions.findByPhone("123456789012")) + .get() + .extracting(session -> session.vin()) + .isEqualTo("LNVIN000000AUTH01"); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void inactiveChannelRemovesSessionStoreEntry() { + InMemorySessionStore sessions = new InMemorySessionStore(); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of()); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + new HandlerRegistry(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new AuthBodyParser()))), + dispatcher, + sessions, + new InMemoryVehicleIdentityService(), + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + channel.writeInbound(buildFrame( + Jt808MessageId.TERMINAL_AUTH, + "123456789012", + 2, + "TOKEN,123456789012345,SW1".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + assertThat(sessions.findByPhone("123456789012")).isPresent(); + + channel.close(); + + assertThat(sessions.findByPhone("123456789012")).isEmpty(); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void successfulFrameArchiveUsesResolvedVehicleIdentity() throws Exception { + RecordingSink sink = new RecordingSink(1); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + new HandlerRegistry(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + identity.bind(new VehicleIdentityBinding( + ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "粤B80808")); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))), + dispatcher, + new InMemorySessionStore(), + identity, + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody()); + + channel.writeInbound(frame); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).singleElement().satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.vin()).isEqualTo("LNVIN000000000808"); + assertThat(archive.metadata()) + .containsEntry("vin", "LNVIN000000000808") + .containsEntry("phone", "123456789012") + .containsEntry("identityResolved", "true") + .containsEntry("identitySource", "BOUND_PHONE"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void malformedFrameIsArchivedAndDispatchedAsPassthrough() throws Exception { + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + registryWithRawHandler(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of())), + dispatcher, + new InMemorySessionStore(), + new InMemoryVehicleIdentityService(), + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] malformed = new byte[]{0x01, 0x02, 0x03}; + + channel.writeInbound(malformed); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive raw = (VehicleEvent.RawArchive) event; + assertThat(raw.source()).isEqualTo(ProtocolId.JT808); + assertThat(raw.command()).isZero(); + assertThat(raw.rawBytes()).containsExactly(malformed); + assertThat(raw.metadata()) + .containsEntry("parseError", "true") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808); + assertThat(passthrough.vin()).isEqualTo("unknown"); + assertThat(passthrough.metadata()) + .containsEntry("parseError", "true") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(passthrough.data()).containsExactly(malformed); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void frameBoundaryMalformedCandidateIsArchivedAndDispatchedAsPassthrough() throws Exception { + RecordingSink sink = new RecordingSink(2); + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + registryWithRawHandler(), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of())), + dispatcher, + new InMemorySessionStore(), + new InMemoryVehicleIdentityService(), + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] raw = new byte[]{0x01, 0x02, 0x03}; + + channel.writeInbound(new Jt808MalformedFrame(raw, "unknown", "jt808 missing frame boundary")); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.source()).isEqualTo(ProtocolId.JT808); + assertThat(archive.rawBytes()).containsExactly(raw); + assertThat(archive.metadata()).containsEntry("frameError", "true") + .containsEntry("frameErrorMessage", "jt808 missing frame boundary") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.source()).isEqualTo(ProtocolId.JT808); + assertThat(passthrough.metadata()).containsEntry("frameError", "true") + .containsEntry("frameErrorMessage", "jt808 missing frame boundary") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN"); + assertThat(passthrough.data()).containsExactly(raw); + }); + + batchExecutor.close(); + eventBus.close(); + } + + @Test + void identityResolverFailureStillArchivesAndDispatchesDecodedLocationWithUnknownIdentity() throws Exception { + RecordingSink sink = new RecordingSink(2); + var failingIdentity = (com.lingniu.ingest.identity.VehicleIdentityResolver) lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }; + DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink)); + AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish); + Dispatcher dispatcher = new Dispatcher( + registryWithLocationHandler(new DirectLocationHandler(new Jt808EventMapper(failingIdentity))), + new InterceptorChain(List.of()), + new HandlerInvoker(), + eventBus, + batchExecutor); + Jt808ChannelHandler handler = new Jt808ChannelHandler( + new Jt808MessageDecoder(new BodyParserRegistry(List.of(new LocationBodyParser()))), + dispatcher, + new InMemorySessionStore(), + new InMemoryVehicleIdentityService(), + failingIdentity, + new Jt808ChannelRegistry(), + new Jt808PendingRequests()); + EmbeddedChannel channel = new EmbeddedChannel(handler); + byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 1, buildLocationBody()); + + channel.writeInbound(frame); + + assertThat(sink.await()).isTrue(); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class); + VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event; + assertThat(archive.source()).isEqualTo(ProtocolId.JT808); + assertThat(archive.command()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION); + assertThat(archive.rawBytes()).containsExactly(frame); + assertThat(archive.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("phone", "123456789012") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + assertThat(sink.events).anySatisfy(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Location.class); + VehicleEvent.Location location = (VehicleEvent.Location) event; + assertThat(location.source()).isEqualTo(ProtocolId.JT808); + assertThat(location.vin()).isEqualTo("unknown"); + assertThat(location.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("phone", "123456789012") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + + batchExecutor.close(); + eventBus.close(); + } + + private static HandlerRegistry registryWithRawHandler() throws NoSuchMethodException { + HandlerRegistry registry = new HandlerRegistry(); + Jt808LocationHandler rawHandler = new Jt808LocationHandler( + new Jt808EventMapper(new InMemoryVehicleIdentityService())); + registry.register(new HandlerDefinition( + ProtocolId.JT808, + 0, + 0, + "test raw", + rawHandler, + Jt808LocationHandler.class.getMethod("onRaw", Jt808Message.class), + Jt808Message.class, + null, + null, + null)); + return registry; + } + + private static HandlerRegistry registryWithLocationHandler(DirectLocationHandler locationHandler) throws NoSuchMethodException { + HandlerRegistry registry = new HandlerRegistry(); + registry.register(new HandlerDefinition( + ProtocolId.JT808, + Jt808MessageId.TERMINAL_LOCATION, + 0, + "test location", + locationHandler, + DirectLocationHandler.class.getMethod("onLocation", Jt808Message.class), + Jt808Message.class, + null, + null, + null)); + return registry; + } + + public static final class DirectLocationHandler { + private final Jt808EventMapper mapper; + + private DirectLocationHandler(Jt808EventMapper mapper) { + this.mapper = mapper; + } + + public List onLocation(Jt808Message msg) { + return mapper.toEvents(msg); + } + } + + private static byte[] buildLocationBody() { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU32(os, 0); + writeU32(os, 0x00030000); + writeU32(os, 39_916_527L); + writeU32(os, 116_397_128L); + writeU16(os, 50); + writeU16(os, 523); + writeU16(os, 90); + byte[] bcd = BcdCodec.encode("240102030405"); + os.write(bcd, 0, 6); + return os.toByteArray(); + } + + private static byte[] buildRegisterBody(String deviceId, String plate) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU16(os, 44); + writeU16(os, 4401); + writeAscii(os, "MAKER", 5); + writeAscii(os, "TYPE-A", 20); + writeAscii(os, deviceId, 7); + os.write(1); + byte[] plateBytes = plate.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + os.write(plateBytes, 0, plateBytes.length); + return os.toByteArray(); + } + + private static void writeAscii(ByteArrayOutputStream os, String value, int len) { + byte[] raw = value.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + int copy = Math.min(raw.length, len); + os.write(raw, 0, copy); + for (int i = copy; i < len; i++) { + os.write(0x20); + } + } + + private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + writeU16(os, msgId); + writeU16(os, body.length & 0x03FF); + byte[] phoneBcd = BcdCodec.encode(phone); + os.write(phoneBcd, 0, 6); + writeU16(os, serial); + os.write(body, 0, body.length); + byte[] raw = os.toByteArray(); + byte bcc = BccChecksum.compute(raw, 0, raw.length); + + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + framed.write(raw, 0, raw.length); + framed.write(bcc & 0xFF); + return framed.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } + + private static final class RecordingSink implements EventSink { + private final List events = new CopyOnWriteArrayList<>(); + private final CountDownLatch latch; + + private RecordingSink(int expectedEvents) { + this.latch = new CountDownLatch(expectedEvents); + } + + @Override + public String name() { + return "recording"; + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + events.add(event); + latch.countDown(); + return CompletableFuture.completedFuture(null); + } + + private boolean await() throws InterruptedException { + return latch.await(3, TimeUnit.SECONDS); + } + } +} diff --git a/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java new file mode 100644 index 00000000..6c2563e1 --- /dev/null +++ b/modules/protocols/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java @@ -0,0 +1,223 @@ +package com.lingniu.ingest.protocol.jt808.mapper; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.identity.InMemoryVehicleIdentityService; +import com.lingniu.ingest.identity.VehicleIdentityBinding; +import com.lingniu.ingest.protocol.jt808.model.Jt808Body; +import com.lingniu.ingest.protocol.jt808.model.Jt808Header; +import com.lingniu.ingest.protocol.jt808.model.Jt808Message; +import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jt808EventMapperTest { + + private final InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService(); + private final Jt808EventMapper mapper = new Jt808EventMapper(identity); + + @Test + void locationBodyProducesLocationEvent() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION, 28, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var body = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z")); + var msg = new Jt808Message(header, body); + + List events = mapper.toEvents(msg); + + assertThat(events).hasSize(1); + assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class); + assertThat(events.get(0).source()).isEqualTo(ProtocolId.JT808); + assertThat(events.get(0).vin()).isEqualTo("123456789012"); + assertThat(events.get(0).metadata()) + .containsEntry("vin", "123456789012") + .containsEntry("identityResolved", "false"); + } + + @Test + void locationExtensionItemsAreExposedAsMetadata() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION, 35, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var body = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, + Instant.parse("2024-01-02T03:04:05Z"), + java.util.Map.of(0x01, new byte[]{0x00, 0x00, 0x30, 0x39}, 0x30, new byte[]{0x58})); + + List events = mapper.toEvents(new Jt808Message(header, body)); + + assertThat(events).singleElement() + .extracting(VehicleEvent::metadata) + .satisfies(meta -> assertThat(meta) + .containsEntry("jt808.extra.0x01", "00003039") + .containsEntry("jt808.extra.0x30", "58")); + } + + @Test + void locationMileageExtensionIsMappedToInternalMileageField() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION, 34, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var body = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, + Instant.parse("2024-01-02T03:04:05Z"), + java.util.Map.of(0x01, new byte[]{0x00, 0x00, 0x30, 0x39})); + + VehicleEvent.Location event = (VehicleEvent.Location) mapper.toEvents(new Jt808Message(header, body)).getFirst(); + + assertThat(event.payload().totalMileageKm()).isEqualTo(1234.5); + assertThat(event.metadata()).containsEntry("jt808.extra.0x01", "00003039"); + } + + @Test + void locationBodyUsesBoundVehicleIdentity() { + identity.bind(new VehicleIdentityBinding(ProtocolId.JT808, + "LNVIN000000000009", "123456789012", "DEV009", "粤B99999")); + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION, 28, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var body = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z")); + + List events = mapper.toEvents(new Jt808Message(header, body)); + + assertThat(events).hasSize(1); + assertThat(events.get(0).vin()).isEqualTo("LNVIN000000000009"); + assertThat(events.get(0).metadata()) + .containsEntry("vin", "LNVIN000000000009") + .containsEntry("identityResolved", "true"); + } + + @Test + void identityResolverFailureStillProducesLocationEventWithUnknownVin() { + Jt808EventMapper mapperWithFailingIdentity = new Jt808EventMapper(lookup -> { + throw new IllegalStateException("identity backend unavailable"); + }); + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION, 28, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var body = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z")); + + List events = mapperWithFailingIdentity.toEvents(new Jt808Message(header, body)); + + assertThat(events).singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Location.class); + assertThat(event.vin()).isEqualTo("unknown"); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("phone", "123456789012") + .containsEntry("identityResolved", "false") + .containsEntry("identitySource", "UNKNOWN") + .containsEntry("identityError", "true") + .containsEntry("identityErrorMessage", "identity backend unavailable"); + }); + } + + @Test + void heartbeatBodyProducesHeartbeatEvent() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_HEARTBEAT, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var msg = new Jt808Message(header, new Jt808Body.Heartbeat()); + assertThat(mapper.toEvents(msg)).hasSize(1) + .first().isInstanceOf(VehicleEvent.Heartbeat.class); + } + + @Test + void unregisterBodyProducesLogoutEvent() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_UNREGISTER, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + + assertThat(mapper.toEvents(new Jt808Message(header, new Jt808Body.Unregister()))) + .singleElement() + .isInstanceOf(VehicleEvent.Logout.class); + } + + @Test + void locationBatchProducesOneLocationEventPerPoint() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_LOCATION_BATCH, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + var loc = new Jt808Body.Location( + 0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z")); + + List events = mapper.toEvents(new Jt808Message( + header, new Jt808Body.LocationBatch(0, List.of(loc, loc)))); + + assertThat(events).hasSize(2) + .allSatisfy(event -> assertThat(event).isInstanceOf(VehicleEvent.Location.class)); + } + + @Test + void mediaAndPassthroughBodiesProduceQueryableEvents() { + var header = new Jt808Header( + Jt808MessageId.TERMINAL_MEDIA_EVENT, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); + assertThat(mapper.toEvents(new Jt808Message( + header, new Jt808Body.MediaEvent(42, 0, 1, 2, 3)))) + .singleElement() + .isInstanceOf(VehicleEvent.MediaMeta.class); + + var passHeader = new Jt808Header( + Jt808MessageId.TERMINAL_PASSTHROUGH, 0, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 2, 0, 0); + assertThat(mapper.toEvents(new Jt808Message( + passHeader, new Jt808Body.Passthrough(0x41, new byte[]{1, 2, 3})))) + .singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.passthroughType()).isEqualTo(Jt808MessageId.TERMINAL_PASSTHROUGH); + assertThat(passthrough.metadata()).containsEntry("passthroughType", "0x41"); + assertThat(passthrough.data()).containsExactly(1, 2, 3); + }); + } + + @Test + void rawBodyProducesPassthroughEventSoUnknownMessagesRemainQueryable() { + var header = new Jt808Header( + 0x0F01, 3, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 9, 0, 0); + + assertThat(mapper.toEvents(new Jt808Message( + header, new Jt808Body.Raw(0x0F01, new byte[]{0x11, 0x22, 0x33})))) + .singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + var passthrough = (VehicleEvent.Passthrough) event; + assertThat(passthrough.passthroughType()).isEqualTo(0x0F01); + assertThat(passthrough.data()).containsExactly(0x11, 0x22, 0x33); + assertThat(passthrough.metadata()) + .containsEntry("vin", "123456789012") + .containsEntry("rawBody", "true"); + }); + } + + @Test + void malformedBodyPassthroughMetadataUsesUnknownInternalVin() { + var header = new Jt808Header( + 0, 3, 0, false, + Jt808Header.ProtocolVersion.V2013, "123456789012", 9, 0, 0); + + assertThat(mapper.toEvents(new Jt808Message( + header, new Jt808Body.Malformed(new byte[]{0x01, 0x02, 0x03}, "127.0.0.1:7611", "decode failed")))) + .singleElement() + .satisfies(event -> { + assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class); + assertThat(event.vin()).isEqualTo("unknown"); + assertThat(event.metadata()) + .containsEntry("vin", "unknown") + .containsEntry("identityResolved", "false") + .containsEntry("parseError", "true"); + }); + } +} diff --git a/protocol-jt808/src/test/resources/samples/jt808/auth_001.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/auth_001.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/auth_001.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/auth_001.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/auth_002.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/auth_002.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/auth_002.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/auth_002.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_001.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_001.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_001.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_001.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_002.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_002.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_002.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_002.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_010.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_010.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_010.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_010.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_100.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_100.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_100.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_100.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_200.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_200.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_200.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_200.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_400.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_400.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_400.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_400.hex diff --git a/protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex b/modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex similarity index 100% rename from protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex rename to modules/protocols/protocol-jt808/src/test/resources/samples/jt808/location_batch_001.hex diff --git a/modules/services/event-history-service/pom.xml b/modules/services/event-history-service/pom.xml new file mode 100644 index 00000000..959d5483 --- /dev/null +++ b/modules/services/event-history-service/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + ../../../pom.xml + + event-history-service + event-history-service + Kafka full-field event consumer + Parquet/DuckDB historical query API. + + + + com.lingniu.ingest + event-file-store + + + com.lingniu.ingest + sink-mq + + + com.lingniu.ingest + sink-archive + + + com.lingniu.ingest + protocol-gb32960 + + + org.apache.kafka + kafka-clients + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-autoconfigure + + + io.swagger.core.v3 + swagger-annotations-jakarta + 2.2.47 + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java new file mode 100644 index 00000000..821921fa --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java @@ -0,0 +1,92 @@ +package com.lingniu.ingest.eventhistory; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; + +@RestControllerAdvice +public final class ApiExceptionHandler { + + private static final ZoneId DEFAULT_TIME_ZONE = ZoneId.of("Asia/Shanghai"); + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity missingRequestParameter(MissingServletRequestParameterException ex, + HttpServletRequest request) { + return error(HttpStatus.BAD_REQUEST, + "missing required query parameter: " + ex.getParameterName(), + request); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity typeMismatch(MethodArgumentTypeMismatchException ex, + HttpServletRequest request) { + String message = "invalid query parameter: " + ex.getName(); + if (ex.getValue() != null) { + message += " value=" + ex.getValue(); + } + return error(HttpStatus.BAD_REQUEST, message, request); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity responseStatus(ResponseStatusException ex, + HttpServletRequest request) { + HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); + if (status == null) { + status = HttpStatus.INTERNAL_SERVER_ERROR; + } + String message = ex.getReason(); + if (message == null || message.isBlank()) { + message = status.getReasonPhrase(); + } + return error(status, message, request); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity illegalArgument(IllegalArgumentException ex, + HttpServletRequest request) { + return error(HttpStatus.BAD_REQUEST, safeMessage(ex, "bad request"), request); + } + + @ExceptionHandler(DateTimeParseException.class) + public ResponseEntity dateTimeParse(DateTimeParseException ex, + HttpServletRequest request) { + return error(HttpStatus.BAD_REQUEST, "invalid date/time parameter: " + ex.getParsedString(), request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity generic(Exception ex, + HttpServletRequest request) { + return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request); + } + + private static ResponseEntity error(HttpStatus status, String message, HttpServletRequest request) { + return ResponseEntity.status(status).body(new ApiError( + OffsetDateTime.now(DEFAULT_TIME_ZONE).toString(), + status.value(), + status.getReasonPhrase(), + message, + request.getRequestURI())); + } + + private static String safeMessage(Exception ex, String fallback) { + String message = ex.getMessage(); + return message == null || message.isBlank() ? fallback : message; + } + + public record ApiError( + String timestamp, + int status, + String error, + String message, + String path) { + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java new file mode 100644 index 00000000..c958ce36 --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java @@ -0,0 +1,196 @@ +package com.lingniu.ingest.eventhistory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.eventfilestore.EventFileQuery; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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 java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") +@ConditionalOnBean(EventFileStore.class) +@RequestMapping("/api/event-history") +public class EventHistoryController { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final EventFileStore store; + + public EventHistoryController(EventFileStore store) { + this.store = store; + } + + @GetMapping("/records") + public List query( + @RequestParam ProtocolId protocol, + @RequestParam String dateFrom, + @RequestParam String dateTo, + @RequestParam(defaultValue = "ASC") EventFileQuery.Order order, + @RequestParam(defaultValue = "100") int limit, + @RequestParam(required = false) String vin) throws IOException { + return queryRecords(protocol, dateFrom, dateTo, order, limit, vin).stream() + .map(RecordResponse::from) + .toList(); + } + + @GetMapping(value = "/export.csv", produces = "text/csv;charset=UTF-8") + public String exportCsv( + @RequestParam ProtocolId protocol, + @RequestParam String dateFrom, + @RequestParam String dateTo, + @RequestParam(defaultValue = "ASC") EventFileQuery.Order order, + @RequestParam(defaultValue = "100000") int limit, + @RequestParam(required = false) String vin, + @RequestParam(required = false) String fields) throws IOException { + List records = queryRecords(protocol, dateFrom, dateTo, order, limit, vin); + List selectedFields = parseFields(fields); + StringBuilder csv = new StringBuilder(256 + records.size() * 128); + csv.append("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri"); + for (String field : selectedFields) { + csv.append(',').append(csv(field)); + } + csv.append(",payload_json\n"); + for (EventFileRecord record : records) { + Map telemetryFields = selectedFields.isEmpty() + ? Map.of() + : telemetryFields(record.payloadJson()); + csv.append(csv(record.eventId())).append(',') + .append(csv(record.protocol().name())).append(',') + .append(csv(record.eventType())).append(',') + .append(csv(record.vin())).append(',') + .append(csv(record.eventTime().toString())).append(',') + .append(csv(record.ingestTime().toString())).append(',') + .append(csv(record.rawArchiveUri())); + for (String field : selectedFields) { + csv.append(',').append(csv(fieldValue(record, telemetryFields, field))); + } + csv.append(',') + .append(csv(record.payloadJson())).append('\n'); + } + return csv.toString(); + } + + public String exportCsv(ProtocolId protocol, + String dateFrom, + String dateTo, + EventFileQuery.Order order, + int limit) throws IOException { + return exportCsv(protocol, dateFrom, dateTo, order, limit, null, null); + } + + private List queryRecords(ProtocolId protocol, + String dateFrom, + String dateTo, + EventFileQuery.Order order, + int limit, + String vin) throws IOException { + QueryTimeRange range = QueryTimeRange.parse(dateFrom, dateTo); + return store.query(new EventFileQuery( + protocol, + range.dateFrom(), + range.dateTo(), + range.eventTimeFrom(), + range.eventTimeTo(), + order, + limit, + vin, + null)); + } + + public record RecordResponse( + String eventId, + ProtocolId protocol, + String eventType, + String vin, + String eventTime, + String ingestTime, + String rawArchiveUri, + Map metadata, + String payloadJson) { + + private static RecordResponse from(EventFileRecord record) { + return new RecordResponse( + record.eventId(), + record.protocol(), + record.eventType(), + record.vin(), + record.eventTime().toString(), + record.ingestTime().toString(), + record.rawArchiveUri(), + record.metadata(), + record.payloadJson()); + } + } + + private static List parseFields(String fields) { + if (fields == null || fields.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String field : fields.split(",")) { + String normalized = field.trim(); + if (!normalized.isBlank()) { + out.add(normalized); + } + } + return List.copyOf(out); + } + + private static Map telemetryFields(String payloadJson) throws IOException { + if (payloadJson == null || payloadJson.isBlank()) { + return Map.of(); + } + JsonNode root = OBJECT_MAPPER.readTree(payloadJson); + JsonNode fields = root.path("fields"); + if (!fields.isArray()) { + return Map.of(); + } + Map out = new LinkedHashMap<>(); + for (JsonNode field : fields) { + String key = field.path("key").asText(""); + if (!key.isBlank()) { + out.put(key, field.path("value").asText("")); + } + } + return out; + } + + private static String fieldValue(EventFileRecord record, + Map telemetryFields, + String field) { + if (field == null || field.isBlank()) { + return ""; + } + if (field.startsWith("metadata.")) { + return record.metadata().get(field.substring("metadata.".length())); + } + return telemetryFields.get(field); + } + + private static String csv(String value) { + if (value == null) { + return ""; + } + boolean quote = value.indexOf(',') >= 0 + || value.indexOf('"') >= 0 + || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0; + if (!quote) { + return value; + } + return "\"" + value.replace("\"", "\"\"") + "\""; + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java new file mode 100644 index 00000000..2d5fca22 --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java @@ -0,0 +1,68 @@ +package com.lingniu.ingest.eventhistory; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import com.lingniu.ingest.api.consumer.EnvelopeIngestor; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; + +import java.io.IOException; + +/** + * Consumer-side entry point that stores one Kafka envelope value into the + * historical detail file store. + */ +public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor { + + private final EventFileStore store; + private final TelemetryEnvelopeRecordMapper mapper; + + public EventHistoryEnvelopeIngestor(EventFileStore store, TelemetryEnvelopeRecordMapper mapper) { + if (store == null) { + throw new IllegalArgumentException("store must not be null"); + } + if (mapper == null) { + throw new IllegalArgumentException("mapper must not be null"); + } + this.store = store; + this.mapper = mapper; + } + + public void ingest(byte[] kafkaValue) throws IOException { + VehicleEnvelope envelope = parse(kafkaValue); + EventFileRecord record = mapper.toRecord(envelope); + store.append(record); + } + + @Override + public EnvelopeIngestResult tryIngest(byte[] kafkaValue) { + VehicleEnvelope envelope = null; + try { + envelope = parse(kafkaValue); + EventFileRecord record = mapper.toRecord(envelope); + store.append(record); + return EnvelopeIngestResult.stored(record.eventId(), record.vin()); + } catch (IllegalArgumentException ex) { + return envelope == null + ? EnvelopeIngestResult.invalid(ex.getMessage()) + : EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage()); + } catch (IOException ex) { + return EnvelopeIngestResult.failed( + envelope == null ? "" : envelope.getEventId(), + envelope == null ? "" : envelope.getVin(), + ex.getMessage()); + } + } + + private static VehicleEnvelope parse(byte[] kafkaValue) { + if (kafkaValue == null || kafkaValue.length == 0) { + throw new IllegalArgumentException("VehicleEnvelope bytes must not be empty"); + } + try { + return VehicleEnvelope.parseFrom(kafkaValue); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("failed to parse VehicleEnvelope", e); + } + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java new file mode 100644 index 00000000..717e4404 --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java @@ -0,0 +1,764 @@ +package com.lingniu.ingest.eventhistory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.eventfilestore.EventFileQuery; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; +import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +public final class Gb32960DecodedFrameService { + + private static final Logger log = LoggerFactory.getLogger(Gb32960DecodedFrameService.class); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final int OUTPUT_DOUBLE_SCALE = 6; + private static final String RAW_ARCHIVE_EVENT_TYPE = "RAW_ARCHIVE"; + + private final EventFileStore store; + private final Gb32960MessageDecoder decoder; + private final Path archiveRoot; + private final ObjectMapper objectMapper; + + public Gb32960DecodedFrameService(EventFileStore store, + Gb32960MessageDecoder decoder, + Path archiveRoot, + ObjectMapper objectMapper) { + if (store == null) { + throw new IllegalArgumentException("store must not be null"); + } + if (decoder == null) { + throw new IllegalArgumentException("decoder must not be null"); + } + if (archiveRoot == null) { + throw new IllegalArgumentException("archiveRoot must not be null"); + } + this.store = store; + this.decoder = decoder; + this.archiveRoot = archiveRoot.toAbsolutePath().normalize(); + this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper; + } + + public List query(LocalDate dateFrom, + LocalDate dateTo, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount) throws IOException { + return query(dateFrom, dateTo, null, null, order, limit, vin, platformAccount); + } + + public List query(LocalDate dateFrom, + LocalDate dateTo, + java.time.Instant eventTimeFrom, + java.time.Instant eventTimeTo, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount) throws IOException { + int frameLimit = Math.max(1, Math.min(limit, 1000)); + List records = store.query(new EventFileQuery( + ProtocolId.GB32960, + dateFrom, + dateTo, + eventTimeFrom, + eventTimeTo, + order == null ? EventFileQuery.Order.DESC : order, + frameLimit, + vin, + RAW_ARCHIVE_EVENT_TYPE)); + + List out = new ArrayList<>(); + LinkedHashSet seenUris = new LinkedHashSet<>(); + for (EventFileRecord record : records) { + String rawArchiveUri = rawArchiveUri(record); + if (rawArchiveUri == null || rawArchiveUri.isBlank() || !seenUris.add(rawArchiveUri)) { + continue; + } + DecodedFrame frame = decodeIfAvailable(record, rawArchiveUri, platformAccount); + if (frame == null) { + continue; + } + out.add(frame); + if (out.size() >= frameLimit) { + break; + } + } + return out; + } + + public List snapshots(QueryTimeRange range, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount) throws IOException { + return snapshots(range.dateFrom(), range.dateTo(), range.eventTimeFrom(), range.eventTimeTo(), + order, limit, vin, platformAccount); + } + + public List snapshotFields(QueryTimeRange range, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount, + String fields) throws IOException { + List selectedFields = parseSelectedFields(fields); + if (selectedFields.isEmpty()) { + throw new IllegalArgumentException("fields is required for gb32960 snapshot field query"); + } + validateSelectedFields(selectedFields); + return snapshots(range, order, limit, vin, platformAccount).stream() + .map(snapshot -> FieldSnapshot.from(snapshot, selectedFields)) + .toList(); + } + + public String snapshotFieldsCsv(QueryTimeRange range, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount, + String fields) throws IOException { + List selectedFields = parseSelectedFields(fields); + if (selectedFields.isEmpty()) { + throw new IllegalArgumentException("fields is required for gb32960 snapshot field query"); + } + validateSelectedFields(selectedFields); + List snapshots = snapshots(range, order, limit, vin, platformAccount).stream() + .map(snapshot -> FieldSnapshot.from(snapshot, selectedFields)) + .toList(); + Map headers = chineseHeadersByField(); + StringBuilder csv = new StringBuilder(256 + snapshots.size() * Math.max(128, selectedFields.size() * 32)); + csv.append("VIN,事件时间,首次接收时间,最后接收时间,原始包"); + for (String field : selectedFields) { + csv.append(',').append(csv(headers.getOrDefault(field, field))); + } + csv.append('\n'); + for (FieldSnapshot snapshot : snapshots) { + csv.append(csv(snapshot.vin())).append(',') + .append(csv(snapshot.eventTime())).append(',') + .append(csv(snapshot.ingestTimeFirst())).append(',') + .append(csv(snapshot.ingestTimeLast())).append(',') + .append(csv(sourceFrameUris(snapshot.sourceFrames()))); + for (String field : selectedFields) { + csv.append(',').append(csv(csvValue(snapshot.fields().get(field)))); + } + csv.append('\n'); + } + return csv.toString(); + } + + public DecodedFrame findByRawArchiveUri(String rawArchiveUri, String platformAccount) throws IOException { + if (rawArchiveUri == null || rawArchiveUri.isBlank()) { + return null; + } + EventFileRecord record = store.findByRawArchiveUri(rawArchiveUri); + if (record != null) { + return decode(record, rawArchiveUri(record), platformAccount); + } + return decodeWithoutIndex(rawArchiveUri, platformAccount); + } + + public List snapshots(LocalDate dateFrom, + LocalDate dateTo, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount) throws IOException { + if (vin == null || vin.isBlank()) { + throw new IllegalArgumentException("vin is required for gb32960 snapshots"); + } + return snapshots(dateFrom, dateTo, null, null, order, limit, vin, platformAccount); + } + + private List snapshots(LocalDate dateFrom, + LocalDate dateTo, + java.time.Instant eventTimeFrom, + java.time.Instant eventTimeTo, + EventFileQuery.Order order, + int limit, + String vin, + String platformAccount) throws IOException { + if (vin == null || vin.isBlank()) { + throw new IllegalArgumentException("vin is required for gb32960 snapshots"); + } + int snapshotLimit = Math.max(1, Math.min(limit, 1000)); + List frames = query(dateFrom, dateTo, eventTimeFrom, eventTimeTo, + order, snapshotLimit * 30, vin, platformAccount); + LinkedHashMap builders = new LinkedHashMap<>(); + for (DecodedFrame frame : frames) { + if (frame.eventTime() == null || frame.eventTime().isBlank()) { + continue; + } + String key = frame.vin() + "|" + frame.eventTime(); + builders.computeIfAbsent(key, ignored -> new SnapshotBuilder(frame)).add(frame); + } + List out = new ArrayList<>(snapshotLimit); + for (SnapshotBuilder builder : builders.values()) { + out.add(builder.build()); + if (out.size() >= snapshotLimit) { + break; + } + } + return out; + } + + private DecodedFrame decodeIfAvailable(EventFileRecord record, + String rawArchiveUri, + String platformAccount) throws IOException { + try { + return decode(record, rawArchiveUri, platformAccount); + } catch (NoSuchFileException e) { + log.warn("skip gb32960 frame because raw archive is missing eventId={} rawArchiveUri={}", + record.eventId(), rawArchiveUri); + return null; + } + } + + private DecodedFrame decode(EventFileRecord record, String rawArchiveUri, String platformAccount) throws IOException { + Path rawPath = archivePath(rawArchiveUri); + byte[] rawBytes = Files.readAllBytes(rawPath); + Gb32960Message message = decoder.decode(ByteBuffer.wrap(rawBytes), platformAccount(record, platformAccount)); + List> blocks = new ArrayList<>(); + for (InfoBlock block : message.infoBlocks()) { + blocks.add(blockJson(block)); + } + return new DecodedFrame( + record.eventId(), + message.header().vin(), + message.header().command().name(), + message.header().responseFlag().name(), + message.header().protocolVersion().name(), + message.header().encryptType().name(), + message.header().dataLength(), + message.header().eventTime() == null ? null : message.header().eventTime().toString(), + record.ingestTime().toString(), + rawArchiveUri, + rawBytes.length, + blocks); + } + + private DecodedFrame decodeWithoutIndex(String rawArchiveUri, String platformAccount) throws IOException { + Path rawPath = archivePath(rawArchiveUri); + byte[] rawBytes = Files.readAllBytes(rawPath); + Gb32960Message message = decoder.decode(ByteBuffer.wrap(rawBytes), platformAccount); + List> blocks = new ArrayList<>(); + for (InfoBlock block : message.infoBlocks()) { + blocks.add(blockJson(block)); + } + String ingestTime = Files.getLastModifiedTime(rawPath).toInstant().toString(); + return new DecodedFrame( + rawArchiveEventId(rawArchiveUri), + message.header().vin(), + message.header().command().name(), + message.header().responseFlag().name(), + message.header().protocolVersion().name(), + message.header().encryptType().name(), + message.header().dataLength(), + message.header().eventTime() == null ? null : message.header().eventTime().toString(), + ingestTime, + rawArchiveUri, + rawBytes.length, + blocks); + } + + private Map blockJson(InfoBlock block) { + Map json = new LinkedHashMap<>(); + json.put("type", block.type().name()); + json.put("className", block.getClass().getSimpleName()); + json.put("data", normalizeJsonValue(objectMapper.convertValue(block, MAP_TYPE))); + if (block instanceof InfoBlock.Raw raw) { + json.put("typeCode", raw.typeCode()); + json.put("bytesHex", hex(raw.bytes())); + } + return json; + } + + private static List parseSelectedFields(String fields) { + if (fields == null || fields.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String field : fields.split(",")) { + String normalized = field.trim(); + if (!normalized.isBlank()) { + out.add(normalized); + } + } + return List.copyOf(out); + } + + private static void validateSelectedFields(List selectedFields) { + LinkedHashSet knownFields = new LinkedHashSet<>(); + for (Gb32960FieldDictionary.Packet packet : Gb32960FieldDictionary.packets()) { + for (Gb32960FieldDictionary.Field field : packet.fields()) { + knownFields.add(packet.code() + "." + field.key()); + } + } + List unknown = selectedFields.stream() + .filter(field -> !knownFields.contains(field)) + .toList(); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("unknown gb32960 snapshot fields: " + String.join(",", unknown)); + } + } + + private static Map chineseHeadersByField() { + Map headers = new LinkedHashMap<>(); + for (Gb32960FieldDictionary.Packet packet : Gb32960FieldDictionary.packets()) { + for (Gb32960FieldDictionary.Field field : packet.fields()) { + String header = packet.nameZh() + "-" + field.nameZh(); + if (field.unit() != null && !field.unit().isBlank()) { + header += "(" + field.unit() + ")"; + } + headers.put(packet.code() + "." + field.key(), header); + } + } + return headers; + } + + private static String sourceFrameUris(List sourceFrames) { + if (sourceFrames == null || sourceFrames.isEmpty()) { + return ""; + } + return sourceFrames.stream() + .map(SourceFrame::rawArchiveUri) + .filter(uri -> uri != null && !uri.isBlank()) + .reduce((left, right) -> left + ";" + right) + .orElse(""); + } + + private static String csvValue(Object value) { + if (value == null) { + return ""; + } + if (value instanceof Iterable iterable) { + List values = new ArrayList<>(); + for (Object item : iterable) { + values.add(String.valueOf(item)); + } + return String.join(";", values); + } + return String.valueOf(value); + } + + private static String csv(String value) { + if (value == null) { + return ""; + } + boolean quote = value.indexOf(',') >= 0 + || value.indexOf('"') >= 0 + || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0; + if (!quote) { + return value; + } + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + static Object normalizeJsonValue(Object value) { + if (value instanceof Double d) { + return normalizeFloatingPoint(d); + } + if (value instanceof Float f) { + return normalizeFloatingPoint(f.doubleValue()); + } + if (value instanceof Map map) { + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + normalized.put(String.valueOf(entry.getKey()), normalizeJsonValue(entry.getValue())); + } + return normalized; + } + if (value instanceof List list) { + List normalized = new ArrayList<>(list.size()); + for (Object item : list) { + normalized.add(normalizeJsonValue(item)); + } + return normalized; + } + return value; + } + + private static BigDecimal normalizeFloatingPoint(double value) { + if (!Double.isFinite(value)) { + return BigDecimal.ZERO; + } + BigDecimal normalized = BigDecimal.valueOf(value) + .setScale(OUTPUT_DOUBLE_SCALE, RoundingMode.HALF_UP) + .stripTrailingZeros(); + return normalized.scale() < 0 ? normalized.setScale(0) : normalized; + } + + @SuppressWarnings("unchecked") + private Map deepCopy(Map value) { + return objectMapper.convertValue(value, MAP_TYPE); + } + + private Path archivePath(String rawArchiveUri) throws IOException { + String key = rawArchiveUri.startsWith("archive://") + ? rawArchiveUri.substring("archive://".length()) + : rawArchiveUri; + Path path = archiveRoot.resolve(key.replace("..", "_").replaceAll("^/+", "")).normalize(); + if (!path.startsWith(archiveRoot)) { + throw new IOException("raw archive path escapes root: " + rawArchiveUri); + } + return path; + } + + private static String rawArchiveUri(EventFileRecord record) { + if (record.rawArchiveUri() != null && !record.rawArchiveUri().isBlank()) { + return record.rawArchiveUri(); + } + if (record.metadata() == null) { + return ""; + } + return record.metadata().getOrDefault("rawArchiveUri", ""); + } + + private static String platformAccount(EventFileRecord record, String override) { + if (override != null && !override.isBlank()) { + return override; + } + if (record.metadata() == null) { + return null; + } + return record.metadata().get("platformAccount"); + } + + private static String rawArchiveEventId(String rawArchiveUri) { + if (rawArchiveUri == null || rawArchiveUri.isBlank()) { + return ""; + } + String key = rawArchiveUri.startsWith("archive://") + ? rawArchiveUri.substring("archive://".length()) + : rawArchiveUri; + int slash = key.lastIndexOf('/'); + String fileName = slash >= 0 ? key.substring(slash + 1) : key; + return fileName.endsWith(".bin") ? fileName.substring(0, fileName.length() - 4) : fileName; + } + + private static String hex(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return ""; + } + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + out.append(Character.forDigit((b >> 4) & 0xF, 16)); + out.append(Character.forDigit(b & 0xF, 16)); + } + return out.toString(); + } + + public record DecodedFrame( + String eventId, + String vin, + String command, + String responseFlag, + String protocolVersion, + String encryptType, + int dataLength, + String eventTime, + String ingestTime, + String rawArchiveUri, + int rawSizeBytes, + List> blocks) { + } + + public record SourceFrame( + String eventId, + String ingestTime, + String rawArchiveUri, + int rawSizeBytes, + List blockTypes) { + } + + public record LogicalSnapshot( + String vin, + String command, + String responseFlag, + String protocolVersion, + String encryptType, + String eventTime, + String ingestTimeFirst, + String ingestTimeLast, + List sourceFrames, + List> blocks) { + } + + public record FieldSnapshot( + String vin, + String eventTime, + String ingestTimeFirst, + String ingestTimeLast, + List sourceFrames, + Map fields) { + + private static FieldSnapshot from(LogicalSnapshot snapshot, List selectedFields) { + Map values = new LinkedHashMap<>(); + for (String selectedField : selectedFields) { + values.put(selectedField, findField(snapshot.blocks(), selectedField)); + } + return new FieldSnapshot( + snapshot.vin(), + snapshot.eventTime(), + snapshot.ingestTimeFirst(), + snapshot.ingestTimeLast(), + snapshot.sourceFrames(), + values); + } + + private static Object findField(List> blocks, String selectedField) { + int dot = selectedField.indexOf('.'); + if (dot <= 0 || dot == selectedField.length() - 1) { + return null; + } + String type = selectedField.substring(0, dot); + String path = selectedField.substring(dot + 1); + for (Map block : blocks) { + if (type.equals(block.get("type"))) { + return valueAt(block.get("data"), path.split("\\.")); + } + } + return null; + } + + private static Object valueAt(Object current, String[] path) { + Object value = current; + for (String part : path) { + value = step(value, part); + if (value == null) { + return null; + } + } + return value; + } + + @SuppressWarnings("unchecked") + private static Object step(Object value, String part) { + if (value instanceof Map map) { + return map.get(part); + } + if (value instanceof List list) { + if (part.matches("\\d+")) { + int index = Integer.parseInt(part); + return index >= 0 && index < list.size() ? list.get(index) : null; + } + List out = new ArrayList<>(list.size()); + for (Object item : list) { + if (item instanceof Map itemMap) { + out.add(((Map) itemMap).get(part)); + } + } + return out; + } + return null; + } + } + + private final class SnapshotBuilder { + private final DecodedFrame first; + private final List sourceFrames = new ArrayList<>(); + private final LinkedHashMap> blocks = new LinkedHashMap<>(); + + private SnapshotBuilder(DecodedFrame first) { + this.first = first; + } + + private void add(DecodedFrame frame) { + sourceFrames.add(new SourceFrame( + frame.eventId(), + frame.ingestTime(), + frame.rawArchiveUri(), + frame.rawSizeBytes(), + frame.blocks().stream().map(block -> String.valueOf(block.get("type"))).toList())); + for (Map block : frame.blocks()) { + String type = String.valueOf(block.get("type")); + if ("GD_FC_STACK".equals(type)) { + mergeStack(block); + } else { + blocks.putIfAbsent(type, deepCopy(block)); + } + } + } + + private void mergeStack(Map block) { + Map current = blocks.get("GD_FC_STACK"); + if (current == null) { + blocks.put("GD_FC_STACK", deepCopy(block)); + return; + } + Map currentData = map(current.get("data")); + Map incomingData = map(block.get("data")); + List> currentStacks = listOfMaps(currentData.get("stacks")); + List> incomingStacks = listOfMaps(incomingData.get("stacks")); + for (int i = 0; i < incomingStacks.size(); i++) { + if (i >= currentStacks.size()) { + currentStacks.add(deepCopy(incomingStacks.get(i))); + } else { + mergeStackItem(currentStacks.get(i), incomingStacks.get(i)); + } + } + currentData.put("stackCount", currentStacks.size()); + } + + private void mergeStackItem(Map target, Map incoming) { + int targetStart = intValue(target.get("frameCellStart"), 1); + int incomingStart = intValue(incoming.get("frameCellStart"), 1); + List targetVoltages = doubleList(target.get("frameCellVoltagesV")); + List incomingVoltages = doubleList(incoming.get("frameCellVoltagesV")); + 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(), + incomingStart - 1 + incomingVoltages.size())); + List merged = new ArrayList<>(needed); + for (int i = 0; i < needed; i++) { + merged.add(null); + } + putVoltages(merged, targetStart, targetVoltages); + putVoltages(merged, incomingStart, incomingVoltages); + while (!merged.isEmpty() && merged.getLast() == null) { + merged.removeLast(); + } + target.put("frameCellStart", 1); + target.put("frameCellCount", merged.size()); + target.put("frameCellVoltagesV", merged); + target.put("cellCount", Math.max(cellCount, merged.size())); + normalizeStackItem(target); + } + + private LogicalSnapshot build() { + List orderedFrames = sourceFrames.stream() + .sorted(Comparator.comparing(SourceFrame::ingestTime)) + .toList(); + Map stackBlock = blocks.get("GD_FC_STACK"); + if (stackBlock != null) { + normalizeStackBlock(stackBlock); + } + List> orderedBlocks = new ArrayList<>(blocks.values()); + orderedBlocks.sort(Comparator.comparingInt(block -> blockOrder(String.valueOf(block.get("type"))))); + List> normalizedBlocks = orderedBlocks.stream() + .map(block -> map(normalizeJsonValue(block))) + .toList(); + return new LogicalSnapshot( + first.vin(), + first.command(), + first.responseFlag(), + first.protocolVersion(), + first.encryptType(), + first.eventTime(), + orderedFrames.isEmpty() ? null : orderedFrames.getFirst().ingestTime(), + orderedFrames.isEmpty() ? null : orderedFrames.getLast().ingestTime(), + sourceFrames, + normalizedBlocks); + } + } + + private static void normalizeStackBlock(Map stackBlock) { + Map data = map(stackBlock.get("data")); + for (Map stack : listOfMaps(data.get("stacks"))) { + normalizeStackItem(stack); + } + } + + private static void normalizeStackItem(Map target) { + int start = intValue(target.get("frameCellStart"), 1); + List voltages = doubleList(target.get("frameCellVoltagesV")); + int cellCount = intValue(target.get("cellCount"), 0); + int needed = Math.max(cellCount, start - 1 + voltages.size()); + List normalized = new ArrayList<>(needed); + for (int i = 0; i < needed; i++) { + normalized.add(null); + } + putVoltages(normalized, start, voltages); + while (cellCount == 0 && !normalized.isEmpty() && normalized.getLast() == null) { + normalized.removeLast(); + } + long missing = normalized.stream().filter(item -> item == null).count(); + target.put("frameCellStart", 1); + target.put("frameCellCount", normalized.size()); + target.put("frameCellVoltagesV", normalized); + target.put("cellCount", Math.max(cellCount, normalized.size())); + target.put("frameCellComplete", missing == 0 && (cellCount == 0 || normalized.size() >= cellCount)); + target.put("frameCellMissingCount", missing); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return value instanceof Map m ? (Map) m : new LinkedHashMap<>(); + } + + @SuppressWarnings("unchecked") + private static List> listOfMaps(Object value) { + return value instanceof List list ? (List>) list : new ArrayList<>(); + } + + private static int intValue(Object value, int fallback) { + return value instanceof Number n ? n.intValue() : fallback; + } + + private static List doubleList(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + List out = new ArrayList<>(list.size()); + for (Object item : list) { + out.add(item instanceof Number n ? n.doubleValue() : null); + } + return out; + } + + private static void putVoltages(List target, int start, List values) { + int offset = Math.max(0, start - 1); + for (int i = 0; i < values.size(); i++) { + int index = offset + i; + if (index >= target.size()) { + target.add(values.get(i)); + } else if (values.get(i) != null) { + target.set(index, values.get(i)); + } + } + } + + private static int blockOrder(String type) { + return switch (type) { + case "VEHICLE" -> 10; + case "DRIVE_MOTOR" -> 20; + case "FUEL_CELL_V2016", "FUEL_CELL_V2025" -> 30; + case "ENGINE" -> 40; + case "POSITION_V2016", "POSITION_V2025" -> 50; + case "EXTREME_V2016" -> 60; + case "ALARM_V2016", "ALARM_V2025" -> 70; + case "VOLTAGE_V2016", "MIN_PARALLEL_VOLTAGE_V2025" -> 80; + case "TEMPERATURE_V2016", "BATTERY_TEMPERATURE_V2025" -> 90; + case "FUEL_CELL_STACK", "GD_FC_STACK" -> 100; + case "SUPER_CAPACITOR" -> 110; + case "SUPER_CAPACITOR_EXTREME" -> 120; + case "GD_FC_AUXILIARY" -> 130; + case "GD_FC_DCDC" -> 140; + case "GD_FC_AIR_CONDITIONER" -> 150; + case "GD_FC_VEHICLE_INFO" -> 160; + case "GD_FC_DEMO_EXTENSION" -> 170; + case "GD_FC_VENDOR_TLV" -> 180; + default -> 1000; + }; + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java new file mode 100644 index 00000000..f362bfbe --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java @@ -0,0 +1,190 @@ +package com.lingniu.ingest.eventhistory; + +import java.util.List; + +public final class Gb32960FieldDictionary { + + private Gb32960FieldDictionary() { + } + + public static List packets() { + return PACKETS; + } + + private static final List PACKETS = List.of( + packet("VEHICLE", "整车数据", "GB/T 32960 整车运行状态、车速、里程、电压、电流、SOC 等核心字段。", + field("vehicleState", "车辆状态", "", "车辆运行、停止等状态编码。", + mappings( + mapping("01", "1", "启动", "车辆处于可行驶启动状态。"), + mapping("02", "2", "熄火", "车辆处于熄火状态。"), + mapping("03", "3", "其他", "车辆处于其他状态。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("chargingState", "充电状态", "", "车辆充电状态编码。", + mappings( + mapping("01", "1", "停车充电", "车辆停车充电。"), + mapping("02", "2", "行驶充电", "车辆行驶充电。"), + mapping("03", "3", "未充电", "车辆未充电。"), + mapping("04", "4", "充电完成", "车辆充电完成。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("runningMode", "运行模式", "", "纯电、混动、燃料电池等运行模式编码。", + mappings( + mapping("01", "1", "纯电", "纯电驱动模式。"), + mapping("02", "2", "混合动力", "混合动力驱动模式。"), + mapping("03", "3", "燃料电池", "燃料电池驱动模式。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("speedKmh", "车速", "km/h", "车辆当前速度。"), + field("totalMileageKm", "累计里程", "km", "车辆累计行驶里程。"), + field("totalVoltageV", "总电压", "V", "动力系统总电压。"), + field("totalCurrentA", "总电流", "A", "动力系统总电流。"), + field("socPercent", "SOC", "%", "动力电池荷电状态。"), + field("dcDcStatus", "DC/DC 状态", "", "DC/DC 工作状态编码。", + mappings( + mapping("01", "1", "工作", "DC/DC 处于工作状态。"), + mapping("02", "2", "断开", "DC/DC 处于断开状态。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("gearRaw", "挡位原始值", "", "挡位、驱动力和制动力组合原始编码。"), + field("insulationResistanceKohm", "绝缘电阻", "kOhm", "高压系统绝缘电阻。"), + field("acceleratorPedal", "加速踏板行程", "%", "2016 版字段,加速踏板开度。"), + field("brakePedal", "制动踏板状态", "%", "2016 版字段,制动踏板状态或开度。")), + packet("DRIVE_MOTOR", "驱动电机数据", "GB/T 32960 驱动电机控制器和电机运行状态。", + field("motors.serialNo", "驱动电机序号", "", "电机编号。"), + field("motors.state", "驱动电机状态", "", "电机工作状态编码。", + mappings( + mapping("01", "1", "耗电", "驱动电机耗电。"), + mapping("02", "2", "发电", "驱动电机发电。"), + mapping("03", "3", "关闭", "驱动电机关闭。"), + mapping("04", "4", "准备", "驱动电机准备。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("motors.controllerTempC", "控制器温度", "C", "驱动电机控制器温度。"), + field("motors.rpm", "转速", "rpm", "驱动电机转速。"), + field("motors.torqueNm", "转矩", "N.m", "驱动电机输出转矩。"), + field("motors.motorTempC", "电机温度", "C", "驱动电机温度。"), + field("motors.controllerInputVoltageV", "控制器输入电压", "V", "电机控制器直流母线输入电压。"), + field("motors.controllerDcCurrentA", "控制器直流母线电流", "A", "电机控制器直流母线电流。")), + packet("FUEL_CELL_V2016", "燃料电池数据(2016)", "GB/T 32960.3-2016 燃料电池电压、电流、氢耗、温度、浓度、压力等字段。", + field("fcVoltageV", "燃料电池电压", "V", "燃料电池系统输出电压。"), + field("fcCurrentA", "燃料电池电流", "A", "燃料电池系统输出电流。"), + field("hydrogenConsumptionKgPer100km", "氢耗", "kg/100km", "燃料电池氢气消耗率。"), + field("probeTempC", "探针温度", "C", "燃料电池温度探针列表。"), + field("maxHydrogenSystemTempC", "氢系统最高温度", "C", "氢系统最高温度。"), + field("maxHydrogenConcentrationPercent", "最高氢浓度", "%", "氢气浓度最高值。"), + field("maxHydrogenPressureMpa", "最高氢压力", "MPa", "氢系统最高压力。"), + field("hvDcDcStatus", "高压 DC/DC 状态", "", "燃料电池高压 DC/DC 状态。")), + packet("POSITION_V2016", "车辆位置数据(2016)", "GB/T 32960.3-2016 定位状态和经纬度。", + field("statusFlag", "定位状态标志", "", "定位有效性及经纬度方向标志。", + mappings( + mapping("bit0=0", "bit0=0", "有效定位", "定位状态有效。"), + mapping("bit0=1", "bit0=1", "无效定位", "定位状态无效。"), + mapping("bit1=0", "bit1=0", "北纬", "纬度方向为北纬。"), + mapping("bit1=1", "bit1=1", "南纬", "纬度方向为南纬。"), + mapping("bit2=0", "bit2=0", "东经", "经度方向为东经。"), + mapping("bit2=1", "bit2=1", "西经", "经度方向为西经。"))), + field("longitude", "经度", "deg", "车辆经度。"), + field("latitude", "纬度", "deg", "车辆纬度。")), + packet("POSITION_V2025", "车辆位置数据(2025)", "GB/T 32960.3-2025 定位状态、坐标系和经纬度。", + field("statusFlag", "定位状态标志", "", "定位有效性及经纬度方向标志。", + mappings( + mapping("bit0=0", "bit0=0", "有效定位", "定位状态有效。"), + mapping("bit0=1", "bit0=1", "无效定位", "定位状态无效。"), + mapping("bit1=0", "bit1=0", "北纬", "纬度方向为北纬。"), + mapping("bit1=1", "bit1=1", "南纬", "纬度方向为南纬。"), + mapping("bit2=0", "bit2=0", "东经", "经度方向为东经。"), + mapping("bit2=1", "bit2=1", "西经", "经度方向为西经。"))), + field("coordSystem", "坐标系", "", "2025 版新增坐标系编码。", + mappings( + mapping("01", "1", "WGS84", "WGS84 坐标系。"), + mapping("02", "2", "GCJ-02", "GCJ-02 坐标系。"), + mapping("03", "3", "其他", "其他坐标系。"))), + field("longitude", "经度", "deg", "车辆经度。"), + field("latitude", "纬度", "deg", "车辆纬度。")), + packet("ALARM_V2016", "报警数据(2016)", "GB/T 32960.3-2016 最高报警等级、通用报警位和故障码列表。", + field("maxLevel", "最高报警等级", "", "当前最高报警等级。", + mappings( + mapping("00", "0", "无故障", "当前无报警故障。"), + mapping("01", "1", "一级故障", "一级报警故障。"), + mapping("02", "2", "二级故障", "二级报警故障。"), + mapping("03", "3", "三级故障", "三级报警故障。"))), + field("generalAlarmFlag", "通用报警标志", "", "通用报警位图。"), + field("batteryFaults", "可充电储能装置故障码", "", "动力电池相关故障码列表。"), + field("motorFaults", "驱动电机故障码", "", "驱动电机相关故障码列表。"), + field("engineFaults", "发动机故障码", "", "发动机相关故障码列表。"), + field("otherFaults", "其他故障码", "", "其他故障码列表。")), + packet("GD_FC_STACK", "广东燃料电池电堆数据", "广东燃料电池汽车数据接入规范扩展,描述电堆状态、压力、温度和单体电压分段。", + field("stackCount", "电堆数量", "", "本包包含的电堆数量。"), + field("stacks.engineWorkState", "发动机工作状态", "", "燃料电池发动机工作状态。"), + field("stacks.stackWaterOutletTempC", "电堆出水温度", "C", "电堆冷却水出口温度。"), + field("stacks.hydrogenInletPressureKpa", "氢气入口压力", "kPa", "电堆氢气入口压力。"), + field("stacks.airInletPressureKpa", "空气入口压力", "kPa", "电堆空气入口压力。"), + field("stacks.airInletTempC", "空气入口温度", "C", "电堆空气入口温度。"), + field("stacks.maxCellVoltageV", "单体最高电压", "V", "本电堆单体最高电压。"), + field("stacks.minCellVoltageV", "单体最低电压", "V", "本电堆单体最低电压。"), + field("stacks.avgCellVoltageV", "单体平均电压", "V", "本电堆单体平均电压。"), + field("stacks.cellCount", "单体总数", "", "电堆单体总数量。"), + field("stacks.frameCellStart", "本帧单体起始序号", "", "当前分段电压起始单体序号。"), + field("stacks.frameCellCount", "本帧单体数量", "", "当前分段包含的单体数量。"), + field("stacks.frameCellVoltagesV", "本帧单体电压列表", "V", "当前分段的单体电压数组。"), + field("stacks.frameCellComplete", "单体电压是否完整", "", "snapshot 合并后是否已覆盖全部单体。"), + field("stacks.frameCellMissingCount", "缺失单体数量", "", "snapshot 合并后仍缺失的单体数量。")), + packet("GD_FC_DCDC", "广东燃料电池 DC/DC 数据", "广东燃料电池汽车数据接入规范扩展,描述 DC/DC 输入输出和控制器温度。", + field("inputVoltageV", "输入电压", "V", "DC/DC 输入电压。"), + field("inputCurrentA", "输入电流", "A", "DC/DC 输入电流。"), + field("outputVoltageV", "输出电压", "V", "DC/DC 输出电压。"), + field("outputCurrentA", "输出电流", "A", "DC/DC 输出电流。"), + field("controllerTempC", "控制器温度", "C", "DC/DC 控制器温度。")), + packet("GD_FC_AIR_CONDITIONER", "广东燃料电池空调数据", "广东燃料电池汽车数据接入规范扩展,描述空调状态、功率和压缩机输入电压。", + field("status", "空调状态", "", "空调工作状态。", + mappings( + mapping("00", "0", "关闭", "空调关闭。"), + mapping("01", "1", "启动", "空调启动。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("powerKw", "空调功率", "kW", "空调消耗功率。"), + field("compressorInputVoltageV", "压缩机输入电压", "V", "空调压缩机输入电压。")), + packet("GD_FC_VEHICLE_INFO", "广东燃料电池车辆信息数据", "广东燃料电池汽车数据接入规范扩展,描述碰撞报警、环境温度压力和车载氢量。", + field("collisionAlarm", "碰撞报警", "", "碰撞报警状态。", + mappings( + mapping("00", "0", "无碰撞报警", "当前无碰撞报警。"), + mapping("01", "1", "有碰撞报警", "当前有碰撞报警。"), + mapping("FE", "254", "异常", "协议异常值。"), + mapping("FF", "255", "无效", "协议无效值。"))), + field("ambientTempC", "环境温度", "C", "车辆环境温度。"), + field("ambientPressureKpa", "环境压力", "kPa", "车辆环境压力。"), + field("hydrogenMassKg", "车载氢量", "kg", "当前车载氢气质量。")) + ); + + private static Packet packet(String code, String nameZh, String description, Field... fields) { + return new Packet(code, nameZh, description, List.of(fields)); + } + + private static Field field(String key, String nameZh, String unit, String description) { + return field(key, nameZh, unit, description, List.of()); + } + + private static Field field(String key, String nameZh, String unit, String description, + List valueMappings) { + return new Field(key, nameZh, unit, description, valueMappings); + } + + private static List mappings(ValueMapping... mappings) { + return List.of(mappings); + } + + private static ValueMapping mapping(String code, String value, String nameZh, String description) { + return new ValueMapping(code, value, nameZh, description); + } + + public record Packet(String code, String nameZh, String description, List fields) { + } + + public record Field(String key, String nameZh, String unit, String description, + List valueMappings) { + } + + public record ValueMapping(String code, String value, String nameZh, String description) { + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java new file mode 100644 index 00000000..afaddb24 --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java @@ -0,0 +1,156 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.eventfilestore.EventFileQuery; +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.http.HttpStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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.util.List; + +@RestController +@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") +@ConditionalOnBean(Gb32960DecodedFrameService.class) +@RequestMapping("/api/event-history/gb32960") +@Tag(name = "gb-32960-frame-controller", description = "GB32960 专用历史帧、业务快照、字段查询和字段字典接口。") +public final class Gb32960FrameController { + + private final Gb32960DecodedFrameService service; + + public Gb32960FrameController(Gb32960DecodedFrameService service) { + if (service == null) { + throw new IllegalArgumentException("service must not be null"); + } + this.service = service; + } + + @GetMapping("/frames") + @Operation( + summary = "查询 GB32960 原始帧解析结果", + description = "按时间范围查询某辆车或账号下的 GB32960 原始帧,并返回每帧解析出的整包 blocks。主要用于排查原始报文、解析结果和子包内容。") + public List frames( + @Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00") + @RequestParam String dateFrom, + @Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00") + @RequestParam String dateTo, + @Parameter(description = "排序方向。", example = "DESC") + @RequestParam(defaultValue = "DESC") EventFileQuery.Order order, + @Parameter(description = "返回帧数量上限。", example = "10") + @RequestParam(defaultValue = "10") int limit, + @Parameter(description = "车辆 VIN;用于缩小到单车查询。", example = "LB9A32A20P0LS1257") + @RequestParam(required = false) String vin, + @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") + @RequestParam(required = false) String platformAccount) throws IOException { + QueryTimeRange range = QueryTimeRange.parse(dateFrom, dateTo); + return service.query( + range.dateFrom(), + range.dateTo(), + range.eventTimeFrom(), + range.eventTimeTo(), + order, + limit, + vin, + platformAccount); + } + + @GetMapping("/frame") + @Operation( + summary = "按 rawArchiveUri 查询单个 GB32960 原始帧", + description = "直接读取指定 archive://*.bin 原始包并返回解析结果。主要用于从 snapshots 的 sourceFrames 回查某个原始报文。") + public Gb32960DecodedFrameService.DecodedFrame frame( + @Parameter(description = "原始包归档地址。", example = "archive://2026/06/23/GB32960/LB9A32A20P0LS1257/1782182749928000.bin") + @RequestParam String rawArchiveUri, + @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") + @RequestParam(required = false) String platformAccount) throws IOException { + return service.findByRawArchiveUri(rawArchiveUri, platformAccount); + } + + @GetMapping("/snapshots") + @Operation( + summary = "查询 GB32960 完整业务快照", + description = "按 VIN 和时间范围查询完整业务快照。会把同一车辆、同一 eventTime 的多个子包合并为一个 snapshot,适合车辆详情页和某一时刻全字段查看。VIN 为必填。") + public List snapshots( + @Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00") + @RequestParam String dateFrom, + @Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00") + @RequestParam String dateTo, + @Parameter(description = "排序方向。", example = "DESC") + @RequestParam(defaultValue = "DESC") EventFileQuery.Order order, + @Parameter(description = "返回 snapshot 数量上限。", example = "10") + @RequestParam(defaultValue = "10") int limit, + @Parameter(description = "车辆 VIN,必填;snapshot 查询只支持单车,避免跨车合并和低效扫描。", required = true, example = "LB9A32A20P0LS1257") + @RequestParam String vin, + @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") + @RequestParam(required = false) String platformAccount) throws IOException { + if (vin == null || vin.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshots"); + } + return service.snapshots(QueryTimeRange.parse(dateFrom, dateTo), order, limit, vin, platformAccount); + } + + @GetMapping("/snapshots/fields") + @Operation( + summary = "按所需字段查询 GB32960 快照", + description = "高频业务查询接口。按 VIN、时间范围和字段列表返回轻量快照,只包含 fields 指定字段;字段格式为 数据包代码.字段名,例如 VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV。字段名应来自 dictionary 接口。VIN 和 fields 为必填。") + public List snapshotFields( + @Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00") + @RequestParam String dateFrom, + @Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00") + @RequestParam String dateTo, + @Parameter(description = "排序方向。", example = "DESC") + @RequestParam(defaultValue = "DESC") EventFileQuery.Order order, + @Parameter(description = "返回 snapshot 数量上限。", example = "10") + @RequestParam(defaultValue = "10") int limit, + @Parameter(description = "车辆 VIN,必填;字段查询只支持单车。", required = true, example = "LB9A32A20P0LS1257") + @RequestParam String vin, + @Parameter(description = "逗号分隔的字段列表,字段来自 dictionary;格式为 数据包代码.字段名。", required = true, example = "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA") + @RequestParam String fields, + @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") + @RequestParam(required = false) String platformAccount) throws IOException { + if (vin == null || vin.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field query"); + } + return service.snapshotFields(QueryTimeRange.parse(dateFrom, dateTo), order, limit, vin, platformAccount, fields); + } + + @GetMapping(value = "/snapshots/fields.csv", produces = "text/csv;charset=UTF-8") + @Operation( + summary = "导出 GB32960 指定字段快照 CSV", + description = "参数与 snapshots/fields 一致,返回 CSV 文本。基础列和字段列表表头均为中文,字段表头来自 dictionary,例如 整车数据-累计里程(km)。适合前端下载和临时报表。") + public String snapshotFieldsCsv( + @Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00") + @RequestParam String dateFrom, + @Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00") + @RequestParam String dateTo, + @Parameter(description = "排序方向。", example = "DESC") + @RequestParam(defaultValue = "DESC") EventFileQuery.Order order, + @Parameter(description = "返回 snapshot 数量上限。", example = "1000") + @RequestParam(defaultValue = "1000") int limit, + @Parameter(description = "车辆 VIN,必填;字段导出只支持单车。", required = true, example = "LB9A32A20P0LS1257") + @RequestParam String vin, + @Parameter(description = "逗号分隔的字段列表,字段来自 dictionary;格式为 数据包代码.字段名。", required = true, example = "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA") + @RequestParam String fields, + @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") + @RequestParam(required = false) String platformAccount) throws IOException { + if (vin == null || vin.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field csv export"); + } + return service.snapshotFieldsCsv(QueryTimeRange.parse(dateFrom, dateTo), order, limit, vin, platformAccount, fields); + } + + @GetMapping("/dictionary") + @Operation( + summary = "查询 GB32960 字段字典", + description = "返回数据包代码、数据包中文名、字段 key、中文名、单位、解释和枚举/位图值域对照。前端应用可用它渲染字段选择器、表头、Tooltip 和状态码中文展示。") + public List dictionary() { + return Gb32960FieldDictionary.packets(); + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java new file mode 100644 index 00000000..f16b400a --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java @@ -0,0 +1,77 @@ +package com.lingniu.ingest.eventhistory; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; + +final class QueryTimeRange { + + private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai"); + + private final LocalDate dateFrom; + private final LocalDate dateTo; + private final Instant eventTimeFrom; + private final Instant eventTimeTo; + + private QueryTimeRange(LocalDate dateFrom, LocalDate dateTo, Instant eventTimeFrom, Instant eventTimeTo) { + this.dateFrom = dateFrom; + this.dateTo = dateTo; + this.eventTimeFrom = eventTimeFrom; + this.eventTimeTo = eventTimeTo; + } + + static QueryTimeRange parse(String from, String to) { + Boundary start = parseBoundary(from, false); + Boundary end = parseBoundary(to, true); + if (end.instant() != null && start.instant() != null && end.instant().isBefore(start.instant())) { + throw new IllegalArgumentException("dateTo must not be before dateFrom"); + } + if (end.date().isBefore(start.date())) { + throw new IllegalArgumentException("dateTo must not be before dateFrom"); + } + return new QueryTimeRange(start.date(), end.date(), start.instant(), end.instant()); + } + + LocalDate dateFrom() { + return dateFrom; + } + + LocalDate dateTo() { + return dateTo; + } + + Instant eventTimeFrom() { + return eventTimeFrom; + } + + Instant eventTimeTo() { + return eventTimeTo; + } + + private static Boundary parseBoundary(String raw, boolean endOfRange) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("date range must not be blank"); + } + String value = raw.trim(); + if (value.length() == 10) { + LocalDate date = LocalDate.parse(value); + Instant instant = endOfRange + ? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1) + : date.atStartOfDay(DEFAULT_ZONE).toInstant(); + return new Boundary(date, instant); + } + try { + Instant instant = OffsetDateTime.parse(value).toInstant(); + return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant); + } catch (RuntimeException ignored) { + LocalDateTime local = LocalDateTime.parse(value); + Instant instant = local.atZone(DEFAULT_ZONE).toInstant(); + return new Boundary(local.toLocalDate(), instant); + } + } + + private record Boundary(LocalDate date, Instant instant) { + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java new file mode 100644 index 00000000..e38d466d --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java @@ -0,0 +1,64 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Maps Kafka protobuf envelopes into records stored by the historical detail + * file store. + */ +public final class TelemetryEnvelopeRecordMapper { + + private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer(); + + public EventFileRecord toRecord(VehicleEnvelope envelope) { + if (envelope == null) { + throw new IllegalArgumentException("envelope must not be null"); + } + if (!envelope.hasTelemetrySnapshot()) { + throw new IllegalArgumentException("envelope telemetry_snapshot is required"); + } + ProtocolId protocol = protocol(envelope.getSource()); + Map metadata = new LinkedHashMap<>(envelope.getMetadataMap()); + if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) { + metadata.putIfAbsent("originalSource", envelope.getSource()); + } + return new EventFileRecord( + envelope.getEventId(), + protocol, + envelope.getTelemetrySnapshot().getEventType(), + envelope.getVin(), + Instant.ofEpochMilli(envelope.getEventTimeMs()), + Instant.ofEpochMilli(envelope.getIngestTimeMs()), + envelope.getTelemetrySnapshot().getRawArchiveUri(), + metadata, + snapshotJson(envelope) + ); + } + + private static ProtocolId protocol(String source) { + if (source == null || source.isBlank()) { + return ProtocolId.UNKNOWN; + } + try { + return ProtocolId.valueOf(source); + } catch (IllegalArgumentException ex) { + return ProtocolId.UNKNOWN; + } + } + + private static String snapshotJson(VehicleEnvelope envelope) { + try { + return JSON_PRINTER.print(envelope.getTelemetrySnapshot()); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("failed to serialize telemetry_snapshot", e); + } + } +} diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java new file mode 100644 index 00000000..c16fef72 --- /dev/null +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java @@ -0,0 +1,84 @@ +package com.lingniu.ingest.eventhistory.config; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.eventhistory.EventHistoryController; +import com.lingniu.ingest.eventhistory.EventHistoryEnvelopeIngestor; +import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService; +import com.lingniu.ingest.eventhistory.Gb32960FrameController; +import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration; +import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration; +import com.lingniu.ingest.sink.archive.config.SinkArchiveProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +import java.net.URI; +import java.nio.file.Path; + +@AutoConfiguration +@AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class}) +@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") +public class EventHistoryAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public TelemetryEnvelopeRecordMapper telemetryEnvelopeRecordMapper() { + return new TelemetryEnvelopeRecordMapper(); + } + + @Bean + @ConditionalOnBean(EventFileStore.class) + @ConditionalOnMissingBean + public EventHistoryEnvelopeIngestor eventHistoryEnvelopeIngestor(EventFileStore store, + TelemetryEnvelopeRecordMapper mapper) { + return new EventHistoryEnvelopeIngestor(store, mapper); + } + + @Bean + @ConditionalOnBean({EventHistoryEnvelopeIngestor.class, EnvelopeDeadLetterSink.class}) + @ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor") + public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor, + EnvelopeDeadLetterSink deadLetterSink) { + return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink); + } + + @Bean + @ConditionalOnBean(EventFileStore.class) + @ConditionalOnMissingBean + public EventHistoryController eventHistoryController(EventFileStore store) { + return new EventHistoryController(store); + } + + @Bean + @ConditionalOnBean({EventFileStore.class, Gb32960MessageDecoder.class}) + @ConditionalOnMissingBean + public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store, + Gb32960MessageDecoder decoder, + SinkArchiveProperties archiveProperties) { + return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null); + } + + @Bean + @ConditionalOnBean(Gb32960DecodedFrameService.class) + @ConditionalOnMissingBean + public Gb32960FrameController gb32960FrameController(Gb32960DecodedFrameService service) { + return new Gb32960FrameController(service); + } + + static Path archiveRoot(String value) { + if (value == null || value.isBlank()) { + return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive"); + } + if (value.startsWith("file://")) { + return Path.of(URI.create(value)); + } + return Path.of(value); + } +} diff --git a/modules/services/event-history-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/services/event-history-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..04b40140 --- /dev/null +++ b/modules/services/event-history-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.eventhistory.config.EventHistoryAutoConfiguration diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/ApiExceptionHandlerTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/ApiExceptionHandlerTest.java new file mode 100644 index 00000000..e118593b --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/ApiExceptionHandlerTest.java @@ -0,0 +1,72 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.file.Path; +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class ApiExceptionHandlerTest { + + @TempDir + Path archiveRoot; + + @Test + void missingRequiredQueryParameterReturnsConcreteReason() throws Exception { + MockMvc mvc = standaloneSetup(new Gb32960FrameController(service())) + .setControllerAdvice(new ApiExceptionHandler()) + .build(); + + mvc.perform(get("/api/event-history/gb32960/snapshots") + .param("dateFrom", "2026-06-23") + .param("dateTo", "2026-06-23") + .param("limit", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("missing required query parameter: vin")) + .andExpect(jsonPath("$.timestamp").value(org.hamcrest.Matchers.endsWith("+08:00"))) + .andExpect(jsonPath("$.path").value("/api/event-history/gb32960/snapshots")); + } + + @Test + void responseStatusExceptionReturnsReason() throws Exception { + MockMvc mvc = standaloneSetup(new Gb32960FrameController(service())) + .setControllerAdvice(new ApiExceptionHandler()) + .build(); + + mvc.perform(get("/api/event-history/gb32960/snapshots") + .param("dateFrom", "2026-06-23") + .param("dateTo", "2026-06-23") + .param("limit", "1") + .param("vin", "")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("vin is required for gb32960 snapshots")); + } + + private Gb32960DecodedFrameService service() { + EventFileStore store = new EventFileStore() { + @Override + public void appendAll(List records) { + } + + @Override + public List query(com.lingniu.ingest.eventfilestore.EventFileQuery query) { + return List.of(); + } + }; + return new Gb32960DecodedFrameService( + store, + mock(Gb32960MessageDecoder.class), + archiveRoot, + null); + } +} diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryControllerTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryControllerTest.java new file mode 100644 index 00000000..06041560 --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryControllerTest.java @@ -0,0 +1,152 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.eventfilestore.EventFileQuery; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventHistoryControllerTest { + + @Test + void queriesRecordsByDateRangeAndOrder() throws Exception { + CapturingStore store = new CapturingStore(List.of(record("event-1"))); + EventHistoryController controller = new EventHistoryController(store); + + List records = controller.query( + ProtocolId.GB32960, + "2026-06-21", + "2026-06-22", + EventFileQuery.Order.DESC, + 50, + "VIN001"); + + assertThat(records).extracting(EventHistoryController.RecordResponse::eventId).containsExactly("event-1"); + assertThat(records.getFirst().eventTime()).isEqualTo("2026-06-22T08:00:00Z"); + assertThat(records.getFirst().ingestTime()).isEqualTo("2026-06-22T08:00:01Z"); + assertThat(store.lastQuery.protocol()).isEqualTo(ProtocolId.GB32960); + assertThat(store.lastQuery.dateFrom().toString()).isEqualTo("2026-06-21"); + assertThat(store.lastQuery.dateTo().toString()).isEqualTo("2026-06-22"); + assertThat(store.lastQuery.order()).isEqualTo(EventFileQuery.Order.DESC); + assertThat(store.lastQuery.limit()).isEqualTo(50); + assertThat(store.lastQuery.vin()).isEqualTo("VIN001"); + } + + @Test + void exportsRecordsAsCsv() throws Exception { + EventHistoryController controller = new EventHistoryController(new CapturingStore(List.of(record("event-1")))); + + String csv = controller.exportCsv( + ProtocolId.GB32960, + "2026-06-22", + "2026-06-22", + EventFileQuery.Order.ASC, + 100); + + assertThat(csv).startsWith("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri,payload_json\n"); + assertThat(csv).contains("event-1,GB32960,REALTIME,VIN001"); + assertThat(csv).contains("\"{\"\"fields\"\":[]}\""); + } + + @Test + void exportsConfiguredTelemetryFieldsAsCsvColumns() throws Exception { + EventHistoryController controller = new EventHistoryController(new CapturingStore(List.of( + new EventFileRecord( + "media-1", + ProtocolId.JT1078, + "MEDIA_META", + "VIN001", + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "file:///archive/jt1078/media-1.rtp", + Map.of(), + """ + {"fields":[ + {"key":"media_archive_ref","value":"file:///archive/jt1078/media-1.rtp"}, + {"key":"passthrough_size_bytes","value":"128"} + ]} + """)))); + + String csv = controller.exportCsv( + ProtocolId.JT1078, + "2026-06-22", + "2026-06-22", + EventFileQuery.Order.ASC, + 100, + null, + "media_archive_ref,passthrough_size_bytes"); + + assertThat(csv).startsWith("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri,media_archive_ref,passthrough_size_bytes,payload_json\n"); + assertThat(csv).contains("media-1,JT1078,MEDIA_META,VIN001,2026-06-22T08:00:00Z,2026-06-22T08:00:01Z,file:///archive/jt1078/media-1.rtp,file:///archive/jt1078/media-1.rtp,128,"); + } + + @Test + void exportsConfiguredMetadataFieldsAsCsvColumns() throws Exception { + EventHistoryController controller = new EventHistoryController(new CapturingStore(List.of( + new EventFileRecord( + "media-2", + ProtocolId.JT1078, + "MEDIA_META", + "VIN001", + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "file:///archive/jt1078/media-2.rtp", + Map.of( + "channelId", "3", + "segmentSizeBytes", "512", + "archiveKey", "jt1078/2026/06/22/VIN001/ch-3/dt-0/1782115200.media"), + "{\"fields\":[]}")))); + + String csv = controller.exportCsv( + ProtocolId.JT1078, + "2026-06-22", + "2026-06-22", + EventFileQuery.Order.ASC, + 100, + null, + "metadata.channelId,metadata.segmentSizeBytes,metadata.archiveKey"); + + assertThat(csv).startsWith("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri,metadata.channelId,metadata.segmentSizeBytes,metadata.archiveKey,payload_json\n"); + assertThat(csv).contains("media-2,JT1078,MEDIA_META,VIN001,2026-06-22T08:00:00Z,2026-06-22T08:00:01Z,file:///archive/jt1078/media-2.rtp,3,512,jt1078/2026/06/22/VIN001/ch-3/dt-0/1782115200.media,"); + } + + private static EventFileRecord record(String eventId) { + return new EventFileRecord( + eventId, + ProtocolId.GB32960, + "REALTIME", + "VIN001", + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "archive://raw/" + eventId + ".bin", + Map.of(), + "{\"fields\":[]}"); + } + + private static final class CapturingStore implements EventFileStore { + private final List records; + private EventFileQuery lastQuery; + + private CapturingStore(List records) { + this.records = records; + } + + @Override + public void appendAll(List records) { + throw new UnsupportedOperationException(); + } + + @Override + public List query(EventFileQuery query) throws IOException { + this.lastQuery = query; + return records; + } + } +} diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestorTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestorTest.java new file mode 100644 index 00000000..07ee5b6c --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestorTest.java @@ -0,0 +1,89 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import com.lingniu.ingest.api.consumer.EnvelopeIngestor; +import com.lingniu.ingest.eventfilestore.EventFileQuery; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EventHistoryEnvelopeIngestorTest { + + @Test + void parsesKafkaEnvelopeBytesAndAppendsRecord() throws Exception { + CapturingStore store = new CapturingStore(); + EventHistoryEnvelopeIngestor ingestor = + new EventHistoryEnvelopeIngestor(store, new TelemetryEnvelopeRecordMapper()); + + assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class); + ingestor.ingest(envelope("event-1").toByteArray()); + + assertThat(store.records).extracting(EventFileRecord::eventId).containsExactly("event-1"); + assertThat(store.records.getFirst().payloadJson()).contains("total_mileage_km"); + } + + @Test + void rejectsInvalidEnvelopeBytes() { + EventHistoryEnvelopeIngestor ingestor = + new EventHistoryEnvelopeIngestor(new CapturingStore(), new TelemetryEnvelopeRecordMapper()); + + assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("VehicleEnvelope"); + } + + @Test + void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutAppendingRecord() { + CapturingStore store = new CapturingStore(); + EventHistoryEnvelopeIngestor ingestor = + new EventHistoryEnvelopeIngestor(store, new TelemetryEnvelopeRecordMapper()); + + var result = ingestor.tryIngest(new byte[]{0x01, 0x02}); + + assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE); + assertThat(result.message()).contains("VehicleEnvelope"); + assertThat(store.records).isEmpty(); + } + + private static VehicleEnvelope envelope(String eventId) { + return VehicleEnvelope.newBuilder() + .setEventId(eventId) + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .setTelemetrySnapshot(TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .addFields(TelemetryField.newBuilder() + .setKey("total_mileage_km") + .setValueType("DOUBLE") + .setValue("12345.6") + .setUnit("km") + .setQuality("GOOD"))) + .build(); + } + + private static final class CapturingStore implements EventFileStore { + private final List records = new ArrayList<>(); + + @Override + public void appendAll(List records) { + this.records.addAll(records); + } + + @Override + public List query(EventFileQuery query) throws IOException { + return List.copyOf(records); + } + } +} diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java new file mode 100644 index 00000000..2a9681ac --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java @@ -0,0 +1,564 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.codec.BccChecksum; +import com.lingniu.ingest.eventfilestore.EventFileQuery; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry; +import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog; +import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +class Gb32960DecodedFrameServiceTest { + + @TempDir + Path archiveRoot; + + @Test + void decodesFullGb32960BlocksFromRawArchive() throws Exception { + String key = "2026/06/22/GB32960/LNVFC000000000001/raw-1.bin"; + Path rawFile = archiveRoot.resolve(key); + Files.createDirectories(rawFile.getParent()); + Files.write(rawFile, realtimeFrame()); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of(new EventFileRecord( + "raw-1", + ProtocolId.GB32960, + "RAW_ARCHIVE", + "LNVFC000000000001", + Instant.parse("2026-06-22T10:00:00Z"), + Instant.parse("2026-06-22T10:00:01Z"), + "archive://" + key, + Map.of("platformAccount", "Hyundai"), + "{}"))), + decoder(), + archiveRoot, + null); + + List frames = service.query( + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10, + null, + null); + + assertThat(frames).hasSize(1); + Gb32960DecodedFrameService.DecodedFrame frame = frames.getFirst(); + assertThat(frame.vin()).isEqualTo("LNVFC000000000001"); + assertThat(frame.command()).isEqualTo("REALTIME_REPORT"); + assertThat(frame.blocks()).extracting(block -> block.get("type")) + .containsExactly("VEHICLE", "GD_FC_DCDC"); + + @SuppressWarnings("unchecked") + Map vehicle = (Map) frames.getFirst().blocks().getFirst().get("data"); + assertThat(vehicle.get("totalMileageKm").toString()).isEqualTo("10000"); + assertThat(vehicle).containsEntry("socPercent", 85); + + @SuppressWarnings("unchecked") + Map dcdc = (Map) frames.getFirst().blocks().get(1).get("data"); + assertThat(dcdc.get("inputVoltageV").toString()).isEqualTo("300"); + assertThat(dcdc.get("outputCurrentA").toString()).isEqualTo("110"); + } + + @Test + void queryNormalizesFloatingPointNoiseForJsonOutput() throws Exception { + Object normalized = Gb32960DecodedFrameService.normalizeJsonValue(636.3000000000001); + + assertThat(normalized).isInstanceOf(BigDecimal.class); + assertThat(normalized.toString()).isEqualTo("636.3"); + } + + @Test + void findsSingleFrameDirectlyByRawArchiveUri() throws Exception { + String key = "2026/06/22/GB32960/LNVFC000000000001/1792527837000000.bin"; + writeArchive(key, realtimeFrame()); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of()), + decoder(), + archiveRoot, + null); + + Gb32960DecodedFrameService.DecodedFrame frame = + service.findByRawArchiveUri("archive://" + key, "Hyundai"); + + assertThat(frame.eventId()).isEqualTo("1792527837000000"); + assertThat(frame.vin()).isEqualTo("LNVFC000000000001"); + assertThat(frame.blocks()).extracting(block -> block.get("type")) + .containsExactly("VEHICLE", "GD_FC_DCDC"); + } + + @Test + void findsSingleFrameByRawArchiveUriIndexWhenAvailable() throws Exception { + String key = "2026/06/22/GB32960/LNVFC000000000001/1792527837000001.bin"; + String uri = "archive://" + key; + writeArchive(key, realtimeFrame()); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of(record("indexed-raw", uri))), + decoder(), + archiveRoot, + null); + + Gb32960DecodedFrameService.DecodedFrame frame = service.findByRawArchiveUri(uri, null); + + assertThat(frame.eventId()).isEqualTo("indexed-raw"); + assertThat(frame.rawArchiveUri()).isEqualTo(uri); + } + + @Test + void skipsMissingRawArchiveAndContinues() throws Exception { + String missingKey = "2026/06/22/GB32960/LNVFC000000000001/missing.bin"; + String availableKey = "2026/06/22/GB32960/LNVFC000000000001/raw-2.bin"; + Path rawFile = archiveRoot.resolve(availableKey); + Files.createDirectories(rawFile.getParent()); + Files.write(rawFile, realtimeFrame()); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of( + record("missing", "archive://" + missingKey), + record("available", "archive://" + availableKey))), + decoder(), + archiveRoot, + null); + + List frames = service.query( + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10, + null, + null); + + assertThat(frames).hasSize(1); + assertThat(frames.getFirst().eventId()).isEqualTo("available"); + assertThat(frames.getFirst().blocks()).extracting(block -> block.get("type")) + .containsExactly("VEHICLE", "GD_FC_DCDC"); + } + + @Test + void queryRequestsOnlyRawArchiveRecordsForOneVin() throws Exception { + String key = "2026/06/22/GB32960/LNVFC000000000001/raw-vin.bin"; + writeArchive(key, realtimeFrame()); + CapturingStore store = new CapturingStore(List.of( + businessRecord("business-newer", "LNVFC000000000001"), + record("raw-target", "archive://" + key), + record("raw-other-vin", "archive://2026/06/22/GB32960/OTHER/raw.bin", "OTHER000000000000"))); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + store, + decoder(), + archiveRoot, + null); + + List frames = service.query( + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 1, + "LNVFC000000000001", + null); + + assertThat(store.lastQuery.eventType()).isEqualTo("RAW_ARCHIVE"); + assertThat(store.lastQuery.vin()).isEqualTo("LNVFC000000000001"); + assertThat(frames).hasSize(1); + assertThat(frames.getFirst().eventId()).isEqualTo("raw-target"); + } + + @Test + void snapshotsRequireVinSoSegmentsAreMergedOnlyWithinOneVehicle() { + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of()), + decoder(), + archiveRoot, + null); + + assertThatThrownBy(() -> service.snapshots( + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10, + null, + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("vin is required"); + } + + @Test + void snapshotsMergeGb32960StackCellVoltageSegmentsByEventTime() throws Exception { + String vin = "LNVFC000000000001"; + String key1 = "2026/06/22/GB32960/" + vin + "/stack-1.bin"; + String key2 = "2026/06/22/GB32960/" + vin + "/stack-2.bin"; + String key3 = "2026/06/22/GB32960/" + vin + "/stack-3.bin"; + writeArchive(key1, stackFrame(1, 4, true)); + writeArchive(key2, stackFrame(5, 3, false)); + writeArchive(key3, stackFrame(8, 2, false)); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of( + record("segment-3", "archive://" + key3), + record("segment-2", "archive://" + key2), + record("segment-1", "archive://" + key1))), + decoder(), + archiveRoot, + null); + + List snapshots = service.snapshots( + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10, + vin, + null); + + assertThat(snapshots).hasSize(1); + Gb32960DecodedFrameService.LogicalSnapshot snapshot = snapshots.getFirst(); + assertThat(snapshot.vin()).isEqualTo(vin); + assertThat(snapshot.eventTime()).isEqualTo("2026-06-22T10:00:00Z"); + assertThat(snapshot.sourceFrames()).hasSize(3); + assertThat(snapshot.blocks()).extracting(block -> block.get("type")) + .contains("VEHICLE", "GD_FC_STACK"); + + @SuppressWarnings("unchecked") + Map stackBlock = (Map) snapshot.blocks().stream() + .filter(block -> "GD_FC_STACK".equals(block.get("type"))) + .findFirst() + .orElseThrow() + .get("data"); + @SuppressWarnings("unchecked") + List> stacks = (List>) stackBlock.get("stacks"); + assertThat(stacks).hasSize(1); + @SuppressWarnings("unchecked") + List mergedVoltages = (List) stacks.getFirst().get("frameCellVoltagesV"); + assertThat(stacks.getFirst()).containsEntry("frameCellStart", 1); + assertThat(stacks.getFirst()).containsEntry("frameCellCount", 9); + assertThat(stacks.getFirst()).containsEntry("frameCellComplete", true); + assertThat(stacks.getFirst()).containsEntry("frameCellMissingCount", 0L); + assertThat(mergedVoltages).hasSize(9); + List expected = List.of(1.001, 1.002, 1.003, 1.004, 1.005, 1.006, 1.007, 1.008, 1.009); + for (int i = 0; i < expected.size(); i++) { + assertThat(((Number) mergedVoltages.get(i)).doubleValue()).isCloseTo(expected.get(i), within(0.000001)); + } + } + + @Test + void snapshotsKeepMissingStackCellsAsNullsUpToCellCount() throws Exception { + String vin = "LNVFC000000000001"; + String key = "2026/06/22/GB32960/" + vin + "/stack-tail-missing.bin"; + writeArchive(key, stackFrame(1, 4, true)); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of(record("partial", "archive://" + key))), + decoder(), + archiveRoot, + null); + + List snapshots = service.snapshots( + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10, + vin, + null); + + @SuppressWarnings("unchecked") + Map stackBlock = (Map) snapshots.getFirst().blocks().stream() + .filter(block -> "GD_FC_STACK".equals(block.get("type"))) + .findFirst() + .orElseThrow() + .get("data"); + @SuppressWarnings("unchecked") + List> stacks = (List>) stackBlock.get("stacks"); + @SuppressWarnings("unchecked") + List voltages = (List) stacks.getFirst().get("frameCellVoltagesV"); + + assertThat(stacks.getFirst()).containsEntry("frameCellStart", 1); + assertThat(stacks.getFirst()).containsEntry("frameCellCount", 9); + assertThat(stacks.getFirst()).containsEntry("frameCellComplete", false); + assertThat(stacks.getFirst()).containsEntry("frameCellMissingCount", 5L); + assertThat(voltages).hasSize(9); + assertThat(voltages.subList(4, 9)).containsOnlyNulls(); + } + + @Test + void snapshotFieldsReturnsOnlySelectedFields() throws Exception { + String vin = "LNVFC000000000001"; + String key = "2026/06/22/GB32960/" + vin + "/selected-fields.bin"; + writeArchive(key, realtimeFrame()); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of(record("selected", "archive://" + key))), + decoder(), + archiveRoot, + null); + + List snapshots = service.snapshotFields( + QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"), + EventFileQuery.Order.DESC, + 10, + vin, + null, + "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA"); + + assertThat(snapshots).hasSize(1); + assertThat(snapshots.getFirst().fields()) + .containsEntry("VEHICLE.totalMileageKm", new BigDecimal("10000")) + .containsEntry("GD_FC_DCDC.inputVoltageV", new BigDecimal("300")) + .containsEntry("GD_FC_DCDC.outputCurrentA", new BigDecimal("110")); + } + + @Test + void snapshotFieldsCsvUsesChineseDictionaryHeaders() throws Exception { + String vin = "LNVFC000000000001"; + String key = "2026/06/22/GB32960/" + vin + "/selected-fields-csv.bin"; + writeArchive(key, realtimeFrame()); + + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of(record("selected-csv", "archive://" + key))), + decoder(), + archiveRoot, + null); + + String csv = service.snapshotFieldsCsv( + QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"), + EventFileQuery.Order.DESC, + 10, + vin, + null, + "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV"); + + assertThat(csv).startsWith("VIN,事件时间,首次接收时间,最后接收时间,原始包,整车数据-累计里程(km),广东燃料电池 DC/DC 数据-输入电压(V)\n"); + assertThat(csv).contains("LNVFC000000000001,2026-06-22T10:00:00Z"); + assertThat(csv).contains(",10000,300\n"); + } + + @Test + void snapshotFieldsRejectsUnknownDictionaryFields() throws Exception { + Gb32960DecodedFrameService service = new Gb32960DecodedFrameService( + new CapturingStore(List.of()), + decoder(), + archiveRoot, + null); + + assertThatThrownBy(() -> service.snapshotFields( + QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"), + EventFileQuery.Order.DESC, + 10, + "LNVFC000000000001", + null, + "VEHICLE.totalMileageKm,VEHICLE.notExists")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown gb32960 snapshot fields: VEHICLE.notExists"); + } + + private static Gb32960MessageDecoder decoder() { + List standardParsers = List.of(new VehicleV2016BlockParser()); + VendorExtensionCatalog catalog = new VendorExtensionCatalog(Map.of( + "guangdong-fc", List.of(new GdFcDcDcBlockParser(), new GdFcStackBlockParser()))); + Gb32960ProfileRegistry registry = new Gb32960ProfileRegistry( + standardParsers, catalog, Set.of("guangdong-fc")); + VendorExtensionSelector selector = ctx -> "guangdong-fc"; + return new Gb32960MessageDecoder(new Gb32960BodyParser(registry, selector)); + } + + private static EventFileRecord record(String eventId, String rawArchiveUri) { + return record(eventId, rawArchiveUri, "LNVFC000000000001"); + } + + private static EventFileRecord record(String eventId, String rawArchiveUri, String vin) { + return new EventFileRecord( + eventId, + ProtocolId.GB32960, + "RAW_ARCHIVE", + vin, + Instant.parse("2026-06-22T10:00:00Z"), + Instant.parse("2026-06-22T10:00:01Z"), + rawArchiveUri, + Map.of("platformAccount", "Hyundai"), + "{}"); + } + + private static EventFileRecord businessRecord(String eventId, String vin) { + return new EventFileRecord( + eventId, + ProtocolId.GB32960, + "REALTIME", + vin, + Instant.parse("2026-06-22T10:00:03Z"), + Instant.parse("2026-06-22T10:00:04Z"), + "archive://business/" + eventId + ".bin", + Map.of("platformAccount", "Hyundai"), + "{}"); + } + + private static byte[] realtimeFrame() { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0); + + body.write(0x01); + body.write(0x01); + body.write(0x03); + body.write(0x02); + writeU16(body, 0); + writeU32(body, 100_000L); + writeU16(body, 5605); + writeU16(body, 10000); + body.write(85); + body.write(0x01); + body.write(0x00); + writeU16(body, 10000); + body.write(0x00); + body.write(0x00); + + body.write(0x32); + writeU16(body, 3000); + writeU16(body, 1200); + writeU16(body, 5600); + writeU16(body, 1100); + body.write(105); + + byte[] bodyBytes = body.toByteArray(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x02); + out.write(0xFE); + byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII); + out.write(vin, 0, 17); + out.write(0x01); + writeU16(out, bodyBytes.length); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF); + return out.toByteArray(); + } + + private void writeArchive(String key, byte[] frame) throws Exception { + Path rawFile = archiveRoot.resolve(key); + Files.createDirectories(rawFile.getParent()); + Files.write(rawFile, frame); + } + + private static byte[] stackFrame(int frameCellStart, int frameCellCount, boolean includeVehicle) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0); + + if (includeVehicle) { + body.write(0x01); + body.write(0x01); + body.write(0x03); + body.write(0x02); + writeU16(body, 0); + writeU32(body, 100_000L); + writeU16(body, 5605); + writeU16(body, 10000); + body.write(85); + body.write(0x01); + body.write(0x00); + writeU16(body, 10000); + body.write(0x00); + body.write(0x00); + } + + body.write(0x30); + body.write(0x01); + body.write(0x01); + body.write(90); + writeU16(body, 1850); + writeU16(body, 1500); + body.write(70); + writeU16(body, 12); + writeU16(body, 88); + writeU16(body, 1009); + writeU16(body, 1001); + writeU16(body, 1005); + writeU16(body, 9); + writeU16(body, frameCellStart); + body.write(frameCellCount); + for (int i = 0; i < frameCellCount; i++) { + writeU16(body, 1000 + frameCellStart + i); + } + + byte[] bodyBytes = body.toByteArray(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x23); out.write(0x23); + out.write(0x02); + out.write(0xFE); + byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII); + out.write(vin, 0, 17); + out.write(0x01); + writeU16(out, bodyBytes.length); + out.write(bodyBytes, 0, bodyBytes.length); + byte[] almost = out.toByteArray(); + out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF); + return out.toByteArray(); + } + + private static void writeU16(ByteArrayOutputStream os, int v) { + os.write((v >> 8) & 0xFF); + os.write(v & 0xFF); + } + + private static void writeU32(ByteArrayOutputStream os, long v) { + os.write((int) ((v >> 24) & 0xFF)); + os.write((int) ((v >> 16) & 0xFF)); + os.write((int) ((v >> 8) & 0xFF)); + os.write((int) (v & 0xFF)); + } + + private static final class CapturingStore implements EventFileStore { + private final List records; + private EventFileQuery lastQuery; + + private CapturingStore(List records) { + this.records = records; + } + + @Override + public void appendAll(List records) { + throw new UnsupportedOperationException(); + } + + @Override + public List query(EventFileQuery query) { + lastQuery = query; + return records.stream() + .filter(record -> query.vin() == null || query.vin().equals(record.vin())) + .filter(record -> query.eventType() == null || query.eventType().equals(record.eventType())) + .limit(query.limit()) + .toList(); + } + + @Override + public EventFileRecord findByRawArchiveUri(String rawArchiveUri) { + return records.stream() + .filter(record -> record.rawArchiveUri().equals(rawArchiveUri)) + .findFirst() + .orElse(null); + } + } +} diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionaryTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionaryTest.java new file mode 100644 index 00000000..250194de --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionaryTest.java @@ -0,0 +1,30 @@ +package com.lingniu.ingest.eventhistory; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.stream.Collectors.toMap; +import static org.assertj.core.api.Assertions.assertThat; + +class Gb32960FieldDictionaryTest { + + @Test + void vehicleRunningModeIncludesProtocolValueMappings() { + Gb32960FieldDictionary.Field runningMode = Gb32960FieldDictionary.packets().stream() + .filter(packet -> packet.code().equals("VEHICLE")) + .flatMap(packet -> packet.fields().stream()) + .filter(field -> field.key().equals("runningMode")) + .findFirst() + .orElseThrow(); + + Map namesByCode = runningMode.valueMappings().stream() + .collect(toMap(Gb32960FieldDictionary.ValueMapping::code, + Gb32960FieldDictionary.ValueMapping::nameZh)); + + assertThat(namesByCode) + .containsEntry("01", "纯电") + .containsEntry("02", "混合动力") + .containsEntry("03", "燃料电池"); + } +} diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapperTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapperTest.java new file mode 100644 index 00000000..b1bce6bf --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapperTest.java @@ -0,0 +1,94 @@ +package com.lingniu.ingest.eventhistory; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.eventfilestore.EventFileRecord; +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TelemetryEnvelopeRecordMapperTest { + + @Test + void mapsFullFieldEnvelopeToEventFileRecord() { + VehicleEnvelope envelope = VehicleEnvelope.newBuilder() + .setSchemaVersion("1.0") + .setEventId("event-1") + .setTraceId("trace-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .putMetadata("protocolVersion", "V2016") + .setTelemetrySnapshot(TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .setRawArchiveUri("archive://raw/event-1.bin") + .addFields(TelemetryField.newBuilder() + .setKey("total_mileage_km") + .setValueType("DOUBLE") + .setValue("12345.6") + .setUnit("km") + .setQuality("GOOD") + .setSourcePath("event.realtime.totalMileageKm")) + .build()) + .build(); + + EventFileRecord record = new TelemetryEnvelopeRecordMapper().toRecord(envelope); + + assertThat(record.eventId()).isEqualTo("event-1"); + assertThat(record.protocol()).isEqualTo(ProtocolId.GB32960); + assertThat(record.eventType()).isEqualTo("REALTIME"); + assertThat(record.vin()).isEqualTo("VIN001"); + assertThat(record.rawArchiveUri()).isEqualTo("archive://raw/event-1.bin"); + assertThat(record.metadata()).containsEntry("protocolVersion", "V2016"); + assertThat(record.payloadJson()).contains("\"fields\""); + assertThat(record.payloadJson()).contains("\"total_mileage_km\""); + } + + @Test + void rejectsEnvelopeWithoutTelemetrySnapshot() { + VehicleEnvelope envelope = VehicleEnvelope.newBuilder() + .setEventId("event-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1) + .setIngestTimeMs(2) + .build(); + + assertThatThrownBy(() -> new TelemetryEnvelopeRecordMapper().toRecord(envelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("telemetry_snapshot"); + } + + @Test + void unknownEnvelopeSourceIsStoredAsUnknownProtocolWithOriginalSourceMetadata() { + VehicleEnvelope envelope = VehicleEnvelope.newBuilder() + .setSchemaVersion("1.0") + .setEventId("event-future-1") + .setTraceId("trace-future-1") + .setVin("VIN-FUTURE-001") + .setSource("FUTURE_OEM") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .putMetadata("tenant", "ln") + .setTelemetrySnapshot(TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .addFields(TelemetryField.newBuilder() + .setKey("speed_kmh") + .setValueType("DOUBLE") + .setValue("30.5") + .setQuality("GOOD")) + .build()) + .build(); + + EventFileRecord record = new TelemetryEnvelopeRecordMapper().toRecord(envelope); + + assertThat(record.protocol()).isEqualTo(ProtocolId.UNKNOWN); + assertThat(record.metadata()) + .containsEntry("tenant", "ln") + .containsEntry("originalSource", "FUTURE_OEM"); + } +} diff --git a/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfigurationTest.java b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfigurationTest.java new file mode 100644 index 00000000..e8810771 --- /dev/null +++ b/modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfigurationTest.java @@ -0,0 +1,71 @@ +package com.lingniu.ingest.eventhistory.config; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.eventhistory.EventHistoryController; +import com.lingniu.ingest.eventhistory.EventHistoryEnvelopeIngestor; +import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService; +import com.lingniu.ingest.eventhistory.Gb32960FrameController; +import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper; +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; +import com.lingniu.ingest.sink.archive.config.SinkArchiveProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class EventHistoryAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(EventHistoryAutoConfiguration.class)) + .withBean(EnvelopeDeadLetterSink.class, () -> record -> {}) + .withBean(EventFileStore.class, () -> mock(EventFileStore.class)); + + @Test + void createsHistoryBeansWhenEnabled() { + contextRunner + .withPropertyValues("lingniu.ingest.event-history.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(TelemetryEnvelopeRecordMapper.class); + assertThat(context).hasSingleBean(EventHistoryEnvelopeIngestor.class); + assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class); + assertThat(context).hasSingleBean(EventHistoryController.class); + assertThat(context).doesNotHaveBean(Gb32960DecodedFrameService.class); + assertThat(context).doesNotHaveBean(Gb32960FrameController.class); + }); + } + + @Test + void backsOffWhenDisabled() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(TelemetryEnvelopeRecordMapper.class); + assertThat(context).doesNotHaveBean(EventHistoryEnvelopeIngestor.class); + assertThat(context).doesNotHaveBean(EventHistoryController.class); + }); + } + + @Test + void createsGb32960FrameBeansWhenDecoderAndArchivePropertiesExist() { + contextRunner + .withPropertyValues( + "lingniu.ingest.event-history.enabled=true", + "lingniu.ingest.sink.archive.path=/tmp/lingniu-test-archive") + .withBean(Gb32960MessageDecoder.class, () -> mock(Gb32960MessageDecoder.class)) + .withBean(SinkArchiveProperties.class, SinkArchiveProperties::new) + .run(context -> { + assertThat(context).hasSingleBean(Gb32960DecodedFrameService.class); + assertThat(context).hasSingleBean(Gb32960FrameController.class); + }); + } + + @Test + void parsesFileUriArchiveRoot() { + assertThat(EventHistoryAutoConfiguration.archiveRoot("file:///tmp/lingniu-test-archive")) + .isEqualTo(Path.of("/tmp/lingniu-test-archive")); + } +} diff --git a/modules/services/vehicle-stat-service/pom.xml b/modules/services/vehicle-stat-service/pom.xml new file mode 100644 index 00000000..231d2038 --- /dev/null +++ b/modules/services/vehicle-stat-service/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + ../../../pom.xml + + vehicle-stat-service + vehicle-stat-service + Kafka full-field event consumer + configurable vehicle daily statistics. + + + + com.lingniu.ingest + sink-mq + + + org.apache.kafka + kafka-clients + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java new file mode 100644 index 00000000..4b2da3bf --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java @@ -0,0 +1,71 @@ +package com.lingniu.ingest.vehiclestat; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +public final class DailyMileageCalculator { + + private final ZoneId zoneId; + + public DailyMileageCalculator(ZoneId zoneId) { + if (zoneId == null) { + throw new IllegalArgumentException("zoneId must not be null"); + } + this.zoneId = zoneId; + } + + public OptionalDouble calculate(LocalDate statDate, + DailyMileageStrategy strategy, + List points) { + if (statDate == null) { + throw new IllegalArgumentException("statDate must not be null"); + } + if (strategy == null) { + throw new IllegalArgumentException("strategy must not be null"); + } + if (points == null || points.isEmpty()) { + return OptionalDouble.empty(); + } + + double value = switch (strategy) { + case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points); + case DAY_MAX_MINUS_DAY_MIN -> dayMaxMinusDayMin(statDate, points); + }; + return Double.isFinite(value) && value >= 0 ? OptionalDouble.of(value) : OptionalDouble.empty(); + } + + private double currentLastMinusPreviousLast(LocalDate statDate, List points) { + Optional previousLast = points.stream() + .filter(point -> localDate(point).isBefore(statDate)) + .max(Comparator.comparing(MileagePoint::eventTime)); + Optional currentLast = points.stream() + .filter(point -> localDate(point).isEqual(statDate)) + .max(Comparator.comparing(MileagePoint::eventTime)); + + if (previousLast.isEmpty() || currentLast.isEmpty()) { + return Double.NaN; + } + return currentLast.get().totalMileageKm() - previousLast.get().totalMileageKm(); + } + + private double dayMaxMinusDayMin(LocalDate statDate, List points) { + List currentDay = points.stream() + .filter(point -> localDate(point).isEqual(statDate)) + .toList(); + if (currentDay.size() < 2) { + return Double.NaN; + } + + double min = currentDay.stream().mapToDouble(MileagePoint::totalMileageKm).min().orElse(Double.NaN); + double max = currentDay.stream().mapToDouble(MileagePoint::totalMileageKm).max().orElse(Double.NaN); + return max - min; + } + + private LocalDate localDate(MileagePoint point) { + return point.eventTime().atZone(zoneId).toLocalDate(); + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java new file mode 100644 index 00000000..5098d2f3 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.vehiclestat; + +public enum DailyMileageStrategy { + CURRENT_LAST_MINUS_PREVIOUS_LAST, + DAY_MAX_MINUS_DAY_MIN +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java new file mode 100644 index 00000000..44875507 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java @@ -0,0 +1,48 @@ +package com.lingniu.ingest.vehiclestat; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.OptionalDouble; + +public final class DailyVehicleStatService { + + private final VehicleStatRepository repository; + private final VehicleStatRuleRepository ruleRepository; + private final DailyMileageCalculator mileageCalculator; + + public DailyVehicleStatService(VehicleStatRepository repository, + VehicleStatRuleRepository ruleRepository, + DailyMileageCalculator mileageCalculator) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + if (ruleRepository == null) { + throw new IllegalArgumentException("ruleRepository must not be null"); + } + if (mileageCalculator == null) { + throw new IllegalArgumentException("mileageCalculator must not be null"); + } + this.repository = repository; + this.ruleRepository = ruleRepository; + this.mileageCalculator = mileageCalculator; + } + + public Optional calculateAndSave(String vin, LocalDate statDate) { + VehicleStatRule rule = ruleRepository.ruleFor(vin); + OptionalDouble dailyMileage = mileageCalculator.calculate( + statDate, + rule.dailyMileageStrategy(), + repository.mileagePoints(vin, statDate)); + if (dailyMileage.isEmpty()) { + return Optional.empty(); + } + + VehicleDailyStatResult result = new VehicleDailyStatResult( + vin, + statDate, + dailyMileage, + rule.dailyMileageStrategy()); + repository.saveDailyStat(result); + return Optional.of(result); + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java new file mode 100644 index 00000000..7869461b --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java @@ -0,0 +1,123 @@ +package com.lingniu.ingest.vehiclestat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +public final class FileVehicleStatRepository implements VehicleStatRepository { + + private final Path pointsFile; + private final Path dailyStatsFile; + + public FileVehicleStatRepository(Path root) { + if (root == null) { + throw new IllegalArgumentException("root must not be null"); + } + Path absoluteRoot = root.toAbsolutePath(); + this.pointsFile = absoluteRoot.resolve("mileage-points.tsv"); + this.dailyStatsFile = absoluteRoot.resolve("daily-stats.tsv"); + } + + @Override + public synchronized void appendMileagePoint(String vin, MileagePoint point) { + if (point == null) { + throw new IllegalArgumentException("point must not be null"); + } + appendLine(pointsFile, clean(vin) + '\t' + point.eventTime() + '\t' + point.totalMileageKm()); + } + + @Override + public synchronized List mileagePoints(String vin, LocalDate statDate) { + String normalizedVin = clean(vin); + List out = new ArrayList<>(); + for (String line : readLines(pointsFile)) { + String[] parts = line.split("\\t", -1); + if (parts.length != 3 || !normalizedVin.equals(parts[0])) { + continue; + } + try { + 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. + } + } + return out; + } + + @Override + public synchronized void saveDailyStat(VehicleDailyStatResult result) { + if (result == null) { + throw new IllegalArgumentException("result must not be null"); + } + String mileage = result.dailyMileageKm().isPresent() + ? Double.toString(result.dailyMileageKm().getAsDouble()) + : ""; + appendLine(dailyStatsFile, + clean(result.vin()) + '\t' + + result.statDate() + '\t' + + result.dailyMileageStrategy().name() + '\t' + + mileage); + } + + @Override + public synchronized Optional findDailyStat(String vin, LocalDate statDate) { + String normalizedVin = clean(vin); + VehicleDailyStatResult latest = null; + for (String line : readLines(dailyStatsFile)) { + String[] parts = line.split("\\t", -1); + if (parts.length != 4 || !normalizedVin.equals(parts[0])) { + continue; + } + try { + LocalDate rowDate = LocalDate.parse(parts[1]); + if (!rowDate.equals(statDate)) { + continue; + } + DailyMileageStrategy strategy = DailyMileageStrategy.valueOf(parts[2]); + OptionalDouble mileage = parts[3].isBlank() + ? OptionalDouble.empty() + : OptionalDouble.of(Double.parseDouble(parts[3])); + latest = new VehicleDailyStatResult(normalizedVin, rowDate, mileage, strategy); + } catch (RuntimeException ignored) { + // Ignore corrupt rows; later valid rows can still provide the answer. + } + } + return Optional.ofNullable(latest); + } + + private static void appendLine(Path file, String line) { + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, line + System.lineSeparator(), StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException e) { + throw new IllegalStateException("write vehicle stat file failed: " + file, e); + } + } + + private static List readLines(Path file) { + if (!Files.isRegularFile(file)) { + return List.of(); + } + try { + return Files.readAllLines(file, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("read vehicle stat file failed: " + file, e); + } + } + + private static String clean(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("vin must not be blank"); + } + return value.trim().replace('\t', '_').replace('\n', '_').replace('\r', '_'); + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/MileagePoint.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/MileagePoint.java new file mode 100644 index 00000000..5518f480 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/MileagePoint.java @@ -0,0 +1,15 @@ +package com.lingniu.ingest.vehiclestat; + +import java.time.Instant; + +public record MileagePoint(Instant eventTime, double totalMileageKm) { + + public MileagePoint { + if (eventTime == null) { + throw new IllegalArgumentException("eventTime must not be null"); + } + if (!Double.isFinite(totalMileageKm)) { + throw new IllegalArgumentException("totalMileageKm must be finite"); + } + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java new file mode 100644 index 00000000..6b71fe70 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java @@ -0,0 +1,25 @@ +package com.lingniu.ingest.vehiclestat; + +import java.time.LocalDate; +import java.util.OptionalDouble; + +public record VehicleDailyStatResult(String vin, + LocalDate statDate, + OptionalDouble dailyMileageKm, + DailyMileageStrategy dailyMileageStrategy) { + + public VehicleDailyStatResult { + if (vin == null || vin.isBlank()) { + throw new IllegalArgumentException("vin must not be blank"); + } + if (statDate == null) { + throw new IllegalArgumentException("statDate must not be null"); + } + if (dailyMileageKm == null) { + dailyMileageKm = OptionalDouble.empty(); + } + if (dailyMileageStrategy == null) { + dailyMileageStrategy = DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST; + } + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatController.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatController.java new file mode 100644 index 00000000..80f13bc6 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatController.java @@ -0,0 +1,60 @@ +package com.lingniu.ingest.vehiclestat; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true") +@ConditionalOnBean(DailyVehicleStatService.class) +@RequestMapping(path = "/api/vehicle-stat", produces = MediaType.APPLICATION_JSON_VALUE) +public final class VehicleStatController { + + private final VehicleStatRepository repository; + private final DailyVehicleStatService dailyStatService; + + public VehicleStatController(VehicleStatRepository repository, + DailyVehicleStatService dailyStatService) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + if (dailyStatService == null) { + throw new IllegalArgumentException("dailyStatService must not be null"); + } + this.repository = repository; + this.dailyStatService = dailyStatService; + } + + @GetMapping("/{vin}/daily") + public ResponseEntity> daily( + @PathVariable String vin, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(defaultValue = "true") boolean calculateIfMissing) { + var result = repository.findDailyStat(vin, date) + .or(() -> calculateIfMissing ? dailyStatService.calculateAndSave(vin, date) : java.util.Optional.empty()); + return result.map(stat -> ResponseEntity.ok(toJson(stat))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + private static Map toJson(VehicleDailyStatResult stat) { + Map json = new LinkedHashMap<>(); + json.put("vin", stat.vin()); + json.put("statDate", stat.statDate().toString()); + json.put("dailyMileageKm", stat.dailyMileageKm().isPresent() + ? stat.dailyMileageKm().getAsDouble() + : null); + json.put("dailyMileageStrategy", stat.dailyMileageStrategy().name()); + return json; + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestor.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestor.java new file mode 100644 index 00000000..19e36e34 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestor.java @@ -0,0 +1,51 @@ +package com.lingniu.ingest.vehiclestat; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import com.lingniu.ingest.api.consumer.EnvelopeIngestor; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; + +public final class VehicleStatEnvelopeIngestor implements EnvelopeIngestor { + + private final VehicleStatEventProcessor processor; + + public VehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor) { + if (processor == null) { + throw new IllegalArgumentException("processor must not be null"); + } + this.processor = processor; + } + + public void ingest(byte[] kafkaValue) { + processor.process(parse(kafkaValue)); + } + + @Override + public EnvelopeIngestResult tryIngest(byte[] kafkaValue) { + VehicleEnvelope envelope = null; + try { + envelope = parse(kafkaValue); + if (!envelope.hasTelemetrySnapshot()) { + return EnvelopeIngestResult.skipped( + envelope.getEventId(), envelope.getVin(), "envelope telemetry_snapshot is required"); + } + processor.process(envelope); + return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin()); + } catch (IllegalArgumentException ex) { + return envelope == null + ? EnvelopeIngestResult.invalid(ex.getMessage()) + : EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage()); + } + } + + private static VehicleEnvelope parse(byte[] kafkaValue) { + if (kafkaValue == null || kafkaValue.length == 0) { + throw new IllegalArgumentException("VehicleEnvelope bytes must not be empty"); + } + try { + return VehicleEnvelope.parseFrom(kafkaValue); + } catch (InvalidProtocolBufferException ex) { + throw new IllegalArgumentException("VehicleEnvelope bytes are invalid", ex); + } + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java new file mode 100644 index 00000000..0f310dd0 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java @@ -0,0 +1,53 @@ +package com.lingniu.ingest.vehiclestat; + +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; + +import java.time.Instant; +import java.util.OptionalDouble; + +public final class VehicleStatEventProcessor { + + private static final String TOTAL_MILEAGE_KEY = "total_mileage_km"; + + private final VehicleStatRepository repository; + + public VehicleStatEventProcessor(VehicleStatRepository repository) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + this.repository = repository; + } + + public void process(VehicleEnvelope envelope) { + if (envelope == null) { + throw new IllegalArgumentException("envelope must not be null"); + } + if (!envelope.hasTelemetrySnapshot()) { + return; + } + + OptionalDouble totalMileage = totalMileage(envelope); + if (totalMileage.isEmpty()) { + return; + } + repository.appendMileagePoint( + envelope.getVin(), + new MileagePoint(Instant.ofEpochMilli(envelope.getEventTimeMs()), totalMileage.getAsDouble())); + } + + private static OptionalDouble totalMileage(VehicleEnvelope envelope) { + for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) { + if (!TOTAL_MILEAGE_KEY.equals(field.getKey())) { + continue; + } + try { + double value = Double.parseDouble(field.getValue()); + return Double.isFinite(value) ? OptionalDouble.of(value) : OptionalDouble.empty(); + } catch (NumberFormatException ex) { + return OptionalDouble.empty(); + } + } + return OptionalDouble.empty(); + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java new file mode 100644 index 00000000..f9f4cb00 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java @@ -0,0 +1,61 @@ +package com.lingniu.ingest.vehiclestat; + +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.sink.EventSink; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.concurrent.CompletableFuture; + +public final class VehicleStatEventSink implements EventSink { + + private final VehicleStatRepository repository; + private final DailyVehicleStatService dailyStatService; + private final ZoneId zoneId; + + public VehicleStatEventSink(VehicleStatRepository repository, + DailyVehicleStatService dailyStatService, + ZoneId zoneId) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + if (dailyStatService == null) { + throw new IllegalArgumentException("dailyStatService must not be null"); + } + this.repository = repository; + this.dailyStatService = dailyStatService; + this.zoneId = zoneId == null ? ZoneId.of("Asia/Shanghai") : zoneId; + } + + @Override + public String name() { + return "vehicle-stat"; + } + + @Override + public boolean accepts(VehicleEvent event) { + return event instanceof VehicleEvent.Realtime realtime + && realtime.payload() != null + && realtime.payload().totalMileageKm() != null; + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + if (!accepts(event)) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture future = new CompletableFuture<>(); + try { + VehicleEvent.Realtime realtime = (VehicleEvent.Realtime) event; + repository.appendMileagePoint( + realtime.vin(), + new MileagePoint(realtime.eventTime(), realtime.payload().totalMileageKm())); + LocalDate statDate = LocalDate.ofInstant(realtime.eventTime(), zoneId); + dailyStatService.calculateAndSave(realtime.vin(), statDate); + future.complete(null); + } catch (RuntimeException e) { + future.completeExceptionally(e); + } + return future; + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java new file mode 100644 index 00000000..715ae41c --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java @@ -0,0 +1,16 @@ +package com.lingniu.ingest.vehiclestat; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface VehicleStatRepository { + + void appendMileagePoint(String vin, MileagePoint point); + + List mileagePoints(String vin, LocalDate statDate); + + void saveDailyStat(VehicleDailyStatResult result); + + Optional findDailyStat(String vin, LocalDate statDate); +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRule.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRule.java new file mode 100644 index 00000000..e9e05735 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRule.java @@ -0,0 +1,13 @@ +package com.lingniu.ingest.vehiclestat; + +public record VehicleStatRule(String vin, DailyMileageStrategy dailyMileageStrategy) { + + public VehicleStatRule { + if (vin == null || vin.isBlank()) { + throw new IllegalArgumentException("vin must not be blank"); + } + if (dailyMileageStrategy == null) { + dailyMileageStrategy = DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST; + } + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRuleRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRuleRepository.java new file mode 100644 index 00000000..8811ec59 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRuleRepository.java @@ -0,0 +1,6 @@ +package com.lingniu.ingest.vehiclestat; + +public interface VehicleStatRuleRepository { + + VehicleStatRule ruleFor(String vin); +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java new file mode 100644 index 00000000..06d4d1d2 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java @@ -0,0 +1,96 @@ +package com.lingniu.ingest.vehiclestat.config; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import com.lingniu.ingest.vehiclestat.DailyMileageCalculator; +import com.lingniu.ingest.vehiclestat.DailyMileageStrategy; +import com.lingniu.ingest.vehiclestat.DailyVehicleStatService; +import com.lingniu.ingest.vehiclestat.FileVehicleStatRepository; +import com.lingniu.ingest.vehiclestat.VehicleStatController; +import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor; +import com.lingniu.ingest.vehiclestat.VehicleStatEventSink; +import com.lingniu.ingest.vehiclestat.VehicleStatEventProcessor; +import com.lingniu.ingest.vehiclestat.VehicleStatRepository; +import com.lingniu.ingest.vehiclestat.VehicleStatRule; +import com.lingniu.ingest.vehiclestat.VehicleStatRuleRepository; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.nio.file.Path; +import java.time.ZoneId; + +@AutoConfiguration +@EnableConfigurationProperties(VehicleStatProperties.class) +@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true") +public class VehicleStatAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public VehicleStatRepository vehicleStatRepository(VehicleStatProperties props) { + return new FileVehicleStatRepository(Path.of(props.getFilePath())); + } + + @Bean + @ConditionalOnMissingBean + public VehicleStatRuleRepository vehicleStatRuleRepository() { + return vin -> new VehicleStatRule(vin, DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST); + } + + @Bean + @ConditionalOnMissingBean + public DailyMileageCalculator dailyMileageCalculator(VehicleStatProperties props) { + return new DailyMileageCalculator(ZoneId.of(props.getZoneId())); + } + + @Bean + @ConditionalOnBean(VehicleStatRepository.class) + @ConditionalOnMissingBean + public DailyVehicleStatService dailyVehicleStatService(VehicleStatRepository repository, + VehicleStatRuleRepository ruleRepository, + DailyMileageCalculator mileageCalculator) { + return new DailyVehicleStatService(repository, ruleRepository, mileageCalculator); + } + + @Bean + @ConditionalOnBean(VehicleStatRepository.class) + @ConditionalOnMissingBean + public VehicleStatEventProcessor vehicleStatEventProcessor(VehicleStatRepository repository) { + return new VehicleStatEventProcessor(repository); + } + + @Bean + @ConditionalOnBean(VehicleStatEventProcessor.class) + @ConditionalOnMissingBean + public VehicleStatEnvelopeIngestor vehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor) { + return new VehicleStatEnvelopeIngestor(processor); + } + + @Bean + @ConditionalOnBean({VehicleStatRepository.class, DailyVehicleStatService.class}) + @ConditionalOnMissingBean + public VehicleStatEventSink vehicleStatEventSink(VehicleStatRepository repository, + DailyVehicleStatService dailyStatService, + VehicleStatProperties props) { + return new VehicleStatEventSink(repository, dailyStatService, ZoneId.of(props.getZoneId())); + } + + @Bean + @ConditionalOnBean(DailyVehicleStatService.class) + @ConditionalOnMissingBean + public VehicleStatController vehicleStatController(VehicleStatRepository repository, + DailyVehicleStatService dailyStatService) { + return new VehicleStatController(repository, dailyStatService); + } + + @Bean + @ConditionalOnBean({VehicleStatEnvelopeIngestor.class, EnvelopeDeadLetterSink.class}) + @ConditionalOnMissingBean(name = "vehicleStatEnvelopeConsumerProcessor") + public EnvelopeConsumerProcessor vehicleStatEnvelopeConsumerProcessor(VehicleStatEnvelopeIngestor ingestor, + EnvelopeDeadLetterSink deadLetterSink) { + return new EnvelopeConsumerProcessor("vehicle-stat", ingestor, deadLetterSink); + } +} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java new file mode 100644 index 00000000..d45d8c21 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java @@ -0,0 +1,27 @@ +package com.lingniu.ingest.vehiclestat.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.vehicle-stat") +public class VehicleStatProperties { + + private String filePath = "./target/vehicle-stat/"; + + private String zoneId = "Asia/Shanghai"; + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public String getZoneId() { + return zoneId; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } +} diff --git a/modules/services/vehicle-stat-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/services/vehicle-stat-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..953cfaa1 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.vehiclestat.config.VehicleStatAutoConfiguration diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculatorTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculatorTest.java new file mode 100644 index 00000000..9b90928a --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculatorTest.java @@ -0,0 +1,59 @@ +package com.lingniu.ingest.vehiclestat; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.OptionalDouble; + +import static org.assertj.core.api.Assertions.assertThat; + +class DailyMileageCalculatorTest { + + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private final DailyMileageCalculator calculator = new DailyMileageCalculator(ZONE); + + @Test + void calculatesCurrentLastMinusPreviousLast() { + OptionalDouble mileage = calculator.calculate( + LocalDate.of(2026, 6, 22), + DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST, + List.of( + point("2026-06-21T15:59:00Z", 1_000.0), + point("2026-06-22T00:10:00Z", 1_002.0), + point("2026-06-22T10:00:00Z", 1_035.5))); + + assertThat(mileage).hasValue(35.5); + } + + @Test + void calculatesDayMaxMinusDayMin() { + OptionalDouble mileage = calculator.calculate( + LocalDate.of(2026, 6, 22), + DailyMileageStrategy.DAY_MAX_MINUS_DAY_MIN, + List.of( + point("2026-06-22T00:10:00Z", 1_002.0), + point("2026-06-22T10:00:00Z", 1_035.5), + point("2026-06-22T11:00:00Z", 1_030.0))); + + assertThat(mileage).hasValue(33.5); + } + + @Test + void ignoresNegativeMileageFromCounterReset() { + OptionalDouble mileage = calculator.calculate( + LocalDate.of(2026, 6, 22), + DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST, + List.of( + point("2026-06-21T15:59:00Z", 1_000.0), + point("2026-06-22T10:00:00Z", 10.0))); + + assertThat(mileage).isEmpty(); + } + + private static MileagePoint point(String instant, double totalMileageKm) { + return new MileagePoint(Instant.parse(instant), totalMileageKm); + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatServiceTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatServiceTest.java new file mode 100644 index 00000000..93cddecb --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatServiceTest.java @@ -0,0 +1,90 @@ +package com.lingniu.ingest.vehiclestat; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class DailyVehicleStatServiceTest { + + private final InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository(); + private final VehicleStatRuleRepository rules = vin -> new VehicleStatRule( + vin, + "VIN-DAYMAX".equals(vin) + ? DailyMileageStrategy.DAY_MAX_MINUS_DAY_MIN + : DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST); + private final DailyVehicleStatService service = new DailyVehicleStatService( + repository, + rules, + new DailyMileageCalculator(ZoneId.of("Asia/Shanghai"))); + + @Test + void calculatesAndSavesDailyMileageWithVehicleRule() { + repository.appendMileagePoint("VIN001", point("2026-06-21T15:59:00Z", 100.0)); + repository.appendMileagePoint("VIN001", point("2026-06-22T10:00:00Z", 135.5)); + + Optional result = service.calculateAndSave("VIN001", LocalDate.of(2026, 6, 22)); + + assertThat(result).hasValueSatisfying(stat -> { + assertThat(stat.vin()).isEqualTo("VIN001"); + assertThat(stat.statDate()).isEqualTo(LocalDate.of(2026, 6, 22)); + assertThat(stat.dailyMileageKm()).hasValue(35.5); + }); + assertThat(repository.findDailyStat("VIN001", LocalDate.of(2026, 6, 22))).isEqualTo(result); + } + + @Test + void usesPerVehicleDayMaxStrategy() { + repository.appendMileagePoint("VIN-DAYMAX", point("2026-06-22T00:10:00Z", 100.0)); + repository.appendMileagePoint("VIN-DAYMAX", point("2026-06-22T10:00:00Z", 118.0)); + repository.appendMileagePoint("VIN-DAYMAX", point("2026-06-22T11:00:00Z", 115.0)); + + Optional result = + service.calculateAndSave("VIN-DAYMAX", LocalDate.of(2026, 6, 22)); + + assertThat(result).hasValueSatisfying(stat -> assertThat(stat.dailyMileageKm()).hasValue(18.0)); + } + + @Test + void returnsEmptyWhenMileageCannotBeCalculated() { + repository.appendMileagePoint("VIN001", point("2026-06-22T10:00:00Z", 135.5)); + + Optional result = service.calculateAndSave("VIN001", LocalDate.of(2026, 6, 22)); + + assertThat(result).isEmpty(); + } + + private static MileagePoint point(String instant, double totalMileageKm) { + return new MileagePoint(Instant.parse(instant), totalMileageKm); + } + + private static final class InMemoryVehicleStatRepository implements VehicleStatRepository { + private final java.util.Map> points = new java.util.HashMap<>(); + private final java.util.Map results = new java.util.HashMap<>(); + + @Override + public void appendMileagePoint(String vin, MileagePoint point) { + points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point); + } + + @Override + public List mileagePoints(String vin, LocalDate statDate) { + return points.getOrDefault(vin, List.of()); + } + + @Override + public void saveDailyStat(VehicleDailyStatResult result) { + results.put(result.vin() + ":" + result.statDate(), result); + } + + @Override + public Optional findDailyStat(String vin, LocalDate statDate) { + return Optional.ofNullable(results.get(vin + ":" + statDate)); + } + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepositoryTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepositoryTest.java new file mode 100644 index 00000000..0ad4a6bf --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepositoryTest.java @@ -0,0 +1,38 @@ +package com.lingniu.ingest.vehiclestat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.util.OptionalDouble; + +import static org.assertj.core.api.Assertions.assertThat; + +class FileVehicleStatRepositoryTest { + + @TempDir + Path tempDir; + + @Test + void persistsMileagePointsAndLatestDailyStat() { + FileVehicleStatRepository repository = new FileVehicleStatRepository(tempDir); + + repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-21T15:59:00Z"), 100.0)); + repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-22T10:00:00Z"), 135.5)); + repository.saveDailyStat(new VehicleDailyStatResult( + "VIN001", + LocalDate.parse("2026-06-22"), + OptionalDouble.of(35.5), + DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST)); + + FileVehicleStatRepository reopened = new FileVehicleStatRepository(tempDir); + + assertThat(reopened.mileagePoints("VIN001", LocalDate.parse("2026-06-22"))) + .extracting(MileagePoint::totalMileageKm) + .containsExactly(100.0, 135.5); + assertThat(reopened.findDailyStat("VIN001", LocalDate.parse("2026-06-22"))) + .hasValueSatisfying(stat -> assertThat(stat.dailyMileageKm()).hasValue(35.5)); + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java new file mode 100644 index 00000000..3d1675e7 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java @@ -0,0 +1,60 @@ +package com.lingniu.ingest.vehiclestat; + +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class VehicleStatControllerTest { + + @Test + void returnsDailyStatJson() throws Exception { + InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository(); + DailyVehicleStatService service = new DailyVehicleStatService( + repository, + vin -> new VehicleStatRule(vin, DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST), + new DailyMileageCalculator(ZoneId.of("Asia/Shanghai"))); + repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-21T15:59:00Z"), 100.0)); + repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-22T10:00:00Z"), 135.5)); + service.calculateAndSave("VIN001", LocalDate.parse("2026-06-22")); + MockMvc mvc = standaloneSetup(new VehicleStatController(repository, service)).build(); + + mvc.perform(get("/api/vehicle-stat/VIN001/daily").param("date", "2026-06-22")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.vin").value("VIN001")) + .andExpect(jsonPath("$.statDate").value("2026-06-22")) + .andExpect(jsonPath("$.dailyMileageKm").value(35.5)); + } + + private static final class InMemoryVehicleStatRepository implements VehicleStatRepository { + private final java.util.Map> points = new java.util.HashMap<>(); + private final java.util.Map results = new java.util.HashMap<>(); + + @Override + public void appendMileagePoint(String vin, MileagePoint point) { + points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point); + } + + @Override + public java.util.List mileagePoints(String vin, LocalDate statDate) { + return points.getOrDefault(vin, java.util.List.of()); + } + + @Override + public void saveDailyStat(VehicleDailyStatResult result) { + results.put(result.vin() + ":" + result.statDate(), result); + } + + @Override + public java.util.Optional findDailyStat(String vin, LocalDate statDate) { + return java.util.Optional.ofNullable(results.get(vin + ":" + statDate)); + } + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestorTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestorTest.java new file mode 100644 index 00000000..3f80f9b5 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestorTest.java @@ -0,0 +1,116 @@ +package com.lingniu.ingest.vehiclestat; + +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import com.lingniu.ingest.api.consumer.EnvelopeIngestor; +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +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 VehicleStatEnvelopeIngestorTest { + + @Test + void parsesEnvelopeBytesAndProcessesMileagePoint() { + InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository(); + VehicleStatEnvelopeIngestor ingestor = + new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(repository)); + + assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class); + ingestor.ingest(envelope().toByteArray()); + + assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))) + .extracting(MileagePoint::totalMileageKm) + .containsExactly(123.45); + } + + @Test + void rejectsInvalidEnvelopeBytes() { + VehicleStatEnvelopeIngestor ingestor = + new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(new InMemoryVehicleStatRepository())); + + assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("VehicleEnvelope"); + } + + @Test + void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutAppendingMileagePoint() { + InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository(); + VehicleStatEnvelopeIngestor ingestor = + new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(repository)); + + var result = ingestor.tryIngest(new byte[]{0x01, 0x02}); + + assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE); + assertThat(result.message()).contains("VehicleEnvelope"); + assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))).isEmpty(); + } + + @Test + void tryIngestEnvelopeWithoutTelemetrySnapshotReturnsSkipped() { + InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository(); + VehicleStatEnvelopeIngestor ingestor = + new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(repository)); + VehicleEnvelope envelope = VehicleEnvelope.newBuilder() + .setEventId("event-login-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .build(); + + var result = ingestor.tryIngest(envelope.toByteArray()); + + assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.SKIPPED); + assertThat(result.eventId()).isEqualTo("event-login-1"); + assertThat(result.message()).contains("telemetry_snapshot"); + assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))).isEmpty(); + } + + private static VehicleEnvelope envelope() { + return VehicleEnvelope.newBuilder() + .setEventId("event-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .setTelemetrySnapshot(TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .addFields(TelemetryField.newBuilder() + .setKey("total_mileage_km") + .setValueType("DOUBLE") + .setValue("123.45") + .setQuality("GOOD"))) + .build(); + } + + private static final class InMemoryVehicleStatRepository implements VehicleStatRepository { + private final java.util.Map> points = new java.util.HashMap<>(); + + @Override + public void appendMileagePoint(String vin, MileagePoint point) { + points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point); + } + + @Override + public List mileagePoints(String vin, LocalDate statDate) { + return points.getOrDefault(vin, List.of()); + } + + @Override + public void saveDailyStat(VehicleDailyStatResult result) { + } + + @Override + public Optional findDailyStat(String vin, LocalDate statDate) { + return Optional.empty(); + } + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessorTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessorTest.java new file mode 100644 index 00000000..a26dcc38 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessorTest.java @@ -0,0 +1,74 @@ +package com.lingniu.ingest.vehiclestat; + +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleStatEventProcessorTest { + + private final InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository(); + private final VehicleStatEventProcessor processor = new VehicleStatEventProcessor(repository); + + @Test + void appendsMileagePointFromInternalField() { + processor.process(envelope("total_mileage_km", "123.45")); + + assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))) + .extracting(MileagePoint::totalMileageKm) + .containsExactly(123.45); + } + + @Test + void ignoresEventsWithoutMileageField() { + processor.process(envelope("speed_kmh", "80.5")); + + assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))).isEmpty(); + } + + private static VehicleEnvelope envelope(String key, String value) { + return VehicleEnvelope.newBuilder() + .setEventId("event-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .setTelemetrySnapshot(TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .addFields(TelemetryField.newBuilder() + .setKey(key) + .setValueType("DOUBLE") + .setValue(value) + .setQuality("GOOD"))) + .build(); + } + + private static final class InMemoryVehicleStatRepository implements VehicleStatRepository { + private final java.util.Map> points = new java.util.HashMap<>(); + + @Override + public void appendMileagePoint(String vin, MileagePoint point) { + points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point); + } + + @Override + public List mileagePoints(String vin, LocalDate statDate) { + return points.getOrDefault(vin, List.of()); + } + + @Override + public void saveDailyStat(VehicleDailyStatResult result) { + } + + @Override + public Optional findDailyStat(String vin, LocalDate statDate) { + return Optional.empty(); + } + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSinkTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSinkTest.java new file mode 100644 index 00000000..4d1cc851 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSinkTest.java @@ -0,0 +1,54 @@ +package com.lingniu.ingest.vehiclestat; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleStatEventSinkTest { + + @TempDir + Path tempDir; + + @Test + void consumesRealtimeEventsAndCalculatesDailyMileage() { + FileVehicleStatRepository repository = new FileVehicleStatRepository(tempDir); + DailyVehicleStatService service = new DailyVehicleStatService( + repository, + vin -> new VehicleStatRule(vin, DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST), + new DailyMileageCalculator(ZoneId.of("Asia/Shanghai"))); + VehicleStatEventSink sink = new VehicleStatEventSink(repository, service, ZoneId.of("Asia/Shanghai")); + + sink.publish(realtime("previous", Instant.parse("2026-06-21T15:59:00Z"), 100.0)).join(); + sink.publish(realtime("current", Instant.parse("2026-06-22T10:00:00Z"), 135.5)).join(); + + assertThat(repository.findDailyStat("VIN001", LocalDate.parse("2026-06-22"))) + .hasValueSatisfying(stat -> assertThat(stat.dailyMileageKm()).hasValue(35.5)); + } + + private static VehicleEvent.Realtime realtime(String id, Instant eventTime, double totalMileageKm) { + return new VehicleEvent.Realtime( + id, + "VIN001", + ProtocolId.GB32960, + eventTime, + eventTime.plusMillis(100), + "trace-" + id, + Map.of(), + new RealtimePayload( + 42.1, totalMileageKm, 87.0, null, null, + null, null, null, 8.3, null, null, + RealtimePayload.VehicleState.STARTED, + null, null, null, null, null, + 113.12, 23.45, null, null, null, null)); + } +} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java new file mode 100644 index 00000000..e4a39a78 --- /dev/null +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java @@ -0,0 +1,85 @@ +package com.lingniu.ingest.vehiclestat.config; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import com.lingniu.ingest.vehiclestat.DailyMileageCalculator; +import com.lingniu.ingest.vehiclestat.DailyMileageStrategy; +import com.lingniu.ingest.vehiclestat.DailyVehicleStatService; +import com.lingniu.ingest.vehiclestat.FileVehicleStatRepository; +import com.lingniu.ingest.vehiclestat.MileagePoint; +import com.lingniu.ingest.vehiclestat.VehicleStatController; +import com.lingniu.ingest.vehiclestat.VehicleDailyStatResult; +import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor; +import com.lingniu.ingest.vehiclestat.VehicleStatEventSink; +import com.lingniu.ingest.vehiclestat.VehicleStatEventProcessor; +import com.lingniu.ingest.vehiclestat.VehicleStatRepository; +import com.lingniu.ingest.vehiclestat.VehicleStatRuleRepository; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleStatAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VehicleStatAutoConfiguration.class)) + .withBean(EnvelopeDeadLetterSink.class, () -> record -> {}) + .withBean(VehicleStatRepository.class, InMemoryVehicleStatRepository::new); + + @Test + void createsStatBeansWhenEnabledAndRepositoryExists() { + contextRunner + .withPropertyValues("lingniu.ingest.vehicle-stat.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(VehicleStatRuleRepository.class); + assertThat(context).hasSingleBean(DailyMileageCalculator.class); + assertThat(context).hasSingleBean(DailyVehicleStatService.class); + assertThat(context).hasSingleBean(VehicleStatEventProcessor.class); + assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class); + assertThat(context).hasSingleBean(VehicleStatEventSink.class); + assertThat(context).hasSingleBean(VehicleStatController.class); + assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class); + assertThat(context.getBean(VehicleStatRuleRepository.class) + .ruleFor("VIN001").dailyMileageStrategy()) + .isEqualTo(DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST); + }); + } + + @Test + void backsOffWhenDisabled() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(DailyVehicleStatService.class); + assertThat(context).doesNotHaveBean(VehicleStatEventProcessor.class); + assertThat(context).doesNotHaveBean(VehicleStatEnvelopeIngestor.class); + }); + } + + @Test + void createsFileRepositoryWhenNoRepositoryBeanExists() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VehicleStatAutoConfiguration.class)) + .withPropertyValues("lingniu.ingest.vehicle-stat.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(VehicleStatRepository.class); + assertThat(context).hasSingleBean(FileVehicleStatRepository.class); + assertThat(context).hasSingleBean(VehicleStatRuleRepository.class); + assertThat(context).hasSingleBean(DailyMileageCalculator.class); + assertThat(context).hasSingleBean(DailyVehicleStatService.class); + assertThat(context).hasSingleBean(VehicleStatEventProcessor.class); + }); + } + + private static final class InMemoryVehicleStatRepository implements VehicleStatRepository { + @Override public void appendMileagePoint(String vin, MileagePoint point) {} + @Override public List mileagePoints(String vin, LocalDate statDate) { return List.of(); } + @Override public void saveDailyStat(VehicleDailyStatResult result) {} + @Override public Optional findDailyStat(String vin, LocalDate statDate) { + return Optional.empty(); + } + } +} diff --git a/modules/services/vehicle-state-service/pom.xml b/modules/services/vehicle-state-service/pom.xml new file mode 100644 index 00000000..c3279287 --- /dev/null +++ b/modules/services/vehicle-state-service/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + ../../../pom.xml + + vehicle-state-service + vehicle-state-service + Kafka full-field event consumer + Redis hot vehicle state query API. + + + + com.lingniu.ingest + sink-mq + + + org.apache.kafka + kafka-clients + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java new file mode 100644 index 00000000..d77bf688 --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java @@ -0,0 +1,72 @@ +package com.lingniu.ingest.vehiclestate; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Optional; + +public final class RedisVehicleStateRepository implements VehicleStateRepository { + + private final StringRedisTemplate redis; + + public RedisVehicleStateRepository(StringRedisTemplate redis) { + if (redis == null) { + throw new IllegalArgumentException("redis must not be null"); + } + this.redis = redis; + } + + @Override + public void putState(String vin, String json) { + put(key("vehicle:state:", vin), json); + } + + @Override + public void putLocation(String vin, String json) { + put(key("vehicle:location:", vin), json); + } + + @Override + public void putSafety(String vin, String json) { + put(key("vehicle:safety:", vin), json); + } + + @Override + public void putLastEvent(String vin, String json) { + put(key("vehicle:event:last:", vin), json); + } + + @Override + public Optional getState(String vin) { + return get(key("vehicle:state:", vin)); + } + + @Override + public Optional getLocation(String vin) { + return get(key("vehicle:location:", vin)); + } + + @Override + public Optional getSafety(String vin) { + return get(key("vehicle:safety:", vin)); + } + + @Override + public Optional getLastEvent(String vin) { + return get(key("vehicle:event:last:", vin)); + } + + private void put(String key, String json) { + redis.opsForValue().set(key, json == null ? "{}" : json); + } + + private Optional get(String key) { + return Optional.ofNullable(redis.opsForValue().get(key)); + } + + private static String key(String prefix, String vin) { + if (vin == null || vin.isBlank()) { + throw new IllegalArgumentException("vin must not be blank"); + } + return prefix + vin; + } +} diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java new file mode 100644 index 00000000..11b0534f --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java @@ -0,0 +1,55 @@ +package com.lingniu.ingest.vehiclestate; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +@RestController +@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-state", name = "enabled", havingValue = "true") +@ConditionalOnBean(VehicleStateRepository.class) +@RequestMapping(path = "/api/vehicle-state", produces = MediaType.APPLICATION_JSON_VALUE) +public final class VehicleStateController { + + private final VehicleStateRepository repository; + + public VehicleStateController(VehicleStateRepository repository) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + this.repository = repository; + } + + @GetMapping("/{vin}") + public ResponseEntity state(@PathVariable String vin) { + return json(repository.getState(vin)); + } + + @GetMapping("/{vin}/location") + public ResponseEntity location(@PathVariable String vin) { + return json(repository.getLocation(vin)); + } + + @GetMapping("/{vin}/safety") + public ResponseEntity safety(@PathVariable String vin) { + return json(repository.getSafety(vin)); + } + + @GetMapping("/{vin}/last-event") + public ResponseEntity lastEvent(@PathVariable String vin) { + return json(repository.getLastEvent(vin)); + } + + private static ResponseEntity json(Optional value) { + return value.map(json -> ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(json)) + .orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java new file mode 100644 index 00000000..2f25144a --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java @@ -0,0 +1,47 @@ +package com.lingniu.ingest.vehiclestate; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import com.lingniu.ingest.api.consumer.EnvelopeIngestor; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; + +public final class VehicleStateEnvelopeIngestor implements EnvelopeIngestor { + + private final VehicleStateUpdater updater; + + public VehicleStateEnvelopeIngestor(VehicleStateUpdater updater) { + if (updater == null) { + throw new IllegalArgumentException("updater must not be null"); + } + this.updater = updater; + } + + public void ingest(byte[] kafkaValue) { + updater.update(parse(kafkaValue)); + } + + @Override + public EnvelopeIngestResult tryIngest(byte[] kafkaValue) { + VehicleEnvelope envelope = null; + try { + envelope = parse(kafkaValue); + updater.update(envelope); + return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin()); + } catch (IllegalArgumentException ex) { + return envelope == null + ? EnvelopeIngestResult.invalid(ex.getMessage()) + : EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage()); + } + } + + private static VehicleEnvelope parse(byte[] kafkaValue) { + if (kafkaValue == null || kafkaValue.length == 0) { + throw new IllegalArgumentException("VehicleEnvelope bytes must not be empty"); + } + try { + return VehicleEnvelope.parseFrom(kafkaValue); + } catch (InvalidProtocolBufferException ex) { + throw new IllegalArgumentException("VehicleEnvelope bytes are invalid", ex); + } + } +} diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateRepository.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateRepository.java new file mode 100644 index 00000000..0fc571bf --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateRepository.java @@ -0,0 +1,22 @@ +package com.lingniu.ingest.vehiclestate; + +import java.util.Optional; + +public interface VehicleStateRepository { + + void putState(String vin, String json); + + void putLocation(String vin, String json); + + void putSafety(String vin, String json); + + void putLastEvent(String vin, String json); + + Optional getState(String vin); + + Optional getLocation(String vin); + + Optional getSafety(String vin); + + Optional getLastEvent(String vin); +} diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java new file mode 100644 index 00000000..4dc49444 --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java @@ -0,0 +1,142 @@ +package com.lingniu.ingest.vehiclestate; + +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class VehicleStateUpdater { + + private final VehicleStateRepository repository; + + public VehicleStateUpdater(VehicleStateRepository repository) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + this.repository = repository; + } + + public void update(VehicleEnvelope envelope) { + if (envelope == null) { + throw new IllegalArgumentException("envelope must not be null"); + } + if (!envelope.hasTelemetrySnapshot()) { + throw new IllegalArgumentException("envelope telemetry_snapshot is required"); + } + Map fields = fields(envelope); + String vin = envelope.getVin(); + + repository.putState(vin, json(base(envelope, fields))); + repository.putLastEvent(vin, json(lastEvent(envelope))); + + if (fields.containsKey("longitude") && fields.containsKey("latitude")) { + repository.putLocation(vin, json(location(envelope, fields))); + } + if (hasSafetyFields(fields)) { + repository.putSafety(vin, json(safety(envelope, fields))); + } + } + + private static Map fields(VehicleEnvelope envelope) { + Map fields = new LinkedHashMap<>(); + for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) { + fields.put(field.getKey(), field.getValue()); + } + return fields; + } + + private static Map base(VehicleEnvelope envelope, Map fields) { + Map map = eventIdentity(envelope); + map.put("eventType", envelope.getTelemetrySnapshot().getEventType()); + map.put("fields", fields); + return map; + } + + private static Map location(VehicleEnvelope envelope, Map fields) { + Map map = eventIdentity(envelope); + putIfPresent(map, fields, "longitude"); + putIfPresent(map, fields, "latitude"); + putIfPresent(map, fields, "altitude_m"); + putIfPresent(map, fields, "direction_deg"); + putIfPresent(map, fields, "speed_kmh"); + return map; + } + + private static Map safety(VehicleEnvelope envelope, Map fields) { + Map map = eventIdentity(envelope); + putIfPresent(map, fields, "safety_category"); + putIfPresent(map, fields, "hydrogen_leak_detected"); + putIfPresent(map, fields, "hydrogen_leak_level"); + putIfPresent(map, fields, "hydrogen_leak_action_required"); + putIfPresent(map, fields, "alarm_level"); + return map; + } + + private static Map lastEvent(VehicleEnvelope envelope) { + return eventIdentity(envelope); + } + + private static Map eventIdentity(VehicleEnvelope envelope) { + Map map = new LinkedHashMap<>(); + map.put("eventId", envelope.getEventId()); + map.put("traceId", envelope.getTraceId()); + map.put("vin", envelope.getVin()); + map.put("source", envelope.getSource()); + map.put("eventTimeMs", Long.toString(envelope.getEventTimeMs())); + map.put("ingestTimeMs", Long.toString(envelope.getIngestTimeMs())); + return map; + } + + private static boolean hasSafetyFields(Map fields) { + return fields.containsKey("safety_category") + || fields.containsKey("hydrogen_leak_detected") + || fields.containsKey("hydrogen_leak_level") + || fields.containsKey("hydrogen_leak_action_required") + || fields.containsKey("alarm_level"); + } + + private static void putIfPresent(Map map, Map fields, String key) { + if (fields.containsKey(key)) { + map.put(key, fields.get(key)); + } + } + + private static String json(Map map) { + StringBuilder out = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + out.append(','); + } + first = false; + out.append('"').append(escape(entry.getKey())).append('"').append(':'); + Object value = entry.getValue(); + if (value instanceof Map nested) { + out.append(jsonObject(nested)); + } else { + out.append('"').append(escape(String.valueOf(value))).append('"'); + } + } + return out.append('}').toString(); + } + + private static String jsonObject(Map map) { + StringBuilder out = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + out.append(','); + } + first = false; + out.append('"').append(escape(String.valueOf(entry.getKey()))).append('"') + .append(':') + .append('"').append(escape(String.valueOf(entry.getValue()))).append('"'); + } + return out.append('}').toString(); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java new file mode 100644 index 00000000..52f22df7 --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java @@ -0,0 +1,56 @@ +package com.lingniu.ingest.vehiclestate.config; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import com.lingniu.ingest.vehiclestate.RedisVehicleStateRepository; +import com.lingniu.ingest.vehiclestate.VehicleStateController; +import com.lingniu.ingest.vehiclestate.VehicleStateEnvelopeIngestor; +import com.lingniu.ingest.vehiclestate.VehicleStateRepository; +import com.lingniu.ingest.vehiclestate.VehicleStateUpdater; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-state", name = "enabled", havingValue = "true") +public class VehicleStateAutoConfiguration { + + @Bean + @ConditionalOnBean(StringRedisTemplate.class) + @ConditionalOnMissingBean + public VehicleStateRepository vehicleStateRepository(StringRedisTemplate redis) { + return new RedisVehicleStateRepository(redis); + } + + @Bean + @ConditionalOnBean(VehicleStateRepository.class) + @ConditionalOnMissingBean + public VehicleStateUpdater vehicleStateUpdater(VehicleStateRepository repository) { + return new VehicleStateUpdater(repository); + } + + @Bean + @ConditionalOnBean(VehicleStateUpdater.class) + @ConditionalOnMissingBean + public VehicleStateEnvelopeIngestor vehicleStateEnvelopeIngestor(VehicleStateUpdater updater) { + return new VehicleStateEnvelopeIngestor(updater); + } + + @Bean + @ConditionalOnBean({VehicleStateEnvelopeIngestor.class, EnvelopeDeadLetterSink.class}) + @ConditionalOnMissingBean(name = "vehicleStateEnvelopeConsumerProcessor") + public EnvelopeConsumerProcessor vehicleStateEnvelopeConsumerProcessor(VehicleStateEnvelopeIngestor ingestor, + EnvelopeDeadLetterSink deadLetterSink) { + return new EnvelopeConsumerProcessor("vehicle-state", ingestor, deadLetterSink); + } + + @Bean + @ConditionalOnBean(VehicleStateRepository.class) + @ConditionalOnMissingBean + public VehicleStateController vehicleStateController(VehicleStateRepository repository) { + return new VehicleStateController(repository); + } +} diff --git a/modules/services/vehicle-state-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/services/vehicle-state-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..e6d5607b --- /dev/null +++ b/modules/services/vehicle-state-service/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.vehiclestate.config.VehicleStateAutoConfiguration diff --git a/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/InMemoryVehicleStateRepository.java b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/InMemoryVehicleStateRepository.java new file mode 100644 index 00000000..4419eab6 --- /dev/null +++ b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/InMemoryVehicleStateRepository.java @@ -0,0 +1,50 @@ +package com.lingniu.ingest.vehiclestate; + +import java.util.Optional; + +final class InMemoryVehicleStateRepository implements VehicleStateRepository { + String state; + String location; + String safety; + String lastEvent; + + @Override + public void putState(String vin, String json) { + this.state = json; + } + + @Override + public void putLocation(String vin, String json) { + this.location = json; + } + + @Override + public void putSafety(String vin, String json) { + this.safety = json; + } + + @Override + public void putLastEvent(String vin, String json) { + this.lastEvent = json; + } + + @Override + public Optional getState(String vin) { + return Optional.ofNullable(state); + } + + @Override + public Optional getLocation(String vin) { + return Optional.ofNullable(location); + } + + @Override + public Optional getSafety(String vin) { + return Optional.ofNullable(safety); + } + + @Override + public Optional getLastEvent(String vin) { + return Optional.ofNullable(lastEvent); + } +} diff --git a/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepositoryTest.java b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepositoryTest.java new file mode 100644 index 00000000..06f59006 --- /dev/null +++ b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepositoryTest.java @@ -0,0 +1,50 @@ +package com.lingniu.ingest.vehiclestate; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RedisVehicleStateRepositoryTest { + + @Test + void writesAndReadsHotStateJsonByStableRedisKey() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations ops = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(ops); + when(ops.get("vehicle:state:VIN001")).thenReturn("{\"vin\":\"VIN001\"}"); + + RedisVehicleStateRepository repository = new RedisVehicleStateRepository(redis); + + repository.putState("VIN001", "{\"vin\":\"VIN001\"}"); + Optional value = repository.getState("VIN001"); + + verify(ops).set("vehicle:state:VIN001", "{\"vin\":\"VIN001\"}"); + assertThat(value).contains("{\"vin\":\"VIN001\"}"); + } + + @Test + void writesLocationSafetyAndLastEventToSeparateKeys() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations ops = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(ops); + + RedisVehicleStateRepository repository = new RedisVehicleStateRepository(redis); + + repository.putLocation("VIN001", "{\"longitude\":113.12}"); + repository.putSafety("VIN001", "{\"hydrogen_leak_detected\":true}"); + repository.putLastEvent("VIN001", "{\"eventId\":\"event-1\"}"); + + verify(ops).set("vehicle:location:VIN001", "{\"longitude\":113.12}"); + verify(ops).set("vehicle:safety:VIN001", "{\"hydrogen_leak_detected\":true}"); + verify(ops).set("vehicle:event:last:VIN001", "{\"eventId\":\"event-1\"}"); + } +} diff --git a/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateControllerTest.java b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateControllerTest.java new file mode 100644 index 00000000..2aac4541 --- /dev/null +++ b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateControllerTest.java @@ -0,0 +1,54 @@ +package com.lingniu.ingest.vehiclestate; + +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class VehicleStateControllerTest { + + @Test + void returnsLatestStateJson() throws Exception { + VehicleStateRepository repository = mock(VehicleStateRepository.class); + when(repository.getState("VIN001")).thenReturn(java.util.Optional.of("{\"speed_kmh\":\"80.5\"}")); + MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build(); + + mvc.perform(get("/api/vehicle-state/VIN001")) + .andExpect(status().isOk()) + .andExpect(content().json("{\"speed_kmh\":\"80.5\"}")); + } + + @Test + void returnsLocationSafetyAndLastEventJson() throws Exception { + VehicleStateRepository repository = mock(VehicleStateRepository.class); + when(repository.getLocation("VIN001")).thenReturn(java.util.Optional.of("{\"longitude\":\"113.12\"}")); + when(repository.getSafety("VIN001")).thenReturn(java.util.Optional.of("{\"hydrogen_leak_detected\":\"true\"}")); + when(repository.getLastEvent("VIN001")).thenReturn(java.util.Optional.of("{\"eventId\":\"event-1\"}")); + MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build(); + + mvc.perform(get("/api/vehicle-state/VIN001/location")) + .andExpect(status().isOk()) + .andExpect(content().json("{\"longitude\":\"113.12\"}")); + mvc.perform(get("/api/vehicle-state/VIN001/safety")) + .andExpect(status().isOk()) + .andExpect(content().json("{\"hydrogen_leak_detected\":\"true\"}")); + mvc.perform(get("/api/vehicle-state/VIN001/last-event")) + .andExpect(status().isOk()) + .andExpect(content().json("{\"eventId\":\"event-1\"}")); + } + + @Test + void returnsNotFoundWhenStateIsMissing() throws Exception { + VehicleStateRepository repository = mock(VehicleStateRepository.class); + when(repository.getState("VIN001")).thenReturn(java.util.Optional.empty()); + MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build(); + + mvc.perform(get("/api/vehicle-state/VIN001")) + .andExpect(status().isNotFound()); + } +} diff --git a/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestorTest.java b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestorTest.java new file mode 100644 index 00000000..8a8dfcc1 --- /dev/null +++ b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestorTest.java @@ -0,0 +1,77 @@ +package com.lingniu.ingest.vehiclestate; + +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import com.lingniu.ingest.api.consumer.EnvelopeIngestor; +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class VehicleStateEnvelopeIngestorTest { + + @Test + void parsesEnvelopeBytesAndUpdatesRepository() { + InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository(); + VehicleStateEnvelopeIngestor ingestor = new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(repository)); + + assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class); + ingestor.ingest(envelope().toByteArray()); + + assertThat(repository.getState("VIN001")).hasValueSatisfying(json -> assertThat(json).contains("speed_kmh")); + } + + @Test + void rejectsInvalidEnvelopeBytes() { + VehicleStateEnvelopeIngestor ingestor = + new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(new EmptyRepository())); + + assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("VehicleEnvelope"); + } + + @Test + void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutUpdatingRepository() { + InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository(); + VehicleStateEnvelopeIngestor ingestor = new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(repository)); + + var result = ingestor.tryIngest(new byte[]{0x01, 0x02}); + + assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE); + assertThat(result.message()).contains("VehicleEnvelope"); + assertThat(repository.getState("VIN001")).isEmpty(); + } + + private static VehicleEnvelope envelope() { + return VehicleEnvelope.newBuilder() + .setEventId("event-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1) + .setIngestTimeMs(2) + .setTelemetrySnapshot(TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .addFields(TelemetryField.newBuilder() + .setKey("speed_kmh") + .setValueType("DOUBLE") + .setValue("80.5") + .setUnit("km/h"))) + .build(); + } + + private static final class EmptyRepository implements VehicleStateRepository { + @Override public void putState(String vin, String json) {} + @Override public void putLocation(String vin, String json) {} + @Override public void putSafety(String vin, String json) {} + @Override public void putLastEvent(String vin, String json) {} + @Override public Optional getState(String vin) { return Optional.empty(); } + @Override public Optional getLocation(String vin) { return Optional.empty(); } + @Override public Optional getSafety(String vin) { return Optional.empty(); } + @Override public Optional getLastEvent(String vin) { return Optional.empty(); } + } +} diff --git a/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdaterTest.java b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdaterTest.java new file mode 100644 index 00000000..7f93be7c --- /dev/null +++ b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdaterTest.java @@ -0,0 +1,80 @@ +package com.lingniu.ingest.vehiclestate; + +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleStateUpdaterTest { + + @Test + void writesStateLocationSafetyAndLastEventFromTelemetrySnapshot() { + InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository(); + VehicleStateUpdater updater = new VehicleStateUpdater(repository); + + updater.update(envelope() + .addField("speed_kmh", "DOUBLE", "80.5", "km/h") + .addField("longitude", "DOUBLE", "113.12", "deg") + .addField("latitude", "DOUBLE", "23.45", "deg") + .addField("hydrogen_leak_detected", "BOOLEAN", "true", "") + .addField("hydrogen_leak_level", "STRING", "CRITICAL", "") + .build()); + + assertThat(repository.state).contains("\"vin\":\"VIN001\""); + assertThat(repository.state).contains("\"speed_kmh\""); + assertThat(repository.location).contains("\"longitude\":\"113.12\""); + assertThat(repository.location).contains("\"latitude\":\"23.45\""); + assertThat(repository.safety).contains("\"hydrogen_leak_detected\":\"true\""); + assertThat(repository.lastEvent).contains("\"eventId\":\"event-1\""); + } + + @Test + void doesNotOverwriteLocationOrSafetyWhenFieldsAreMissing() { + InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository(); + repository.location = "{\"longitude\":\"old\"}"; + repository.safety = "{\"hydrogen_leak_detected\":\"old\"}"; + + new VehicleStateUpdater(repository).update(envelope() + .addField("speed_kmh", "DOUBLE", "80.5", "km/h") + .build()); + + assertThat(repository.location).isEqualTo("{\"longitude\":\"old\"}"); + assertThat(repository.safety).isEqualTo("{\"hydrogen_leak_detected\":\"old\"}"); + assertThat(repository.state).contains("\"speed_kmh\""); + } + + private static EnvelopeBuilder envelope() { + return new EnvelopeBuilder(); + } + + private static final class EnvelopeBuilder { + private final TelemetrySnapshot.Builder snapshot = TelemetrySnapshot.newBuilder() + .setEventType("REALTIME") + .setRawArchiveUri("archive://raw/event-1.bin"); + + EnvelopeBuilder addField(String key, String valueType, String value, String unit) { + snapshot.addFields(TelemetryField.newBuilder() + .setKey(key) + .setValueType(valueType) + .setValue(value) + .setUnit(unit) + .setQuality("GOOD") + .setSourcePath("test")); + return this; + } + + VehicleEnvelope build() { + return VehicleEnvelope.newBuilder() + .setEventId("event-1") + .setTraceId("trace-1") + .setVin("VIN001") + .setSource("GB32960") + .setEventTimeMs(1_782_112_400_000L) + .setIngestTimeMs(1_782_112_401_000L) + .setTelemetrySnapshot(snapshot) + .build(); + } + } +} diff --git a/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfigurationTest.java b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfigurationTest.java new file mode 100644 index 00000000..ba9cda84 --- /dev/null +++ b/modules/services/vehicle-state-service/src/test/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfigurationTest.java @@ -0,0 +1,56 @@ +package com.lingniu.ingest.vehiclestate.config; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import com.lingniu.ingest.vehiclestate.RedisVehicleStateRepository; +import com.lingniu.ingest.vehiclestate.VehicleStateController; +import com.lingniu.ingest.vehiclestate.VehicleStateEnvelopeIngestor; +import com.lingniu.ingest.vehiclestate.VehicleStateRepository; +import com.lingniu.ingest.vehiclestate.VehicleStateUpdater; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class VehicleStateAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VehicleStateAutoConfiguration.class)) + .withBean(EnvelopeDeadLetterSink.class, () -> record -> {}) + .withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class)); + + @Test + void createsVehicleStateBeansWhenEnabled() { + contextRunner + .withPropertyValues("lingniu.ingest.vehicle-state.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(VehicleStateRepository.class); + assertThat(context).hasSingleBean(RedisVehicleStateRepository.class); + assertThat(context).hasSingleBean(VehicleStateUpdater.class); + assertThat(context).hasSingleBean(VehicleStateEnvelopeIngestor.class); + assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class); + assertThat(context).hasSingleBean(VehicleStateController.class); + }); + } + + @Test + void backsOffWhenDisabled() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(VehicleStateRepository.class); + assertThat(context).doesNotHaveBean(VehicleStateUpdater.class); + assertThat(context).doesNotHaveBean(VehicleStateEnvelopeIngestor.class); + assertThat(context).doesNotHaveBean(VehicleStateController.class); + }); + } + + @Test + void doesNotCreateRepositoryWithoutRedisTemplate() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VehicleStateAutoConfiguration.class)) + .withPropertyValues("lingniu.ingest.vehicle-state.enabled=true") + .run(context -> assertThat(context).doesNotHaveBean(VehicleStateRepository.class)); + } +} diff --git a/modules/sinks/event-file-store/pom.xml b/modules/sinks/event-file-store/pom.xml new file mode 100644 index 00000000..a89261d8 --- /dev/null +++ b/modules/sinks/event-file-store/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.lingniu.ingest + lingniu-vehicle-ingest + 0.1.0-SNAPSHOT + ../../../pom.xml + + event-file-store + event-file-store + 车辆事件文件型明细库:Parquet 存储 + DuckDB 时间顺序查询。 + + + + com.lingniu.ingest + ingest-api + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.duckdb + duckdb_jdbc + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java new file mode 100644 index 00000000..1127275e --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java @@ -0,0 +1,485 @@ +package com.lingniu.ingest.eventfilestore; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +public final class DuckDbParquetEventFileStore implements EventFileStore { + + private static final TypeReference> STRING_MAP = + new TypeReference<>() {}; + private static final String HEADER_VERSION = "event-records-v1"; + + private final Path root; + private final Path indexPath; + private final ZoneId partitionZone; + private final ObjectMapper objectMapper; + private volatile boolean indexInitialized; + + public DuckDbParquetEventFileStore(Path root, ZoneId partitionZone) { + this(root, partitionZone, new ObjectMapper()); + } + + public DuckDbParquetEventFileStore(Path root, ZoneId partitionZone, ObjectMapper objectMapper) { + if (root == null) { + throw new IllegalArgumentException("root must not be null"); + } + this.root = root.toAbsolutePath(); + this.indexPath = this.root.resolve("events.duckdb"); + this.partitionZone = partitionZone == null ? ZoneId.of("Asia/Shanghai") : partitionZone; + this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper; + } + + @Override + public synchronized void appendAll(List records) throws IOException { + if (records == null || records.isEmpty()) { + return; + } + Map> byPartition = new LinkedHashMap<>(); + for (EventFileRecord record : records) { + Partition partition = partition(record); + byPartition.computeIfAbsent(partition, ignored -> new ArrayList<>()).add(record); + } + for (Map.Entry> entry : byPartition.entrySet()) { + writePartition(entry.getKey(), entry.getValue()); + appendIndex(entry.getKey(), entry.getValue()); + } + } + + @Override + public List query(EventFileQuery query) throws IOException { + if (query.vin() != null) { + return queryParquet(query); + } + ensureIndexInitialized(); + return queryIndex(query); + } + + @Override + public synchronized EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException { + if (rawArchiveUri == null || rawArchiveUri.isBlank()) { + return null; + } + ensureIndexInitialized(); + String sql = """ + SELECT event_id, protocol, event_type, vin, + event_time_ms, ingest_time_ms, + raw_archive_uri, metadata_json, payload_json + FROM event_records + WHERE raw_archive_uri = ? + ORDER BY event_type = 'RAW_ARCHIVE' DESC, ingest_time_ms DESC, event_id DESC + LIMIT 1 + """; + try (Connection connection = DriverManager.getConnection(indexJdbcUrl()); + PreparedStatement ps = connection.prepareStatement(sql)) { + ps.setString(1, rawArchiveUri); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? record(rs) : null; + } + } catch (SQLException e) { + throw new IOException("query duckdb event index by raw archive uri failed", e); + } + } + + private List queryParquet(EventFileQuery query) throws IOException { + List parquetFiles = parquetFiles(query); + if (parquetFiles.isEmpty()) { + return List.of(); + } + String fileList = toDuckDbList(parquetFiles); + String order = query.order() == EventFileQuery.Order.DESC ? "DESC" : "ASC"; + List predicates = new ArrayList<>(); + if (query.vin() != null) { + predicates.add("vin = '" + escapeSql(query.vin()) + "'"); + } + if (query.eventType() != null) { + predicates.add("event_type = '" + escapeSql(query.eventType()) + "'"); + } + if (query.eventTimeFrom() != null) { + predicates.add("event_time_ms >= " + query.eventTimeFrom().toEpochMilli()); + } + if (query.eventTimeTo() != null) { + predicates.add("event_time_ms <= " + query.eventTimeTo().toEpochMilli()); + } + String where = predicates.isEmpty() ? "" : "WHERE " + String.join(" AND ", predicates) + "\n"; + String sql = """ + SELECT event_id, protocol, event_type, vin, + event_time_ms, event_time, ingest_time_ms, ingest_time, + raw_archive_uri, metadata_json, payload_json + FROM read_parquet(%s) + %s + ORDER BY event_time_ms %s, ingest_time_ms %s, event_id %s + LIMIT %d + """.formatted(fileList, where, order, order, order, query.limit()); + try (Connection connection = DriverManager.getConnection("jdbc:duckdb:"); + Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery(sql)) { + List result = new ArrayList<>(); + while (rs.next()) { + result.add(new EventFileRecord( + rs.getString("event_id"), + protocol(rs.getString("protocol")), + rs.getString("event_type"), + rs.getString("vin"), + Instant.ofEpochMilli(rs.getLong("event_time_ms")), + Instant.ofEpochMilli(rs.getLong("ingest_time_ms")), + rs.getString("raw_archive_uri"), + readMetadata(rs.getString("metadata_json")), + rs.getString("payload_json") + )); + } + return result; + } catch (SQLException e) { + throw new IOException("query parquet event store failed", e); + } + } + + private List queryIndex(EventFileQuery query) throws IOException { + String order = query.order() == EventFileQuery.Order.DESC ? "DESC" : "ASC"; + StringBuilder where = new StringBuilder(""" + WHERE protocol = ? + AND partition_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) + """); + if (query.vin() != null) { + where.append(" AND vin = ?\n"); + } + if (query.eventType() != null) { + where.append(" AND event_type = ?\n"); + } + if (query.eventTimeFrom() != null) { + where.append(" AND event_time_ms >= ?\n"); + } + if (query.eventTimeTo() != null) { + where.append(" AND event_time_ms <= ?\n"); + } + String sql = """ + SELECT event_id, protocol, event_type, vin, + event_time_ms, ingest_time_ms, + raw_archive_uri, metadata_json, payload_json + FROM event_records + %s + ORDER BY event_time_ms %s, ingest_time_ms %s, event_id %s + LIMIT ? + """.formatted(where, order, order, order); + try (Connection connection = DriverManager.getConnection(indexJdbcUrl()); + PreparedStatement ps = connection.prepareStatement(sql)) { + int index = 1; + ps.setString(index++, query.protocol().name()); + ps.setString(index++, query.dateFrom().toString()); + ps.setString(index++, query.dateTo().toString()); + if (query.vin() != null) { + ps.setString(index++, query.vin()); + } + if (query.eventType() != null) { + ps.setString(index++, query.eventType()); + } + if (query.eventTimeFrom() != null) { + ps.setLong(index++, query.eventTimeFrom().toEpochMilli()); + } + if (query.eventTimeTo() != null) { + ps.setLong(index++, query.eventTimeTo().toEpochMilli()); + } + ps.setInt(index, query.limit()); + try (ResultSet rs = ps.executeQuery()) { + List result = new ArrayList<>(); + while (rs.next()) { + result.add(record(rs)); + } + return result; + } + } catch (SQLException e) { + throw new IOException("query duckdb event index failed", e); + } + } + + private void writePartition(Partition partition, List records) throws IOException { + Path dir = partition.dir(root); + Files.createDirectories(dir); + Path partFile = dir.resolve("events.parquet"); + Path tempFile = dir.resolve("events-" + UUID.randomUUID() + ".tmp.parquet"); + + try (Connection connection = DriverManager.getConnection("jdbc:duckdb:"); + Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TEMPORARY TABLE event_records ( + event_id VARCHAR, + protocol VARCHAR, + event_type VARCHAR, + vin VARCHAR, + event_time_ms BIGINT, + event_time VARCHAR, + ingest_time_ms BIGINT, + ingest_time VARCHAR, + raw_archive_uri VARCHAR, + metadata_json VARCHAR, + payload_json VARCHAR + ) + """); + try (PreparedStatement ps = connection.prepareStatement(""" + INSERT INTO event_records VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + for (EventFileRecord record : records) { + ps.setString(1, record.eventId()); + ps.setString(2, record.protocol().name()); + ps.setString(3, record.eventType()); + ps.setString(4, record.vin()); + ps.setLong(5, record.eventTime().toEpochMilli()); + ps.setString(6, record.eventTime().toString()); + ps.setLong(7, record.ingestTime().toEpochMilli()); + ps.setString(8, record.ingestTime().toString()); + ps.setString(9, record.rawArchiveUri()); + ps.setString(10, objectMapper.writeValueAsString(record.metadata())); + ps.setString(11, record.payloadJson()); + ps.addBatch(); + } + ps.executeBatch(); + } + if (Files.exists(partFile)) { + statement.execute(""" + CREATE TEMPORARY TABLE combined_records AS + SELECT event_id, protocol, event_type, vin, + event_time_ms, event_time, ingest_time_ms, ingest_time, + raw_archive_uri, metadata_json, payload_json + FROM read_parquet('%s') + UNION ALL + SELECT event_id, protocol, event_type, vin, + event_time_ms, event_time, ingest_time_ms, ingest_time, + raw_archive_uri, metadata_json, payload_json + FROM event_records + """.formatted(escapeSql(partFile))); + statement.execute("COPY combined_records TO '" + escapeSql(tempFile) + "' (FORMAT parquet)"); + } else { + statement.execute("COPY event_records TO '" + escapeSql(tempFile) + "' (FORMAT parquet)"); + } + Files.move(tempFile, partFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (SQLException e) { + throw new IOException("write parquet event store failed: " + partFile, e); + } finally { + Files.deleteIfExists(tempFile); + } + } + + private void appendIndex(Partition partition, List records) throws IOException { + ensureIndexInitialized(); + try (Connection connection = DriverManager.getConnection(indexJdbcUrl())) { + insertIndex(connection, partition, records); + } catch (SQLException e) { + throw new IOException("write duckdb event index failed", e); + } + } + + private void ensureIndexInitialized() throws IOException { + if (indexInitialized) { + return; + } + try { + Files.createDirectories(root); + } catch (IOException e) { + throw new IOException("create event store root failed: " + root, e); + } + try (Connection connection = DriverManager.getConnection(indexJdbcUrl()); + Statement statement = connection.createStatement()) { + createIndexSchema(statement); + if (isIndexEmpty(statement)) { + backfillIndexFromParquet(statement); + } + indexInitialized = true; + } catch (SQLException e) { + throw new IOException("initialize duckdb event index failed", e); + } + } + + private void createIndexSchema(Statement statement) throws SQLException { + statement.execute(""" + CREATE TABLE IF NOT EXISTS event_records ( + event_id VARCHAR PRIMARY KEY, + protocol VARCHAR, + event_type VARCHAR, + vin VARCHAR, + event_time_ms BIGINT, + ingest_time_ms BIGINT, + partition_date DATE, + raw_archive_uri VARCHAR, + metadata_json VARCHAR, + payload_json VARCHAR + ) + """); + statement.execute("CREATE INDEX IF NOT EXISTS event_records_protocol_date_time_idx ON event_records(protocol, partition_date, event_time_ms)"); + statement.execute("CREATE INDEX IF NOT EXISTS event_records_vin_date_time_idx ON event_records(vin, partition_date, event_time_ms)"); + statement.execute("CREATE INDEX IF NOT EXISTS event_records_vin_type_date_time_idx ON event_records(protocol, vin, event_type, partition_date, event_time_ms)"); + statement.execute("CREATE INDEX IF NOT EXISTS event_records_raw_archive_uri_idx ON event_records(raw_archive_uri)"); + } + + private boolean isIndexEmpty(Statement statement) throws SQLException { + try (ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM event_records")) { + return rs.next() && rs.getLong(1) == 0L; + } + } + + private void backfillIndexFromParquet(Statement statement) throws SQLException, IOException { + List files = allParquetFiles(); + if (files.isEmpty()) { + return; + } + String fileList = toDuckDbList(files); + statement.execute(""" + INSERT OR IGNORE INTO event_records + SELECT event_id, protocol, event_type, vin, + event_time_ms, ingest_time_ms, + CAST(date AS DATE) AS partition_date, + raw_archive_uri, metadata_json, payload_json + FROM read_parquet(%s, hive_partitioning = true) + """.formatted(fileList)); + } + + private void insertIndex(Connection connection, Partition partition, List records) + throws SQLException, IOException { + try (PreparedStatement ps = connection.prepareStatement(""" + INSERT OR REPLACE INTO event_records VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + for (EventFileRecord record : records) { + ps.setString(1, record.eventId()); + ps.setString(2, record.protocol().name()); + ps.setString(3, record.eventType()); + ps.setString(4, record.vin()); + ps.setLong(5, record.eventTime().toEpochMilli()); + ps.setLong(6, record.ingestTime().toEpochMilli()); + ps.setString(7, partition.date().toString()); + ps.setString(8, record.rawArchiveUri()); + ps.setString(9, objectMapper.writeValueAsString(record.metadata())); + ps.setString(10, record.payloadJson()); + ps.addBatch(); + } + ps.executeBatch(); + } + } + + private List parquetFiles(EventFileQuery query) throws IOException { + List files = new ArrayList<>(); + LocalDate date = query.dateFrom(); + while (!date.isAfter(query.dateTo())) { + Path dir = query.vin() == null + ? partitionDir(query.protocol(), date) + : partitionDir(query.protocol(), date).resolve("vehicle=" + storageName(query.vin())); + if (Files.isDirectory(dir)) { + try (Stream stream = Files.walk(dir)) { + stream.filter(path -> path.getFileName().toString().endsWith(".parquet")) + .sorted() + .forEach(files::add); + } + } + date = date.plusDays(1); + } + return files; + } + + private List allParquetFiles() throws IOException { + if (!Files.isDirectory(root)) { + return List.of(); + } + try (Stream stream = Files.walk(root)) { + return stream.filter(path -> path.getFileName().toString().endsWith(".parquet")) + .sorted() + .toList(); + } + } + + private EventFileRecord record(ResultSet rs) throws SQLException, IOException { + return new EventFileRecord( + rs.getString("event_id"), + protocol(rs.getString("protocol")), + rs.getString("event_type"), + rs.getString("vin"), + Instant.ofEpochMilli(rs.getLong("event_time_ms")), + Instant.ofEpochMilli(rs.getLong("ingest_time_ms")), + rs.getString("raw_archive_uri"), + readMetadata(rs.getString("metadata_json")), + rs.getString("payload_json") + ); + } + + private Partition partition(EventFileRecord record) { + return new Partition( + record.protocol(), + LocalDate.ofInstant(record.eventTime(), partitionZone), + storageName(record.vin())); + } + + private Path partitionDir(ProtocolId protocol, LocalDate date) { + return root.resolve("protocol=" + protocol.name()).resolve("date=" + date); + } + + private static ProtocolId protocol(String value) { + if (value == null || value.isBlank()) { + return ProtocolId.UNKNOWN; + } + try { + return ProtocolId.valueOf(value); + } catch (IllegalArgumentException ex) { + return ProtocolId.UNKNOWN; + } + } + + private Map readMetadata(String json) throws IOException { + if (json == null || json.isBlank()) { + return Map.of(); + } + return objectMapper.readValue(json, STRING_MAP); + } + + private static String toDuckDbList(List files) { + return files.stream() + .map(path -> "'" + escapeSql(path) + "'") + .reduce((left, right) -> left + "," + right) + .map(paths -> "[" + paths + "]") + .orElse("[]"); + } + + private static String escapeSql(Path path) { + return escapeSql(path.toAbsolutePath().toString()); + } + + private static String escapeSql(String value) { + return value.replace("'", "''"); + } + + private String indexJdbcUrl() { + return "jdbc:duckdb:" + indexPath; + } + + private static String storageName(String vin) { + if (vin == null || vin.isBlank()) { + return "_unknown"; + } + return vin.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private record Partition(ProtocolId protocol, LocalDate date, String vehicle) { + private Path dir(Path root) { + return root.resolve("protocol=" + protocol.name()) + .resolve("date=" + date) + .resolve("vehicle=" + vehicle) + .resolve("header=" + HEADER_VERSION); + } + } +} diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java new file mode 100644 index 00000000..e152aa2a --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java @@ -0,0 +1,69 @@ +package com.lingniu.ingest.eventfilestore; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.time.LocalDate; + +public record EventFileQuery( + ProtocolId protocol, + LocalDate dateFrom, + LocalDate dateTo, + Instant eventTimeFrom, + Instant eventTimeTo, + Order order, + int limit, + String vin, + String eventType +) { + public enum Order { + ASC, + DESC + } + + public EventFileQuery { + if (protocol == null) { + throw new IllegalArgumentException("protocol must not be null"); + } + if (dateFrom == null || dateTo == null) { + throw new IllegalArgumentException("date range must not be null"); + } + if (dateTo.isBefore(dateFrom)) { + throw new IllegalArgumentException("dateTo must not be before dateFrom"); + } + if (eventTimeFrom != null && eventTimeTo != null && eventTimeTo.isBefore(eventTimeFrom)) { + throw new IllegalArgumentException("eventTimeTo must not be before eventTimeFrom"); + } + order = order == null ? Order.ASC : order; + limit = limit <= 0 ? 100 : limit; + vin = vin == null || vin.isBlank() ? null : vin.trim(); + eventType = eventType == null || eventType.isBlank() ? null : eventType.trim(); + } + + public EventFileQuery(ProtocolId protocol, + LocalDate dateFrom, + LocalDate dateTo, + Order order, + int limit, + String vin, + String eventType) { + this(protocol, dateFrom, dateTo, null, null, order, limit, vin, eventType); + } + + public EventFileQuery(ProtocolId protocol, + LocalDate dateFrom, + LocalDate dateTo, + Order order, + int limit) { + this(protocol, dateFrom, dateTo, order, limit, null, null); + } + + public EventFileQuery(ProtocolId protocol, + LocalDate dateFrom, + LocalDate dateTo, + Order order, + int limit, + String vin) { + this(protocol, dateFrom, dateTo, order, limit, vin, null); + } +} diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java new file mode 100644 index 00000000..d7287b1e --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java @@ -0,0 +1,44 @@ +package com.lingniu.ingest.eventfilestore; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.util.Map; + +/** + * 文件型明细库的一条可查询记录。 + * + *

    {@code payloadJson} 保存统一 telemetry snapshot JSON;公共列只保留排序、分区、 + * 追溯和通用展示需要的最小字段。 + */ +public record EventFileRecord( + String eventId, + ProtocolId protocol, + String eventType, + String vin, + Instant eventTime, + Instant ingestTime, + String rawArchiveUri, + Map metadata, + String payloadJson +) { + public EventFileRecord { + if (eventId == null || eventId.isBlank()) { + throw new IllegalArgumentException("eventId must not be blank"); + } + if (protocol == null) { + throw new IllegalArgumentException("protocol must not be null"); + } + if (eventTime == null) { + throw new IllegalArgumentException("eventTime must not be null"); + } + if (ingestTime == null) { + throw new IllegalArgumentException("ingestTime must not be null"); + } + eventType = eventType == null ? "" : eventType; + vin = vin == null ? "" : vin; + rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri; + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + payloadJson = payloadJson == null || payloadJson.isBlank() ? "{}" : payloadJson; + } +} diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java new file mode 100644 index 00000000..ab12be84 --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java @@ -0,0 +1,19 @@ +package com.lingniu.ingest.eventfilestore; + +import java.io.IOException; +import java.util.List; + +public interface EventFileStore { + + default void append(EventFileRecord record) throws IOException { + appendAll(List.of(record)); + } + + void appendAll(List records) throws IOException; + + List query(EventFileQuery query) throws IOException; + + default EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException { + return null; + } +} diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java new file mode 100644 index 00000000..7eac04b9 --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java @@ -0,0 +1,244 @@ +package com.lingniu.ingest.eventfilestore; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.event.RawArchiveKeys; +import com.lingniu.ingest.api.event.TelemetrySnapshot; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.event.VehicleEventTelemetrySnapshotMapper; +import com.lingniu.ingest.api.sink.EventSink; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * EventBus 出口:把解析后的 {@link VehicleEvent} 明细写入 Parquet 文件库。 + * + *

    {@link VehicleEvent.RawArchive} 只写可查询索引,不写原始 bytes;原始 bytes 本体仍由 + * sink-archive 独立冷存。 + */ +public final class EventFileStoreSink implements EventSink, AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(EventFileStoreSink.class); + + private final EventFileStore store; + private final ObjectMapper objectMapper; + private final int batchSize; + private final List buffer = new ArrayList<>(); + private final ScheduledExecutorService flusher; + + public EventFileStoreSink(EventFileStore store, ObjectMapper objectMapper) { + this(store, objectMapper, 1); + } + + public EventFileStoreSink(EventFileStore store, ObjectMapper objectMapper, int batchSize) { + this(store, objectMapper, batchSize, 0); + } + + public EventFileStoreSink(EventFileStore store, + ObjectMapper objectMapper, + int batchSize, + long flushIntervalMillis) { + if (store == null) { + throw new IllegalArgumentException("store must not be null"); + } + this.store = store; + this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper; + this.batchSize = Math.max(1, batchSize); + this.flusher = flushIntervalMillis > 0 && this.batchSize > 1 + ? Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Thread.ofVirtual().name("event-file-store-flush-", 0).factory().newThread(r); + t.setDaemon(true); + return t; + }) + : null; + if (flusher != null) { + flusher.scheduleWithFixedDelay(this::flushQuietly, + flushIntervalMillis, flushIntervalMillis, TimeUnit.MILLISECONDS); + } + } + + @Override + public String name() { + return "event-file-store"; + } + + @Override + public boolean accepts(VehicleEvent event) { + return event != null + && !(event instanceof VehicleEvent.Realtime) + && !(event instanceof VehicleEvent.Location); + } + + @Override + public CompletableFuture publish(VehicleEvent event) { + if (!accepts(event)) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture future = new CompletableFuture<>(); + try { + EventFileRecord record = toRecord(event); + if (batchSize <= 1) { + store.append(record); + } else { + appendBuffered(record); + } + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + return future; + } + + @Override + public CompletableFuture publishBatch(List batch) { + if (batch == null || batch.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture future = new CompletableFuture<>(); + try { + List records = new ArrayList<>(); + for (VehicleEvent event : batch) { + if (accepts(event)) { + records.add(toRecord(event)); + } + } + if (!records.isEmpty()) { + store.appendAll(records); + } + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + return future; + } + + @Override + public void close() throws Exception { + if (flusher != null) { + flusher.shutdownNow(); + } + flush(); + } + + private void appendBuffered(EventFileRecord record) throws IOException { + List toFlush = null; + synchronized (buffer) { + buffer.add(record); + if (buffer.size() >= batchSize) { + toFlush = new ArrayList<>(buffer); + buffer.clear(); + } + } + if (toFlush != null) { + store.appendAll(toFlush); + } + } + + private void flush() throws IOException { + List toFlush; + synchronized (buffer) { + if (buffer.isEmpty()) { + return; + } + toFlush = new ArrayList<>(buffer); + buffer.clear(); + } + store.appendAll(toFlush); + } + + private void flushQuietly() { + try { + flush(); + } catch (Exception e) { + log.warn("event file store scheduled flush failed", e); + } + } + + private EventFileRecord toRecord(VehicleEvent event) throws IOException { + if (event instanceof VehicleEvent.RawArchive raw) { + return rawArchiveIndexRecord(raw); + } + TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event) + .orElseThrow(() -> new IllegalArgumentException("event cannot be converted to telemetry snapshot")); + return new EventFileRecord( + event.eventId(), + event.source(), + snapshot.eventType(), + event.vin(), + event.eventTime(), + event.ingestTime(), + snapshot.rawArchiveUri(), + event.metadata(), + objectMapper.writeValueAsString(snapshotPayload(snapshot)) + ); + } + + private EventFileRecord rawArchiveIndexRecord(VehicleEvent.RawArchive raw) throws IOException { + String rawArchiveKey = RawArchiveKeys.key(raw); + String rawArchiveUri = RawArchiveKeys.logicalUri(rawArchiveKey); + Map metadata = new LinkedHashMap<>(); + if (raw.metadata() != null) { + metadata.putAll(raw.metadata()); + } + metadata.putIfAbsent(RawArchiveKeys.META_EVENT_ID, raw.eventId()); + metadata.putIfAbsent(RawArchiveKeys.META_KEY, rawArchiveKey); + metadata.putIfAbsent(RawArchiveKeys.META_URI, rawArchiveUri); + return new EventFileRecord( + raw.eventId(), + raw.source(), + "RAW_ARCHIVE", + raw.vin(), + raw.eventTime(), + raw.ingestTime(), + rawArchiveUri, + metadata, + objectMapper.writeValueAsString(rawArchivePayload(raw, rawArchiveKey, rawArchiveUri)) + ); + } + + private static Map snapshotPayload(TelemetrySnapshot snapshot) { + Map payload = new LinkedHashMap<>(); + payload.put("eventId", snapshot.eventId()); + payload.put("vin", snapshot.vin()); + payload.put("protocol", snapshot.protocol().name()); + payload.put("eventType", snapshot.eventType()); + payload.put("eventTime", snapshot.eventTime().toString()); + payload.put("ingestTime", snapshot.ingestTime().toString()); + payload.put("rawArchiveUri", snapshot.rawArchiveUri()); + payload.put("metadata", snapshot.metadata()); + payload.put("fields", snapshot.fields()); + return payload; + } + + private static Map rawArchivePayload(VehicleEvent.RawArchive raw, + String rawArchiveKey, + String rawArchiveUri) { + Map payload = new LinkedHashMap<>(); + payload.put("eventId", raw.eventId()); + payload.put("vin", raw.vin()); + payload.put("protocol", raw.source().name()); + payload.put("eventType", "RAW_ARCHIVE"); + payload.put("eventTime", raw.eventTime().toString()); + payload.put("ingestTime", raw.ingestTime().toString()); + payload.put("rawArchiveUri", rawArchiveUri); + payload.put("rawArchiveKey", rawArchiveKey); + payload.put("command", hex(raw.command())); + payload.put("infoType", hex(raw.infoType())); + payload.put("rawSizeBytes", raw.rawBytes() == null ? 0 : raw.rawBytes().length); + payload.put("metadata", raw.metadata()); + return payload; + } + + private static String hex(int value) { + return "0x" + String.format("%04X", value); + } +} diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java new file mode 100644 index 00000000..b10c86f0 --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java @@ -0,0 +1,53 @@ +package com.lingniu.ingest.eventfilestore.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.lingniu.ingest.eventfilestore.DuckDbParquetEventFileStore; +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.eventfilestore.EventFileStoreSink; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; + +import java.nio.file.Path; +import java.time.ZoneId; + +@AutoConfiguration +@EnableConfigurationProperties(EventFileStoreProperties.class) +@ConditionalOnProperty( + prefix = "lingniu.ingest.event-file-store", + name = "enabled", + havingValue = "true") +public class EventFileStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public EventFileStore eventFileStore(EventFileStoreProperties properties, + ObjectProvider objectMapper) { + ObjectMapper mapper = mapper(objectMapper); + return new DuckDbParquetEventFileStore( + Path.of(properties.getPath()), + ZoneId.of(properties.getZoneId()), + mapper); + } + + @Bean + @ConditionalOnMissingBean + public EventFileStoreSink eventFileStoreSink(EventFileStore store, + EventFileStoreProperties properties, + ObjectProvider objectMapper) { + return new EventFileStoreSink( + store, + mapper(objectMapper), + properties.getBatchSize(), + properties.getFlushIntervalMillis()); + } + + private static ObjectMapper mapper(ObjectProvider provider) { + ObjectMapper base = provider.getIfAvailable(ObjectMapper::new); + return base.copy().registerModule(new JavaTimeModule()); + } +} diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java new file mode 100644 index 00000000..0d4e568f --- /dev/null +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java @@ -0,0 +1,72 @@ +package com.lingniu.ingest.eventfilestore.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "lingniu.ingest.event-file-store") +public class EventFileStoreProperties { + + /** + * 默认关闭,避免仅引入依赖就开始写明细文件。 + */ + private boolean enabled = false; + + /** + * Parquet 文件库根路径。 + */ + private String path = "./event-store"; + + /** + * 按事件时间分区时使用的业务时区。 + */ + private String zoneId = "Asia/Shanghai"; + + /** + * Sink 缓冲多少条事件后批量写一个 Parquet part 文件。 + */ + private int batchSize = 500; + + /** + * 未达到 batchSize 时的最长刷盘间隔。 + */ + private long flushIntervalMillis = 1000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getZoneId() { + return zoneId; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public int getBatchSize() { + return batchSize; + } + + public void setBatchSize(int batchSize) { + this.batchSize = batchSize; + } + + public long getFlushIntervalMillis() { + return flushIntervalMillis; + } + + public void setFlushIntervalMillis(long flushIntervalMillis) { + this.flushIntervalMillis = flushIntervalMillis; + } +} diff --git a/modules/sinks/event-file-store/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/sinks/event-file-store/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..4a2b4534 --- /dev/null +++ b/modules/sinks/event-file-store/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.lingniu.ingest.eventfilestore.config.EventFileStoreAutoConfiguration diff --git a/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStoreTest.java b/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStoreTest.java new file mode 100644 index 00000000..94e3cf79 --- /dev/null +++ b/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStoreTest.java @@ -0,0 +1,257 @@ +package com.lingniu.ingest.eventfilestore; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DuckDbParquetEventFileStoreTest { + + @TempDir + Path tempDir; + + @Test + void appendsRecordsToParquetAndQueriesByTimeAscendingAndDescending() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + EventFileRecord first = record("e1", Instant.parse("2026-06-21T16:00:02Z"), "粤A00001"); + EventFileRecord second = record("e2", Instant.parse("2026-06-21T16:00:01Z"), "粤A00002"); + + store.appendAll(List.of(first, second)); + + Path dateDir = tempDir.resolve("protocol=GB32960").resolve("date=2026-06-22"); + assertThat(parquetFiles(dateDir)).hasSize(2); + + EventFileQuery ascending = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 100); + assertThat(store.query(ascending)) + .extracting(EventFileRecord::eventId) + .containsExactly("e2", "e1"); + + EventFileQuery descending = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 100); + assertThat(store.query(descending)) + .extracting(EventFileRecord::eventId) + .containsExactly("e1", "e2"); + } + + @Test + void queriesAcrossMultipleDaysInTimeOrder() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(record("d1", Instant.parse("2026-06-20T16:00:00Z"), "粤A00001")); + store.append(record("d2", Instant.parse("2026-06-21T16:00:00Z"), "粤A00001")); + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-21"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 100); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("d1", "d2"); + } + + @Test + void filtersByVinBeforeApplyingLimit() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(record("other-1", Instant.parse("2026-06-21T16:00:03Z"), "VIN-OTHER")); + store.append(record("target-1", Instant.parse("2026-06-21T16:00:02Z"), "VIN-TARGET")); + store.append(record("other-2", Instant.parse("2026-06-21T16:00:01Z"), "VIN-OTHER")); + store.append(record("target-2", Instant.parse("2026-06-21T16:00:00Z"), "VIN-TARGET")); + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 1, + "VIN-TARGET"); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("target-1"); + } + + @Test + void filtersByVinAndEventTypeBeforeApplyingLimit() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(rawRecord("raw-other", Instant.parse("2026-06-21T16:00:04Z"), "VIN-OTHER")); + store.append(record("business-target", Instant.parse("2026-06-21T16:00:03Z"), "VIN-TARGET")); + store.append(rawRecord("raw-target", Instant.parse("2026-06-21T16:00:02Z"), "VIN-TARGET")); + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 1, + "VIN-TARGET", + "RAW_ARCHIVE"); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("raw-target"); + } + + @Test + void filtersByExactEventTimeRangeBeforeApplyingLimit() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(rawRecord("before", Instant.parse("2026-06-21T16:00:00Z"), "VIN-TIME")); + store.append(rawRecord("inside", Instant.parse("2026-06-21T16:00:02Z"), "VIN-TIME")); + store.append(rawRecord("after", Instant.parse("2026-06-21T16:00:04Z"), "VIN-TIME")); + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + Instant.parse("2026-06-21T16:00:01Z"), + Instant.parse("2026-06-21T16:00:03Z"), + EventFileQuery.Order.ASC, + 10, + "VIN-TIME", + "RAW_ARCHIVE"); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("inside"); + } + + @Test + void appendsSameVehicleDayHeaderToOneParquetFile() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(record("first", Instant.parse("2026-06-21T16:00:03Z"), "VIN-SAME")); + store.append(record("second", Instant.parse("2026-06-21T16:00:04Z"), "VIN-SAME")); + + Path vehicleDayDir = tempDir.resolve("protocol=GB32960") + .resolve("date=2026-06-22") + .resolve("vehicle=VIN-SAME") + .resolve("header=event-records-v1"); + assertThat(parquetFiles(vehicleDayDir)).containsExactly(vehicleDayDir.resolve("events.parquet")); + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 10, + "VIN-SAME"); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("first", "second"); + } + + @Test + void queriesUseSidecarIndexInsteadOfScanningParquetPartsEveryTime() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(record("indexed", Instant.parse("2026-06-21T16:00:03Z"), "VIN-INDEXED")); + + Path vehicleDayDir = tempDir.resolve("protocol=GB32960") + .resolve("date=2026-06-22") + .resolve("vehicle=VIN-INDEXED") + .resolve("header=event-records-v1"); + try (var files = Files.list(vehicleDayDir)) { + for (Path file : files.filter(p -> p.getFileName().toString().endsWith(".parquet")).toList()) { + Files.delete(file); + } + } + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("indexed"); + } + + @Test + void vinQueriesReadVehicleDayParquetWithoutGlobalDuckDbIndex() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + store.append(rawRecord("raw-target", Instant.parse("2026-06-21T16:00:03Z"), "VIN-DIRECT")); + Files.deleteIfExists(tempDir.resolve("events.duckdb")); + + EventFileQuery query = new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.DESC, + 10, + "VIN-DIRECT", + "RAW_ARCHIVE"); + + assertThat(store.query(query)) + .extracting(EventFileRecord::eventId) + .containsExactly("raw-target"); + } + + @Test + void findsRecordByRawArchiveUriThroughSidecarIndex() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai")); + EventFileRecord record = record("raw-001", Instant.parse("2026-06-21T16:00:03Z"), "VIN-RAW"); + store.append(record); + + EventFileRecord found = store.findByRawArchiveUri(record.rawArchiveUri()); + + assertThat(found).isNotNull(); + assertThat(found.eventId()).isEqualTo("raw-001"); + assertThat(found.rawArchiveUri()).isEqualTo(record.rawArchiveUri()); + } + + private static List parquetFiles(Path root) throws Exception { + try (var files = Files.walk(root)) { + return files.filter(p -> p.getFileName().toString().endsWith(".parquet")) + .sorted() + .toList(); + } + } + + private static EventFileRecord record(String eventId, Instant eventTime, String vin) { + return new EventFileRecord( + eventId, + ProtocolId.GB32960, + "REALTIME", + vin, + eventTime, + eventTime.plusMillis(200), + "file:///archive/" + eventId + ".bin", + Map.of("source", "test"), + """ + {"gb32960":{"vehicle":{"speedKmh":42.1},"fuelCell":{"hydrogenRemainingKg":8.3}}} + """.strip()); + } + + private static EventFileRecord rawRecord(String eventId, Instant eventTime, String vin) { + return new EventFileRecord( + eventId, + ProtocolId.GB32960, + "RAW_ARCHIVE", + vin, + eventTime, + eventTime.plusMillis(200), + "file:///archive/" + eventId + ".bin", + Map.of("source", "test"), + "{}"); + } +} diff --git a/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/EventFileStoreSinkTest.java b/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/EventFileStoreSinkTest.java new file mode 100644 index 00000000..36e37c4a --- /dev/null +++ b/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/EventFileStoreSinkTest.java @@ -0,0 +1,154 @@ +package com.lingniu.ingest.eventfilestore; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.RawArchiveKeys; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventFileStoreSinkTest { + + @TempDir + Path tempDir; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void eventSinkSkipsRealtimeAndLocationEvents() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), objectMapper); + EventFileStoreSink sink = new EventFileStoreSink(store, objectMapper); + + sink.publish(realtimeEvent("evt-1")).join(); + sink.publish(locationEvent("loc-1")).join(); + + var records = store.query(new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 10)); + + assertThat(records).isEmpty(); + } + + @Test + void batchedSinkFlushesRecordsWhenClosed() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), objectMapper); + EventFileStoreSink sink = new EventFileStoreSink(store, objectMapper, 10); + + sink.publish(rawEvent("raw-batch-1")).join(); + assertThat(store.query(new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 10))).isEmpty(); + + sink.close(); + + assertThat(store.query(new EventFileQuery( + ProtocolId.GB32960, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 10))) + .extracting(EventFileRecord::eventId) + .containsExactly("raw-batch-1"); + } + + @Test + void eventSinkPersistsRawArchiveAsQueryableIndexWithoutEmbeddingRawBytes() throws Exception { + EventFileStore store = new DuckDbParquetEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), objectMapper); + EventFileStoreSink sink = new EventFileStoreSink(store, objectMapper); + VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( + "raw-1", + "LTEST000000000808", + ProtocolId.JT808, + Instant.parse("2026-06-21T16:00:03Z"), + Instant.parse("2026-06-21T16:00:04Z"), + "trace-raw-1", + Map.of("phone", "013800138000", "identityResolved", "true"), + 0x0200, + 0, + new byte[]{0x7e, 0x02, 0x00, 0x7e}); + + sink.publish(raw).join(); + + var records = store.query(new EventFileQuery( + ProtocolId.JT808, + LocalDate.parse("2026-06-22"), + LocalDate.parse("2026-06-22"), + EventFileQuery.Order.ASC, + 10)); + + assertThat(records).hasSize(1); + EventFileRecord record = records.getFirst(); + String rawArchiveKey = RawArchiveKeys.key(raw); + assertThat(record.eventId()).isEqualTo("raw-1"); + assertThat(record.eventType()).isEqualTo("RAW_ARCHIVE"); + assertThat(record.rawArchiveUri()).isEqualTo(RawArchiveKeys.logicalUri(rawArchiveKey)); + assertThat(record.metadata()) + .containsEntry(RawArchiveKeys.META_KEY, rawArchiveKey) + .containsEntry(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(rawArchiveKey)); + JsonNode payload = objectMapper.readTree(record.payloadJson()); + assertThat(payload.get("rawSizeBytes").asLong()).isEqualTo(4); + assertThat(payload.get("command").asText()).isEqualTo("0x0200"); + assertThat(payload.toString()).doesNotContain("7e02007e"); + } + + private static VehicleEvent.Realtime realtimeEvent(String eventId) { + return new VehicleEvent.Realtime( + eventId, + "LTEST000000000001", + ProtocolId.GB32960, + Instant.parse("2026-06-21T16:00:00Z"), + Instant.parse("2026-06-21T16:00:01Z"), + "trace-1", + Map.of("rawArchiveUri", "file:///archive/" + eventId + ".bin"), + new RealtimePayload( + 42.1, 1234.5, 87.0, null, null, + null, null, null, 8.3, null, null, + RealtimePayload.VehicleState.STARTED, + null, null, null, null, null, + 113.12, 23.45, null, null, null, null + )); + } + + private static VehicleEvent.Location locationEvent(String eventId) { + return new VehicleEvent.Location( + eventId, + "LTEST000000000001", + ProtocolId.GB32960, + Instant.parse("2026-06-21T16:00:00Z"), + Instant.parse("2026-06-21T16:00:01Z"), + "trace-location", + Map.of("rawArchiveUri", "file:///archive/" + eventId + ".bin"), + new LocationPayload(113.12, 23.45, 0, 42.1, 90, 0, 0)); + } + + private static VehicleEvent.RawArchive rawEvent(String eventId) { + return new VehicleEvent.RawArchive( + eventId, + "LTEST000000000001", + ProtocolId.GB32960, + Instant.parse("2026-06-21T16:00:00Z"), + Instant.parse("2026-06-21T16:00:01Z"), + "trace-raw", + Map.of(), + 0x0002, + 0, + new byte[]{0x23, 0x23}); + } +} diff --git a/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java b/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java new file mode 100644 index 00000000..57546aee --- /dev/null +++ b/modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java @@ -0,0 +1,41 @@ +package com.lingniu.ingest.eventfilestore.config; + +import com.lingniu.ingest.eventfilestore.EventFileStore; +import com.lingniu.ingest.eventfilestore.EventFileStoreSink; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventFileStoreAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(EventFileStoreAutoConfiguration.class)); + + @Test + void createsStoreAndSinkWhenEnabled() { + contextRunner + .withPropertyValues( + "lingniu.ingest.event-file-store.enabled=true", + "lingniu.ingest.event-file-store.path=target/test-event-store") + .run(context -> { + assertThat(context).hasSingleBean(EventFileStore.class); + assertThat(context).hasSingleBean(EventFileStoreSink.class); + assertThat(context.getBean(EventFileStoreProperties.class).getPath()) + .isEqualTo(Path.of("target/test-event-store").toString()); + }); + } + + @Test + void backsOffWhenDisabled() { + contextRunner + .withPropertyValues("lingniu.ingest.event-file-store.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(EventFileStore.class); + assertThat(context).doesNotHaveBean(EventFileStoreSink.class); + }); + } +} diff --git a/sink-archive/pom.xml b/modules/sinks/sink-archive/pom.xml similarity index 96% rename from sink-archive/pom.xml rename to modules/sinks/sink-archive/pom.xml index 9669e047..aafc63af 100644 --- a/sink-archive/pom.xml +++ b/modules/sinks/sink-archive/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml sink-archive sink-archive diff --git a/sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/sinks/sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/sinks/sink-archive/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/sink-mq/pom.xml b/modules/sinks/sink-mq/pom.xml similarity index 83% rename from sink-mq/pom.xml rename to modules/sinks/sink-mq/pom.xml index 639dc7d3..3e81b45a 100644 --- a/sink-mq/pom.xml +++ b/modules/sinks/sink-mq/pom.xml @@ -5,6 +5,7 @@ com.lingniu.ingest lingniu-vehicle-ingest 0.1.0-SNAPSHOT + ../../../pom.xml sink-mq sink-mq @@ -49,6 +50,21 @@ kafka test + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java similarity index 75% rename from sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java rename to modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java index e2684279..5b2fbcf5 100644 --- a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java @@ -2,8 +2,14 @@ package com.lingniu.ingest.sink.mq; import com.lingniu.ingest.api.event.AlarmPayload; import com.lingniu.ingest.api.event.LocationPayload; +import com.lingniu.ingest.api.event.RawArchiveKeys; import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.TelemetryFieldValue; +import com.lingniu.ingest.api.event.TelemetrySnapshot; import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.api.event.VehicleEventTelemetrySnapshotMapper; +import com.lingniu.ingest.sink.mq.proto.TelemetryField; +import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot.Builder; import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; /** @@ -30,6 +36,9 @@ public final class EnvelopeMapper { .setIngestTimeMs(event.ingestTime().toEpochMilli()) .setIngestNodeId(nodeId); if (event.metadata() != null) b.putAllMetadata(event.metadata()); + VehicleEventTelemetrySnapshotMapper.toSnapshot(event) + .map(EnvelopeMapper::buildTelemetrySnapshot) + .ifPresent(b::setTelemetrySnapshot); switch (event) { case VehicleEvent.Realtime r -> b.setRealtime(buildRealtime(r.payload())); @@ -58,11 +67,14 @@ public final class EnvelopeMapper { p.data() == null ? new byte[0] : p.data())) .build()); case VehicleEvent.RawArchive ra -> { - // RawArchive 原则上由 ArchiveEventSink 独占消费,本分支仅在有人显式把它送到 - // Kafka 时生效:不设 payload oneof,只填 raw_archive ref 里的 size_bytes, - // 下游如需真正拉取字节要依赖 archive 写成功后回填的 URI(后续迭代)。 int size = ra.rawBytes() == null ? 0 : ra.rawBytes().length; + String key = rawArchiveKey(ra); + String uri = rawArchiveUri(ra, key); + b.putMetadata(RawArchiveKeys.META_KEY, key); + b.putMetadata(RawArchiveKeys.META_URI, uri); + b.putMetadata(RawArchiveKeys.META_EVENT_ID, ra.eventId()); b.setRawArchive(com.lingniu.ingest.sink.mq.proto.RawArchiveRef.newBuilder() + .setUri(uri) .setSizeBytes(size) .build()); } @@ -70,6 +82,23 @@ public final class EnvelopeMapper { return b.build(); } + private static com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot buildTelemetrySnapshot(TelemetrySnapshot snapshot) { + Builder b = com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot.newBuilder() + .setEventType(snapshot.eventType()) + .setRawArchiveUri(snapshot.rawArchiveUri()); + for (TelemetryFieldValue field : snapshot.fields()) { + b.addFields(TelemetryField.newBuilder() + .setKey(field.key()) + .setValueType(field.valueType().name()) + .setValue(field.value()) + .setUnit(field.unit()) + .setQuality(field.quality().name()) + .setSourcePath(field.sourcePath()) + .build()); + } + return b.build(); + } + private static com.lingniu.ingest.sink.mq.proto.RealtimePayload buildRealtime(RealtimePayload p) { var b = com.lingniu.ingest.sink.mq.proto.RealtimePayload.newBuilder(); if (p.speedKmh() != null) b.setSpeedKmh(p.speedKmh()); @@ -125,4 +154,14 @@ public final class EnvelopeMapper { private static String nullToEmpty(String s) { return s == null ? "" : s; } + + private static String rawArchiveKey(VehicleEvent.RawArchive raw) { + String key = raw.metadata() == null ? "" : raw.metadata().getOrDefault(RawArchiveKeys.META_KEY, ""); + return key == null || key.isBlank() ? RawArchiveKeys.key(raw) : key; + } + + private static String rawArchiveUri(VehicleEvent.RawArchive raw, String key) { + String uri = raw.metadata() == null ? "" : raw.metadata().getOrDefault(RawArchiveKeys.META_URI, ""); + return uri == null || uri.isBlank() ? RawArchiveKeys.logicalUri(key) : uri; + } } diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java new file mode 100644 index 00000000..fd1dc871 --- /dev/null +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java @@ -0,0 +1,123 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +import org.apache.kafka.common.serialization.StringDeserializer; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.function.Function; + +public final class KafkaEnvelopeConsumerFactory { + + private final Function> consumerFactory; + + public KafkaEnvelopeConsumerFactory() { + this(KafkaConsumer::new); + } + + KafkaEnvelopeConsumerFactory( + Function> consumerFactory) { + this.consumerFactory = consumerFactory; + } + + public List createWorkers(Map processors, + SinkMqProperties props) { + Map bindings = effectiveBindings(props); + List workers = new ArrayList<>(); + for (Map.Entry entry : bindings.entrySet()) { + String processorBeanName = entry.getKey(); + EnvelopeConsumerProcessor processor = processors.get(processorBeanName); + SinkMqProperties.Binding binding = entry.getValue(); + if (processor == null || binding == null || !binding.isEnabled()) { + continue; + } + List topics = cleanTopics(binding.getTopics()); + if (topics.isEmpty()) { + continue; + } + KafkaEnvelopeConsumerWorker worker = new KafkaEnvelopeConsumerWorker( + consumerFactory.apply(consumerProperties(props, binding, processorBeanName)), + topicProcessors(topics, processor)); + worker.subscribe(topics); + workers.add(worker); + } + return workers; + } + + private Map effectiveBindings(SinkMqProperties props) { + Map configured = props.getConsumer().getBindings(); + if (configured != null && !configured.isEmpty()) { + return configured; + } + SinkMqProperties.Topics topics = props.getTopics(); + Map defaults = new LinkedHashMap<>(); + defaults.put("eventHistoryEnvelopeConsumerProcessor", binding( + "vehicle-event-history", + topics.getRealtime(), topics.getLocation(), topics.getAlarm(), topics.getSession(), topics.getMediaMeta())); + defaults.put("vehicleStateEnvelopeConsumerProcessor", binding( + "vehicle-state", + topics.getRealtime(), topics.getLocation(), topics.getAlarm())); + defaults.put("vehicleStatEnvelopeConsumerProcessor", binding( + "vehicle-stat", + topics.getRealtime(), topics.getLocation())); + return defaults; + } + + private SinkMqProperties.Binding binding(String groupId, String... topics) { + SinkMqProperties.Binding binding = new SinkMqProperties.Binding(); + binding.setGroupId(groupId); + binding.setTopics(List.of(topics)); + return binding; + } + + private Map topicProcessors(List topics, EnvelopeConsumerProcessor processor) { + Map byTopic = new LinkedHashMap<>(); + for (String topic : topics) { + byTopic.put(topic, processor); + } + return byTopic; + } + + private List cleanTopics(List topics) { + if (topics == null || topics.isEmpty()) { + return List.of(); + } + LinkedHashSet clean = new LinkedHashSet<>(); + for (String topic : topics) { + if (topic != null && !topic.isBlank()) { + clean.add(topic); + } + } + return List.copyOf(clean); + } + + private Properties consumerProperties(SinkMqProperties props, + SinkMqProperties.Binding binding, + String processorBeanName) { + SinkMqProperties.Consumer consumer = props.getConsumer(); + Properties p = new Properties(); + p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, props.getBootstrapServers()); + p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class.getName()); + p.put(ConsumerConfig.GROUP_ID_CONFIG, groupId(binding, processorBeanName)); + p.put(ConsumerConfig.CLIENT_ID_CONFIG, consumer.getClientIdPrefix() + "-" + processorBeanName); + p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, consumer.getAutoOffsetReset()); + p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, consumer.getMaxPollRecords()); + return p; + } + + private String groupId(SinkMqProperties.Binding binding, String processorBeanName) { + if (binding.getGroupId() != null && !binding.getGroupId().isBlank()) { + return binding.getGroupId(); + } + return processorBeanName.replace("EnvelopeConsumerProcessor", ""); + } +} diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java new file mode 100644 index 00000000..694452a2 --- /dev/null +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java @@ -0,0 +1,125 @@ +package com.lingniu.ingest.sink.mq; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.SmartLifecycle; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class KafkaEnvelopeConsumerRunner implements SmartLifecycle, AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(KafkaEnvelopeConsumerRunner.class); + + private final List workers; + private final Duration pollTimeout; + private final Duration loopBackoff; + private final boolean autoStartup; + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicBoolean closed = new AtomicBoolean(false); + private ExecutorService executor; + + public KafkaEnvelopeConsumerRunner(List workers, + Duration pollTimeout, + Duration loopBackoff, + boolean autoStartup) { + if (workers == null || workers.isEmpty()) { + throw new IllegalArgumentException("workers must not be empty"); + } + this.workers = List.copyOf(workers); + this.pollTimeout = pollTimeout == null ? Duration.ofSeconds(1) : pollTimeout; + this.loopBackoff = loopBackoff == null ? Duration.ofSeconds(1) : loopBackoff; + this.autoStartup = autoStartup; + } + + public List workers() { + return workers; + } + + @Override + public void start() { + if (!running.compareAndSet(false, true)) { + return; + } + executor = Executors.newFixedThreadPool(workers.size(), r -> { + Thread thread = new Thread(r, "kafka-envelope-consumer"); + thread.setDaemon(true); + return thread; + }); + for (KafkaEnvelopeConsumerWorker worker : workers) { + executor.submit(() -> pollLoop(worker)); + } + } + + private void pollLoop(KafkaEnvelopeConsumerWorker worker) { + while (running.get()) { + try { + worker.pollOnce(pollTimeout); + } catch (RuntimeException ex) { + log.warn("Kafka envelope consumer poll failed; the worker will retry after backoff", ex); + sleepBackoff(); + } + } + } + + private void sleepBackoff() { + try { + Thread.sleep(loopBackoff.toMillis()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void stop() { + if (!running.compareAndSet(true, false)) { + return; + } + if (executor != null) { + executor.shutdownNow(); + } + closeWorkers(); + if (executor != null) { + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void stop(Runnable callback) { + stop(); + callback.run(); + } + + @Override + public boolean isRunning() { + return running.get(); + } + + @Override + public boolean isAutoStartup() { + return autoStartup; + } + + @Override + public void close() { + stop(); + closeWorkers(); + } + + private void closeWorkers() { + if (!closed.compareAndSet(false, true)) { + return; + } + for (KafkaEnvelopeConsumerWorker worker : workers) { + worker.close(); + } + } +} diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java new file mode 100644 index 00000000..ad38138d --- /dev/null +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java @@ -0,0 +1,60 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeConsumerRecord; +import org.apache.kafka.clients.consumer.Consumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; + +import java.time.Duration; +import java.util.Collection; +import java.util.Map; + +public final class KafkaEnvelopeConsumerWorker implements AutoCloseable { + + private final Consumer consumer; + private final Map processorsByTopic; + + public KafkaEnvelopeConsumerWorker(Consumer consumer, + Map processorsByTopic) { + if (consumer == null) { + throw new IllegalArgumentException("consumer must not be null"); + } + if (processorsByTopic == null || processorsByTopic.isEmpty()) { + throw new IllegalArgumentException("processorsByTopic must not be empty"); + } + this.consumer = consumer; + this.processorsByTopic = Map.copyOf(processorsByTopic); + } + + public void subscribe(Collection topics) { + consumer.subscribe(topics); + } + + public int pollOnce(Duration timeout) { + ConsumerRecords records = consumer.poll(timeout == null ? Duration.ZERO : timeout); + int processed = 0; + for (ConsumerRecord record : records) { + EnvelopeConsumerProcessor processor = processorsByTopic.get(record.topic()); + if (processor == null) { + continue; + } + processor.process(new EnvelopeConsumerRecord( + record.topic(), + record.partition(), + record.offset(), + record.key(), + record.value())); + processed++; + } + if (processed > 0) { + consumer.commitSync(); + } + return processed; + } + + @Override + public void close() { + consumer.close(); + } +} diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java new file mode 100644 index 00000000..04e1627c --- /dev/null +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java @@ -0,0 +1,52 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterRecord; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; + +import java.nio.charset.StandardCharsets; + +public final class KafkaEnvelopeDeadLetterSink implements EnvelopeDeadLetterSink { + + private final Producer producer; + private final String topic; + + public KafkaEnvelopeDeadLetterSink(KafkaProducer producer, String topic) { + this((Producer) producer, topic); + } + + KafkaEnvelopeDeadLetterSink(Producer producer, String topic) { + if (producer == null) { + throw new IllegalArgumentException("producer must not be null"); + } + if (topic == null || topic.isBlank()) { + throw new IllegalArgumentException("topic must not be blank"); + } + this.producer = producer; + this.topic = topic; + } + + @Override + public void publish(EnvelopeDeadLetterRecord record) { + if (record == null) { + throw new IllegalArgumentException("record must not be null"); + } + ProducerRecord out = new ProducerRecord<>(topic, record.key(), record.payload()); + header(out, "dlq-service", record.service()); + header(out, "dlq-source-topic", record.topic()); + header(out, "dlq-source-partition", Integer.toString(record.partition())); + header(out, "dlq-source-offset", Long.toString(record.offset())); + header(out, "dlq-status", record.status().name()); + header(out, "dlq-event-id", record.eventId()); + header(out, "dlq-vin", record.vin()); + header(out, "dlq-message", record.message()); + header(out, "dlq-created-at", record.createdAt().toString()); + producer.send(out); + } + + private static void header(ProducerRecord record, String key, String value) { + record.headers().add(key, value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java similarity index 100% rename from sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java rename to modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java similarity index 71% rename from sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java rename to modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java index 412cf1a5..4e4fb5db 100644 --- a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java @@ -1,5 +1,6 @@ package com.lingniu.ingest.sink.mq; +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; import org.apache.kafka.clients.producer.KafkaProducer; @@ -7,12 +8,15 @@ import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.ByteArraySerializer; import org.apache.kafka.common.serialization.StringSerializer; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import java.time.Duration; +import java.util.List; +import java.util.Map; import java.util.Properties; /** @@ -82,4 +86,29 @@ public class SinkMqAutoConfiguration { CircuitBreaker breaker) { return new KafkaEventSink(producer, mapper, router, props.getTopics().getDlq(), breaker); } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq", name = "type", havingValue = "kafka", matchIfMissing = true) + public KafkaEnvelopeDeadLetterSink kafkaEnvelopeDeadLetterSink(KafkaProducer producer, + SinkMqProperties props) { + return new KafkaEnvelopeDeadLetterSink(producer, props.getTopics().getDlq()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(EnvelopeConsumerProcessor.class) + @ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq.consumer", name = "enabled", havingValue = "true") + public KafkaEnvelopeConsumerRunner kafkaEnvelopeConsumerRunner(Map processors, + SinkMqProperties props) { + List workers = new KafkaEnvelopeConsumerFactory().createWorkers(processors, props); + if (workers.isEmpty()) { + throw new IllegalStateException("no kafka envelope consumer workers created; check consumer bindings"); + } + return new KafkaEnvelopeConsumerRunner( + workers, + Duration.ofMillis(props.getConsumer().getPollTimeoutMillis()), + Duration.ofMillis(props.getConsumer().getLoopBackoffMillis()), + props.getConsumer().isAutoStartup()); + } } diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java similarity index 57% rename from sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java rename to modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java index ed802942..7829a6f3 100644 --- a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java @@ -2,6 +2,11 @@ package com.lingniu.ingest.sink.mq; import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @ConfigurationProperties(prefix = "lingniu.ingest.sink.mq") public class SinkMqProperties { @@ -15,7 +20,7 @@ public class SinkMqProperties { /** MQ 后端类型。目前仅支持 kafka,预留 rocketmq/pulsar 等。 */ private String type = "kafka"; - private String bootstrapServers = "localhost:9092"; + private String bootstrapServers = "114.55.58.251:9092"; private String compressionType = "zstd"; private int lingerMs = 20; private int batchSize = 65536; @@ -23,6 +28,7 @@ public class SinkMqProperties { private boolean enableIdempotence = true; private String nodeId = "ingest-local"; private Topics topics = new Topics(); + private Consumer consumer = new Consumer(); public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } @@ -44,6 +50,8 @@ public class SinkMqProperties { public void setNodeId(String nodeId) { this.nodeId = nodeId; } public Topics getTopics() { return topics; } public void setTopics(Topics topics) { this.topics = topics; } + public Consumer getConsumer() { return consumer; } + public void setConsumer(Consumer consumer) { this.consumer = consumer; } public static class Topics { private String realtime = "vehicle.realtime"; @@ -69,4 +77,45 @@ public class SinkMqProperties { public String getDlq() { return dlq; } public void setDlq(String dlq) { this.dlq = dlq; } } + + public static class Consumer { + private boolean enabled = false; + private boolean autoStartup = true; + private String clientIdPrefix = "lingniu-envelope-consumer"; + private int pollTimeoutMillis = 1000; + private int loopBackoffMillis = 1000; + private String autoOffsetReset = "earliest"; + private int maxPollRecords = 500; + private Map bindings = new LinkedHashMap<>(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isAutoStartup() { return autoStartup; } + public void setAutoStartup(boolean autoStartup) { this.autoStartup = autoStartup; } + public String getClientIdPrefix() { return clientIdPrefix; } + public void setClientIdPrefix(String clientIdPrefix) { this.clientIdPrefix = clientIdPrefix; } + public int getPollTimeoutMillis() { return pollTimeoutMillis; } + public void setPollTimeoutMillis(int pollTimeoutMillis) { this.pollTimeoutMillis = pollTimeoutMillis; } + public int getLoopBackoffMillis() { return loopBackoffMillis; } + public void setLoopBackoffMillis(int loopBackoffMillis) { this.loopBackoffMillis = loopBackoffMillis; } + public String getAutoOffsetReset() { return autoOffsetReset; } + public void setAutoOffsetReset(String autoOffsetReset) { this.autoOffsetReset = autoOffsetReset; } + public int getMaxPollRecords() { return maxPollRecords; } + public void setMaxPollRecords(int maxPollRecords) { this.maxPollRecords = maxPollRecords; } + public Map getBindings() { return bindings; } + public void setBindings(Map bindings) { this.bindings = bindings; } + } + + public static class Binding { + private boolean enabled = true; + private String groupId; + private List topics = new ArrayList<>(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getGroupId() { return groupId; } + public void setGroupId(String groupId) { this.groupId = groupId; } + public List getTopics() { return topics; } + public void setTopics(List topics) { this.topics = topics; } + } } diff --git a/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java similarity index 100% rename from sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java rename to modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/TopicRouter.java diff --git a/sink-mq/src/main/proto/vehicle_envelope.proto b/modules/sinks/sink-mq/src/main/proto/vehicle_envelope.proto similarity index 87% rename from sink-mq/src/main/proto/vehicle_envelope.proto rename to modules/sinks/sink-mq/src/main/proto/vehicle_envelope.proto index 4fed5e91..8dc92256 100644 --- a/sink-mq/src/main/proto/vehicle_envelope.proto +++ b/modules/sinks/sink-mq/src/main/proto/vehicle_envelope.proto @@ -32,6 +32,9 @@ message VehicleEnvelope { // 可选:原始字节指针 RawArchiveRef raw_archive = 40; + + // 全字段内部遥测快照。新下游消费者优先读取这里,避免依赖协议字段名。 + TelemetrySnapshot telemetry_snapshot = 50; } message RawArchiveRef { @@ -40,6 +43,21 @@ message RawArchiveRef { int64 size_bytes = 3; } +message TelemetrySnapshot { + string event_type = 1; + string raw_archive_uri = 2; + repeated TelemetryField fields = 3; +} + +message TelemetryField { + string key = 1; + string value_type = 2; + string value = 3; + string unit = 4; + string quality = 5; + string source_path = 6; +} + message RealtimePayload { optional double speed_kmh = 1; optional double total_mileage_km = 2; diff --git a/sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/modules/sinks/sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to modules/sinks/sink-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java new file mode 100644 index 00000000..4ca1f25a --- /dev/null +++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java @@ -0,0 +1,94 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.ProtocolId; +import com.lingniu.ingest.api.event.RawArchiveKeys; +import com.lingniu.ingest.api.event.RealtimePayload; +import com.lingniu.ingest.api.event.VehicleEvent; +import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnvelopeMapperTelemetrySnapshotTest { + + @Test + void mapsVehicleEventToFullFieldTelemetrySnapshot() { + VehicleEvent.Realtime event = new VehicleEvent.Realtime( + "event-1", + "VIN001", + ProtocolId.GB32960, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-1", + Map.of("protocolVersion", "V2016", "rawArchiveUri", "archive://raw/event-1.bin"), + new RealtimePayload( + 80.5, + 12345.6, + 55.0, + 610.0, + -20.5, + null, + null, + null, + 8.2, + 35.0, + null, + RealtimePayload.VehicleState.STARTED, + RealtimePayload.ChargingState.UNCHARGED, + RealtimePayload.RunningMode.FUEL, + 3, + null, + null, + 113.12, + 23.45, + null, + null, + null, + null + ) + ); + + VehicleEnvelope envelope = new EnvelopeMapper("node-1").toEnvelope(event); + + assertThat(envelope.getTelemetrySnapshot().getEventType()).isEqualTo("REALTIME"); + assertThat(envelope.getTelemetrySnapshot().getRawArchiveUri()).isEqualTo("archive://raw/event-1.bin"); + assertThat(envelope.getTelemetrySnapshot().getFieldsList()) + .anySatisfy(field -> { + assertThat(field.getKey()).isEqualTo("total_mileage_km"); + assertThat(field.getValueType()).isEqualTo("DOUBLE"); + assertThat(field.getValue()).isEqualTo("12345.6"); + assertThat(field.getUnit()).isEqualTo("km"); + }) + .anySatisfy(field -> { + assertThat(field.getKey()).isEqualTo("hydrogen_high_pressure_mpa"); + assertThat(field.getValue()).isEqualTo("35.0"); + assertThat(field.getUnit()).isEqualTo("MPa"); + }); + } + + @Test + void rawArchiveEnvelopeCarriesLogicalArchiveUri() { + VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( + "raw-808-1", + "VIN-JT808-001", + ProtocolId.JT808, + Instant.parse("2026-06-22T08:00:00Z"), + Instant.parse("2026-06-22T08:00:01Z"), + "trace-raw-1", + Map.of(), + 0x0200, + 0, + new byte[]{0x7e, 0x02, 0x00, 0x7e}); + + VehicleEnvelope envelope = new EnvelopeMapper("node-1").toEnvelope(raw); + + String key = RawArchiveKeys.key(raw); + assertThat(envelope.hasRawArchive()).isTrue(); + assertThat(envelope.getRawArchive().getUri()).isEqualTo(RawArchiveKeys.logicalUri(key)); + assertThat(envelope.getRawArchive().getSizeBytes()).isEqualTo(4); + assertThat(envelope.getMetadataMap()).containsEntry(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(key)); + } +} diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactoryTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactoryTest.java new file mode 100644 index 00000000..291a1372 --- /dev/null +++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactoryTest.java @@ -0,0 +1,52 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.MockConsumer; +import org.apache.kafka.clients.consumer.OffsetResetStrategy; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaEnvelopeConsumerFactoryTest { + + @Test + void createsDefaultIndependentConsumersForKnownServiceProcessors() { + List created = new ArrayList<>(); + KafkaEnvelopeConsumerFactory factory = new KafkaEnvelopeConsumerFactory(props -> { + created.add(props); + return new MockConsumer<>(OffsetResetStrategy.EARLIEST); + }); + SinkMqProperties props = new SinkMqProperties(); + props.setBootstrapServers("kafka-1:9092"); + EnvelopeConsumerProcessor stateProcessor = processor(); + EnvelopeConsumerProcessor statProcessor = processor(); + + List workers = factory.createWorkers(Map.of( + "vehicleStateEnvelopeConsumerProcessor", stateProcessor, + "vehicleStatEnvelopeConsumerProcessor", statProcessor), props); + + assertThat(workers).hasSize(2); + assertThat(created) + .extracting(p -> p.getProperty(ConsumerConfig.GROUP_ID_CONFIG)) + .containsExactly("vehicle-state", "vehicle-stat"); + assertThat(created) + .allSatisfy(p -> { + assertThat(p.getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG)).isEqualTo("kafka-1:9092"); + assertThat(p.get(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG)).isEqualTo(false); + }); + } + + private EnvelopeConsumerProcessor processor() { + return new EnvelopeConsumerProcessor( + "service", + bytes -> EnvelopeIngestResult.processed("evt-1", "VIN001"), + record -> {}); + } +} diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorkerTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorkerTest.java new file mode 100644 index 00000000..323fdcdf --- /dev/null +++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorkerTest.java @@ -0,0 +1,47 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerRecord; +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterRecord; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.MockConsumer; +import org.apache.kafka.clients.consumer.OffsetResetStrategy; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaEnvelopeConsumerWorkerTest { + + @Test + void pollsKafkaRecordsAndDispatchesThemToConfiguredTopicProcessor() { + MockConsumer consumer = new MockConsumer<>(OffsetResetStrategy.EARLIEST); + TopicPartition partition = new TopicPartition("vehicle.realtime", 0); + consumer.assign(List.of(partition)); + consumer.updateBeginningOffsets(Map.of(partition, 0L)); + consumer.addRecord(new ConsumerRecord<>("vehicle.realtime", 0, 12L, "VIN001", new byte[]{0x01, 0x02})); + AtomicReference captured = new AtomicReference<>(); + EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor( + "vehicle-state", + bytes -> EnvelopeIngestResult.invalid("bad envelope"), + captured::set); + + int processed = new KafkaEnvelopeConsumerWorker( + consumer, Map.of("vehicle.realtime", processor)).pollOnce(Duration.ZERO); + + assertThat(processed).isEqualTo(1); + assertThat(captured.get()).satisfies(record -> { + assertThat(record.topic()).isEqualTo("vehicle.realtime"); + assertThat(record.partition()).isEqualTo(0); + assertThat(record.offset()).isEqualTo(12L); + assertThat(record.key()).isEqualTo("VIN001"); + assertThat(record.payload()).containsExactly(0x01, 0x02); + }); + } +} diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSinkTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSinkTest.java new file mode 100644 index 00000000..4c9232ca --- /dev/null +++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSinkTest.java @@ -0,0 +1,54 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterRecord; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import org.apache.kafka.clients.producer.MockProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaEnvelopeDeadLetterSinkTest { + + @Test + void publishesDeadLetterRecordToConfiguredDlqTopicWithDiagnosticHeaders() { + MockProducer producer = new MockProducer<>( + true, new StringSerializer(), new ByteArraySerializer()); + KafkaEnvelopeDeadLetterSink sink = new KafkaEnvelopeDeadLetterSink(producer, "vehicle.consumer.dlq"); + EnvelopeDeadLetterRecord record = new EnvelopeDeadLetterRecord( + "event-history", + "vehicle.realtime", + 3, + 99L, + "VIN001", + EnvelopeIngestResult.Status.INVALID_ENVELOPE, + "", + "", + "VehicleEnvelope bytes are invalid", + new byte[]{0x01, 0x02}, + Instant.parse("2026-06-22T08:00:00Z")); + + sink.publish(record); + + assertThat(producer.history()).hasSize(1); + ProducerRecord sent = producer.history().getFirst(); + assertThat(sent.topic()).isEqualTo("vehicle.consumer.dlq"); + assertThat(sent.key()).isEqualTo("VIN001"); + assertThat(sent.value()).containsExactly(0x01, 0x02); + assertThat(header(sent, "dlq-service")).isEqualTo("event-history"); + assertThat(header(sent, "dlq-source-topic")).isEqualTo("vehicle.realtime"); + assertThat(header(sent, "dlq-source-partition")).isEqualTo("3"); + assertThat(header(sent, "dlq-source-offset")).isEqualTo("99"); + assertThat(header(sent, "dlq-status")).isEqualTo("INVALID_ENVELOPE"); + assertThat(header(sent, "dlq-message")).isEqualTo("VehicleEnvelope bytes are invalid"); + } + + private static String header(ProducerRecord record, String key) { + return new String(record.headers().lastHeader(key).value(), StandardCharsets.UTF_8); + } +} diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqConsumerAutoConfigurationTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqConsumerAutoConfigurationTest.java new file mode 100644 index 00000000..fe7f4379 --- /dev/null +++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqConsumerAutoConfigurationTest.java @@ -0,0 +1,34 @@ +package com.lingniu.ingest.sink.mq; + +import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor; +import com.lingniu.ingest.api.consumer.EnvelopeIngestResult; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class SinkMqConsumerAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(SinkMqAutoConfiguration.class) + .withBean("vehicleStateEnvelopeConsumerProcessor", EnvelopeConsumerProcessor.class, + () -> new EnvelopeConsumerProcessor( + "vehicle-state", + bytes -> EnvelopeIngestResult.processed("evt-1", "VIN001"), + record -> {})) + .withPropertyValues( + "lingniu.ingest.sink.mq.enabled=true", + "lingniu.ingest.sink.mq.type=kafka", + "lingniu.ingest.sink.mq.consumer.enabled=true", + "lingniu.ingest.sink.mq.consumer.auto-startup=false", + "lingniu.ingest.sink.mq.consumer.bindings.vehicleStateEnvelopeConsumerProcessor.group-id=vehicle-state", + "lingniu.ingest.sink.mq.consumer.bindings.vehicleStateEnvelopeConsumerProcessor.topics[0]=vehicle.realtime"); + + @Test + void createsKafkaEnvelopeConsumerRunnerWhenConsumerBindingIsConfigured() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(KafkaEnvelopeConsumerRunner.class); + assertThat(context.getBean(KafkaEnvelopeConsumerRunner.class).workers()).hasSize(1); + }); + } +} diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqPropertiesTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqPropertiesTest.java new file mode 100644 index 00000000..39bbb3c1 --- /dev/null +++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/SinkMqPropertiesTest.java @@ -0,0 +1,18 @@ +package com.lingniu.ingest.sink.mq; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SinkMqPropertiesTest { + + @Test + void defaultKafkaBrokerUsesProductionAddressButCanStillBeOverriddenByConfigBinding() { + SinkMqProperties props = new SinkMqProperties(); + + assertThat(props.getBootstrapServers()).isEqualTo("114.55.58.251:9092"); + + props.setBootstrapServers("kafka.internal:9092"); + assertThat(props.getBootstrapServers()).isEqualTo("kafka.internal:9092"); + } +} diff --git a/pom.xml b/pom.xml index e9462c4a..e2f81ed8 100644 --- a/pom.xml +++ b/pom.xml @@ -17,21 +17,26 @@ - ingest-api - ingest-codec-common - ingest-core - session-core - observability - sink-mq - sink-archive - protocol-gb32960 - protocol-jt808 - protocol-jt1078 - protocol-jsatl12 - inbound-mqtt - inbound-xinda-push - command-gateway - bootstrap-all + modules/core/ingest-api + modules/core/ingest-codec-common + modules/core/ingest-core + modules/core/session-core + modules/core/vehicle-identity + modules/core/observability + modules/sinks/sink-mq + modules/sinks/sink-archive + modules/sinks/event-file-store + modules/services/event-history-service + modules/services/vehicle-state-service + modules/services/vehicle-stat-service + modules/protocols/protocol-gb32960 + modules/protocols/protocol-jt808 + modules/protocols/protocol-jt1078 + modules/protocols/protocol-jsatl12 + modules/inbound/inbound-mqtt + modules/inbound/inbound-xinda-push + modules/apps/command-gateway + modules/apps/bootstrap-all @@ -52,6 +57,8 @@ 1.13.6 1.42.1 1.3.3 + 1.1.3 + 2.8.17 1.18.34 5.11.3 3.26.3 @@ -140,6 +147,11 @@ session-core ${project.version} + + com.lingniu.ingest + vehicle-identity + ${project.version} + com.lingniu.ingest observability @@ -155,6 +167,26 @@ sink-archive ${project.version} + + com.lingniu.ingest + event-file-store + ${project.version} + + + com.lingniu.ingest + event-history-service + ${project.version} + + + com.lingniu.ingest + vehicle-state-service + ${project.version} + + + com.lingniu.ingest + vehicle-stat-service + ${project.version} + com.lingniu.ingest protocol-gb32960 @@ -217,6 +249,16 @@ hivemq-mqtt-client ${hivemq-mqtt.version} + + org.duckdb + duckdb_jdbc + ${duckdb.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi.version} + com.github.ben-manes.caffeine caffeine diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java deleted file mode 100644 index 8fe4a847..00000000 --- a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapperTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.lingniu.ingest.protocol.gb32960.mapper; - -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser; -import com.lingniu.ingest.protocol.gb32960.codec.Gb32960DecoderTest; -import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder; -import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry; -import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; -import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; -import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message; -import org.junit.jupiter.api.Test; - -import java.nio.ByteBuffer; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class Gb32960EventMapperTest { - - @Test - void realtimeReportProducesRealtimeAndLocationEvents() { - byte[] frame = Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000002"); - - Gb32960BodyParser body = new Gb32960BodyParser(new InfoBlockParserRegistry( - List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser()))); - Gb32960Message msg = new Gb32960MessageDecoder(body).decode(ByteBuffer.wrap(frame)); - - List events = new Gb32960EventMapper().toEvents(msg); - - assertThat(events).hasSize(2); - assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Realtime); - assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Location); - assertThat(events).allMatch(e -> e.source() == ProtocolId.GB32960); - assertThat(events).allMatch(e -> e.vin().equals("LTEST000000000002")); - } -} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java deleted file mode 100644 index 7960b249..00000000 --- a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/codec/Jsatl12RawFrame.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.lingniu.ingest.protocol.jsatl12.codec; - -/** - * JSATL12 原始帧:区分信令帧与数据帧。 - */ -public record Jsatl12RawFrame(Type type, byte[] bytes) { - public enum Type { JT_MESSAGE, DATA_CHUNK } -} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java deleted file mode 100644 index 923f22df..00000000 --- a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/config/Jsatl12AutoConfiguration.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.lingniu.ingest.protocol.jsatl12.config; - -import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer; -import com.lingniu.ingest.sink.archive.ArchiveStore; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; - -@AutoConfiguration -@EnableConfigurationProperties(Jsatl12Properties.class) -@ConditionalOnProperty(prefix = "lingniu.ingest.jsatl12", name = "enabled", havingValue = "true") -@ConditionalOnBean(ArchiveStore.class) -public class Jsatl12AutoConfiguration { - - @Bean - @ConditionalOnMissingBean - public Jsatl12NettyServer jsatl12NettyServer(Jsatl12Properties props, ArchiveStore archive) { - return new Jsatl12NettyServer(props.getPort(), archive); - } -} diff --git a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java b/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java deleted file mode 100644 index e5d612d2..00000000 --- a/protocol-jsatl12/src/main/java/com/lingniu/ingest/protocol/jsatl12/inbound/Jsatl12ChannelHandler.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.lingniu.ingest.protocol.jsatl12.inbound; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame; -import com.lingniu.ingest.sink.archive.ArchiveStore; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; - -/** - * JSATL12 入站处理器。 - * - *

    当前实现:把 DATA_CHUNK 帧写入 {@link ArchiveStore},文件名按日期 + 对端 IP + - * 时间戳生成;JT_MESSAGE 帧暂仅打日志(后续批次可接入 jt808 的 BodyParserRegistry 做完整解析)。 - * - *

    路径模板:{@code yyyy/MM/dd//.bin}。 - */ -public class Jsatl12ChannelHandler extends SimpleChannelInboundHandler { - - private static final Logger log = LoggerFactory.getLogger(Jsatl12ChannelHandler.class); - private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault()); - - /** 为每个 Channel 持久化一个文件 key,保证同一次传输追加到同一个文件。 */ - private static final Cache KEYS = Caffeine.newBuilder() - .expireAfterAccess(Duration.ofMinutes(30)) - .maximumSize(10_000) - .build(); - - private final ArchiveStore archive; - - public Jsatl12ChannelHandler(ArchiveStore archive) { - this.archive = archive; - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, Jsatl12RawFrame frame) { - switch (frame.type()) { - case JT_MESSAGE -> { - // 信令帧:打印摘要。完整解析留到 protocol-jt808 BodyParserRegistry 接入后完成。 - log.info("[jsatl12] signaling frame peer={} len={}", addr(ctx), frame.bytes().length); - } - case DATA_CHUNK -> { - String key = KEYS.get(ctx, k -> { - String day = DAY_FMT.format(Instant.now()); - return "jsatl12/" + day + "/" + addr(ctx).replace(':', '_') - + "/" + Instant.now().toEpochMilli() + ".bin"; - }); - try { - // 去除 4B 魔数 + 4B 长度的 header,仅写真实数据 - byte[] all = frame.bytes(); - int payloadOffset = 8; - int payloadLen = all.length - payloadOffset; - if (payloadLen > 0) { - byte[] payload = new byte[payloadLen]; - System.arraycopy(all, payloadOffset, payload, 0, payloadLen); - archive.append(key, payload); - } - } catch (IOException e) { - log.warn("[jsatl12] write chunk failed key={}", key, e); - } - } - } - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) { - String key = KEYS.getIfPresent(ctx); - if (key != null) { - log.info("[jsatl12] channel closed, archived key={}", key); - KEYS.invalidate(ctx); - } - } - - private static String addr(ChannelHandlerContext ctx) { - if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { - return i.getAddress().getHostAddress() + ":" + i.getPort(); - } - return "unknown"; - } -} diff --git a/protocol-jt1078/pom.xml b/protocol-jt1078/pom.xml deleted file mode 100644 index ded61a56..00000000 --- a/protocol-jt1078/pom.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - 4.0.0 - - com.lingniu.ingest - lingniu-vehicle-ingest - 0.1.0-SNAPSHOT - - protocol-jt1078 - protocol-jt1078 - - JT/T 1078 音视频信令接入。复用 protocol-jt808 的 Netty server 与会话, - 只通过注册新的 BodyParser 热插拔扩展消息集(0x1003 / 0x1005 / 0x1205 / 0x1206)。 - - - - - com.lingniu.ingest - ingest-api - - - com.lingniu.ingest - ingest-core - - - com.lingniu.ingest - protocol-jt808 - - - org.springframework.boot - spring-boot-starter - - - diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java deleted file mode 100644 index 9aacaf29..00000000 --- a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078AutoConfiguration.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.lingniu.ingest.protocol.jt1078.config; - -import com.lingniu.ingest.protocol.jt1078.codec.parser.FileUploadCompleteBodyParser; -import com.lingniu.ingest.protocol.jt1078.codec.parser.MediaAttributesBodyParser; -import com.lingniu.ingest.protocol.jt808.codec.BodyParser; -import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; - -/** - * jt1078 在 jt808 之前装配它自己的 Parser Bean,Spring 会把它们注入到 jt808 的 - * {@code BodyParserRegistry} 里,实现"同一个 Netty 端口复用、消息集热插拔"。 - */ -@AutoConfiguration(before = Jt808AutoConfiguration.class) -@ConditionalOnProperty(prefix = "lingniu.ingest.jt1078", name = "enabled", havingValue = "true") -public class Jt1078AutoConfiguration { - - @Bean - public BodyParser jt1078MediaAttributesBodyParser() { - return new MediaAttributesBodyParser(); - } - - @Bean - public BodyParser jt1078FileUploadCompleteBodyParser() { - return new FileUploadCompleteBodyParser(); - } -} diff --git a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java b/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java deleted file mode 100644 index 7f8557d9..00000000 --- a/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/model/Jt1078MessageId.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.lingniu.ingest.protocol.jt1078.model; - -/** JT/T 1078 消息 ID 常量(上行部分核心子集)。 */ -public final class Jt1078MessageId { - - private Jt1078MessageId() {} - - public static final int TERMINAL_MEDIA_ATTRS_REPORT = 0x1003; - public static final int TERMINAL_PASSENGER_VOLUME = 0x1005; - public static final int TERMINAL_FILE_LIST = 0x1205; - public static final int TERMINAL_FILE_UPLOAD_DONE = 0x1206; -} diff --git a/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 236a8ad5..00000000 --- a/protocol-jt1078/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java deleted file mode 100644 index 908b904b..00000000 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.lingniu.ingest.protocol.jt808.inbound; - -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.pipeline.RawFrame; -import com.lingniu.ingest.core.dispatcher.Dispatcher; -import com.lingniu.ingest.protocol.jt808.codec.Jt808FrameEncoder; -import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder; -import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands; -import com.lingniu.ingest.protocol.jt808.model.Jt808Body; -import com.lingniu.ingest.protocol.jt808.model.Jt808Message; -import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; -import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry; -import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests; -import com.lingniu.ingest.session.DeviceSession; -import com.lingniu.ingest.session.SessionStore; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.InetSocketAddress; -import java.nio.ByteBuffer; -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -/** - * JT808 Netty 入站处理器。职责: - *

      - *
    1. 解码 → 构造 RawFrame → 交给 {@link Dispatcher} - *
    2. 对 0x0100 注册 / 0x0102 鉴权 → 创建 / 更新 {@link DeviceSession} 并绑定 Channel - *
    3. 对 0x0001 终端通用应答 → 唤醒 {@link Jt808PendingRequests} 里等待的 future - *
    4. 对 0x0002 心跳 → 下发 0x8001 通用应答(若平台要求) - *
    5. 连接断开 → 清理 Channel 绑定 - *
    - */ -public class Jt808ChannelHandler extends SimpleChannelInboundHandler { - - private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class); - - private final Jt808MessageDecoder decoder; - private final Dispatcher dispatcher; - private final SessionStore sessions; - private final Jt808ChannelRegistry channelRegistry; - private final Jt808PendingRequests pendingRequests; - - public Jt808ChannelHandler(Jt808MessageDecoder decoder, Dispatcher dispatcher, - SessionStore sessions, Jt808ChannelRegistry channelRegistry, - Jt808PendingRequests pendingRequests) { - this.decoder = decoder; - this.dispatcher = dispatcher; - this.sessions = sessions; - this.channelRegistry = channelRegistry; - this.pendingRequests = pendingRequests; - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, byte[] frame) { - Jt808Message msg; - try { - msg = decoder.decode(ByteBuffer.wrap(frame)); - } catch (Exception e) { - log.warn("[jt808] decode failed peer={} len={}", addr(ctx), frame.length, e); - return; - } - - // ====== 会话维护 & 同步应答匹配 ====== - String phone = msg.header().phone(); - switch (msg.header().messageId()) { - case Jt808MessageId.TERMINAL_REGISTER -> { - DeviceSession session = upsertSession(ctx, msg); - channelRegistry.bind(phone, ctx.channel()); - byte[] registerAck = Jt808FrameEncoder.encode( - Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).messageId(), - phone, - channelRegistry.nextSerial(phone), - Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).body()); - ctx.writeAndFlush(Unpooled.wrappedBuffer(registerAck)); - } - case Jt808MessageId.TERMINAL_AUTH -> { - upsertSession(ctx, msg); - channelRegistry.bind(phone, ctx.channel()); - ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0); - } - case Jt808MessageId.TERMINAL_HEARTBEAT -> { - channelRegistry.bind(phone, ctx.channel()); - ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0); - } - case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> { - // body: ackSerial(2) + ackMsgId(2) + result(1) - if (msg.body() instanceof Jt808Body.Raw raw && raw.bytes().length >= 5) { - int ackSerial = ((raw.bytes()[0] & 0xFF) << 8) | (raw.bytes()[1] & 0xFF); - pendingRequests.complete(phone, ackSerial, msg); - } - } - default -> { - // 定位/其他上行:也可能是对同步命令的应答 - pendingRequests.complete(phone, msg.header().serialNo(), msg); - } - } - - // ====== 派发到 Dispatcher ====== - Map meta = new HashMap<>(4); - meta.put("vin", phone); - meta.put("seq", Integer.toString(msg.header().serialNo())); - meta.put("peer", addr(ctx)); - RawFrame rf = new RawFrame( - ProtocolId.JT808, - msg.header().messageId(), - 0, - msg, - frame, - meta, - Instant.now()); - dispatcher.dispatch(rf); - } - - private DeviceSession upsertSession(ChannelHandlerContext ctx, Jt808Message msg) { - String phone = msg.header().phone(); - String sessionId = "jt808-" + phone; - DeviceSession session = sessions.findBySessionId(sessionId) - .map(s -> s.touch(Instant.now())) - .orElseGet(() -> new DeviceSession( - sessionId, ProtocolId.JT808, null, phone, - generateToken(phone), - addr(ctx), - Instant.now(), Instant.now(), - Map.of("protocolVersion", msg.header().version().name()))); - if (msg.body() instanceof Jt808Body.Register reg && reg.plate() != null) { - session = session.withVin(reg.deviceId()); // 没有 VIN 则用 deviceId 占位 - } - sessions.put(session); - return session; - } - - private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) { - var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result); - byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body()); - ctx.writeAndFlush(Unpooled.wrappedBuffer(frame)); - } - - private static String generateToken(String phone) { - return UUID.nameUUIDFromBytes(("jt808:" + phone).getBytes()).toString().replace("-", "").substring(0, 16); - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) { - channelRegistry.unbind(ctx.channel()); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - log.warn("[jt808] channel error peer={}", addr(ctx), cause); - ctx.close(); - } - - private static String addr(ChannelHandlerContext ctx) { - if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) { - return i.getAddress().getHostAddress() + ":" + i.getPort(); - } - return "unknown"; - } -} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java deleted file mode 100644 index abcbc4a0..00000000 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.lingniu.ingest.protocol.jt808.mapper; - -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.LocationPayload; -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.api.spi.EventMapper; -import com.lingniu.ingest.protocol.jt808.model.Jt808Body; -import com.lingniu.ingest.protocol.jt808.model.Jt808Message; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * JT808 消息 → 领域事件映射。sealed body 允许 switch 穷尽处理。 - * - *

    备注:JT808 里没有强制的 VIN 字段 —— 我们用 {@code phone}(终端手机号/设备号) - * 作为 VIN 占位,真实 VIN 需要通过后续的 session-core 从注册消息里补齐。 - */ -public final class Jt808EventMapper implements EventMapper { - - @Override - public List toEvents(Jt808Message message) { - var header = message.header(); - Instant ingestTime = Instant.now(); - Map meta = Map.of( - "messageId", "0x" + Integer.toHexString(header.messageId()), - "protocolVersion", header.version().name(), - "phone", header.phone()); - String vin = header.phone(); // TODO: 由 session-core 注入真实 VIN - - return switch (message.body()) { - case Jt808Body.Location loc -> List.of(new VehicleEvent.Location( - UUID.randomUUID().toString(), - vin, ProtocolId.JT808, loc.deviceTime(), ingestTime, null, meta, - new LocationPayload(loc.longitude(), loc.latitude(), - loc.altitudeM(), loc.speedKmh(), loc.directionDeg(), - loc.alarmFlag(), loc.statusFlag()))); - - case Jt808Body.Register reg -> List.of(new VehicleEvent.Login( - UUID.randomUUID().toString(), - vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta, - reg.deviceId(), header.version().name())); - - case Jt808Body.Auth auth -> List.of(new VehicleEvent.Login( - UUID.randomUUID().toString(), - vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta, - auth.imei() == null ? "" : auth.imei(), header.version().name())); - - case Jt808Body.Heartbeat _ -> List.of(new VehicleEvent.Heartbeat( - UUID.randomUUID().toString(), - vin, ProtocolId.JT808, ingestTime, ingestTime, null, meta)); - - case Jt808Body.Raw _ -> List.of(); // 未解析的消息体:暂不产出事件 - }; - } -} diff --git a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java b/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java deleted file mode 100644 index e9334bb5..00000000 --- a/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/model/Jt808Body.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.lingniu.ingest.protocol.jt808.model; - -import java.time.Instant; - -/** - * 已解析的消息体。sealed,新增消息体时必须同步新增子类型与 Parser。 - */ -public sealed interface Jt808Body - permits Jt808Body.Register, Jt808Body.Auth, Jt808Body.Location, - Jt808Body.Heartbeat, Jt808Body.Raw { - - /** 终端注册 0x0100。 */ - record Register( - int province, - int city, - String maker, - String deviceType, - String deviceId, - int plateColor, - String plate - ) implements Jt808Body {} - - /** 终端鉴权 0x0102。 */ - record Auth(String token, String imei, String softwareVersion) implements Jt808Body {} - - /** 位置信息汇报 0x0200。 */ - record Location( - long alarmFlag, - long statusFlag, - double longitude, - double latitude, - int altitudeM, - double speedKmh, - int directionDeg, - Instant deviceTime - ) implements Jt808Body {} - - /** 心跳 0x0002(无 body)。 */ - record Heartbeat() implements Jt808Body {} - - /** 未实现 Parser 的消息体,透传原始字节。 */ - record Raw(int messageId, byte[] bytes) implements Jt808Body {} -} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java deleted file mode 100644 index bcca163e..00000000 --- a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/codec/Jt808DecoderTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.lingniu.ingest.protocol.jt808.codec; - -import com.lingniu.ingest.codec.BccChecksum; -import com.lingniu.ingest.codec.BcdCodec; -import com.lingniu.ingest.protocol.jt808.codec.parser.HeartbeatBodyParser; -import com.lingniu.ingest.protocol.jt808.codec.parser.LocationBodyParser; -import com.lingniu.ingest.protocol.jt808.codec.parser.RegisterBodyParser; -import com.lingniu.ingest.protocol.jt808.model.Jt808Body; -import com.lingniu.ingest.protocol.jt808.model.Jt808Message; -import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class Jt808DecoderTest { - - private final Jt808MessageDecoder decoder = new Jt808MessageDecoder( - new BodyParserRegistry(List.of( - new RegisterBodyParser(), - new LocationBodyParser(), - new HeartbeatBodyParser()))); - - @Test - void decodesSyntheticLocationFrame() { - byte[] body = buildLocationBody(); - byte[] frame = buildFrame(Jt808MessageId.TERMINAL_LOCATION, "123456789012", 0x0001, body); - Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame)); - - assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_LOCATION); - assertThat(msg.header().phone()).isEqualTo("123456789012"); - assertThat(msg.header().serialNo()).isEqualTo(1); - - assertThat(msg.body()).isInstanceOf(Jt808Body.Location.class); - Jt808Body.Location loc = (Jt808Body.Location) msg.body(); - assertThat(loc.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001)); - assertThat(loc.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001)); - assertThat(loc.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01)); - } - - @Test - void decodesSyntheticHeartbeatFrame() { - byte[] frame = buildFrame(Jt808MessageId.TERMINAL_HEARTBEAT, "123456789012", 0x0002, new byte[0]); - Jt808Message msg = decoder.decode(ByteBuffer.wrap(frame)); - assertThat(msg.header().messageId()).isEqualTo(Jt808MessageId.TERMINAL_HEARTBEAT); - assertThat(msg.body()).isInstanceOf(Jt808Body.Heartbeat.class); - } - - // ===== helpers ===== - - private static byte[] buildLocationBody() { - ByteArrayOutputStream os = new ByteArrayOutputStream(); - writeU32(os, 0); // alarmFlag - writeU32(os, 0x00030000); // statusFlag - writeU32(os, 39_916_527L); // lat - writeU32(os, 116_397_128L); // lon - writeU16(os, 50); // altitude - writeU16(os, 523); // speed 52.3 - writeU16(os, 90); // direction - byte[] bcd = BcdCodec.encode("240102030405"); - os.write(bcd, 0, 6); - return os.toByteArray(); - } - - private static byte[] buildFrame(int msgId, String phone, int serial, byte[] body) { - // header: msgId(2) attrs(2) phone(6 BCD) serial(2) - ByteArrayOutputStream os = new ByteArrayOutputStream(); - writeU16(os, msgId); - int attrs = body.length & 0x03FF; // 2013 版,无子包、无加密 - writeU16(os, attrs); - byte[] phoneBcd = BcdCodec.encode(phone); - os.write(phoneBcd, 0, 6); - writeU16(os, serial); - os.write(body, 0, body.length); - byte[] raw = os.toByteArray(); - byte bcc = BccChecksum.compute(raw, 0, raw.length); - - ByteArrayOutputStream framed = new ByteArrayOutputStream(); - framed.write(raw, 0, raw.length); - framed.write(bcc & 0xFF); - return framed.toByteArray(); - } - - private static void writeU16(ByteArrayOutputStream os, int v) { - os.write((v >> 8) & 0xFF); - os.write(v & 0xFF); - } - - private static void writeU32(ByteArrayOutputStream os, long v) { - os.write((int) ((v >> 24) & 0xFF)); - os.write((int) ((v >> 16) & 0xFF)); - os.write((int) ((v >> 8) & 0xFF)); - os.write((int) (v & 0xFF)); - } -} diff --git a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java b/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java deleted file mode 100644 index 53dbb73e..00000000 --- a/protocol-jt808/src/test/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapperTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.lingniu.ingest.protocol.jt808.mapper; - -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.protocol.jt808.model.Jt808Body; -import com.lingniu.ingest.protocol.jt808.model.Jt808Header; -import com.lingniu.ingest.protocol.jt808.model.Jt808Message; -import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class Jt808EventMapperTest { - - private final Jt808EventMapper mapper = new Jt808EventMapper(); - - @Test - void locationBodyProducesLocationEvent() { - var header = new Jt808Header( - Jt808MessageId.TERMINAL_LOCATION, 28, 0, false, - Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); - var body = new Jt808Body.Location( - 0, 0, 116.397128, 39.916527, 50, 52.3, 90, Instant.parse("2024-01-02T03:04:05Z")); - var msg = new Jt808Message(header, body); - - List events = mapper.toEvents(msg); - - assertThat(events).hasSize(1); - assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class); - assertThat(events.get(0).source()).isEqualTo(ProtocolId.JT808); - assertThat(events.get(0).vin()).isEqualTo("123456789012"); - } - - @Test - void heartbeatBodyProducesHeartbeatEvent() { - var header = new Jt808Header( - Jt808MessageId.TERMINAL_HEARTBEAT, 0, 0, false, - Jt808Header.ProtocolVersion.V2013, "123456789012", 1, 0, 0); - var msg = new Jt808Message(header, new Jt808Body.Heartbeat()); - assertThat(mapper.toEvents(msg)).hasSize(1) - .first().isInstanceOf(VehicleEvent.Heartbeat.class); - } -} diff --git a/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java b/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java deleted file mode 100644 index 8c63d795..00000000 --- a/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.lingniu.ingest.session.config; - -import com.lingniu.ingest.session.CommandDispatcher; -import com.lingniu.ingest.session.InMemorySessionStore; -import com.lingniu.ingest.session.NoopCommandDispatcher; -import com.lingniu.ingest.session.SessionStore; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.context.annotation.Bean; - -@AutoConfiguration -public class SessionCoreAutoConfiguration { - - @Bean - @ConditionalOnMissingBean - public SessionStore sessionStore() { - return new InMemorySessionStore(); - } - - @Bean - @ConditionalOnMissingBean - public CommandDispatcher commandDispatcher() { - return new NoopCommandDispatcher(); - } -} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveEventSink.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveEventSink.java deleted file mode 100644 index adf5b5a3..00000000 --- a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveEventSink.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.lingniu.ingest.sink.archive; - -import com.lingniu.ingest.api.event.VehicleEvent; -import com.lingniu.ingest.api.sink.EventSink; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.concurrent.CompletableFuture; - -/** - * 原始报文冷存 Sink:消费 {@link VehicleEvent.RawArchive},把原始入站字节写入 - * {@link ArchiveStore},实现"一帧一落盘"的可回放目标。 - * - *

    key 组装:{@code yyyy/MM/dd///.bin} - * (基于 {@link VehicleEvent#ingestTime()} 的 UTC 日期)。相同 eventId 调两次会覆盖前次写入。 - * - *

    写操作本身是同步阻塞的本地/对象存储 IO,外层由 Disruptor 的虚拟线程消费者驱动, - * 不阻塞平台线程。失败会标在返回的 {@link CompletableFuture} 上,交由 EventBus 打 WARN。 - */ -public final class ArchiveEventSink implements EventSink { - - private static final Logger log = LoggerFactory.getLogger(ArchiveEventSink.class); - - private static final DateTimeFormatter DATE_KEY = - DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC); - - private final ArchiveStore store; - - public ArchiveEventSink(ArchiveStore store) { - this.store = store; - } - - @Override - public String name() { - return "archive"; - } - - @Override - public boolean accepts(VehicleEvent event) { - return event instanceof VehicleEvent.RawArchive; - } - - @Override - public CompletableFuture publish(VehicleEvent event) { - CompletableFuture cf = new CompletableFuture<>(); - if (!(event instanceof VehicleEvent.RawArchive raw)) { - // 理论上 accepts 过滤后不会进入;保险起见快速返回避免误落盘。 - cf.complete(null); - return cf; - } - byte[] bytes = raw.rawBytes(); - if (bytes == null || bytes.length == 0) { - cf.complete(null); - return cf; - } - String key = buildKey(raw); - try { - String uri = store.put(key, new ByteArrayInputStream(bytes), bytes.length); - if (log.isDebugEnabled()) { - log.debug("archive put vin={} eventId={} size={} uri={}", - raw.vin(), raw.eventId(), bytes.length, uri); - } - cf.complete(null); - } catch (Exception e) { - log.warn("archive put failed vin={} eventId={} key={}", - raw.vin(), raw.eventId(), key, e); - cf.completeExceptionally(e); - } - return cf; - } - - private static String buildKey(VehicleEvent.RawArchive raw) { - String dateKey = DATE_KEY.format(raw.ingestTime()); - String vin = raw.vin() == null || raw.vin().isBlank() ? "unknown-vin" : raw.vin(); - String eventId = raw.eventId() == null || raw.eventId().isBlank() - ? Long.toHexString(System.nanoTime()) : raw.eventId(); - return dateKey + "/" + raw.source().name() + "/" + vin + "/" + eventId + ".bin"; - } -} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java deleted file mode 100644 index 1e7f7205..00000000 --- a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/ArchiveStore.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.lingniu.ingest.sink.archive; - -import java.io.IOException; -import java.io.InputStream; - -/** - * 冷存后端 SPI。 - * - *

    当前提供本地文件系统实现 {@link LocalArchiveStore},未来可加 S3/OSS 实现。 - * 同一接口既用于 JSATL12 报警附件的分块写入,也用于 Envelope 的原始报文存档。 - */ -public interface ArchiveStore { - - /** - * 写入一段字节流。 - * - * @param key 逻辑路径,例如 {@code 2026/04/13/LTEST000.../session-abc/file.bin} - * @param data 数据流(调用方负责关闭) - * @return 可回传给下游的引用 URI({@code file://...} 或 {@code s3://...}) - */ - String put(String key, InputStream data, long length) throws IOException; - - /** 追加写:用于分块上传场景。key 应在同一文件上稳定。 */ - String append(String key, byte[] chunk) throws IOException; - - /** 读取(用于重放 / 回放测试)。 */ - InputStream get(String key) throws IOException; - - boolean exists(String key); - - long size(String key) throws IOException; -} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java deleted file mode 100644 index 039d908e..00000000 --- a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/LocalArchiveStore.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.lingniu.ingest.sink.archive; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; - -/** - * 本地文件系统冷存实现。根路径支持 {@code file:///abs/path} 或相对路径。 - */ -public final class LocalArchiveStore implements ArchiveStore { - - private static final Logger log = LoggerFactory.getLogger(LocalArchiveStore.class); - - private final Path root; - - public LocalArchiveStore(String rootUri) { - Path path; - if (rootUri == null || rootUri.isBlank()) { - path = Paths.get(System.getProperty("java.io.tmpdir"), "lingniu-archive"); - } else if (rootUri.startsWith("file://")) { - path = Paths.get(rootUri.substring("file://".length())); - } else { - path = Paths.get(rootUri); - } - this.root = path.toAbsolutePath(); - try { - Files.createDirectories(this.root); - } catch (IOException e) { - throw new IllegalStateException("archive root init failed: " + this.root, e); - } - log.info("local archive store initialized root={}", this.root); - } - - @Override - public String put(String key, InputStream data, long length) throws IOException { - Path target = resolve(key); - Files.createDirectories(target.getParent()); - try (InputStream in = data) { - Files.copy(in, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - } - return "file://" + target; - } - - @Override - public String append(String key, byte[] chunk) throws IOException { - Path target = resolve(key); - Files.createDirectories(target.getParent()); - Files.write(target, chunk, - StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); - return "file://" + target; - } - - @Override - public InputStream get(String key) throws IOException { - return Files.newInputStream(resolve(key)); - } - - @Override - public boolean exists(String key) { - return Files.exists(resolve(key)); - } - - @Override - public long size(String key) throws IOException { - return Files.size(resolve(key)); - } - - public Path root() { - return root; - } - - private Path resolve(String key) { - // 防目录穿越 - String normalized = key.replace("..", "_").replaceAll("^/+", ""); - return root.resolve(normalized); - } -} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java deleted file mode 100644 index a5346255..00000000 --- a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.lingniu.ingest.sink.archive.config; - -import com.lingniu.ingest.sink.archive.ArchiveEventSink; -import com.lingniu.ingest.sink.archive.ArchiveStore; -import com.lingniu.ingest.sink.archive.LocalArchiveStore; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; - -@AutoConfiguration -@EnableConfigurationProperties(SinkArchiveProperties.class) -@ConditionalOnProperty(prefix = "lingniu.ingest.sink.archive", name = "enabled", havingValue = "true", matchIfMissing = true) -public class SinkArchiveAutoConfiguration { - - @Bean - @ConditionalOnMissingBean - public ArchiveStore archiveStore(SinkArchiveProperties props) { - return switch (props.getType() == null ? "local" : props.getType().toLowerCase()) { - case "local" -> new LocalArchiveStore(props.getPath()); - default -> throw new IllegalStateException( - "archive type not supported yet: " + props.getType() - + " (only 'local' is implemented; s3/oss TBD)"); - }; - } - - /** - * 注册 {@link ArchiveEventSink} 为 EventBus 的一个出口,消费 {@link - * com.lingniu.ingest.api.event.VehicleEvent.RawArchive} 事件。仅在存在 - * {@link ArchiveStore} bean 时装配;若未启用 sink-archive 或 archive 配置缺失, - * 则不落地原始字节,但 RawArchive 事件本身仍会被 Disruptor 丢弃(无消费者)。 - */ - @Bean - @ConditionalOnBean(ArchiveStore.class) - @ConditionalOnMissingBean - public ArchiveEventSink archiveEventSink(ArchiveStore store) { - return new ArchiveEventSink(store); - } -} diff --git a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java b/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java deleted file mode 100644 index a7174655..00000000 --- a/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.lingniu.ingest.sink.archive.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "lingniu.ingest.sink.archive") -public class SinkArchiveProperties { - - private boolean enabled = true; - /** local / s3 / oss (仅 local 有实现,其余待补) */ - private String type = "local"; - /** 根路径(URI 或绝对路径)。 */ - private String path = System.getProperty("java.io.tmpdir") + "/lingniu-archive"; - - public boolean isEnabled() { return enabled; } - public void setEnabled(boolean enabled) { this.enabled = enabled; } - public String getType() { return type; } - public void setType(String type) { this.type = type; } - public String getPath() { return path; } - public void setPath(String path) { this.path = path; } -} diff --git a/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/ArchiveEventSinkTest.java b/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/ArchiveEventSinkTest.java deleted file mode 100644 index 33c94d78..00000000 --- a/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/ArchiveEventSinkTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.lingniu.ingest.sink.archive; - -import com.lingniu.ingest.api.ProtocolId; -import com.lingniu.ingest.api.event.VehicleEvent; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.Map; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * {@link ArchiveEventSink} 单元测试: - * - *

      - *
    • 只接受 {@link VehicleEvent.RawArchive},其它事件通过 {@link - * ArchiveEventSink#accepts(VehicleEvent)} 拒绝 - *
    • publish 成功将原始字节写到 ArchiveStore 的稳定 key 路径下 - *
    • key 包含日期分片 + 协议名 + VIN + eventId,便于按车按天回放 - *
    - */ -class ArchiveEventSinkTest { - - private static final DateTimeFormatter DATE_KEY = - DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC); - - @Test - void acceptsOnlyRawArchiveEvent(@TempDir Path root) { - LocalArchiveStore store = new LocalArchiveStore(root.toString()); - ArchiveEventSink sink = new ArchiveEventSink(store); - - VehicleEvent.RawArchive raw = newRawArchive("LTESTVIN000000001", new byte[]{0x23, 0x23, 0x02}); - VehicleEvent.Heartbeat hb = new VehicleEvent.Heartbeat( - UUID.randomUUID().toString(), - "LTESTVIN000000001", - ProtocolId.GB32960, - Instant.now(), - Instant.now(), - null, - Map.of()); - - assertThat(sink.accepts(raw)).isTrue(); - assertThat(sink.accepts(hb)).isFalse(); - } - - @Test - void publishWritesRawBytesToStableKey(@TempDir Path root) throws Exception { - LocalArchiveStore store = new LocalArchiveStore(root.toString()); - ArchiveEventSink sink = new ArchiveEventSink(store); - - Instant t = Instant.parse("2026-04-20T10:11:12Z"); - byte[] bytes = new byte[]{0x23, 0x23, 0x02, 0x01, (byte) 0xFE}; - VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive( - "evt-abc", - "LTESTVIN000000001", - ProtocolId.GB32960, - t, - t, - null, - Map.of(), - 0x02, - 0, - bytes); - - sink.publish(raw).get(); - - String expectedKey = DATE_KEY.format(t) + "/GB32960/LTESTVIN000000001/evt-abc.bin"; - Path expectedFile = root.resolve(expectedKey); - assertThat(expectedFile).exists(); - assertThat(Files.readAllBytes(expectedFile)).containsExactly(bytes); - } - - @Test - void publishIsIdempotentOnSameKey(@TempDir Path root) throws Exception { - LocalArchiveStore store = new LocalArchiveStore(root.toString()); - ArchiveEventSink sink = new ArchiveEventSink(store); - - byte[] first = new byte[]{0x01, 0x02}; - byte[] second = new byte[]{0x03, 0x04}; - Instant t = Instant.parse("2026-04-20T10:11:12Z"); - VehicleEvent.RawArchive e1 = new VehicleEvent.RawArchive( - "evt-same", "VIN", ProtocolId.GB32960, t, t, null, Map.of(), 0, 0, first); - VehicleEvent.RawArchive e2 = new VehicleEvent.RawArchive( - "evt-same", "VIN", ProtocolId.GB32960, t, t, null, Map.of(), 0, 0, second); - - sink.publish(e1).get(); - sink.publish(e2).get(); - - String key = DATE_KEY.format(t) + "/GB32960/VIN/evt-same.bin"; - Path file = root.resolve(key); - // 约定:同一 eventId 路径重复写时直接覆盖(put 语义),保留最新一份。 - assertThat(Files.readAllBytes(file)).containsExactly(second); - } - - private static VehicleEvent.RawArchive newRawArchive(String vin, byte[] bytes) { - return new VehicleEvent.RawArchive( - UUID.randomUUID().toString(), - vin, - ProtocolId.GB32960, - Instant.now(), - Instant.now(), - null, - Map.of(), - 0, - 0, - bytes); - } -} diff --git a/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java b/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java deleted file mode 100644 index 3dbb60b7..00000000 --- a/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/LocalArchiveStoreTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.lingniu.ingest.sink.archive; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.ByteArrayInputStream; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class LocalArchiveStoreTest { - - @Test - void putThenGet(@TempDir Path tmp) throws Exception { - LocalArchiveStore store = new LocalArchiveStore(tmp.toString()); - byte[] data = "hello-raw".getBytes(); - String ref = store.put("2026/04/13/sample.bin", new ByteArrayInputStream(data), data.length); - assertThat(ref).startsWith("file://"); - assertThat(store.exists("2026/04/13/sample.bin")).isTrue(); - assertThat(store.size("2026/04/13/sample.bin")).isEqualTo(data.length); - } - - @Test - void appendChunksAccumulate(@TempDir Path tmp) throws Exception { - LocalArchiveStore store = new LocalArchiveStore(tmp.toString()); - store.append("chunks/a.bin", new byte[]{1, 2, 3}); - store.append("chunks/a.bin", new byte[]{4, 5}); - assertThat(store.size("chunks/a.bin")).isEqualTo(5); - } - - @Test - void rejectsPathTraversal(@TempDir Path tmp) throws Exception { - LocalArchiveStore store = new LocalArchiveStore(tmp.toString()); - store.append("../../evil.bin", new byte[]{1}); - // 目录穿越:每个 .. 被替换为 _ - assertThat(store.exists("_/_/evil.bin")).isTrue(); - } -}