feat(gb32960): introduce parser profile registry and rule-based vendor selector
Adds a per-context routing layer for InfoBlock parsers, so different peers can be routed to different parser sets (e.g. standard GB/T 32960 vs. a vendor extension that adds blocks in the 0x30~0x7F reserved range). New types in protocol-gb32960: - Gb32960ParserContext (record): version + vin + platformAccount tuple - codec/profile/VendorExtensionCatalog: name -> list of vendor parsers - codec/profile/Gb32960ProfileRegistry: name -> InfoBlockParserRegistry, with a default registry built from the standard parsers and one extra registry per enabled vendor extension (default + vendor parsers) - codec/profile/VendorExtensionSelector: interface returning the matching extension name (or null) for a given context - codec/profile/RuleBasedVendorExtensionSelector: top-down first-match scan over Gb32960Properties.vendorExtensions with a (account,vin) cache keyed on case-insensitive normalized form Gb32960Properties gains a `vendorExtensions` list with per-entry `name` + `match` (platformAccounts / vins / vinPrefixes). All matching fields are OR'd within an entry; entries are scanned top-down with first-match-wins as documented. Gb32960BodyParser keeps its legacy single-registry constructor for back-compat (used by existing tests) and adds a new constructor that takes the profile registry + selector. The parse loop now lives under parse(Gb32960ParserContext, ByteBuffer); the legacy parse(version,body) delegates to it with a minimal context (selector returns null -> default). This commit only introduces the framework. Vendor parsers, autoconfig wiring, and decoder/handler context plumbing land in follow-ups. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||||
|
|
||||||
import com.lingniu.ingest.api.spi.DecodeException;
|
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.InfoBlock;
|
||||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
|
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
|
||||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||||
@@ -48,13 +50,42 @@ public final class Gb32960BodyParser {
|
|||||||
/** 2025 版实时上报尾部签名标识(表 9)。 */
|
/** 2025 版实时上报尾部签名标识(表 9)。 */
|
||||||
private static final int SIGNATURE_MARKER = 0xFF;
|
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) {
|
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) {
|
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<InfoBlock> blocks = new ArrayList<>(8);
|
List<InfoBlock> blocks = new ArrayList<>(8);
|
||||||
byte[] signature = null;
|
byte[] signature = null;
|
||||||
// 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。
|
// 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||||
|
|
||||||
|
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body parser 调用上下文:携带选 profile 时需要的三元组。
|
||||||
|
*
|
||||||
|
* <p>由 {@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} 映射。
|
||||||
|
*
|
||||||
|
* <p>构造时接收:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code defaultParsers} —— 标准 GB/T 32960 parser 集合(v2016 + v2025)
|
||||||
|
* <li>{@code catalog} —— vendor 扩展套件目录
|
||||||
|
* <li>{@code enabledExtensionNames} —— 在配置文件里被引用过的 vendor 套件名集合
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* 构造内部为 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<String, InfoBlockParserRegistry> registriesByProfile;
|
||||||
|
|
||||||
|
public Gb32960ProfileRegistry(List<InfoBlockParser> defaultParsers,
|
||||||
|
VendorExtensionCatalog catalog,
|
||||||
|
Set<String> enabledExtensionNames) {
|
||||||
|
this.defaultRegistry = new InfoBlockParserRegistry(defaultParsers);
|
||||||
|
Map<String, InfoBlockParserRegistry> map = new HashMap<>();
|
||||||
|
for (String name : enabledExtensionNames) {
|
||||||
|
List<InfoBlockParser> 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<InfoBlockParser> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} 即返回值。
|
||||||
|
*
|
||||||
|
* <p>命中字段:{@code platformAccount} / {@code vin} / {@code vinPrefix}(任一命中即整条
|
||||||
|
* entry 命中)。比较为 case-insensitive。
|
||||||
|
*
|
||||||
|
* <p>结果按 {@code (account, vin)} 缓存以避免每帧重复扫描;约定 cache key 不含 version
|
||||||
|
* 因为版本不参与匹配。Cache 用 {@link Optional} 包裹以同时缓存 "no match"。
|
||||||
|
*/
|
||||||
|
public final class RuleBasedVendorExtensionSelector implements VendorExtensionSelector {
|
||||||
|
|
||||||
|
private final List<CompiledRule> rules;
|
||||||
|
private final ConcurrentHashMap<CacheKey, Optional<String>> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public RuleBasedVendorExtensionSelector(List<Gb32960Properties.VendorExtension> entries,
|
||||||
|
Set<String> validNames) {
|
||||||
|
List<CompiledRule> 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<String> 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<String> accounts;
|
||||||
|
final Set<String> vins;
|
||||||
|
final List<String> vinPrefixes;
|
||||||
|
|
||||||
|
CompiledRule(String name, Set<String> accounts, Set<String> vins, List<String> 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<String> toUpper(List<String> list) {
|
||||||
|
if (list == null || list.isEmpty()) return List.of();
|
||||||
|
List<String> out = new ArrayList<>(list.size());
|
||||||
|
for (String s : list) {
|
||||||
|
if (s == null || s.isBlank()) continue;
|
||||||
|
out.add(s.trim().toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} 列表。
|
||||||
|
*
|
||||||
|
* <p>Catalog 在启动时由 {@code Gb32960AutoConfiguration} 一次性构建并注入到
|
||||||
|
* {@link Gb32960ProfileRegistry},运行期不可变。每个 entry 代表一个**已实现的**厂商扩展
|
||||||
|
* 协议(如广东燃料电池汽车示范规范),其 parser 集合会在选中时**追加**到默认标准 parser
|
||||||
|
* 集合之上,组成完整的 profile registry。
|
||||||
|
*
|
||||||
|
* <p>当前内置 key:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code guangdong-fc} —— 广东燃料电池汽车示范规范 0x30~0x34、0x80
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class VendorExtensionCatalog {
|
||||||
|
|
||||||
|
private final Map<String, List<InfoBlockParser>> extensions;
|
||||||
|
|
||||||
|
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
|
||||||
|
this.extensions = Map.copyOf(extensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已注册的全部 vendor 扩展名集合。 */
|
||||||
|
public java.util.Set<String> names() {
|
||||||
|
return extensions.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取指定 vendor 扩展套件的 parser 列表;不存在返回空列表。 */
|
||||||
|
public List<InfoBlockParser> parsersFor(String name) {
|
||||||
|
List<InfoBlockParser> list = extensions.get(name);
|
||||||
|
return list == null ? Collections.emptyList() : list;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.lingniu.ingest.protocol.gb32960.codec.profile;
|
||||||
|
|
||||||
|
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile)。
|
||||||
|
*
|
||||||
|
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
|
||||||
|
*/
|
||||||
|
public interface VendorExtensionSelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return 命中的扩展套件名(与 {@code Gb32960ProfileRegistry} 注册名一致),
|
||||||
|
* 未命中返回 {@code null}。
|
||||||
|
*/
|
||||||
|
String select(Gb32960ParserContext ctx);
|
||||||
|
|
||||||
|
/** 永远返回 null 的实现,用于不启用任何 vendor 扩展的默认场景。 */
|
||||||
|
VendorExtensionSelector NONE = ctx -> null;
|
||||||
|
}
|
||||||
@@ -26,6 +26,22 @@ public class Gb32960Properties {
|
|||||||
/** TLS 双向认证。 */
|
/** TLS 双向认证。 */
|
||||||
private Tls tls = new Tls();
|
private Tls tls = new Tls();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 厂商扩展协议(vendor extensions)路由配置。
|
||||||
|
*
|
||||||
|
* <p>每个 entry 声明一个**已被实现**的 vendor 扩展套件名({@code name})以及该套件
|
||||||
|
* 的命中规则({@link VendorExtension.Match})。命中规则由
|
||||||
|
* {@code RuleBasedVendorExtensionSelector} 在收到帧时按上下文(platformAccount /
|
||||||
|
* vin / vin-prefix)逐条按 list 顺序首匹,命中后用对应套件的 parser 集合替换默认的
|
||||||
|
* 标准解析器集合(仍包含 0x01..0x09 标准块)。
|
||||||
|
*
|
||||||
|
* <p>套件名必须与 {@code VendorExtensionCatalog} 里注册的 key 一致;
|
||||||
|
* 当前内置:{@code guangdong-fc}(广东燃料电池汽车示范规范 0x30~0x34 + 0x80)。
|
||||||
|
*
|
||||||
|
* <p>未匹配任何规则时回退到默认 profile,仅保留 GB/T 32960 标准块。
|
||||||
|
*/
|
||||||
|
private List<VendorExtension> vendorExtensions = new ArrayList<>();
|
||||||
|
|
||||||
public boolean isEnabled() { return enabled; }
|
public boolean isEnabled() { return enabled; }
|
||||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
public int getPort() { return port; }
|
public int getPort() { return port; }
|
||||||
@@ -38,6 +54,10 @@ public class Gb32960Properties {
|
|||||||
public void setAuth(Auth auth) { this.auth = auth; }
|
public void setAuth(Auth auth) { this.auth = auth; }
|
||||||
public Tls getTls() { return tls; }
|
public Tls getTls() { return tls; }
|
||||||
public void setTls(Tls tls) { this.tls = tls; }
|
public void setTls(Tls tls) { this.tls = tls; }
|
||||||
|
public List<VendorExtension> getVendorExtensions() { return vendorExtensions; }
|
||||||
|
public void setVendorExtensions(List<VendorExtension> vendorExtensions) {
|
||||||
|
this.vendorExtensions = vendorExtensions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VIN 白名单认证。
|
* VIN 白名单认证。
|
||||||
@@ -143,4 +163,42 @@ public class Gb32960Properties {
|
|||||||
public boolean isRequireClientAuth() { return requireClientAuth; }
|
public boolean isRequireClientAuth() { return requireClientAuth; }
|
||||||
public void setRequireClientAuth(boolean requireClientAuth) { this.requireClientAuth = 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<String> platformAccounts = new ArrayList<>();
|
||||||
|
/** VIN 精确匹配列表。 */
|
||||||
|
private List<String> vins = new ArrayList<>();
|
||||||
|
/** VIN 前缀匹配列表(startsWith)。 */
|
||||||
|
private List<String> vinPrefixes = new ArrayList<>();
|
||||||
|
|
||||||
|
public List<String> getPlatformAccounts() { return platformAccounts; }
|
||||||
|
public void setPlatformAccounts(List<String> platformAccounts) {
|
||||||
|
this.platformAccounts = platformAccounts;
|
||||||
|
}
|
||||||
|
public List<String> getVins() { return vins; }
|
||||||
|
public void setVins(List<String> vins) { this.vins = vins; }
|
||||||
|
public List<String> getVinPrefixes() { return vinPrefixes; }
|
||||||
|
public void setVinPrefixes(List<String> vinPrefixes) { this.vinPrefixes = vinPrefixes; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> accounts,
|
||||||
|
List<String> vins,
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user