diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java index 7dc69a62..4ba474b1 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java @@ -1,6 +1,8 @@ package com.lingniu.ingest.protocol.gb32960.codec; import com.lingniu.ingest.api.spi.DecodeException; +import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry; +import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector; import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType; import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; @@ -48,13 +50,42 @@ public final class Gb32960BodyParser { /** 2025 版实时上报尾部签名标识(表 9)。 */ private static final int SIGNATURE_MARKER = 0xFF; - private final InfoBlockParserRegistry registry; + private final Gb32960ProfileRegistry profileRegistry; + private final VendorExtensionSelector selector; + /** + * 单 registry 构造(向后兼容旧测试)。等价于 default profile + 无 vendor 扩展。 + */ public Gb32960BodyParser(InfoBlockParserRegistry registry) { - this.registry = registry; + this.profileRegistry = new Gb32960ProfileRegistry( + new java.util.ArrayList<>(), // ignored, defaultRegistry overridden via wrapper below + new com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog(java.util.Map.of()), + java.util.Set.of()) { + @Override public InfoBlockParserRegistry forProfile(String name) { return registry; } + @Override public InfoBlockParserRegistry defaultRegistry() { return registry; } + }; + this.selector = VendorExtensionSelector.NONE; } + /** + * 标准构造:profile 注册表 + vendor 选择器。运行期由 + * {@link Gb32960MessageDecoder} 调用 {@link #parse(Gb32960ParserContext, ByteBuffer)} + * 携带上下文,按 selector 路由到具体 profile 的 registry。 + */ + public Gb32960BodyParser(Gb32960ProfileRegistry profileRegistry, VendorExtensionSelector selector) { + this.profileRegistry = profileRegistry; + this.selector = selector; + } + + /** 旧入口:无上下文 → 走默认 profile。保留用于不需要 vendor 路由的单测。 */ public Gb32960MessageDecoder.BodyParseResult parse(ProtocolVersion version, ByteBuffer body) { + return parse(Gb32960ParserContext.minimal(version), body); + } + + /** 主入口:根据上下文按 selector 选 vendor profile,然后照旧解析循环。 */ + public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) { + ProtocolVersion version = ctx.version(); + InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx)); List blocks = new ArrayList<>(8); byte[] signature = null; // 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。 diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java new file mode 100644 index 00000000..994b50a7 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java @@ -0,0 +1,21 @@ +package com.lingniu.ingest.protocol.gb32960.codec; + +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; + +/** + * Body parser 调用上下文:携带选 profile 时需要的三元组。 + * + *

由 {@link Gb32960MessageDecoder} 在解析帧时构造(vin / version 来自帧字节, + * platformAccount 由 handler 通过 channel attribute 传入)。 + * + * @param version 协议版本(来自帧起始符) + * @param vin 17 字节 VIN(trim 后),平台登入 0x05 等帧 VIN 全 0 时为空串 + * @param platformAccount 平台登入用户名;非平台对接场景为 null + */ +public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) { + + /** 占位上下文:版本不可缺,vin/account 允许为 null,便于内部构造和单测。 */ + public static Gb32960ParserContext minimal(ProtocolVersion version) { + return new Gb32960ParserContext(version, null, null); + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java new file mode 100644 index 00000000..e71cdefd --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java @@ -0,0 +1,64 @@ +package com.lingniu.ingest.protocol.gb32960.codec.profile; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 命名 profile → {@link InfoBlockParserRegistry} 映射。 + * + *

构造时接收: + *

+ * + * 构造内部为 default profile 和每个 enabled 扩展套件分别构建一个 registry: + * 套件 registry = default parsers + catalog 里该套件的 parsers。运行期通过 + * {@link #forProfile(String)} 按名字查找;查不到或传 null 时返回 default。 + */ +public class Gb32960ProfileRegistry { + + /** 约定的默认 profile 名(不会出现在配置里,仅作为 forProfile(null) 的 fallback 标识)。 */ + public static final String DEFAULT_PROFILE = "__default__"; + + private final InfoBlockParserRegistry defaultRegistry; + private final Map registriesByProfile; + + public Gb32960ProfileRegistry(List defaultParsers, + VendorExtensionCatalog catalog, + Set enabledExtensionNames) { + this.defaultRegistry = new InfoBlockParserRegistry(defaultParsers); + Map map = new HashMap<>(); + for (String name : enabledExtensionNames) { + List vendorParsers = catalog.parsersFor(name); + if (vendorParsers.isEmpty()) { + throw new IllegalStateException( + "vendor extension '" + name + "' referenced in config but not present in catalog. " + + "Available: " + catalog.names()); + } + List combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size()); + combined.addAll(defaultParsers); + combined.addAll(vendorParsers); + map.put(name, new InfoBlockParserRegistry(combined)); + } + this.registriesByProfile = Map.copyOf(map); + } + + /** 按 profile 名取 registry;null 或未注册的名字返回默认 registry。 */ + public InfoBlockParserRegistry forProfile(String profileName) { + if (profileName == null) return defaultRegistry; + InfoBlockParserRegistry r = registriesByProfile.get(profileName); + return r != null ? r : defaultRegistry; + } + + public InfoBlockParserRegistry defaultRegistry() { + return defaultRegistry; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java new file mode 100644 index 00000000..0472c810 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java @@ -0,0 +1,117 @@ +package com.lingniu.ingest.protocol.gb32960.codec.profile; + +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext; +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 配置驱动的 vendor 扩展选择器。按 {@link Gb32960Properties#getVendorExtensions()} 配置的 + * 顺序自上而下首匹(first-match-wins),命中的 entry 的 {@code name} 即返回值。 + * + *

命中字段:{@code platformAccount} / {@code vin} / {@code vinPrefix}(任一命中即整条 + * entry 命中)。比较为 case-insensitive。 + * + *

结果按 {@code (account, vin)} 缓存以避免每帧重复扫描;约定 cache key 不含 version + * 因为版本不参与匹配。Cache 用 {@link Optional} 包裹以同时缓存 "no match"。 + */ +public final class RuleBasedVendorExtensionSelector implements VendorExtensionSelector { + + private final List rules; + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + public RuleBasedVendorExtensionSelector(List entries, + Set validNames) { + List compiled = new ArrayList<>(entries.size()); + for (Gb32960Properties.VendorExtension e : entries) { + if (e.getName() == null || e.getName().isBlank()) { + throw new IllegalStateException("vendor extension entry missing 'name'"); + } + if (!validNames.contains(e.getName())) { + throw new IllegalStateException( + "vendor extension '" + e.getName() + "' not registered in catalog. " + + "Available: " + validNames); + } + compiled.add(CompiledRule.from(e)); + } + this.rules = List.copyOf(compiled); + } + + @Override + public String select(Gb32960ParserContext ctx) { + if (rules.isEmpty()) return null; + CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin())); + Optional cached = cache.get(key); + if (cached != null) return cached.orElse(null); + String hit = scan(key); + cache.put(key, Optional.ofNullable(hit)); + return hit; + } + + private String scan(CacheKey key) { + for (CompiledRule r : rules) { + if (r.matches(key.account, key.vin)) return r.name; + } + return null; + } + + private static String normalize(String s) { + return s == null || s.isEmpty() ? null : s.toUpperCase(Locale.ROOT); + } + + /** 暴露给测试和 actuator 端点(如果以后加)。 */ + public int cacheSize() { return cache.size(); } + + private record CacheKey(String account, String vin) {} + + /** 预编译后的单条规则:所有列表已 normalize 成大写 set。 */ + private static final class CompiledRule { + final String name; + final Set accounts; + final Set vins; + final List vinPrefixes; + + CompiledRule(String name, Set accounts, Set vins, List vinPrefixes) { + this.name = name; + this.accounts = accounts; + this.vins = vins; + this.vinPrefixes = vinPrefixes; + } + + static CompiledRule from(Gb32960Properties.VendorExtension e) { + Gb32960Properties.VendorExtension.Match m = e.getMatch(); + return new CompiledRule( + e.getName(), + Set.copyOf(toUpper(m.getPlatformAccounts())), + Set.copyOf(toUpper(m.getVins())), + List.copyOf(toUpper(m.getVinPrefixes()))); + } + + boolean matches(String account, String vin) { + if (account != null && accounts.contains(account)) return true; + if (vin != null && vins.contains(vin)) return true; + if (vin != null) { + for (String prefix : vinPrefixes) { + if (vin.startsWith(prefix)) return true; + } + } + return false; + } + + private static List toUpper(List list) { + if (list == null || list.isEmpty()) return List.of(); + List out = new ArrayList<>(list.size()); + for (String s : list) { + if (s == null || s.isBlank()) continue; + out.add(s.trim().toUpperCase(Locale.ROOT)); + } + return out; + } + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java new file mode 100644 index 00000000..9ec37994 --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java @@ -0,0 +1,40 @@ +package com.lingniu.ingest.protocol.gb32960.codec.profile; + +import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Vendor 扩展套件目录:从套件名映射到该套件包含的 {@link InfoBlockParser} 列表。 + * + *

Catalog 在启动时由 {@code Gb32960AutoConfiguration} 一次性构建并注入到 + * {@link Gb32960ProfileRegistry},运行期不可变。每个 entry 代表一个**已实现的**厂商扩展 + * 协议(如广东燃料电池汽车示范规范),其 parser 集合会在选中时**追加**到默认标准 parser + * 集合之上,组成完整的 profile registry。 + * + *

当前内置 key: + *

    + *
  • {@code guangdong-fc} —— 广东燃料电池汽车示范规范 0x30~0x34、0x80 + *
+ */ +public final class VendorExtensionCatalog { + + private final Map> extensions; + + public VendorExtensionCatalog(Map> extensions) { + this.extensions = Map.copyOf(extensions); + } + + /** 已注册的全部 vendor 扩展名集合。 */ + public java.util.Set names() { + return extensions.keySet(); + } + + /** 取指定 vendor 扩展套件的 parser 列表;不存在返回空列表。 */ + public List parsersFor(String name) { + List list = extensions.get(name); + return list == null ? Collections.emptyList() : list; + } +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java new file mode 100644 index 00000000..5838784a --- /dev/null +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java @@ -0,0 +1,20 @@ +package com.lingniu.ingest.protocol.gb32960.codec.profile; + +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext; + +/** + * 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile)。 + * + *

实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。 + */ +public interface VendorExtensionSelector { + + /** + * @return 命中的扩展套件名(与 {@code Gb32960ProfileRegistry} 注册名一致), + * 未命中返回 {@code null}。 + */ + String select(Gb32960ParserContext ctx); + + /** 永远返回 null 的实现,用于不启用任何 vendor 扩展的默认场景。 */ + VendorExtensionSelector NONE = ctx -> null; +} diff --git a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java index 4c3f3e38..55d41c13 100644 --- a/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java +++ b/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java @@ -26,6 +26,22 @@ public class Gb32960Properties { /** TLS 双向认证。 */ private Tls tls = new Tls(); + /** + * 厂商扩展协议(vendor extensions)路由配置。 + * + *

每个 entry 声明一个**已被实现**的 vendor 扩展套件名({@code name})以及该套件 + * 的命中规则({@link VendorExtension.Match})。命中规则由 + * {@code RuleBasedVendorExtensionSelector} 在收到帧时按上下文(platformAccount / + * vin / vin-prefix)逐条按 list 顺序首匹,命中后用对应套件的 parser 集合替换默认的 + * 标准解析器集合(仍包含 0x01..0x09 标准块)。 + * + *

套件名必须与 {@code VendorExtensionCatalog} 里注册的 key 一致; + * 当前内置:{@code guangdong-fc}(广东燃料电池汽车示范规范 0x30~0x34 + 0x80)。 + * + *

未匹配任何规则时回退到默认 profile,仅保留 GB/T 32960 标准块。 + */ + private List vendorExtensions = new ArrayList<>(); + public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public int getPort() { return port; } @@ -38,6 +54,10 @@ public class Gb32960Properties { public void setAuth(Auth auth) { this.auth = auth; } public Tls getTls() { return tls; } public void setTls(Tls tls) { this.tls = tls; } + public List getVendorExtensions() { return vendorExtensions; } + public void setVendorExtensions(List vendorExtensions) { + this.vendorExtensions = vendorExtensions; + } /** * VIN 白名单认证。 @@ -143,4 +163,42 @@ public class Gb32960Properties { public boolean isRequireClientAuth() { return requireClientAuth; } public void setRequireClientAuth(boolean requireClientAuth) { this.requireClientAuth = requireClientAuth; } } + + /** + * 单条 vendor 扩展路由配置。{@code name} 必须与 + * {@code VendorExtensionCatalog} 注册的 key 一致。 + */ + public static class VendorExtension { + /** 扩展套件名,必须在 catalog 中已注册(如 {@code guangdong-fc})。 */ + private String name; + /** 命中规则。任一字段命中即视为整条 entry 命中。 */ + private Match match = new Match(); + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public Match getMatch() { return match; } + public void setMatch(Match match) { this.match = match; } + + /** + * 命中规则。所有字段为 OR 关系:任意一个命中即匹配。 + * 字段内部为 case-insensitive 比较;字段为空 list 视为不参与匹配(不会主动命中)。 + */ + public static class Match { + /** 平台登入用户名精确匹配列表(来自 0x05 PlatformLogin 的 username 字段)。 */ + private List platformAccounts = new ArrayList<>(); + /** VIN 精确匹配列表。 */ + private List vins = new ArrayList<>(); + /** VIN 前缀匹配列表(startsWith)。 */ + private List vinPrefixes = new ArrayList<>(); + + public List getPlatformAccounts() { return platformAccounts; } + public void setPlatformAccounts(List platformAccounts) { + this.platformAccounts = platformAccounts; + } + public List getVins() { return vins; } + public void setVins(List vins) { this.vins = vins; } + public List getVinPrefixes() { return vinPrefixes; } + public void setVinPrefixes(List vinPrefixes) { this.vinPrefixes = vinPrefixes; } + } + } } diff --git a/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java new file mode 100644 index 00000000..c4e4ddff --- /dev/null +++ b/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelectorTest.java @@ -0,0 +1,106 @@ +package com.lingniu.ingest.protocol.gb32960.codec.profile; + +import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext; +import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties; +import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RuleBasedVendorExtensionSelectorTest { + + @Test + void platformAccountMatchHits() { + var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of()); + var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")); + var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1234567890ABCD", "lingniu"); + assertThat(selector.select(ctx)).isEqualTo("guangdong-fc"); + } + + @Test + void vinExactMatchHits() { + var ext = entry("guangdong-fc", List.of(), List.of("LNV1234567890ABCD"), List.of()); + var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")); + var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "lnv1234567890abcd", null); + assertThat(selector.select(ctx)) + .as("VIN match should be case-insensitive") + .isEqualTo("guangdong-fc"); + } + + @Test + void vinPrefixMatchHits() { + var ext = entry("guangdong-fc", List.of(), List.of(), List.of("LNVFC", "LZG")); + var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")); + var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNVFC0001", null); + assertThat(selector.select(ctx)).isEqualTo("guangdong-fc"); + } + + @Test + void noMatchReturnsNull() { + var ext = entry("guangdong-fc", List.of("other"), List.of(), List.of()); + var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")); + var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ZZZ", "lingniu"); + assertThat(selector.select(ctx)).isNull(); + } + + @Test + void firstMatchWinsTopDown() { + var first = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of()); + var second = entry("guangdong-fc", List.of(), List.of(), List.of("LNV")); + var selector = new RuleBasedVendorExtensionSelector( + List.of(first, second), Set.of("guangdong-fc")); + // 即便两条都能命中,应返回第一条匹配 entry 的 name(这里凑巧一样) + var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1", "lingniu"); + assertThat(selector.select(ctx)).isEqualTo("guangdong-fc"); + } + + @Test + void emptyConfigReturnsNullWithoutScanning() { + var selector = new RuleBasedVendorExtensionSelector(List.of(), Set.of("guangdong-fc")); + var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ANY", "ANY"); + assertThat(selector.select(ctx)).isNull(); + } + + @Test + void unknownExtensionNameRejectedAtConstruction() { + var ext = entry("not-in-catalog", List.of("x"), List.of(), List.of()); + assertThatThrownBy(() -> + new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not-in-catalog"); + } + + @Test + void resultIsCachedPerAccountVinTuple() { + var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of()); + var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")); + var ctx1 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu"); + var ctx2 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu"); + selector.select(ctx1); + selector.select(ctx2); + // 同一个 (account, vin) 应只产生一个 cache 条目 + assertThat(selector.cacheSize()).isEqualTo(1); + + var ctx3 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN2", "lingniu"); + selector.select(ctx3); + assertThat(selector.cacheSize()).isEqualTo(2); + } + + private static Gb32960Properties.VendorExtension entry(String name, + List accounts, + List vins, + List vinPrefixes) { + var e = new Gb32960Properties.VendorExtension(); + e.setName(name); + var m = new Gb32960Properties.VendorExtension.Match(); + m.setPlatformAccounts(accounts); + m.setVins(vins); + m.setVinPrefixes(vinPrefixes); + e.setMatch(m); + return e; + } +}