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:
lingniu-dev
2026-04-15 16:37:15 +08:00
parent 3b52e6a617
commit 0e6f120ca9
8 changed files with 459 additions and 2 deletions

View File

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