visionA/local-tool/.autoflow/05-implementation/review/m9-3-api-handler-ws-review-round2.md
jim800121chen 5e281ed449 feat(local-tool): M9-3 — firmware API handlers + WebSocket progress room
A 階段第三個 milestone、暴露 firmware service 給 Frontend / Wails control panel。

New / modified:
- server/internal/api/handlers/firmware_handler.go: 新檔 465 行(upgrade + active-tasks endpoint + WS broadcast goroutine)
- server/internal/api/handlers/firmware_handler_test.go: 新檔 938 行、26+ subtests
- server/internal/api/handlers/device_handler.go: +47 行(3 個 firmware 衍生欄位)
- server/internal/api/router.go: +23 行
- server/main.go: +10 行(wire firmware service + handler)

4 endpoints 全到位(對齊 TDD §3.1):
- GET /api/devices: 加 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion(firmwareVersion 沿用既有 DeviceInfo 鍵)
- POST /api/devices/scan: 同步走 enrichDevices
- POST /api/devices/:id/firmware/upgrade: 202 + {taskId}
- GET /api/firmware/active-tasks: HasActiveTask + GetActiveTaskInfo
- WebSocket room firmware:<deviceID> broadcast 對齊 §4.2

關鍵設計:
- 3 層 interface(firmwareBroadcaster / firmwareService / deviceLookupSource)+ DeviceManagerAdapter 解 import cycle
- bundledVersion cache(只 cache success、避免 thundering herd / poison)
- isLegacyFirmware 對齊 bridge.py 規則(legacy_exact set + KDP1.x prefix + KDP2-9 forward-compat)+ parity 真值表測試
- 5 個錯誤碼齊全(DEVICE_NOT_FOUND / FW_UNSUPPORTED_CHIP / FW_DEVICE_BUSY / FW_UPGRADE_FAILED / FW_UPGRADE_BRICK_RISK)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion(全部 backend 不需處理、純評估)
- Major 1(JSON 雙鍵衝突 firmwareVer vs firmwareVersion)方案 A 完全到位、3 個 test 鎖定 regression

TDD 同步:firmware-management.md §3.1 line 131 firmwareVer → firmwareVersion 對齊實作。

測試:go test ./... -race -count=1 全綠(handlers 2.489s / api 3.522s / ws 4.623s / device 1.931s / firmware 2.695s / driver/kneron 5.583s / model 5.022s)

SIGTERM main.go 整合留 M9-4.5(與 Wails OnBeforeClose 一起做)。

下一步:M9-4 Frontend Devices 頁 FW badge + 升級 modal + i18n(1.5 人天)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:05:42 +08:00

285 lines
20 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.

# M9-3 Reviewer Round 2 — API handler + WebSocket第 2 輪驗證)
- **審查日期**2026-05-25
- **被審任務**M9-3 第 2 輪修改(吸收第 1 輪 1 Major + 3 Minor + 5 Suggestion
- **第 1 輪報告**`.autoflow/05-implementation/review/m9-3-api-handler-ws-review.md`
- **本輪範圍**delta 驗證(不重審第 1 輪)
## TL;DR
**通過。** Major 1雙 JSON 鍵)採方案 A 修法乾淨、`firmwareVer` 鍵已從 derived struct 移除、JSON 輸出單一 SoT 是 `DeviceInfo.firmwareVersion``TestEnrichDevicesJSONOutput` + `TestEnrichDevicesJSONOutput_NilFwHandler` 兩個 test 鎖死不會再 regression。Minor 1-3 修法到位ctx godoc + cache 不污染 + JSON 結構斷言。Suggestion S-1 / S-3 / S-5 都修了S-2 / S-4 backend 選擇不修分析正確、同意。**第 2 輪無新發現 Critical / Major / Minor**、只有 2 個極小 Suggestion不阻擋。**不阻擋 M9-4 frontend 開發**、**不需 backend 第 3 輪**。
## 第 1 輪 issue 修改驗證(逐項)
| Issue | 第 1 輪 | 第 2 輪修法摘要 | 驗證結果 |
|-------|--------|---------------|---------|
| Major 1 — JSON 雙鍵衝突 | derived `firmwareVer` 與 DeviceInfo `firmwareVersion` 同時輸出 | **方案 A**:刪 derived 的 `FirmwareVer` 欄位、frontend 直讀 `DeviceInfo.firmwareVersion` | ✅ 完全到位見下方「Major 1 修法品質」) |
| Minor 1 — ctx.Background() 設計意圖 | 註解不夠清楚未來讀 code 會誤判為 bug | `firmware_handler.go:136-148` 補完整三段註解(為何不用 request ctx / service 內部 timeout / GracefulShutdown 路徑) | ✅ godoc 清楚解釋 |
| Minor 2 — bundledVersion cache poison | 失敗(檔不存在 / 讀錯 / 空檔)也 cache "unknown" 永遠不會 retry | 改成「只 cache success」、3 條失敗路徑os.Open / io.ReadAll / TrimSpace=="" )皆 return "unknown" **不**寫 cache | ✅ 邏輯清楚、`TestBundledVersionFor_CacheMissDoesNotPoisonRetry` + `TestBundledVersionFor_EmptyFileNotCached` 驗證 |
| Minor 3 — Test error 斷言過依賴 error message string | 3 處 case 用 `strings.Contains(error message)` | 改用 JSON 結構斷言 `resp.Error.Code` | ✅ DeviceNotFoundL310-326/ UnsupportedChipL347-357/ ServiceErrorsL391-402全 3 處改完 |
| Suggestion S-1 — isLegacyFirmware 對齊 bridge.py | 沒 cross-test 防 drift | 重寫規則(顯式列舉 + KDP1.x prefix + KDP2-9 forward-compat+ `TestIsLegacyFirmware_BridgeParity` 21 個 case | ✅見下方「S-1 規則重寫評估」) |
| Suggestion S-2 — forward done sleep grace | WS broadcast 完成 vs CleanupTask race | backend 分析 broadcast 同步、無 race、不修 | ✅ 分析正確見下方「S-2 backend 不修評估」) |
| Suggestion S-3 — unknown error log | default branch 沒 log | `classifyServiceError` default branch 加 `log.Printf("[firmware_handler] classifyServiceError: unknown error type %T: %v", err, err)` | ✅ 含 type + value、診斷友善 |
| Suggestion S-4 — firmwareFeatureEnabled flag | nil fwHandler 與 disabled feature 無法區分 | YAGNI、留 follow-up | ✅ 同意見下方「S-4 backend 不修評估」) |
| Suggestion S-5 — goroutine leak 驗證 | 沒測 forward goroutine 退出 | `TestForwardGoroutine_ExitsOnChannelClose``CleanupTask` 被呼叫 1 次 == goroutine 退出 | ✅ 取代直接觀察 `runtime.NumGoroutine()` 的合理替代方案 |
**修復統計**8/9 直接修了、1 個S-4backend 選不修並附理由。Reviewer 同意。
## Major 1 修法品質
### Before / After diffconcept
```go
// Before第 1 輪):
type FirmwareDerivedFields struct {
FirmwareVer string `json:"firmwareVer"` // ← 與 DeviceInfo.firmwareVersion 衝突
FirmwareIsLegacy bool `json:"firmwareIsLegacy"`
FirmwareCanUpgrade bool `json:"firmwareCanUpgrade"`
BundledFirmwareVersion string `json:"bundledFirmwareVersion"`
}
// After第 2 輪):
type FirmwareDerivedFields struct {
FirmwareIsLegacy bool `json:"firmwareIsLegacy"`
FirmwareCanUpgrade bool `json:"firmwareCanUpgrade"`
BundledFirmwareVersion string `json:"bundledFirmwareVersion"`
}
// → device list response 只有一個 firmware 字串鍵 = firmwareVersion來自 DeviceInfo
```
### 驗證點
| 驗證項 | 狀態 |
|--------|------|
| `firmware_handler.go:249-253` FirmwareDerivedFields struct 真的只剩 3 欄位 | ✅ |
| `DeriveFirmwareFields` return 不再含 firmwareVer | ✅ `firmware_handler.go:267-278` 確認只 set 3 欄位 |
| `device_handler.go:64-67` deviceWithFirmware 結構未變、但 embed 的 FirmwareDerivedFields 已縮減 | ✅ |
| `enrichDevices` nil fwHandler 分支 fallback 也不洩漏 firmwareVer | ✅ `device_handler.go:82-84` 只 set BundledFirmwareVersion |
| `POST /api/devices/scan`ScanDevices走相同 enrichDevices、一致性 | ✅ `device_handler.go:91-104` |
| `TestEnrichDevicesJSONOutput` JSON marshal 驗 `firmwareVersion:"KDP"` + 否定 `firmwareVer` | ✅ `firmware_handler_test.go:650-697` |
| `TestEnrichDevicesJSONOutput_NilFwHandler` fallback 路徑也驗雙鍵不出現 | ✅ `firmware_handler_test.go:701-726` |
| `TestDeriveFirmwareFields_NoFirmwareVerKey` 直接驗 FirmwareDerivedFields JSON 不含 firmwareVer | ✅ `firmware_handler_test.go:619-640` |
| `TestDeriveFirmwareFields_EmptyFirmwareDir` 補一個 grep firmwareVer 否定斷言 | ✅ `firmware_handler_test.go:609-613` |
| 註解 `device_handler.go:60-63` + `firmware_handler.go:243-248` 留下「為何不再有 firmwareVer」決策足跡 | ✅ 未來 reader 知道是刻意設計 |
### Frontend 影響評估
- M9-4 frontend 尚未上線消費此 endpoint → 0 regression 風險。
- Frontend 之後實作時直接用 `device.firmwareVersion`既有命名、TypeScript 既有 type 應已含此欄位)+ 3 個衍生 boolean 欄位、API contract 乾淨。
- TDD §3.1 line 131 原本寫 `firmwareVer`、與實作 drift。建議 backend 在第 2 輪後**同步更新 TDD 文件**(把 `firmwareVer` 改為 `firmwareVersion`、註明改動原因)。此非 Critical、可由 architect 在 M9-4 / M9-5 任一時機追加。
### Round 1 Major 1 升級理由 vs Round 2 修法
第 1 輪報告L112-115的升級理由是「屬 API contract 對外可見的 schema bug、影響 M9-4」。Round 2 採方案 A 的修法**完全消除**這個問題:
- ✅ API contract 對外只有單一 firmware 字串鍵
- ✅ M9-4 不需要二選一決策(直用既有 `firmwareVersion`
- ✅ 沒破壞既有 `/api/devices` APIDeviceInfo.FirmwareVer JSON tag 維持 `firmwareVersion`
- ✅ 新增 3 個衍生欄位isLegacy / canUpgrade / bundledFirmwareVersion是純加法、不衝突
## S-1isLegacyFirmware規則重寫評估
### 規則邏輯(`firmware_handler.go:375-406`
```
1. fw == "" → falseGo 端 list 場景保守、與 bridge.py 差異已 godoc
2. fw in {KDP, KDP1, USB BOOT, USB BOOT LOADER, LOADER, BOOTLOADER} → true
3. HasPrefix "KDP1." or "KDP1 " → true
4. HasPrefix "KDP2".."KDP9" → falseforward-compat 明示放行)
5. default → false未知字串保守
```
### TestIsLegacyFirmware_BridgeParity 真值表評估
| 群組 | case 數 | 評估 |
|------|--------|------|
| KDP1 legacy exact | 7KDP / KDP1 / USB BOOT / USB Boot / USB BOOT LOADER / LOADER / BOOTLOADER| ✅ case insensitive 也測 |
| KDP1.x 變體 | 3KDP1.0 / KDP1.5 / KDP1 alpha| ✅ 覆蓋 prefix space 與 dot 兩種 |
| KDP2-9 forward-compat | 6KDP2 / KDP2.0 / KDP2-v2.2.0 / KDP3 / KDP3.1 / KDP9| ✅ 含 plain prefix + version suffix |
| 未知字串 → 保守 | 3NEF / K3 / v2.2.0| ✅ |
| 空字串 Go 特例 | 1""| ✅ 含 godoc 註明 Go vs bridge.py 差異 |
| **合計** | **20** | ✅ |
### 「Go vs bridge.py 空字串差異」評估
第 1 輪 Suggestion 提的「跨端 drift 防護」核心目的是「將來 bridge.py 字串變動時、Go 端能立刻 fail test 提醒同步」。本輪實作的差異設計:
- bridge.py空字串 = legacy因為 USB Boot state 在 connect / upgrade 流程下不回 firmware 字串)
- Go list endpoint空字串 = 非 legacy因為 list 場景看到空字串 = device 還沒 connect 過、不該顯示「升級按鈕」)
**這個差異是正確的設計**(不是 bug原因
1. **語意分層**bridge.py 的 `_fw_classify_legacy` 跑在 connect / upgrade 上下文、空字串確實代表「device 在 USB Boot 等 loader stage」、應視為 legacyGo list endpoint 跑在「user 看 device 清單」上下文、空字串通常代表「driver 還沒讀過 firmware 字串」、不該誤導 user 點升級。
2. **不會造成功能漏洞**:真正會升級的 legacy device、一定是先 connect 過、`d.FirmwareVer` 會是 "KDP" 等非空字串、走 rule 2 / 3 判定為 legacy空字串只發生在 device 剛 detect 但未 connect 的中間狀態。
3. **godoc 已明確標記**`firmware_handler.go:382-385` + test L734-735→ 未來 reader 不會誤認為 bug、也不會被 test 誤導去「修齊」。
**Reviewer 結論**差異設計合理、godoc 完整、test 鎖定 invariant。**沒問題**。
### S-1 邊角顧慮(極小、列為 Suggestion
- **潛在 edge case**bridge.py 真值表也含「product_id == 0x0200 → legacy」一條KL720 USB Boot product ID。Go 端 test L737-738 明示「不檢查 product_id」、理由是 KL720 KDP legacy 由 chip type + upgrade 流程處理。**確認此設計:** Go list 場景下 KL720 device 的 `Type` 字串會是 `kneron_kl720``FirmwareVer` 若為 `"KDP"` 會被 rule 2 判 legacy、`SupportedUpgradeChip("KL720")` = true → CanUpgrade=true。流程閉合、product_id 在此 endpoint 沒有獨立角色。**OK**。
## S-2 backend 選擇不修評估
### Backend 第 2 輪分析
「forward 同步消費完才 return、WS broadcast 是同步呼叫、無 race」
### Reviewer 確認分析
逐行檢查 `forwardProgressToWS``firmware_handler.go:173-186`
```go
func (h *FirmwareHandler) forwardProgressToWS(deviceID string, progressCh <-chan firmware.FirmwareProgress) {
room := "firmware:" + deviceID
for ev := range progressCh {
h.wsHub.BroadcastToRoom(room, firmwareProgressMessage{...}) // ← 同步呼叫
}
h.svc.CleanupTask(deviceID) // ← for-range 退出後才呼叫
}
```
**Race 分析**
1. Service close progressCh 時、最後一個 event 已經先 send 進 channel`firmware/service.go``runUpgrade` 終態先 send done event → 再 close
2. forward goroutine for-range 會把 channel 內所有剩餘 event 全部消耗完才退出
3. `BroadcastToRoom` 是**同步**呼叫ws.Hub 的實作)→ 該方法 return 才表示已把 message hand off 給 ws connections非同步 deliver 但同步 enqueue
4. for-range 退出 → 表示「最後一個 event 的 enqueue 已完成」→ 此時呼 `CleanupTask` 移除 tracker entry
**race window 是否存在?**
| 場景 | 結果 |
|------|------|
| WS connections 還沒收到最後的 done event、但 `BroadcastToRoom` 已 return | 是 ws.Hub 內部的「enqueue 完成 vs network deliver 完成」 lag、與 CleanupTask 無關 |
| client 收到 done event 後立刻 GET /api/firmware/active-tasks | 此時 CleanupTask 已執行、看不到 task | **這才是 S-2 提的問題** |
**但是!** S-2 提的問題的根因是「client 用 GET /active-tasks 來確認 task 結束」、而**正確的 UX flow** 應該是「client 收到 done event 後直接信任、不再 poll /active-tasks」。`/active-tasks` 的目的是 graceful shutdown 偵測TDD §8.6.2、Wails OnBeforeClose、不是 task 結束確認。
**所以 backend 的分析是對的**:當前架構下不需要 sleep grace。如果 M9-4 frontend 設計時真的撞到 UX flick 問題modal 還在「完成」動畫、就被 /active-tasks 告知 hasActive=false那是 frontend 端的「等動畫播完才 refresh active-tasks」職責、不是 backend。
**Reviewer 同意 backend 不修 S-2**
## S-4 backend 選擇不修評估
### Backend 第 2 輪分析
YAGNI、留 follow-up。
### Reviewer 確認
S-4 提的場景:**fwHandler=nilfeature disabled / 沒帶 firmware bundlevs fwHandler 存在但 device 不可升**、前端無法區分。
**現實場景檢查**
- Production build一定有 firmware bundlefirmware/<chip>/VERSION 包進 release→ fwHandler 一定非 nil
- Dev mode可能 fwHandler 是 nil、但 dev 不會關心 user UX 細節
- Test mode明示 mock、不在乎
**結論**production 路徑 fwHandler 一定非 nil、`firmwareFeatureEnabled` flag 確實沒有 immediate value。等真有「shipping product 想刻意關掉 firmware feature」場景如 OEM custom build再加。**YAGNI 同意**。
**Reviewer 同意 backend 不修 S-4**
## 第 2 輪新發現
### Critical
無。
### Major
無。
### Minor
無。
### Suggestion極小、不阻擋
#### S2-1 — `bundledVersionFor` 失敗不 cache 的 thundering herd 顧慮(極低風險)
**位置**`firmware_handler.go:288-320`
**情境**production 環境若有 N 個 user 同時 GET /api/devices、每個都 call enrichDevices → 對所有 device chip 都 call bundledVersionFor → 若 VERSION 檔在那個時間點剛好不存在不合理、production 應該一定有、N 個請求都會走 `os.Open` + `io.ReadAll`、不命中 cache。
**評估**
- production VERSION 檔不存在 = bug、屬於部署錯誤、不該設計 cache 容忍
- dev / CI first build 場景下「沒 VERSION 檔」是 transient、第一次 ready 後立刻成功 cache、不會持續打 disk
- disk stat / read 64 bytes 的 cost ~µs 級、即使一秒 100 個請求同時打、cost 仍 negligible
- 加 negative cache如 cache "unknown" 30s TTL會帶來時序複雜度需要 timestamp、需要過期判斷、value 不明顯
**Reviewer 結論****不阻擋、不修**。當前設計(只 cache success的可預測性 > 加 TTL 的最佳化、是正確的取捨。如果未來 production 真的觀察到 disk I/O 熱點再加 TTL。
#### S2-2 — TestIsLegacyFirmware_BridgeParity test 名稱命名
**位置**`firmware_handler_test.go:770-778`
**現況**`t.Run(tc.fw+"_"+tc.note, ...)` 用 firmware 字串 + note 拼出 subtest name。Go test name 對空格 / 特殊字元的處理會自動 escape`USB BOOT``USB_BOOT`)、不會 break、但 test 輸出可讀性稍差(一行 subtest 名稱很長)。
**評估**:純美學、不影響功能 / 覆蓋率。
**建議**(純可選):將來若想優化、可改為 `t.Run("case_NN_"+shortLabel, ...)`、或拆 cases 為多個 named t.Run 群組KDP1Legacy / KDP1xVariant / KDP2to9 / Unknown / Empty。M9-3 階段不需要。
#### S2-3 — TestForwardGoroutine_ExitsOnChannelClose 的「goroutine 沒 leak」是間接驗證
**位置**`firmware_handler_test.go:850-882`
**現況**:透過 `cleanupCalls == 1` 證明 forward goroutine 退出。**這個證明是充分的**goroutine 退出後才呼 CleanupTask、且只呼一次
**評估**:第 1 輪 S-5 建議「`runtime.NumGoroutine()` snapshot 對比」是更直接、但本實作的「觀察副作用」也達成相同目的、且不依賴 runtime 內部行為(有時 NumGoroutine 不穩定、會被 test framework / GC goroutine 干擾)。
**Reviewer 結論**:當前實作策略**比建議的更穩定**。✅ 不修。
## TDD 文件 follow-up
`docs/autoflow/04-architecture/v2/firmware-management.md` §3.1 line 131 原本寫 `firmwareVer`衍生欄位之一。Backend 第 2 輪刪掉這個欄位、改用既有 `firmwareVersion`。**建議在 M9-4 或 M9-5 任一時機**請 architect 同步更新 TDD
```diff
- 衍生欄位firmwareVer / firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion
+ 衍生欄位firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion
+ 注意firmware 字串值用 DeviceInfo 既有 `firmwareVersion` 欄位、不重複輸出)
```
非 blocker、不影響 M9-4 frontend 開發frontend 用實際 API contract 而非 TDD 文字)。
## 5 軸評估Round 2 delta
| 軸 | Round 1 結果 | Round 2 變化 |
|----|-------------|-------------|
| Correctness | ✅ Pass | ✅ Pass — 修法不引入新 edge case、isLegacy 規則重寫覆蓋齊全、bundledVersion 失敗不 cache 邏輯正確 |
| Readability | ✅ Pass | ✅ Pass — godoc 改善ctx.Background / isLegacy / FirmwareDerivedFields 都有「為何這樣寫」決策足跡)、可讀性比 Round 1 更高 |
| Architecture | ✅ Pass | ✅ Pass — 沒動結構、只調整 struct 欄位與 helper 內部邏輯 |
| Security | ✅ Pass | ✅ Pass — 沒新增 endpoint / 沒動 auth、無新攻擊面 |
| Performance | ✅ Pass | ✅ Pass — bundledVersion 失敗不 cache 的 I/O 顧慮已評估為可接受(見 S2-1 |
| Test | ✅ Pass | ✅ Pass — 新增 6 個 testRound 1 26 個 → Round 2 32+ 個、Major 1 + S-1 + S-5 + M-3 + M-4 都有專屬覆蓋 |
## 結論
| 維度 | 狀態 |
|------|------|
| 第 1 輪 issue 處理 | ✅ 8/9 修了、1 個S-4合理 defer |
| 第 2 輪新發現 | 0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion純評估、不阻擋 |
| 是否阻擋 M9-4 | ❌ **不阻擋** — frontend 可以開工 |
| 是否需 backend 第 3 輪 | ❌ **不需要** — 當前狀態已可進 M9-4 |
| 是否升 security agent | ❌ 不需要(與 Round 1 結論同) |
**整體:✅ 通過。**
### 優點(明確讚美)
1. **Major 1 採方案 A 修法最乾淨**:不破壞既有 `firmwareVersion` API contract、derived struct 縮減 1 欄、frontend 不需學新 schema、test 同時補了「在 enrichDevices JSON 輸出層」與「在 FirmwareDerivedFields 自身層」兩層 guard、未來想 regression 也辦不到。
2. **isLegacyFirmware 重寫質量**:規則從第 1 輪的「KDP 字串簡單比對」升級為「顯式列舉 + KDP1.x prefix + KDP2-9 forward-compat 放行 + default 保守」、Go vs bridge.py 差異點(空字串)有 godoc 註記、20-case 真值表鎖定 invariant。明顯有 cross-端思考python ↔ go drift 防護、不只是「pass test 就好」的工程心態。
3. **bundledVersion cache poison 修法**3 條失敗路徑os.Open / io.ReadAll / TrimSpace == "")全部用「不寫 cache」處理、test 兩種 fail mode無檔 + 空檔)都覆蓋、未來 reader 一看就懂為何分支多。比起加 TTL 的方案、可預測性更高、value/complexity 比優異。
4. **godoc 「為何這樣寫」的決策足跡**ctx.Background 三段註解、Major 1 修改決策、isLegacy 跨端差異說明 — 都不只是「描述 what」而是「解釋 why」。M9-5 / M9-11 future maintainer 不會誤改。
## Verification 自評
### A 層(每個 review 必做)
- [x] R-A15 軸 + 測試軸全跑過、每軸實質判斷 ≥ 20 字見上方「5 軸評估」表)
- [x] R-A2第 1 輪 issue 修法逐項表 + Major 1 修法品質表(含驗證點 + frontend 影響評估、2 張 cross-doc 表完整
- [x] R-A3本輪 Critical / Major / Minor 都 0、Suggestion 3 個附 line number + 評估
- [x] R-A4寫明優點 ≥ 1 條(實際 4 條)
- [x] R-A5明示「本輪無不確定項」
- [x] R-A6§12.2 通用 6 條no silent failurescache miss 明確 return + 不 cache/ no dead code / no hardcoded secrets / no unsafe HTML/SQL / doc 同步TDD §3.1 follow-up 已列出、待 architect 同步)/ 被審 commit clean屬被審 agent 責任、未深查)
### B 層milestone
- 暫緩 B 層 verification。
- 原因:本輪是 Round 2 delta 驗證、檔案範圍與 Round 1 完全重疊3 個檔案)、不存在新增跨檔案不一致風險。
- 預計補做時機M9-5 三平台驗收時統一跑跨檔案 + 跨端 parity 驗證。
- 風險評估M9-4 frontend 是 schema 消費端、會自然 catch backend schema 不一致)。
### C 層PR / merge
- 不適用M9-3 內部 milestone review、非 PR 階段)。