feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

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,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.
*
* <p>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<DeviceSession> findBySessionId(String sessionId) {
if (!hasText(sessionId)) {
return Optional.empty();
}
Map<Object, Object> 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<DeviceSession> findByVin(String vin) {
if (!hasText(vin)) {
return Optional.empty();
}
String sessionId = redis.opsForValue().get(vinIndexKey(vin));
Optional<DeviceSession> session = findBySessionId(sessionId);
if (session.isEmpty() && hasText(sessionId)) {
redis.delete(vinIndexKey(vin));
}
return session;
}
@Override
public Optional<DeviceSession> findByPhone(String phone) {
if (!hasText(phone)) {
return Optional.empty();
}
String sessionId = redis.opsForValue().get(phoneIndexKey(phone));
Optional<DeviceSession> session = findBySessionId(sessionId);
if (session.isEmpty() && hasText(sessionId)) {
redis.delete(phoneIndexKey(phone));
}
return session;
}
@Override
public synchronized Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater) {
Optional<DeviceSession> 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<DeviceSession> 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<String, String> toHash(DeviceSession session) {
Map<String, String> 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<Object, Object> values) {
Map<String, String> 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<String, String> values, String key, String value) {
if (value != null) {
values.put(key, value);
}
}
private static String value(Map<Object, Object> 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();
}
}

View File

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

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

View File

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

View File

@@ -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<Object, Object> 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<DeviceSession> byId = store.findBySessionId("sid-1");
Optional<DeviceSession> byVin = store.findByVin("VIN001");
Optional<DeviceSession> 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<DeviceSession> 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<Object, Object> storedSession(String sid, String vin, String phone) {
Map<Object, Object> 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<String, Object, Object> hashOps = mock(HashOperations.class);
@SuppressWarnings("unchecked")
private final ValueOperations<String, String> valueOps = mock(ValueOperations.class);
@SuppressWarnings("unchecked")
private final SetOperations<String, String> 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());
}
}
}

View File

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