# 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=-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) <noreply@anthropic.com>
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) <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)` 构造。
