feat: make gb32960 archive history query production ready
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.session.config.SessionCoreAutoConfiguration
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user