Files
lingniu-vehicle-ingest/docs/superpowers/plans/2026-04-20-gb32960-body-parser-block-isolation.md
2026-07-01 10:56:33 +08:00

601 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
/**
* 报文解析行为配置。
*
* <p>默认 {@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}
* 继续解析。默认开启。
*
* <ul>
* <li>{@code true}默认parser 抛 DecodeException / BufferUnderflowException /
* IndexOutOfBoundsException 时,固定长度块按 fixedLen 截取 Raw 后 continue
* 变长块或剩余字节不足时,剩余全部兜成 Raw 后 break 循环。
* <li>{@code false}:保留旧行为,任意异常直接抛出 DecodeException 放弃整帧。
* </ul>
*/
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) <noreply@anthropic.com>
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} 单块异常隔离行为。
*
* <p>三个隔离场景:
* <ol>
* <li>固定长度块 parser 抛异常 → 失败块兜 Raw按 fixedLen 截取)+ 后续块继续解析
* <li>固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环
* <li>变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环
* </ol>
*
* <p>外加一个严格模式回退测试:{@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=-1parse 时消费若干字节后抛。模拟 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 bodyparser 爆)
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=0offset 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 前进 fixedLencontinue 循环继续解析后续块
- 变长块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) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: 把配置注入 BodyParserAutoConfiguration 连线)
**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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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-kafka` 等)不受影响(无改动应自动通过)
若有红,停下来诊断。
---
## 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) <noreply@anthropic.com>
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)` 构造。