# GB32960 Body Parser — Per-Block Exception Isolation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `Gb32960BodyParser` 主循环对单信息块的解析异常实现**单块隔离**:一块解析失败不再整帧放弃,改为把失败块兜成 `InfoBlock.Raw` 并继续解析后续信息块。 **Architecture:** 在主循环每次 `parser.parse(body)` 外面套 try/catch(仅捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`,放行 RuntimeException 以免掩盖 parser bug)。失败时: - 若 `fixedLen ≥ 0` 且剩余字节 ≥ fixedLen → 回滚 reader → 按 fixedLen 截取 Raw → position 前进 fixedLen → `continue` 循环 - 否则(变长 parser 或剩余不足)→ 剩余字节全部兜成 Raw → `break` 循环(变长块无法安全找下一块边界) 通过 `Gb32960Properties.Parse.lenientBlockFailure`(默认 true)控制开关,可以回退到严格模式。**不改 `InfoBlock.Raw` record 结构**(避免 downstream 兼容风险——`Gb32960EventMapper` 按 `findBlock(Class)` 类型查找,新增 Raw 不影响其行为)。 **Tech Stack:** Java 25, JUnit 5, AssertJ, Spring Boot ConfigurationProperties, Maven。仅改动 `protocol-gb32960` 模块。 --- ## File Structure - **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java` — 新增 `Parse` 内嵌类 + `parse` 字段/getter/setter - **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java` — 主循环 try/catch + recovery 逻辑 + `lenientBlockFailure` 字段 - **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` — 把配置穿给 BodyParser - **Create** `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java` — 3 个隔离场景 + 1 个严格模式回退场景 - **Modify** `bootstrap-all/src/main/resources/application.yml` — 添加 `parse:` 注释段示例 - **Modify** `CHANGELOG.md` — 追加本次变更条目 --- ## Task 1: 基线验证 —— 现有测试全绿 **Files:** 无 - [ ] **Step 1.1: 跑 protocol-gb32960 模块测试** Run: `mvn -pl protocol-gb32960 test -q` Expected: BUILD SUCCESS,所有现有测试通过(`Gb32960DecoderGoldenTest`、`Gb32960FullBlocksTest`、`Gb32960DecoderTest`、`GuangdongFcEndToEndTest`、parser/* 各 Block Parser 单测、profile/* 选择器单测)。 如果基线红了,**先停下来修复基线**再进入 Task 2。 --- ## Task 2: 添加 Parse 配置子节点 **Files:** - Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java` - [ ] **Step 2.1: 在 `Gb32960Properties` 类体中(在 `Auth` 字段后、`Tls` 字段前)新增 `parse` 字段** 位置参考:现在 L24 `private Auth auth = new Auth();` 下面一行,在 L27 `private Tls tls = new Tls();` 之前插入: ```java /** * 报文解析行为配置。 * *

默认 {@code lenientBlockFailure=true}:单个信息块解析异常时兜成 * {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} 继续解析, * 不再整帧放弃。关闭后恢复旧行为(任意异常直接抛 {@code DecodeException} * 放弃整帧),仅在灰度回滚时使用。 */ private Parse parse = new Parse(); ``` - [ ] **Step 2.2: 在类底部(`Tls` 静态类之后、`VendorExtension` 静态类之前)添加 `Parse` 静态内嵌类** ```java /** * 报文解析容错策略。 */ public static class Parse { /** * 单块解析异常时是否兜底为 {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} * 继续解析。默认开启。 * *

*/ private boolean lenientBlockFailure = true; public boolean isLenientBlockFailure() { return lenientBlockFailure; } public void setLenientBlockFailure(boolean lenientBlockFailure) { this.lenientBlockFailure = lenientBlockFailure; } } ``` - [ ] **Step 2.3: 在属性 getter/setter 区域(现有 `getTls` 之后)补充 `getParse` / `setParse`** ```java public Parse getParse() { return parse; } public void setParse(Parse parse) { this.parse = parse; } ``` - [ ] **Step 2.4: 编译验证** Run: `mvn -pl protocol-gb32960 compile -q` Expected: BUILD SUCCESS。 - [ ] **Step 2.5: 提交** ```bash git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java git commit -m "$(cat <<'EOF' config(gb32960): add parse.lenientBlockFailure toggle 新增 lingniu.ingest.gb32960.parse.lenientBlockFailure 配置开关(默认 true), 为 Gb32960BodyParser 的单块异常隔离行为做回退开关。实现在后续 commit。 Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 3: RED —— 写三个失败测试覆盖隔离场景 **Files:** - Create: `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java` - [ ] **Step 3.1: 创建测试文件** 完整内容: ```java package com.lingniu.ingest.protocol.gb32960.codec; import com.lingniu.ingest.api.spi.DecodeException; import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser; import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser; import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser; import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType; import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; import org.junit.jupiter.api.Test; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * 验证 {@link Gb32960BodyParser} 单块异常隔离行为。 * *

三个隔离场景: *

    *
  1. 固定长度块 parser 抛异常 → 失败块兜 Raw(按 fixedLen 截取)+ 后续块继续解析 *
  2. 固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环 *
  3. 变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环 *
* *

外加一个严格模式回退测试:{@code lenientBlockFailure=false} 时任意异常应抛 DecodeException。 */ class Gb32960BodyParserIsolationTest { /** 构造一个"看起来合法但中途 parser 失败"用来注入失败的 Position parser。 */ private static final InfoBlockParser EXPLODING_FIXED_LEN_POSITION = new InfoBlockParser() { @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } @Override public int typeCode() { return 0x05; } @Override public int fixedLength() { return 9; } @Override public InfoBlock parse(ByteBuffer buffer) { // 模拟 parser 读了几字节后发现不合法就抛 buffer.get(); buffer.get(); throw new DecodeException("simulated parser failure in Position"); } }; /** 变长块(fixedLength=-1),parse 时消费若干字节后抛。模拟 Alarm/Voltage 类列表读越界。 */ private static final InfoBlockParser EXPLODING_VAR_LEN_ALARM = new InfoBlockParser() { @Override public ProtocolVersion version() { return ProtocolVersion.V2016; } @Override public int typeCode() { return 0x07; } @Override public int fixedLength() { return -1; } @Override public InfoBlock parse(ByteBuffer buffer) { // 假装读了个长度字段就爆 buffer.get(); throw new DecodeException("simulated parser failure in Alarm list-length read"); } }; @Test void fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed() { // 帧布局:Vehicle(0x01, 20B) + Position(0x05, 9B 但 parser 爆) + Engine(0x04) 不构造, // 简化为 Vehicle + FailingPosition + Vehicle 再次,验证"Position 之后还能继续"。 InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( new VehicleV2016BlockParser(), EXPLODING_FIXED_LEN_POSITION)); Gb32960BodyParser parser = new Gb32960BodyParser(registry); ByteArrayOutputStream os = new ByteArrayOutputStream(); writeValidVehicle(os); // 1+20 = 21B writePositionTypeAnd9ByteBody(os); // 1+9 = 10B (parser 会爆) writeValidVehicle(os); // 1+20 = 21B ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); var result = parser.parse(ProtocolVersion.V2016, body); assertThat(result.blocks()).hasSize(3); assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> { assertThat(raw.typeCode()).isEqualTo(0x05); assertThat(raw.type()).isEqualTo(InfoBlockType.RAW); assertThat(raw.bytes()).hasSize(9); // 按 fixedLen 截取 }); assertThat(result.blocks().get(2)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); assertThat(body.hasRemaining()).isFalse(); } @Test void truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates() { // Vehicle(21B) + Position(type 0x05)但 body 只给 3B —— 不足 9B InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( new VehicleV2016BlockParser(), new PositionV2016BlockParser())); Gb32960BodyParser parser = new Gb32960BodyParser(registry); ByteArrayOutputStream os = new ByteArrayOutputStream(); writeValidVehicle(os); os.write(0x05); // Position typeCode os.write(0); os.write(0); os.write(0); // 只 3B,远远不够 9B ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); var result = parser.parse(ProtocolVersion.V2016, body); assertThat(result.blocks()).hasSize(2); assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> { assertThat(raw.typeCode()).isEqualTo(0x05); assertThat(raw.bytes()).hasSize(3); // 剩余字节全兜成 Raw }); } @Test void variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks() { // Vehicle(21B) + FailingAlarm(0x07)后面还有一个 Vehicle,但因 Alarm 变长无法安全跳过, // 整段 Alarm 起的剩余字节都被兜成 Raw 后 break。 InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( new VehicleV2016BlockParser(), EXPLODING_VAR_LEN_ALARM)); Gb32960BodyParser parser = new Gb32960BodyParser(registry); ByteArrayOutputStream os = new ByteArrayOutputStream(); writeValidVehicle(os); // 21B os.write(0x07); // Alarm typeCode for (int i = 0; i < 10; i++) os.write(0xAA); // 10B 的 alarm body(parser 爆) writeValidVehicle(os); // 21B(应当不再被解析) ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); var result = parser.parse(ProtocolVersion.V2016, body); assertThat(result.blocks()).hasSize(2); assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class); assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> { assertThat(raw.typeCode()).isEqualTo(0x07); // 剩余 10B alarm body + 1+20=21B 后续 Vehicle = 31B assertThat(raw.bytes()).hasSize(31); }); } @Test void strictMode_throwsOnAnyBlockFailure() { InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of( new VehicleV2016BlockParser(), EXPLODING_FIXED_LEN_POSITION)); Gb32960BodyParser parser = new Gb32960BodyParser(registry); parser.setLenientBlockFailure(false); ByteArrayOutputStream os = new ByteArrayOutputStream(); writeValidVehicle(os); writePositionTypeAnd9ByteBody(os); ByteBuffer body = ByteBuffer.wrap(os.toByteArray()); assertThatThrownBy(() -> parser.parse(ProtocolVersion.V2016, body)) .isInstanceOf(DecodeException.class); } // ------------------------------------------------------------------------ // helpers // ------------------------------------------------------------------------ /** 写一个完整的合法 V2016 Vehicle 块(typeCode + 20B body)。 */ private static void writeValidVehicle(ByteArrayOutputStream os) { os.write(0x01); // typeCode Vehicle os.write(0x01); // vehicleState=1 os.write(0x01); // chargingState=1 os.write(0x01); // runningMode=1 os.write(0); os.write(0); // speed=0 os.write(0); os.write(0); os.write(0); os.write(0); // mileage=0 os.write(0); os.write(0); // totalVoltage=0 os.write(0); os.write(0); // totalCurrent=0(offset 1000,原值0) os.write(50); // soc=50% os.write(0x01); // dcdc os.write(0); // gear os.write(0); os.write(0); // insulation os.write(0); // accelerator os.write(0); // brake } /** 写 Position typeCode(0x05) + 9 字节 body(内容不重要,parser 替换成 EXPLODING 的)。 */ private static void writePositionTypeAnd9ByteBody(ByteArrayOutputStream os) { os.write(0x05); for (int i = 0; i < 9; i++) os.write(0); } } ``` - [ ] **Step 3.2: 跑测试验证 RED** Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q` Expected: - `fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed` FAIL(当前会抛 DecodeException) - `truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates` FAIL(当前 L132 会抛 DecodeException) - `variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks` FAIL(异常冒出整帧失败) - `strictMode_throwsOnAnyBlockFailure` FAIL(没有 `setLenientBlockFailure` 方法,编译就红) **验证点**:编译失败(strictMode test 里 `setLenientBlockFailure` 尚未存在)是**预期** RED 信号——下一步实现。 --- ## Task 4: GREEN —— 实现单块异常隔离 **Files:** - Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java` - [ ] **Step 4.1: 在类字段区新增 `lenientBlockFailure` 字段 + setter** 在 `private final VendorExtensionSelector selector;`(现 L54)下面插入: ```java /** * 单块解析异常时是否兜底为 Raw 后继续。由 * {@link com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties.Parse#isLenientBlockFailure()} * 注入;默认 true。 */ private boolean lenientBlockFailure = true; public void setLenientBlockFailure(boolean lenientBlockFailure) { this.lenientBlockFailure = lenientBlockFailure; } ``` - [ ] **Step 4.2: 改造主循环:替换 L130~L150 的整个 "parse + 长度校验" 段** **完整替换块**:以下代码替换原来从 `int fixedLen = parser.fixedLength();`(L130)开始到 `blocks.add(block);`(L150)结束的整块。 ```java int fixedLen = parser.fixedLength(); int posBefore = body.position(); // 旧行为:fixedLen 预检不足 → 直接抛。新行为(lenient 模式):走统一恢复路径。 if (fixedLen >= 0 && body.remaining() < fixedLen) { if (!lenientBlockFailure) { throw new DecodeException( "info block 0x" + Integer.toHexString(typeCode) + " needs " + fixedLen + " bytes but got " + body.remaining()); } // 剩余字节数不足 fixedLen —— 无法按块截取,整尾兜 Raw + break int remaining = body.remaining(); byte[] tail = new byte[remaining]; body.get(tail); log.warn("[gb32960] truncated block typeCode=0x{} declaredFixedLen={} remaining={} — wrapping tail as Raw", Integer.toHexString(typeCode), fixedLen, remaining); blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail)); break; } InfoBlock block; try { block = parser.parse(body); } catch (DecodeException | java.nio.BufferUnderflowException | IndexOutOfBoundsException e) { if (!lenientBlockFailure) { if (e instanceof DecodeException de) throw de; throw new DecodeException( "parser " + parser.getClass().getSimpleName() + " failed: " + e.getMessage(), e); } // 恢复路径:先回滚 position 到 parse 入口 body.position(posBefore); if (fixedLen >= 0) { // 固定长度块:按 fixedLen 截取 → 兜 Raw → continue 循环 byte[] corrupt = new byte[fixedLen]; body.get(corrupt); log.warn("[gb32960] block parse failed typeCode=0x{} parser={} fixedLen={} — isolated as Raw, continuing", Integer.toHexString(typeCode), parser.getClass().getSimpleName(), fixedLen, e); blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, corrupt)); continue; } else { // 变长块:无法安全找下一块边界 → 剩余整段兜 Raw + break int remaining = body.remaining(); byte[] tail = new byte[remaining]; body.get(tail); log.warn("[gb32960] variable-length block parse failed typeCode=0x{} parser={} remaining={} — wrapping remainder as Raw, stopping loop", Integer.toHexString(typeCode), parser.getClass().getSimpleName(), remaining, e); blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail)); break; } } int consumed = body.position() - posBefore; if (fixedLen >= 0 && consumed != fixedLen) { // 这是 parser 契约违反(声明 fixedLen=X 但实际读了 Y),保持抛异常以暴露 parser bug。 throw new DecodeException( "parser " + parser.getClass().getSimpleName() + " consumed " + consumed + " bytes but declared " + fixedLen); } if (log.isDebugEnabled()) { log.debug("[gb32960] block typeCode=0x{} parser={} pos {}→{} consumed={} declaredFixed={}", Integer.toHexString(typeCode), parser.getClass().getSimpleName(), typeStartPos, body.position(), consumed, fixedLen); } blocks.add(block); ``` - [ ] **Step 4.3: 运行隔离测试验证 GREEN** Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q` Expected: 4 个测试全部 PASS。 - [ ] **Step 4.4: 运行全模块测试检查回归** Run: `mvn -pl protocol-gb32960 test -q` Expected: BUILD SUCCESS,所有原有测试(含 Golden、FullBlocks、GuangdongFcEndToEnd)依旧通过。 如果有原有测试红了:**停下来诊断**。最可能的原因是某个现有测试之前靠"抛异常"行为验证错误帧的,需要手动判断这是测试问题还是实现问题。 - [ ] **Step 4.5: 提交** ```bash git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java \ protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java git commit -m "$(cat <<'EOF' feat(gb32960): isolate per-block parse failures in body parser 主循环改造:单信息块 parser 抛 DecodeException / BufferUnderflowException / IndexOutOfBoundsException 时不再放弃整帧。 - 固定长度块(fixedLen≥0):回滚 reader,按 fixedLen 截取字节兜成 InfoBlock.Raw, position 前进 fixedLen,continue 循环继续解析后续块 - 变长块(fixedLen=-1)或剩余不足 fixedLen:剩余字节全部兜成 Raw 后 break - parser 契约违反(consumed != declared fixedLen)仍抛异常,以暴露 parser bug - 通过 lingniu.ingest.gb32960.parse.lenientBlockFailure=false 可回退严格模式 - 新增 Gb32960BodyParserIsolationTest 覆盖 3 种失败场景 + 1 严格模式 不改 InfoBlock.Raw record 结构;下游 Gb32960EventMapper 按 findBlock(Class) 类型 查找,新增 Raw 不影响其行为。 Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 5: 把配置注入 BodyParser(AutoConfiguration 连线) **Files:** - Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` - [ ] **Step 5.1: 定位 `Gb32960BodyParser` bean 定义** 先查看:`grep -n "Gb32960BodyParser" protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` 根据返回的行号,在构造 `Gb32960BodyParser` 的 @Bean 方法里,构造完成后立即调用: ```java Gb32960BodyParser parser = new Gb32960BodyParser(profileRegistry, selector); parser.setLenientBlockFailure(properties.getParse().isLenientBlockFailure()); return parser; ``` (如果目前的构造返回一行表达式,改成先赋给 local 变量再设 flag 再 return。) - [ ] **Step 5.2: 编译并运行全模块测试** Run: `mvn -pl protocol-gb32960 test -q` Expected: BUILD SUCCESS。 - [ ] **Step 5.3: 提交** ```bash git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java git commit -m "$(cat <<'EOF' config(gb32960): wire parse.lenientBlockFailure into BodyParser bean Gb32960AutoConfiguration 在创建 Gb32960BodyParser bean 后,将 Gb32960Properties.Parse.lenientBlockFailure 通过 setter 注入,让运行期配置生效。 Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 6: bootstrap-all 配置文档化 **Files:** - Modify: `bootstrap-all/src/main/resources/application.yml` - [ ] **Step 6.1: 在 `lingniu.ingest.gb32960` 节点的 `vendor-extensions` 段之后、`jt808` 之前,插入 parse 配置示例** 参考位置:现有 application.yml L57 末尾(`vendor-extensions` list 结束)。在 L58 `jt808:` 之前加入: ```yaml # 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成 Raw 后继续, # 不再整帧丢弃。仅在需要严格失败语义(灰度回滚或抓虫)时设为 false。 parse: lenient-block-failure: true ``` - [ ] **Step 6.2: 启动一次 bootstrap-all 的 dry-run 编译** Run: `mvn -pl bootstrap-all compile -q` Expected: BUILD SUCCESS(只是配置段注释变化,不会影响编译)。 - [ ] **Step 6.3: 提交** ```bash git add bootstrap-all/src/main/resources/application.yml git commit -m "$(cat <<'EOF' config(gb32960): document parse.lenient-block-failure in application.yml Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 7: 全仓库回归测试 **Files:** 无(只是验证) - [ ] **Step 7.1: 跑完整项目测试** Run: `mvn test -q` Expected: BUILD SUCCESS。重点关注: - `protocol-gb32960` 模块全绿(Golden、FullBlocks、Isolation、GuangdongFcEndToEnd、Decoder、Mapper、所有 parser 单测、profile) - 其它模块(`ingest-core`、`sink-mq` 等)不受影响(无改动应自动通过) 若有红,停下来诊断。 --- ## Task 8: 更新 CHANGELOG **Files:** - Modify: `CHANGELOG.md` - [ ] **Step 8.1: 在 CHANGELOG.md 文件顶部(在 `## [0.1.0] — 2026-04-15` 标题之前)新增一个 `## [Unreleased]` 段落;如已存在则追加** 新增段落内容(如已存在 Unreleased 段则在其 `### Added` / `### Changed` 内部追加): ```markdown ## [Unreleased] ### Changed —— GB/T 32960 Body Parser 单块异常隔离 - `Gb32960BodyParser` 主循环对单信息块的 parser 异常不再放弃整帧: - 固定长度块(`fixedLength ≥ 0`):回滚 reader → 按 fixedLen 截取字节兜成 `InfoBlock.Raw` → 继续解析后续块; - 变长块或剩余字节不足:剩余字节全部兜成 `Raw` 后终止循环; - `parser` 契约违反(声明 fixedLen=X 但实际读了 Y)仍抛 `DecodeException`, 以暴露 parser bug,不被静默吞掉; - 只捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`, `RuntimeException` 继续向上抛,保留 bug 可见性。 - 新增配置 `lingniu.ingest.gb32960.parse.lenient-block-failure`(默认 `true`); 回退严格模式用 `false`。 - `InfoBlock.Raw` record 结构**未变**,下游 `Gb32960EventMapper` 按 `findBlock(Class)` 类型匹配,新增 Raw 不影响业务事件映射。 - 覆盖测试:`Gb32960BodyParserIsolationTest` —— 3 种隔离场景 + 严格模式回退。 ``` - [ ] **Step 8.2: 提交** ```bash git add CHANGELOG.md git commit -m "$(cat <<'EOF' docs(changelog): record gb32960 body parser block isolation Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Self-Review Checklist - [x] **Spec coverage**: 原讨论的 A1~A7 全部覆盖——A1 状态机(Task 4 Step 4.2)、A2 三类错误策略(同)、A3 收窄异常种类(同,只 catch 三种)、A4 日志分类(warn 带 parser 名/typeCode/原因)、A5 配置开关(Task 2)、A6 合成帧测试(Task 3 三场景 + 严格回退)、A7 下游兼容(不改 Raw record,`findBlock` 类型匹配不受影响,plan 中已说明)。 - [x] **Placeholder scan**: 无 TBD / TODO / "实现上面的" 等占位;每个 Step 含实际代码 or 命令。 - [x] **Type consistency**: `lenientBlockFailure` 在 Properties / BodyParser 字段 / setter / 测试中拼写一致。`InfoBlockType.RAW` 枚举值依赖当前存在(已核对 InfoBlock.Raw record)。 - [x] **测试命名一致**:`Gb32960BodyParserIsolationTest` 在 Task 3 创建、Task 4/7 引用一致。 - [x] **未引入新枚举/proto 变更**:InfoBlock.Raw 沿用现有 `(int typeCode, InfoBlockType type, byte[] bytes)` 构造。