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

20 KiB
Raw Permalink Blame History

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.firmwareVersionTestEnrichDevicesJSONOutput + 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_ExitsOnChannelCloseCleanupTask 被呼叫 1 次 == goroutine 退出 取代直接觀察 runtime.NumGoroutine() 的合理替代方案

修復統計8/9 直接修了、1 個S-4backend 選不修並附理由。Reviewer 同意。

Major 1 修法品質

Before / After diffconcept

// 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/scanScanDevices走相同 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 casebridge.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_kl720FirmwareVer 若為 "KDP" 會被 rule 2 判 legacy、SupportedUpgradeChip("KL720") = true → CanUpgrade=true。流程閉合、product_id 在此 endpoint 沒有獨立角色。OK

S-2 backend 選擇不修評估

Backend 第 2 輪分析

「forward 同步消費完才 return、WS broadcast 是同步呼叫、無 race」

Reviewer 確認分析

逐行檢查 forwardProgressToWSfirmware_handler.go:173-186

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 進 channelfirmware/service.gorunUpgrade 終態先 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 提的問題的根因是「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//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 對空格 / 特殊字元的處理會自動 escapeUSB BOOTUSB_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

- 衍生欄位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 必做)

  • R-A15 軸 + 測試軸全跑過、每軸實質判斷 ≥ 20 字見上方「5 軸評估」表)
  • R-A2第 1 輪 issue 修法逐項表 + Major 1 修法品質表(含驗證點 + frontend 影響評估、2 張 cross-doc 表完整
  • R-A3本輪 Critical / Major / Minor 都 0、Suggestion 3 個附 line number + 評估
  • R-A4寫明優點 ≥ 1 條(實際 4 條)
  • R-A5明示「本輪無不確定項」
  • 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 階段)。