chore: initial import of lingniu-vehicle-ingest

Multi-module Spring Boot ingest service for vehicle telemetry. Modules:

- ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher,
  Disruptor event bus, BCC/BCD codec helpers
- protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser
  packages v2016/v2025), platform login auth, VIN whitelist, idle handler
- protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound
- inbound-mqtt / inbound-xinda-push: alternative ingest channels
- session-core: per-channel session state
- sink-archive / sink-mq: persistence sinks (local file / Kafka)
- command-gateway: terminal control command gateway
- bootstrap-all: aggregator Spring Boot app
- observability: Micrometer / Actuator wiring

Includes hex-dump golden samples under protocol-gb32960/src/test/resources
and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:08:57 +08:00
commit 064ecc479c
220 changed files with 11874 additions and 0 deletions

41
session-core/pom.xml Normal file
View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>session-core</artifactId>
<name>session-core</name>
<description>设备会话 + 鉴权 + Token + Redis 兜底。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,23 @@
package com.lingniu.ingest.session;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* 下行命令调度器 SPI。
*
* <p>用于 command-gateway 把 HTTP 请求转为设备命令,等待设备应答。真正的实现需要与协议
* 模块(如 jt808的 Netty 通道绑定:按 sessionId 找到 Channel → 发送命令字节 → 用
* {@code CompletableFuture + 序列号 map} 实现同步请求/应答。
*
* <p>当前阶段提供接口 + 占位实现,具体 Netty 绑定在 command-gateway / protocol-jt808
* 的下一批次落地。
*/
public interface CommandDispatcher {
/** 异步下发命令,不等应答。 */
CompletableFuture<Void> notify(String sessionId, Object command);
/** 同步下发命令并等待应答。 */
<T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout);
}

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.session;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 设备会话领域模型。不可变;更新通过 {@link SessionStore#update} 原子替换。
*
* @param sessionId 会话 ID通常由 Netty channel id + 终端号生成)
* @param protocol 协议 ID
* @param vin 车架号(注册/鉴权后补齐,注册前为 null
* @param phone 终端手机号 / 设备号
* @param token 鉴权 Token平台下发
* @param peerAddress 对端 IP:port
* @param registeredAt 注册时间
* @param lastSeenAt 最后活跃时间
* @param attributes 协议特定属性(协议版本、型号等)
*/
public record DeviceSession(
String sessionId,
ProtocolId protocol,
String vin,
String phone,
String token,
String peerAddress,
Instant registeredAt,
Instant lastSeenAt,
Map<String, String> attributes
) {
public DeviceSession touch(Instant now) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, now, attributes);
}
public DeviceSession withVin(String vin) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, lastSeenAt, attributes);
}
public DeviceSession withToken(String token) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, lastSeenAt, attributes);
}
}

View File

@@ -0,0 +1,82 @@
package com.lingniu.ingest.session;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.UnaryOperator;
/**
* 纯内存会话存储。支持按 sessionId / vin / phone 三种 key 查询。
*
* <p>失活清理:基于 {@link Caffeine} 的 {@code expireAfterAccess 30 分钟}。
* 生产环境若需多节点一致性,可用 Redis 实现替换此 Bean。
*/
public final class InMemorySessionStore implements SessionStore {
private final Cache<String, DeviceSession> byId;
private final ConcurrentMap<String, String> vinToId = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> phoneToId = new ConcurrentHashMap<>();
public InMemorySessionStore() {
this.byId = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(30))
.removalListener((String sid, DeviceSession s, com.github.benmanes.caffeine.cache.RemovalCause c) -> {
if (s == null) return;
if (s.vin() != null) vinToId.remove(s.vin(), sid);
if (s.phone() != null) phoneToId.remove(s.phone(), sid);
})
.build();
}
@Override
public void put(DeviceSession session) {
byId.put(session.sessionId(), session);
if (session.vin() != null) vinToId.put(session.vin(), session.sessionId());
if (session.phone() != null) phoneToId.put(session.phone(), session.sessionId());
}
@Override
public Optional<DeviceSession> findBySessionId(String sessionId) {
return Optional.ofNullable(byId.getIfPresent(sessionId));
}
@Override
public Optional<DeviceSession> findByVin(String vin) {
String sid = vinToId.get(vin);
return sid == null ? Optional.empty() : findBySessionId(sid);
}
@Override
public Optional<DeviceSession> findByPhone(String phone) {
String sid = phoneToId.get(phone);
return sid == null ? Optional.empty() : findBySessionId(sid);
}
@Override
public synchronized Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater) {
DeviceSession old = byId.getIfPresent(sessionId);
if (old == null) return Optional.empty();
DeviceSession next = updater.apply(old);
put(next);
return Optional.of(next);
}
@Override
public void remove(String sessionId) {
DeviceSession s = byId.getIfPresent(sessionId);
if (s != null) {
byId.invalidate(sessionId);
if (s.vin() != null) vinToId.remove(s.vin(), sessionId);
if (s.phone() != null) phoneToId.remove(s.phone(), sessionId);
}
}
@Override
public int size() {
return (int) byId.estimatedSize();
}
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* 占位命令调度器:返回 {@link UnsupportedOperationException}
* 待 command-gateway 与协议模块的下行通道打通后替换为真实实现。
*/
public final class NoopCommandDispatcher implements CommandDispatcher {
private static final Logger log = LoggerFactory.getLogger(NoopCommandDispatcher.class);
@Override
public CompletableFuture<Void> notify(String sessionId, Object command) {
log.warn("[noop] notify sessionId={} command={} dropped", sessionId, command);
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"CommandDispatcher not yet implemented; configure a real one in protocol module."));
}
@Override
public <T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout) {
log.warn("[noop] request sessionId={} command={} responseType={} dropped",
sessionId, command, responseType.getSimpleName());
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"CommandDispatcher not yet implemented; configure a real one in protocol module."));
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.session;
import java.util.Optional;
import java.util.function.UnaryOperator;
/**
* 设备会话存储 SPI。默认实现是内存 + Caffeine生产可替换为 Redis 兜底。
*/
public interface SessionStore {
void put(DeviceSession session);
Optional<DeviceSession> findBySessionId(String sessionId);
Optional<DeviceSession> findByVin(String vin);
Optional<DeviceSession> findByPhone(String phone);
/** 原子更新:读 → 修改 → 写。返回更新后的会话;若原会话不存在返回 empty。 */
Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater);
void remove(String sessionId);
int size();
}

View File

@@ -0,0 +1,25 @@
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();
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.session.config.SessionCoreAutoConfiguration