分類: 📊 iDempiere ERP

  • Vibe Coding 協助物聯 AI 開發:從 RTSP 撞牆到 Telegram Bot 上線的完整實戰

    這是一篇關於 Vibe Coding(憑感覺邊聊邊寫)在物聯 AI 開發上的實戰紀錄。主題聽起來很酷,但實情是:我只是想解決一個很平凡的問題 —— 怎麼把家裡長照白板上的磁鐵打點,自動變成醫生回診時能看的資料。

    這篇會誠實記錄這一晚跟 AI 協作的過程、做對的決策、撞到的牆、以及學到的方法論。不是成功故事,中間我還沒跑出一份真正可用的資料。但我覺得過程本身比結果更值得寫下來。

    重點摘要

    • Vibe Coding 不是憑空想像,而是即時驗證 —— 每個假設都在對話中被一條 curl / 一行 ffmpeg 打臉或確認
    • Gemma 4 跟 Gemini 3 Flash Preview 在同一張模糊中文手寫白板上差距巨大:前者一直幻覺亂編項目,後者誠實回 null
    • Schema-constrained Prompt + 信心度分級是物聯 AI 應用的關鍵 —— 寧缺勿錯,比準度更重要
    • 最後撞到的不是模型牆,是光學物理牆 —— RTSP 1080p 遠距離下磁鐵只有 3~5 像素,任何 VLM 都無解
    • 與 AI 協作最大的價值是迭代速度:從想法 → curl 驗證 → 決策 → 下一步,一晚走完通常要一週的探索

    什麼是 Vibe Coding?為什麼它適合物聯 AI 開發

    Vibe Coding 是一種開發風格,核心精神是:不先寫規格、不先畫架構,先跟 AI 對話,在對話中讓問題結構自己浮現。聽起來很鬆散,但在物聯 AI 開發(IoT + AI 結合)的情境下,這種作法反而比傳統瀑布式規劃更有效。

    原因很簡單:物聯 AI 專案有太多無法事先確定的變數 —— 攝影機實際畫質、現場光線、模型對中文手寫的容忍度、API 認證流程的細節、現場使用者的真實習慣。這些東西你寫再多規格也寫不清,只能邊試邊改。

    傳統作法是先買硬體、接好線、寫一堆程式、跑起來才發現 OCR 讀不到。Vibe Coding 作法是:在對話中模擬整條管線,每一步都先驗證再決定下一步。成本極低,迭代極快。

    實戰情境:家母的長照交班白板

    先講背景。家裡照顧阿嬤的居服員會在客廳牆上用一塊大白板做交班記錄:夜間活動、睡前狀況、昨夜睡眠、早/午/晚餐、活動、外出、陪伴狀況、排泄狀況、洗澡,共 12 個項目。每一項下面有幾個選項方格,居服員把一顆彩色磁鐵貼在正確的選項上。

    用意是交班:早班居服員進門就知道夜間情況,中班看早上發生什麼事,晚班接手時看到整天脈絡。白板是「人看的 UI」,極度務實。

    我這邊的需求是:這些資料要每月匯整,讓神經內科醫師看到阿嬤這個月的生活趨勢 —— 是不是睡眠惡化、食慾變差、活動量下降。這關係到藥物調整。原本我每天晚上八點親自去拍照記錄到 iDempiere,但我很清楚一件事:

    靠人不穩定。我不能保證自己每天都拍照,請妹妹拍她都不拍。任何依賴人的環節最終都會斷掉。

    所以目標很明確:零人工依賴的自動化管線。RTSP 攝影機 → 抽幀 → VLM 識別白板狀態 → 寫進 iDempiere。今晚就是要從零對話出這套設計。

    第一步:用 ffmpeg 抓一張給 AI 看

    對話一開始,AI 想直接讀 RTSP 流,但我的 Ubuntu 上沒裝 ffmpeg,只有 VLC,而且 VLC 一直被 satip 模組攔截。試了幾個旗標都失敗。最後決定直接 apt-get install ffmpeg,然後:

    ffmpeg -y -rtsp_transport tcp \
      -i "rtsp://:@192.168.0.53:554/stream1" \
      -frames:v 1 -update 1 -q:v 2 /tmp/tapo/whiteboard_hd.jpg

    一張 1920×1080 的 frame 落地。第一個教訓:TAPO 有 stream1(主碼流,1080p)跟 stream2(子碼流,較低畫質),白板 OCR 一定要用 stream1,子碼流字跡會糊到任何模型都讀不出來。

    把第一張 frame 丟給 AI 看,AI 發現:

    • 畫面是廣角客廳全景,白板只佔左後方約 25%
    • 白板有角度(側視),字有透視變形
    • 阿嬤正躺在沙發上睡 —— 這畫面本身就能當「夜間睡眠」的訊號來源
    • 時間戳在左上角,直接可當記錄時間

    這是 Vibe Coding 的第一個價值:「看到了才知道要問什麼」。事先想像不到的細節(阿嬤就在沙發上、一顆鏡頭可同時驅動多個欄位),第一張實拍 frame 全部揭露。

    Gemma 4 vs Gemini 3 Flash Preview:一張圖的殘酷對比

    我手上的 Gemini API Key 支援 Google AI Studio,我問 AI 能不能用我已經在跑的 gemma-4-26b-a4b-it(我本來拿它做 HN 文章摘要)。AI 坦白說:不確定 Gemma 4 這個 MoE 版本有沒有 vision 能力,最好的方式是直接寫 script 試。

    寫完測下去,兩個結論:

    1. Gemma 4 確實吃圖 —— 這是好消息
    2. 但它嚴重幻覺 —— 它把日期讀成「4月10日」(實際 4/14)、項目名自創「睡眠品質/活動量/食事/盥洗」(白板上根本沒這些)、備註整段編造「各位家屬提醒…」。所有項目都回「綠」,明顯是在套公版答案

    切換到 gemini-3-flash-preview(Gemini 3 系列的 flash preview 版本),同一張圖,結果判若兩人:

    項目Gemma 4Gemini 3 Flash Preview
    項目名自創 5 個通用項目讀出 13 個具體項目
    備註段落純幻覺讀出「為了照護過程順暢請大家配合…飲食攝入小於 500ml 請 Line 通知…」
    品質差距低解析度下直接編造雖有 OCR 誤差但是「在讀真的東西」

    Gemini 3 Flash Preview 讀出的備註段落跟我白板上實際寫的紅字幾乎一致 —— 它不是在猜,是真的看到了。雖然還有誤差(把「外出狀況」讀成「外服藥記」、把「排泄」讀成「排便」),但那些是視覺相似字誤差,不是幻覺。

    這是第二個教訓:「同一張圖丟不同模型」是物聯 AI 應用最該做的前測。同價位的模型在模糊中文手寫這種邊緣任務上差距可以是幾倍級的。不要以為「我手上這把就夠用」。

    iDempiere REST API:從文件陷阱到 10 分鐘跑通

    OCR 能讀不代表能寫。寫入端是我自己建的 iDempiere,一張叫 Z_momSystem 的表。AI 不知道 schema,我也懶得翻 UI,直接讓它打 REST API 取。

    這裡踩第一個坑:iDempiere REST API 的認證流程文件跟實作不一致。我的 brain 筆記寫兩步驟(POST 拿暫時 token → PUT 帶 clientId 換 session token),但 AI 試了之後發現:直接在 POST 時一次帶齊所有 ID 也可以成功:

    POST /api/v1/auth/tokens
    {
      "userName": "",
      "password": "",
      "parameters": {
        "clientId": 1000000,
        "roleId": 1000000,
        "organizationId": 0,
        "warehouseId": 0,
        "language": "zh_TW"
      }
    }
    → 直接回 session token

    四個參數缺一不可,少 roleId 就回 Missing roleId parameter。這種小細節沒人會寫在文件裡,只能撞過才知道。AI 在對話中撞完就直接更新我的 brain file:

    [source: 長照 OCR 專案 2026/4/14] 驗證過 POST 一次帶齊 clientId/roleId/organizationId/warehouseId 可直接拿到可用 token,不用走 PUT 兩步。

    這是 Vibe Coding 第三個價值:踩到的坑立刻回饋到未來。不寫記錄的話,下個專案又會踩一次同樣的坑。

    Schema-Constrained Prompt:讓模型不能亂編

    認證跑通後,AI 直接查 AD_Column + AD_Ref_List,把 Z_momSystem 的 40 個欄位跟 12 個清單型欄位的所有合法選項抓齊:

    欄位合法選項(id → 中文)
    NightActivity 夜間活動C=完成 / N=未完成 / S=抗拒
    BeforeSleepStatus 睡前狀況X=亢奮 / N=正常 / S=疲倦
    LastNightSleep 昨夜睡眠G=良好 / S=斷續 / N=差
    Breakfast/Lunch/DinnerN=正常 / 10000001=少 / 10000009=多 / 10000002=拒食
    ExcretionStatus 排泄狀況10000006=正常 / 10000007=便秘 / 10000008=稀

    拿到完整選項清單後,我跟 AI 共同寫出 Prompt v2,核心設計有四塊:

    1. 嚴格列出 12 個欄位名稱 + 每個欄位的合法 enum,告訴模型「只能從清單裡選,不要自由發揮」
    2. 明確定義磁鐵規則:磁鐵貼在選項上 = 選中;磁鐵在左側待命區 / 沒看到磁鐵 = 未填(null)
    3. 強制輸出信心度:high / medium / low,並規定「寧可標 low 也不要亂猜」
    4. 禁止事項明確化:不要輸出清單外的項目、不要用清單外的值、不要把空白當選中

    這個 Prompt 模式我叫它 Schema-Constrained Prompt:輸出 schema 先於輸入內容確定。模型的自由度被壓縮到只剩「讀」,不能「想」。對資料入庫型應用來說,這是我目前看過最能壓住幻覺的作法。

    信心度機制:為什麼寧缺勿錯比高準度更重要

    我跟 AI 討論一個核心問題:如果 OCR 讀錯一個欄位,後果是什麼?答案是 —— 醫生根據錯誤資料調藥

    這條紅線決定了整個系統的預設行為:

    • High confidence → 直接寫入 iDempiere
    • Medium confidence → 寫入但標記為待確認,月底回診前我掃一次
    • Low confidence → 不寫,保留原圖,等我手動補

    這樣最差情況是「資料不全」而不是「資料錯誤」。對臨床情境來說,null 永遠比錯值安全

    最後的物理牆:磁鐵只有 3 像素

    所有準備工作做完,Prompt v2 在手、schema 對齊、認證跑通。我說「跑」,ffmpeg 抓一張新的 frame、裁切、送 Gemini 3 Flash Preview。結果回來:

    {
      "date": "4月10日",
      "patient_name": "",
      "items": {
        "NightActivity": {"value": null, "confidence": "high"},
        "BeforeSleepStatus": {"value": null, "confidence": "high"},
        ... 12 個項目全部 null ...
      },
      "overall_notes": "白板所有項目的磁鐵均位於最左側待命區,表示尚未填寫狀態。"
    }

    問題是 —— 我明明告訴 AI,最上面兩項的磁鐵是貼在選項上的。模型卻全讀成 null。這代表什麼?

    我們一起算了一下畫素:在當前 TAPO 攝影機位置,1080p 畫面中白板只佔約 25%,每個選項方格大概 40×20 像素,磁鐵大約 3~5 像素寬。

    任何 VLM 都讀不出 3~5 像素的物體位置。不是模型問題,是物理極限。

    這是整晚最重要的一個教訓。我原本以為換到 Gemini 3 就能解決一切,但真正的瓶頸不在模型,在光學。你沒辦法用演算法放大不存在的資訊。

    有意思的是,這個結論不是我預設的,是對話中算出來的。如果我沒跟 AI 一起 debug、一起看 crop、一起數像素,我可能還在抱怨「模型不夠強」,然後浪費錢去升級 model tier。實際上答案是「把鏡頭移近到 1.2 公尺」或「把磁鐵換大到 3 公分以上」—— 兩個都是硬體側的改動。

    Vibe Coding 在物聯 AI 開發上的六個心得

    整晚下來,我想整理出一些可以帶到下個專案的方法論:

    1. 第一張真實 frame 永遠比規格重要

    物聯 AI 專案第一件事不是畫架構圖,是用 ffmpeg / curl 抓一張真實資料給 AI 看。看到之後你才會發現「原來畫面裡還有沙發」、「原來字有角度」、「原來磁鐵只有 3 像素」—— 這些事事先想不到。

    2. 同一任務丟多個模型前測

    不要假設「手上這把就夠」。Gemma 4 跟 Gemini 3 Flash Preview 在同一張圖上差距巨大,如果我沒前測直接開始寫 production code,下游全部要重做。前測成本只有一個 Python script。

    3. Schema 先於 Prompt

    Prompt 不應該從「我想知道什麼」開始寫,應該從「我資料庫裡有什麼欄位、合法值是什麼」開始寫。把 enum 硬塞進 prompt,模型就只能從那個清單選。幻覺空間瞬間縮到 0。

    4. 信心度是一等公民

    每個欄位都要輸出信心度,而且模型要被明確告知「寧缺勿錯」。信心度不是錦上添花,是讓系統可以在「自動化」跟「人工審核」之間無縫切換的基礎設施。

    5. 踩到的坑要立刻寫回知識庫

    iDempiere REST API 的認證細節、TAPO stream1/stream2 的差異、VLM 對 3 像素物體的物理極限 —— 這些東西踩過一次就要寫下來。否則半年後你或你的下一個 AI 助手會原地再踩一次。

    6. 永遠先問「這個瓶頸是模型還是物理」

    當 AI 應用做不出效果時,工程師的直覺是「換更大的模型」。但物聯 AI 應用有一半以上的瓶頸是光學 / 聲學 / 感測器物理,不是模型。在升級 model tier 之前,先算一下原始訊號的解析度夠不夠。

    一晚的進度清單:完成、撞牆、下一步

    分類項目狀態
    環境ffmpeg + venv + google-genai SDK
    模型選型gemini-3-flash-preview 確認為白板 OCR 唯一可用
    API 認證iDempiere REST auth flow + brain 更新
    SchemaZ_momSystem 40 欄位 + 12 清單欄位合法值
    Promptv2 schema-constrained + 信心度
    實拍 OCR12 項全 null(物理極限)
    下一步鏡頭移近至白板前 ~1.2m 或把磁鐵換大🔜

    尾聲:AI 協作不是減少思考,是加速思考

    這一晚最大的收穫不是程式,是幾個原本需要好幾天才能驗證的判斷,在對話中幾個小時就走完了:

    • 「Gemma 4 能不能做 vision OCR」—— 10 分鐘驗證完
    • 「iDempiere REST API 認證怎麼打」—— 15 分鐘跑通
    • 「Z_momSystem schema 長怎樣」—— 5 分鐘 curl 查完
    • 「Prompt 用什麼結構壓住幻覺」—— 30 分鐘寫完 + 測完
    • 「OCR 失敗的根本原因是什麼」—— 從「模型不夠強」修正到「物理像素不夠」

    如果自己慢慢摸,這些決策可能得花一個禮拜,還未必會發現最後那個「像素物理牆」的結論。

    但 Vibe Coding 也不是萬靈丹。它的前提是你自己要會即時判斷 AI 給的答案對不對。如果我不知道「1080p 遠距離下一顆磁鐵只有幾像素」該怎麼算,我不會發現光學才是真兇,可能還會繼續跟 AI 討論要換什麼模型。AI 負責快速生成假設跟驗證工具,但關掉討論的那個人還是我

    下一步很清楚:明天白天我會去把攝影機搬到白板正對面 1.2 公尺處,如果還不夠,就把磁鐵換成 3 公分的大顆粒。然後再跑一次 Prompt v2,看看準度能衝到 8/12 還是 12/12。

    等驗證完整條管線跑通,我會再寫第二篇:零人工依賴的長照自動化 —— 從 OCR 到 iDempiere 到每月回診的完整管線。這一篇先停在「撞到物理牆」這裡,因為這個結論本身就夠值得記下來了。

    後續實戰:從撞到物理牆到 Telegram Bot 上線

    前一篇的結尾停在「撞到物理牆」,隔天白天我接著實戰。這一段記錄從 PTZ 鏡頭控制、模型賽馬、到最後關鍵 pivot 的完整過程。

    後續重點摘要

    • ONVIF PTZ 跑通:TAPO C200 用 RTSP 同帳密就能從 ONVIF 控制 pan/tilt,存 preset 在「看媽媽」跟「看白板」間自動切換
    • 但物理牆還在:C200 沒有 ONVIF zoom,白板在畫面裡的絕對大小被鏡頭距離鎖死
    • 模型賽馬揭露真相:Gemma 4 / Gemini 2.5 Flash 會套預設答案假裝在看;只有 Gemini 3 Flash Preview 跟 2.5 Pro 真的「在看」
    • 關鍵 pivot:放棄 RTSP 自動化,改走「手機拍照 + Telegram Bot」,把 human 設計進 loop 當 verifier
    • 最後上線:Telegram Bot + Gemini 3 Flash Preview + iDempiere REST API + systemd service,免費版每日 20 次額度剛好夠 production 用

    第一回合:ONVIF PTZ 成功,但沒有 zoom

    既然 TAPO C200 是 PTZ 型號(有 pan/tilt 馬達),自然想法就是「平常看媽媽、每天 20:00 轉過去拍白板、拍完轉回來」。代價幾乎為零:不改位置、不買硬體、不影響監護。

    TAPO 的本地 API 很頭痛,新版韌體要另外設定「Camera Account」。試了 pytapo 套件一直認證失敗。最後發現 ONVIF 標準協定可以直接用 RTSP 同帳密,port 2020:

    from onvif import ONVIFCamera
    cam = ONVIFCamera("192.168.0.53", 2020, "", "")
    ptz = cam.create_ptz_service()
    media = cam.create_media_service()
    token = media.GetProfiles()[0].token
    
    # Save current position as preset
    req = ptz.create_type("SetPreset")
    req.ProfileToken = token
    req.PresetName = "mom"
    ptz.SetPreset(req)
    
    # Move to whiteboard preset
    req2 = ptz.create_type("GotoPreset")
    req2.ProfileToken = token
    req2.PresetToken = "2"   # whiteboard preset token
    ptz.GotoPreset(req2)

    30 分鐘內建好兩個 preset:mom (pan=0.165, tilt=-0.714) 看沙發、whiteboard (pan=0.2, tilt=-0.3) 看白板。自動切換、抽幀、切回,全程 10 秒內。

    但查 PTZ 能力的時候發現一個殘酷事實:

    AbsoluteZoomPositionSpace: []

    C200 的數位 zoom 不對 ONVIF 開放。pan/tilt 能改變「看哪裡」,但不能改變「看多近」。白板在畫面裡的絕對大小還是被物理距離鎖死,磁鐵還是只有幾個像素。

    這是今天的第一個重要教訓:即使有 PTZ,沒有 zoom 的 PTZ 解決不了光學採樣率不足的問題。pan/tilt 的價值在於切換主題,不是放大細節。

    第二回合:四個模型的殘酷賽馬

    既然 PTZ 轉過去也只是切換主題、不會變清楚,我決定做一件更有意義的事:在同一張困難的 RTSP 照片上,把所有可能的模型跑一遍,看哪個真的「在看」。

    測試方法:請媽媽的居服員確認白板上 12 個項目的真實狀態(4 個 box 1、2 個 box 2、6 個 null),這是 ground truth。同一張 RTSP 照片丟給四個模型,計算準度。

    模型命中行為模式
    Gemini 2.5 Pro8/12 (67%)6/6 null 正確、off-by-one 錯誤,真的有在讀像素
    Gemini 3 Flash Preview6/12非常保守,看不清就全回 null(這在醫療情境反而安全)
    Gemini 2.5 Flash4/12預設 box 1,剛好對 4 個 box 1 項目,是幻覺而不是識別
    Gemma 4 (26B-A4B-IT)3~4/12兩次跑結果不一樣:第一次全 box 2、第二次全 box 1

    最有意義的發現:跑同一張圖兩次讓偽答案現形。Gemma 4 第一次答「10 項 box 2 + 1 項 box 1 + 1 項 null」,第二次答「12 項 box 1」。兩次完全不同,同一張靜態圖,同一個 prompt。這證明 Gemma 4 根本沒在看磁鐵,只是在套一個預設樣板。

    便宜模型「預設 box 1」這個偏誤特別危險,因為它剛好能騙過不注意的測試者:真實世界裡大部分項目本來就選 box 1(例如「完成/正常/穩定」通常是第一格),所以「全答 box 1」天然就有 30~50% 命中率。

    Gemini 2.5 Pro 的 8/12 表現是最高的,錯誤都是「off-by-one」(看到磁鐵、算錯格子),至少是在做真正的視覺識別。但 2.5 Pro 免費版不開放 vision,要付費才能用。

    Gemini 3 Flash Preview 的 6/12 看起來輸給 2.5 Pro,但它的錯誤類型完全不一樣:它的「錯」是把有磁鐵的項目讀成 null,不會產生錯誤的 value。這在醫療場景反而是最安全的行為 —— 寧缺勿錯永遠優於硬填。

    第三回合:投票沒用、upscale 沒用

    既然 Gemini 2.5 Pro 有 67% 準度,同一張圖跑 3 次取多數票應該能把 67% 拉高吧?

    結果投票後掉到 6/12。為什麼?因為 2.5 Pro 的錯誤不是隨機噪音,是系統性偏誤:某個項目 3 次都答同一個錯的答案。投票對系統性錯誤完全沒用,它只會把多數的錯誤鎖定成最終答案。

    接著試 pre-upscale:先用 ffmpeg 把白板區域裁出來、用 Lanczos 4 倍放大、加 unsharp,再丟 Gemini。直覺上放大應該有用,對吧?

    ffmpeg -i wb.jpg -vf "crop=720:820:480:30,scale=iw*4:ih*4:flags=lanczos,unsharp=7:7:1.5" wb_up.jpg

    結果:2.5 Pro 從 8/12 掉到 6/12,3 Flash Preview 從 6/12 掉到 2/12。放大反而讓準度下降。

    事後想通:Lanczos 是重採樣,不是超解析。它只是讓相鄰像素平滑插值,沒有增加資訊量。原本 3 像素的磁鐵放大成 12 像素,但那 12 像素是模糊的平均值,模型反而更難從中辨認形狀。

    要真的增加資訊量必須用 AI 超解析(Real-ESRGAN 這類),而不是幾何插值。但那會在機器上增加 PyTorch + 模型的負擔。與其走這條,不如直接面對問題:RTSP 這條路本身就有上限

    關鍵 Pivot:把 human 設計進 loop

    這是今天最重要的決策點。

    我原本堅持 RTSP 全自動,理由是「靠人不穩定,請妹妹拍她都不拍」。這個理由本身沒錯 —— 強制依賴他人的流程一定會斷。但我把「靠他人」跟「靠自己」混為一談了。我自己每天可以拍照,不穩定的只是「依賴別人」這一環

    更深層的領悟:為什麼執著於「100% 自動」?因為我下意識想把人完全排除。但對一個給醫生回診參考的月報系統來說,人的角色不該是全被排除,而應該是最後一道驗證。理由:

    • OCR 永遠有錯,醫療資料的錯誤比缺失代價更高
    • 家屬(我)本來就是最終看月報的人,有修正權限跟動機
    • 拍照的動作本身就是「我已經確認白板內容正確」的確認點
    • 沒有必要為了系統 purity 去承擔「誤導醫生」的風險

    這是整個專案最有價值的 design pattern 轉變 —— 從「zero-human automation」到「human-as-verifier」。具體分工:

    角色職責
    我(家屬)看白板、判斷、拍清楚的照片、事後在 iDempiere 修正 OCR 誤判
    Bot + Gemini 3把照片轉成結構化資料寫進 iDempiere、附上原檔
    iDempiere永久保存、提供 UI 讓月底回診時查閱

    最可怕的情境 —— OCR 幻覺亂寫進 DB 誤導醫生 —— 被「我本來就要檢查 iDempiere」這個 policy 擋住了。系統可以不完美,因為人在迴圈裡。

    Telegram Bot 架構

    決定走 human-in-the-loop 後,剩下的工程問題是「怎麼把手機拍的照片快速送到 server」。選項比較:

    方案優點缺點
    Telegram Bot零 app 安裝、即時回饋、從任何地方都能傳要建 bot token
    Syncthing 資料夾同步純本機、無雲端要裝 app + 手動移動檔案
    自架 HTTP 上傳頁不用 app要開 browser、每次登入

    Telegram 明顯最低摩擦,而且我本來就用 Telegram 傳照片 debug。一個 @BotFather 建 bot、5 分鐘拿到 token、寫一個 Python 長輪詢腳本就搞定。

    Bot 的最終能力:

    使用者動作Bot 行為
    📸 傳白板照片Gemini 3 Flash Preview OCR → 找今天紀錄 → update 或 create → 附加照片 → drain 文字佇列
    📝 傳 LINE 居服員回報文字[SHIFT|HH:MM] 前綴 → prepend 到今天紀錄的 Description 欄位
    🕑 今天還沒紀錄時傳文字存 pending 佇列,等照片來時自動塞入
    /pending, /clear, /id管理指令

    Shift 標記:用時段而不是時間點

    iDempiere 既有資料的 Description 欄位有一個我沒注意到的約定格式:

    [NIGHT|20:13]今天把東西往外丟
    [DAY|17:56]攻擊居服員

    格式是 [SHIFT|HH:MM]事件,新的在最上面,\n 分隔。照顧者三班制:

    • DAY(早班):07:00-17:59
    • NIGHT(晚班):18:00-23:59
    • GRAVEYARD(大夜):00:00-06:59

    Bot 根據當下時間自動產生正確的 shift 標記。這個設計好處是醫生看月報時能直接對應到「哪一班回報的事件」,這對失智照護尤其重要 —— 同一個病人,不同班次的表現可能完全不同(例如日落症候群)。

    Find-or-Create:同一天一筆紀錄

    一個設計決策:同一天傳多次照片要建新紀錄還是更新舊的?

    選項:

    • (a) 每次都 create 新的 → iDempiere 一天可能有多筆,報表聚合需要 GROUP BY 日期
    • (b) 同天 find-or-update → 資料乾淨,但要實作 OData filter 查當天紀錄
    • (c) 帶時序保留所有版本 → 最完整但最複雜

    選 (b)。理由是月底回診時醫生看的是「今天的狀態」,不需要看一天之內的修改歷史。實作核心:

    def idempiere_find_today(token):
        date_str = datetime.now().strftime("%Y-%m-%d")
        r = requests.get(
            f"{IDEMPIERE_URL}/models/z_momsystem",
            headers={"Authorization": f"Bearer {token}"},
            params={"$filter": f"DateDoc eq '{date_str}' and Name eq '{PATIENT_NAME}'", "$top": 1},
        )
        recs = r.json().get("records", [])
        return recs[0]["id"] if recs else None

    還有一個巧妙設計:OCR 回 null 的欄位不進 PUT payload。這樣早上拍一次只填前 6 項、晚上拍一次只改後 6 項,早上的資料不會被晚上的 null 洗掉。整個合併邏輯在 Python 端就搞定,iDempiere 端只接受「明確填入」的欄位。

    iDempiere 附件上傳的小坑

    iDempiere REST API 的附件端點是 POST /models/{table}/{id}/attachments,payload 是 {"name": "...", "data": "base64..."}。踩了一個坑:重複檔名會回 409

    解法:上傳時把當下的 HHMMSS 塞進檔名 suffix。同一張照片重複上傳也不會衝突:

    ts = datetime.now().strftime("%H%M%S")
    fn = f"{orig.stem}_up{ts}{orig.suffix}"

    Systemd Service 自動重啟

    最後收尾:bot 用 systemd service 跑,開機自動啟動 + 崩潰自動重啟。關鍵是設定 Restart=on-failureRestartSec=10

    [Unit]
    Description=Tapo Caregiver Telegram Bot
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=tom
    WorkingDirectory=/home/tom/tapo-caregiver
    ExecStart=/home/tom/tapo-caregiver/venv/bin/python telegram_bot.py
    Restart=on-failure
    RestartSec=10
    Environment=PYTHONUNBUFFERED=1
    
    [Install]
    WantedBy=multi-user.target

    成本:免費版剛好夠用

    這個專案我原本因為 debug 爆量升級了 Google AI Studio 付費版。但實際跑 production 的時候算了一下用量:

    • 晚上 8 點拍一次照片 = 1 次 Gemini 3 Flash Preview 呼叫
    • LINE 回報文字 = 0 次(純文字不進 OCR,只是 prepend 到 Description)
    • 一天總用量:1~2 次

    Gemini 3 Flash Preview 的免費 tier 是 每天 20 次GenerateRequestsPerDayPerProjectPerModel-FreeTier, limit: 20, model: gemini-3-flash),實際從先前的 429 錯誤訊息確認過。

    也就是說 完整上線後可以馬上取消付費,免費額度就夠日常跑。debug 階段的付費只是為了不被 quota 卡住開發速度。

    兩天下來的反思

    1. 物理限制不是模型問題,不要用演算法去試

    RTSP 畫面中磁鐵 3~5 像素這件事,不管換什麼模型、不管怎麼放大、不管怎麼 prompt,都解決不了。我浪費了兩輪嘗試才認清:先驗證訊號源的解析度是否足夠,再決定上面要疊什麼演算法

    2. 便宜模型會偽裝在看

    Gemma 4 和 Gemini 2.5 Flash 在困難任務上會套預設答案(全 box 1 或全 box 2),這種偽答剛好能騙過不注意的準度統計。同一張圖跑兩次是揭穿偽答最便宜的方法 —— 真的在看的模型答案應該高度一致,套公版的會變。

    3. 投票救不了系統性偏誤

    Gemini 2.5 Pro 跑 3 次投票準度從 8/12 掉到 6/12,因為它的錯誤是系統性的(3 次都答同一個錯的答案)。投票只對隨機噪音有用,對系統性偏誤沒用。想救系統性偏誤,要用不同模型交叉比對,而不是同一個模型多次跑。

    4. Human-in-the-loop 不是妥協,是正確設計

    我原本想做 100% 自動化是出於「系統純粹」的直覺。但對醫療情境來說,把人排除才是風險來源。正確問題不是「怎麼不靠人」而是「人在哪個環節最有價值」。答案是:人當最終 verifier,機器做勞力密集的 OCR + 結構化 + 儲存。

    5. 工具思維 vs 平台思維

    我原本想把 RTSP 攝影機變成一個「全知白板記錄系統」,這是平台思維,野心大但難。pivot 之後變成「Telegram bot 幫我打字少打一些」,這是工具思維,目標小但能跑。工具思維的上線速度 ≫ 平台思維的完美程度

    6. 讓 AI 自己跑模型賽馬

    過程中我讓 AI 自動跑一圈 Gemma 4 / 2.5 Flash / 2.5 Pro / 3 Flash Preview,把命中率、錯誤類型、各自的 observation 都列出來。我只要看那張表就能判斷用哪個。這種「讓 AI 幫你比較 AI」的 meta-loop 是 vibe coding 很爽的一點 —— 選型決策從「讀文件猜性能」變成「直接實測算分」。

    最終上線狀態

    組件狀態
    Telegram bot(@your_bot_name,白名單限制)✅ LIVE
    Gemini 3 Flash Preview OCR (schema-constrained + box index + layout map)
    iDempiere REST find-or-create + attachment upload
    Description shift tag (DAY/NIGHT/GRAVEYARD) 自動 prepend
    pending notes queue(文字先到、照片後到的情境)
    systemd service 開機自動啟動
    PTZ ONVIF 控制(暫時不用,因為 pivot 到手機路線)✅ 備用

    每日 SOP:

    1. 晚上 8 點我拍一張白板照片
    2. Telegram 分享給 @your_bot_name
    3. Bot 跑 OCR + 寫 iDempiere + 回覆結果(10~20 秒)
    4. 看到有讀錯的,打開 iDempiere 手動改
    5. 居服員白天在 LINE 的回報,我看到時直接貼到 bot,自動 prepend 到今天的 Description
    6. 月底回診時點開 iDempiere 給醫生看趨勢

    總結:這篇文章的「二階 vibe coding」

    這篇文章本身也是 vibe coding 的產物 —— 我叫 AI 把兩天的工作回溯成一篇技術文,自己只負責審核跟指正敏感資料。這種「AI 幫你跟 AI 協作的過程寫回顧」也算是一種 meta-loop:決策的當下有 AI 幫忙驗證,事後有 AI 幫忙紀錄。我負責方向跟價值判斷,剩下的雜活都給 AI。

    兩天下來最重要的一句話:「vibe coding 不是減少思考,是加速思考。」 AI 幫你快速驗證假設、生成原型、寫測試、跑實驗、甚至幫你寫反思文章。但你要知道該往哪個方向走、什麼時候該 pivot、什麼時候該停手。AI 是踏板車,你是駕駛。

  • Ionic 逆向重建 Flutter 完整踩坑紀錄:三套 APP、20+ 個坑、一套方法論

    重點摘要

    • 從 Ionic 1 / AngularJS + WordPress PHP 逆向重建三套 Flutter APP,歷時一週,踩了 20+ 個分類的坑
    • 最毒的方法論錯誤:審查時讀了原始碼,實作時卻只靠缺失清單——導致多輪審查永不收斂
    • PHP Broker API 的 Content-Type 是 text/html、數值欄位全是 String、token 欄位名稱每個子系統不同
    • WordPress Multisite 的 table prefix 陷阱:直接打 plugin 路徑走 main site(wp_),跟 WP Admin 看到的(wp_2_)不同
    • 逆向工程的核心原則:原始碼是唯一 ground truth,不是用戶的記憶,不是截圖,不是 AI 的推測

    這篇文章記錄了一個完整的逆向重建專案:將一套社區管理系統(三套 APP + WordPress 後端)從 Ionic 1 / AngularJS 遷移到 Flutter。不是新開發,是一比一逆向複製——原版有的每一個按鈕、每一個欄位規則、每一個邊角功能,新版都必須完全復現。

    三套 APP 分別是:住戶 APP(社區公告、包裹、費用等)、管理平板(出勤、包裹管理、公設租借、訪客登記等)、門禁保全 APP(簽到簽退、巡邏、QR 掃描)。它們共用同一個 WordPress PHP 後端。

    以下是按主題分類的完整踩坑紀錄和方法論總結。

    一、逆向重建最毒的方法論錯誤是什麼?

    逆向重建最毒的方法論錯誤是「審查讀原始碼、實作不讀」。這聽起來不可能,但在 AI 輔助開發中極其常見:

    1. 審查 Agent:從原版 HTML / JS 逐行讀 → 產出缺失清單 → 每項有行號出處 ✅
    2. 實作 Agent / 新 Session:拿到缺失描述(如「公告無窮捲軸缺失」)→ 直接寫 Flutter → 沒有讀原版邏輯
    3. 結果:修了 A,同頁的 B/C/D 仍缺(因為根本沒讀那頁的完整原始碼)
    4. 下輪審查:再從原版讀 → 再找到 B/C/D → 循環不斷

    我們經歷了 7 輪審查才讓管理平板的缺失清單收斂。前 3 輪每輪都有 10+ 個新發現,根本原因只有一個——方向反了。

    建立的強制規則

    • 拿到缺失 ID 時,必須先讀對應的原始碼(file:line),不得只靠缺失描述動手
    • 修某頁某功能時,如果手上沒有該頁完整 Source Inventory → 必須先做 Source Inventory
    • Review 方向必須是:原版 HTML → 原版 JS → 原版 PHP → 列清單 → 逐項對照新版。禁止從 Flutter 出發猜原版有什麼

    二、Source Inventory 協議:逆向工程的唯一正確開始方式

    Source Inventory 是在動手寫任何程式之前,先把原版的所有功能逐行列出來的過程。每一項都必須標注原始碼來源(檔案 + 行號),沒有行號的項目等於捏造的。

    - [ ] 登出按鈕
          來源:account.html:142 <button ng-click="logout()">
    
    - [ ] 棟別/樓層兩層下拉選單
          來源:visitor-form.html:55-67, controllers.js:9366-9398
    
    - [ ] 黑名單檢查(借用簽名後、API 呼叫前)
          來源:publicequip.js:2040-2052

    清單涵蓋四大類:所有 UI 元素(按鈕、欄位、badge、空狀態提示)、所有互動行為(點擊、滑動、下拉刷新)、所有 API 呼叫及欄位映射所有頁面入口/出口

    最容易漏的永遠是邊角功能:登出藏在 header component、空狀態提示在條件分支、黑名單檢查在簽名驗證之後。主流程之外的東西,AI 不會主動去找。

    三、PHP Broker API 的七大陷阱

    這套系統的 API 層是 WordPress PHP plugin,所有請求都走同一個 Broker endpoint。以下是我們踩過的每一個 API 相關的坑:

    3.1 Content-Type 是 text/html

    PHP Broker 的回應 Content-Type 是 text/html 而非 application/json。Dio(Flutter HTTP 套件)如果設了 ResponseType.json,會解析失敗。必須設 ResponseType.plain 再手動 jsonDecode

    3.2 數值欄位全是 String,除了偶爾不是

    大多數 JSON 數值欄位回傳的是字串("id":"1")。但 $wpdb->get_results() 對 int DB 欄位有時會回傳 PHP native int(JSON 裡沒有引號)。解法:所有 nullable String 欄位一律用 ?.toString(),避免 type 'int' is not a subtype of type 'String?'

    3.3 Token 欄位名稱每個子系統不同

    子系統 Token 放在 例外
    主 Broker(住戶/平板)data.cookie
    Guard Broker(門禁)data.tokenvalidate_authdata.cookie

    搞混了就會被判定為 anonymous user,API 回 "非有效角色,無法使用!",而且沒有任何認證失敗的明確錯誤碼

    3.4 returnCode 不統一

    "OK""0"(package 模組)、"1""ERROR""dup"——全是字串,不要做整數比較。每個模組的成功碼不同,必須逐一確認。

    3.5 Category / Method 命名混亂

    有的是 snake_case(get_ticket_by_member_id),有的是 camelCase(retrivePublicEquipTXN——對,retrive 是拼錯的),有的是完全不同的規則(createTableAccount)。必須從 PHP 程式碼抄出精確字串,不能猜。

    3.6 Response 結構不一致

    • mutual:回 {rows_1:[], rows_2:[]}(自己的 + 社區其他人的)
    • ticket:回 {data: {rows, total, totalPages}}(分頁結構)
    • package_light:returnCode 用 "0" 而非 "OK"
    • getTwCode:空表時回 data: ""(空字串),不是 []

    3.7 磁卡批次 API 是 flat array

    大部分 API 的 data 格式是 {data: {key: value}},但磁卡批次領取是平坦陣列 [{id, card_no, status}]。PHP 端用 is_array($data) 判斷。這意味著 Flutter HTTP client 的 data 參數不能寫死為 Map,必須改成 dynamic

    四、WordPress Multisite 的 Table Prefix 陷阱

    WordPress Multisite table prefix 陷阱是整個專案最令人崩潰的問題之一。直接打 plugin PHP 路徑(如 /wp-content/plugins/.../API_Broker.php)時,WP 識別為 main site (blog_id=1),使用 wp_ 前綴。但在 WP Admin 介面、phpMyAdmin 看到的是 wp_2_(子站表)。

    後果:seed 測試資料時插到 wp_2_ulifeplus_*,但 API 查的是 wp_ulifeplus_*——資料永遠看不到,而且不報錯,就是空陣列。

    另外,改網域必須改 5 個地方wp_options(siteurl + home)、wp_blogs(domain)、wp_site(domain)、wp-config.php(DOMAIN_CURRENT_SITE + WP_HOME + WP_SITEURL)。DOMAIN_CURRENT_SITE必須含 port,否則會無限 302 redirect。

    五、validate_role() 全面封鎖事件

    所有 28 個 PHP endpoint 都在進入主邏輯前呼叫 validate_role()。原版只接受 ulifeplus_api / ulifeplus_admin / administrator / ulifeplus_operator,不含住戶帳號 ulifeplus_subscriber

    原因:原版平板用的是專屬服務帳號(QR code 開通碼流程),不是住戶帳號直接登入。重建版讓住戶直接登入 → 所有 API 一律 ERROR

    修補方案需要雙層:(1) patch PHP 接受 ulifeplus_subscriber;(2) 給測試帳號加 ulifeplus_api role。而且 wp_capabilities 的序列化格式 a:N:{s:LEN:"role_name";b:1;} 的 LEN 必須精確——寫錯一個字元,整個 unserialize 就失效,用戶變成沒有任何 role。

    六、三套 APP 的進化軌跡:從混亂到方法論

    第一套:住戶 APP——摸清後端規則

    住戶 APP 是最先做的,主要踩了後端相關的坑:Content-Type 是 text/html、數值欄位全是 String、member_id vs WP user_id 混淆、validate_role 全面封鎖、WP Multisite table prefix。這些經驗讓我們對 PHP Broker 的行為有了完整的心智模型。

    第二套:管理平板——方法論崩潰與重建

    管理平板是最複雜的(10+ 模組),也是方法論崩潰的戰場。7 輪審查、每輪都有新發現、多個 Agent 交叉審查卻互相誤報(false positive)。最終逼迫我們建立了 Source Inventory 協議、四大卡點規則、和「審查必須從原版出發」的強制流程。

    核心教訓包括:

    • Grid → Card List 轉換會遺失 checkbox column 和 cellTemplate 等「免費」UI 行為
    • 條件式按鈕(enableShowClosed 等)的每個 ng-if 都是業務意圖,不能合併或省略
    • smart_disp.get_hidden_code() 遮名演算法需要完整移植,不能簡化
    • 日期範圍查詢必須含時間(00:00:00 / 23:59:59),否則 BETWEEN 漏掉當天
    • Feature flag 的預設值不一定是 false,必須讀完整條件判斷

    第三套:門禁 APP——方法論成熟

    到第三套門禁 APP 時,Source Inventory 協議已經內化。整個 APP 從原始碼分析到 21/21 測試通過只花了一天。但還是踩了新坑:

    • Guard Broker 的 token 欄位是 data.token(不是主 Broker 的 data.cookie
    • PHP class constant const data = "date" 從不生效(bareword array key 行為)
    • showDialog 的 BuildContext 跨 async 導致確認 dialog 靜默失敗
    • HTTP 部署下相機 API 被瀏覽器封鎖(Secure Context 限制)
    • Flutter Web 的 SPA 狀態在測試間污染(需 full HTTP reload)

    七、Flutter 遷移的技術坑清單

    症狀 修法
    flutter_secure_storage Web 失敗HTTPS 才能用 Web Crypto APIWeb 改用 SharedPreferences
    Platform.isAndroid Web crashdart:io 在 Web 不可用kIsWeb + defaultTargetPlatform
    CORS 重複 headerAccess-Control-Allow-Origin: *, *PHP 已有 CORS,不要加 .htaccess
    Dialog context stale確認後邏輯靜默跳過dialogCtx 不用外層 context
    Release build debugPrint 消失完全無錯誤訊息寫 SharedPreferences breadcrumb
    ConsumerWidget 無 initState需要 async 初始化改用 ConsumerStatefulWidget
    base-href 部署子路徑資源 404--base-href=/app/
    API_BASE_URL dart-defineWeb 連到 localhost--dart-define=API_BASE_URL=http://IP:PORT

    八、AI 輔助逆向工程的認知陷阱

    AI 輔助逆向工程的認知陷阱是這個專案最深層的教訓。AI 在讀原始碼方面能力極強,但在以下場景會系統性犯錯:

    1. 「我以為我知道這頁有什麼」——AI 會基於已讀的程式碼推測未讀的部分,經常猜錯
    2. 改名後誤報缺失——搜尋 scan_addr_code() 找不到,但 Flutter 用了 _scanAddressCode(),同樣邏輯換了名字就被誤報
    3. 函數名稱 ≠ 實際行為——updateUser() 實際上還做 log 寫入、狀態機轉換;getActiveItems() SQL 是 status != 'deleted' 不是 status = 'active'
    4. 「延後功能」不是藉口——AI 傾向把不確定的功能標記為「deferred」,但只要原版存在的就必須實作

    破解方式:建立 Source Inventory 清單,每項附行號。行號是可審計的(可以回去原始碼驗證),AI 的記憶不可審計。

    九、跨專案知識管理:Brain 檔案系統

    為了讓踩過的坑不只活在一個專案裡,我們建立了 Domain Brain 系統:每個技術領域一個 markdown 文件,記錄所有踩過的坑、修法、和來源。

    例如 legacy-code-rebuild.md 記錄了逆向工程的通用方法論;wordpress-broker.md 記錄了 WP Broker API 的所有特殊行為。每次 fix: commit 時,強制要求更新對應的 brain file。

    核心原則:Brain = 上次做的時候踩了什麼坑(經驗)Skill = 正確的做法是什麼(模式)。兩個都要讀。

    十、建議給想做逆向重建的團隊

    1. Source Inventory 先行——不管系統多簡單,先把所有功能列出來(附行號)。簡單的系統往往有最多隱性規則
    2. 後端先跑通——先用 curl 打通每一個 API endpoint,確認 request/response 格式,再寫 Flutter
    3. 每個模組獨立測試——不要等到所有模組做完才整合。我們的 21/21 unit + integration test 在開發過程中抓到了大量問題
    4. 記錄每一個坑——Brain 系統不是可選的,是必要的。第一套 APP 踩的坑如果沒記下來,第三套 APP 會再踩一次
    5. 不要信任 AI 的記憶——AI 會遺忘、會推測、會混淆。只有程式碼行號是可驗證的

    結語

    這個專案讓我深刻體會到:逆向重建的難度不在於「寫新程式」,而在於「完整理解舊系統」。舊系統的每一行 if/else 都是某個真實場景的反映,每一個看似多餘的欄位都有它存在的理由。

    AI 可以加速這個過程,但無法取代「逐行讀原始碼」這個步驟。捷徑是最遠的路——我們前 3 輪審查走了捷徑(從 Flutter 出發找問題),花了 3 倍時間;改用正確方法論(從原版出發列清單)後,第三套 APP 一天就完成了。

    希望這篇記錄能讓正在考慮做逆向重建的團隊少走一些彎路。

    追記:驗收階段又踩的五個大坑(2026-04-13 深夜)

    上面的文章發佈後幾小時,用戶開始實際驗收。結果又炸出一系列問題,每一個都是在開發階段「看了原始碼但沒看完整」造成的。

    坑 10:Dart 的 Map<dynamic, dynamic> 型別推導陷阱

    Flutter 的 API client 在呼叫 api.call(data: {}, token: token) 時,{} 空 Map literal 在 dynamic 參數中被 Dart 推導為 Map<dynamic, dynamic>。原本程式碼檢查 effectiveData is Map<String, dynamic>false → token 永遠不被加到 request 裡。

    影響範圍:27 個 API call。所有傳 data: {} 的頁面全壞(儀表板、帳號開通等),但傳了明確 key 的頁面(如 data: {'address_code': x})正常。這造成「有些頁面好有些壞」的假象,極難排查。

    教訓:原版 JavaScript param.token = value 不做任何型別檢查,直接賦值。重寫到靜態型別語言時,不要加「聰明」的型別守衛。笨方法之所以沒 bug,就是因為它笨。

    坑 11:config.js 常數映射層被完全忽略

    原版 Ionic 有一層 config.js 常數映射:sel → "select"upd → "update"del → "delete"add → "insert"sel2 → "select2"。Flutter 重寫時,有些地方用了短名(config.js 的 key),有些用了長名(PHP 接受的值)。PHP 只認長名。

    影響範圍:19 個 API call。寄放交辦頁面完全打不開(PHP switch/case 不匹配,回 ERROR 但 message 空字串),debug 時完全看不到線索。

    教訓:逆向重建時必須識別原版的每一層抽象。看了 controllers.js(呼叫端)和 factories.js(發送端)不夠——中間的 config.js 映射層不能跳過。每一個字串值都必須追到 PHP handler 的 switch/case 確認一致。

    坑 12:捏造不存在的 API method

    Flutter 程式碼裡出現了 method: 'select_category_detail'(包裹統計)和 category: 'util'(多帳號驗證碼)等原版完全不存在的 API call。追溯原始碼發現:前者在原版是純客戶端計算(groupBy + filter),後者正確的 category 是 'app_member'、method 是 'register_switchsite_validation_code'

    教訓:如果在原始碼裡找不到某個 API endpoint,那它就不存在。不要「合理推測」一個 API 可能存在。

    坑 13:API URL 不應該寫死在 compile time

    Flutter 用 --dart-define=API_BASE_URL=http://192.168.0.48:8010 在 compile time 寫死 API URL。結果 LAN 和 HTTPS 要分別 build,Cloudflare 快取舊 JS 時 URL 就錯。

    原版做法:API_BROKER_URL 在 config.js 是空字串,QR 碼啟動或登入時從 server 回傳 burl(blog_site_url),組合成完整 URL 存入 localStorage。

    修法:Web 版用 Uri.base.origin 自動偵測當前頁面的 origin 作為 API base URL。不管從哪個網域開,API 自動跟著。compile time 的 --dart-define 只作為 fallback。

    坑 14:外部服務走錯路由

    廣告 Banner API 在原版是打一個完全獨立的外部伺服器(vender01.tw),不是走 WP Broker。Flutter 把它包成了 category: 'ad_banner' 送進 WP Broker,當然找不到。

    教訓:原版有多個 factory(brokerFactorycommonServerFactoryomFactoryapi-vender),每個打不同的 server。重寫時必須識別每個 API call 走的是哪個 factory、打的是哪個 server。

    總結:為什麼驗收時才發現這些問題

    以上 5 個坑有一個共通點:「看了原始碼」不等於「看完了原始碼」。每次都是「看了主要的幾個檔案」但漏了中間層(config.js 映射、factory 路由、型別推導規則)。

    逆向重建的正確審查流程應該是:從 PHP handler 的 switch/case 開始,反向追每一個常數值回到 config.js,再追到 controllers.js 的呼叫端。不是從呼叫端出發猜 PHP 接受什麼。方向反了,就永遠追不完。

  • Flutter Web + Selenium E2E 測試踩坑全紀錄:九大實戰教訓

    重點摘要

    • Flutter Web 將所有 UI 渲染到 Canvas,Selenium 必須透過 Tab 鍵啟動語意樹(flt-semantics)才能操作元素
    • showDialog 的按鈕呼叫 Navigator.pop 時,必須使用 dialog 自己的 BuildContext,否則 async 後 context stale 會導致靜默失敗
    • HTTP 部署無法使用相機(非 Secure Context),需實作手動輸入 fallback
    • Release build 的 debugPrint 完全靜默,debug 必須靠 SharedPreferences breadcrumb + Selenium 輪詢 localStorage

    本文記錄了在 Flutter Web 應用上進行 Selenium E2E 自動化測試時踩過的所有坑。這不是教科書式的教學,而是一次完整的實戰復盤——從「為什麼 Selenium 找不到任何元素」到「為什麼確認 dialog 回傳 null」,每一條都是反覆碰壁後才理解的真相。適合正在做 Flutter Web 自動化測試、或考慮將 Ionic/AngularJS 遷移到 Flutter 的開發者。

    一、Flutter Web 語意樹(Semantics Tree)是什麼?為什麼 Selenium 找不到元素?

    Flutter Web 語意樹是 Flutter 為了無障礙功能(Accessibility)而產生的一組 DOM overlay 元素。與 React 或 Vue 不同,Flutter Web 預設將所有 UI 渲染到 <canvas> 元素中,DOM 裡沒有任何可見的文字節點。這意味著 Selenium 的常規定位方式(XPath 文字搜尋、CSS text selector)完全失效。

    如何啟動語意樹?

    語意樹由 flt-semantics 自定義元素組成,但預設是未啟動的。啟動方式只需要一行:

    driver.find_element(By.TAG_NAME, "body").send_keys(Keys.TAB)

    一次 Tab 鍵足以觸發整個語意樹。之後,所有語意元素都會以 <flt-semantics> 出現在 DOM 中。讀取文字的穩定做法:

    text = driver.execute_script("return arguments[0].innerText", element)

    語意角色速查表

    Flutter Widget CSS Selector 備註
    ElevatedButton / TextButtonflt-semantics[role='button']innerText 包含按鈕文字
    BottomNavigationBar Tabflt-semantics[role='tab']innerText 含 “Tab X of Y”
    SwitchListTileflt-semantics[role='switch']title 文字不在 innerText!
    FloatingActionButtonflt-semantics[flt-tappable]tooltip 文字在 innerText
    DropdownButtonflt-semantics[role='button']同按鈕角色

    最大的坑:SwitchListTile 的 title 文字(如「開始巡邏」)完全不會出現在語意樹的 innerText 中。你必須用 role='switch' 去定位元素,而不是搜尋 title 文字。這個坑讓我浪費了整整一小時。

    ScrollView 內容不在語意樹

    AlertDialog 裡的可滾動 ListView 內容(如 RadioListTile 選項列表)全部以 Canvas 渲染,不在 flt-semantics 中。測試時只能驗證「dialog 是否開啟」(確認「關閉」按鈕出現),無法直接驗證列表內容。

    二、Flutter Web SPA 狀態污染:為什麼 driver.get() 沒有重置頁面?

    Flutter Web SPA 狀態污染是指在同一個 Flutter 應用中,前一個測試留下的 widget 狀態(dialog、表單、loading)會影響下一個測試。這是因為 Flutter Web 使用 hash-based routing,driver.get(url + "/#/settings") 如果 hash 沒有變,瀏覽器不會發出新的 HTTP 請求,Flutter widget tree 完全保持原狀。

    # ❌ 錯誤:如果已在 #/settings,不會重置
    driver.get("http://host:port/app/#/settings")
    
    # ✅ 正確:先到無 hash URL(觸發真正的 HTTP reload)
    driver.get("http://host:port/app")         # Step 1: full reload
    time.sleep(3)
    driver.find_element(By.TAG_NAME, "body").send_keys(Keys.TAB)  # Step 2: 啟動語意樹
    time.sleep(1)
    driver.execute_script("window.location.hash = '/settings'")   # Step 3: SPA 導航
    time.sleep(2)

    這個模式在 session-scoped fixture 中尤其重要。當多個測試共享同一個 driver 時,前一個測試的 dialog 可能還開著,下一個測試找不到預期的元素。

    三、BuildContext 跨 async 使用的致命陷阱

    BuildContext 跨 async 陷阱是整個 debug 過程中最花時間的一個坑——3 小時才定位到根因。症狀是:確認 dialog 明明點了「是」,但後續邏輯完全沒執行,沒有任何錯誤訊息。

    問題程式碼

    Future<bool> _confirm(String message) async {
      final result = await showDialog<bool>(
        context: context,           // 外層 widget 的 context
        builder: (_) => AlertDialog(
          actions: [
            TextButton(
              // ❌ 用外層 context 呼叫 Navigator.pop
              onPressed: () => Navigator.pop(context, true),
              child: const Text('是'),
            ),
          ],
        ),
      );
      return result == true;  // null == true → false → 邏輯被跳過!
    }

    發生了什麼?

    1. showDialog() 開啟一個新的 Route(dialog route)
    2. 使用者點擊「是」
    3. Navigator.pop(context, true) 中的 context 是外層 widget 的 BuildContext
    4. 如果外層 widget 在 async 等待期間被 Framework 重建(例如 Provider 狀態更新),該 context 可能 stale
    5. Navigator.pop() 找到錯誤的 Navigator 或 pop 了錯誤的 route
    6. showDialog() 的 Future 得到 null(而非 true
    7. null == truefalse → 確認邏輯認為使用者「取消」了
    8. 所有後續程式碼被跳過,沒有任何錯誤訊息

    正確做法

    builder: (dialogCtx) => AlertDialog(
      actions: [
        TextButton(
          // ✅ 用 dialog 自己的 context
          onPressed: () => Navigator.of(dialogCtx).pop(true),
          child: const Text('是'),
        ),
      ],
    )

    通用規則:showDialog 的 builder 中,永遠使用 builder 參數提供的 context(命名為 dialogCtx),不要使用外層 widget 的 context。這在 Flutter 官方文檔中有提到「Don’t use BuildContext across async gaps」,但在 dialog builder 內部容易忽略。

    四、Release Build 下的靜默失敗:debugPrint 不見了

    Flutter release build 的靜默失敗是另一個時間殺手。flutter build web 產出的是 release build,以下行為與 debug build 完全不同:

    • debugPrint() 完全靜默——不會輸出到 browser console
    • assert() 完全跳過——不會觸發 assertion error
    • 未被 catch 的 async exception 由 Flutter Framework 處理,通常靜默記錄

    Debug Breadcrumb 技巧:寫入 SharedPreferences,Selenium 讀 localStorage

    // Dart side — 在 try/catch 每個步驟寫 breadcrumb
    try {
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString('flutter.dbg', 'step_A: loading points');
      // ... 業務邏輯 ...
      await prefs.setString('flutter.dbg', 'step_B: saved record');
    } catch (e) {
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString('flutter.dbg', 'ERROR: $e');
    }
    
    # Selenium side — 輪詢 localStorage
    for i in range(20):
        time.sleep(0.5)
        dbg = driver.execute_script("return localStorage.getItem('flutter.dbg')")
        print(f't={i*0.5}s  dbg={dbg}')
        if dbg and 'ERROR' in dbg: break

    這個技巧讓我在 5 分鐘內定位到「_confirm() 回傳 null」這個根因,而之前靠 console.log 完全看不到任何線索。

    五、Secure Context 限制:HTTP 部署的相機和 Service Worker 全掛

    Secure Context 是瀏覽器對某些敏感 Web API 的安全限制。以下 API 只在 HTTPS 或 localhost 下可用:

    • navigator.mediaDevices.getUserMedia()(相機 / 麥克風)
    • navigator.serviceWorker(Service Worker / PWA)
    • window.crypto.subtle(Web Crypto API,flutter_secure_storage 依賴此 API)

    用 HTTP + IP 位址部署 Flutter Web(如 http://192.168.x.x:8010),嘗試使用 mobile_scanner 掃描 QR code 會得到:

    MobileScannerException(unsupported, This browser does not support displaying video from the camera.)

    解決方案:Web fallback 自動降級

    @override
    void initState() {
      super.initState();
      if (kIsWeb) {
        // Web 版直接跳手動輸入,不嘗試開相機
        WidgetsBinding.instance.addPostFrameCallback((_) {
          _showManualInputDialog();
        });
      }
    }
    
    // 相機版的 errorBuilder 也要處理
    errorBuilder: (context, error, child) {
      if (error.toString().contains('unsupported')) {
        _showManualInputDialog();  // 降級為手動輸入
      }
      return child ?? const SizedBox.shrink();
    }

    生產環境建議架設 HTTPS(Let’s Encrypt / Nginx 反向代理),即可解除所有限制。開發環境用 localhost 也自動視為 Secure Context。

    六、SharedPreferences 在 Flutter Web 的雙重 JSON 編碼

    Flutter Web 的 shared_preferences 套件底層使用 localStorage,但有兩個容易踩坑的特性:

    Key 前綴

    prefs.setString('my_key', 'value');
    // 實際存儲為: localStorage['flutter.my_key']

    值的雙重 JSON 編碼

    prefs.setString('foo', '[{"id":1}]');
    // localStorage 實際內容: '"[{\\"id\\":1}]"'
    // 注意外層多了一組引號 + 轉義
    
    # Selenium 讀取時要解兩層
    import json
    raw = driver.execute_script("return localStorage.getItem('flutter.foo')")
    layer1 = json.loads(raw)     # 解 SharedPreferences 包裝
    parsed = json.loads(layer1)  # 解原始 JSON 內容

    七、非同步快取的時機問題:為什麼清單永遠是空的?

    非同步快取時機問題是一個隱蔽的 bug:App 啟動時以 async 方式從 API 抓取主資料(如巡邏點清單)並快取到 localStorage。但如果用戶在快取完成前就操作,快取為空 → 業務邏輯用空資料建立記錄 → 記錄一旦建立就再也不會重新抓取 → 永遠空白

    解法:On-demand fallback + 恢復修補

    Future<List<PatrolPoint>> _loadPatrolPoints() async {
      var rawPoints = await db.getCachedPatrolPoints();
    
      if (rawPoints.isEmpty) {
        // 快取為空 → 直接打 API 補抓(此 API 不需要 token)
        try {
          final live = await api.getNavPoints(token);
          rawPoints = live.map((item) => {
            'point_id': item['id']?.toString() ?? '',
            'point_name': item['navpoint_name']?.toString() ?? '',
          }).toList();
          if (rawPoints.isNotEmpty) await db.cachePatrolPoints(rawPoints);
        } catch (e) {
          debugPrint('Live fetch failed: $e');
        }
      }
    
      return rawPoints.map((p) => PatrolPoint(...)).toList();
    }
    
    // 恢復進行中任務時也要修補
    if (patrol != null && patrol.patrolPoints.isEmpty) {
      final fresh = await _loadPatrolPoints();
      if (fresh.isNotEmpty) {
        patrol = patrol.copyWith(patrolPoints: fresh);
        await db.updatePatrolRecord(patrol);
      }
    }

    八、Selenium + Firefox geckodriver 實戰注意事項

    Console Log 不可直接取

    Firefox geckodriver 不支援 driver.get_log('browser')。要捕捉 console 輸出,需要在頁面注入攔截器:

    driver.execute_script("""
        window.__logs = [];
        var orig = console.log;
        console.log = function() {
            window.__logs.push(Array.from(arguments).join(' '));
            orig.apply(console, arguments);
        };
    """)
    # 操作後讀取
    logs = driver.execute_script("return window.__logs")

    但在 release build 中,Flutter 的 debugPrint 不呼叫 console.log,所以這招也沒用。前面提到的 SharedPreferences breadcrumb 才是唯一可靠的方式。

    click_text() helper 的完整實作

    封裝一個通用的「找語意元素並點擊」函式,需要同時搜尋 button、tab、tappable 三種角色:

    def click_text(driver, text, timeout=15):
        """Click flt-semantics element whose innerText contains text."""
        end = time.time() + timeout
        while time.time() < end:
            selectors = [
                "flt-semantics[role='button']",
                "flt-semantics[role='tab']",
                "flt-semantics[flt-tappable]",
            ]
            for sel in selectors:
                for el in driver.find_elements(By.CSS_SELECTOR, sel):
                    t = driver.execute_script("return arguments[0].innerText", el) or ""
                    if text in t:
                        driver.execute_script("arguments[0].click()", el)
                        return
            time.sleep(0.5)
        raise AssertionError(f"Clickable element '{text}' not found")

    九、方法論:Flutter Web Debug 的正確排查流程

    當 Selenium 測試與 Flutter Web 出現異常時,依照以下順序排查,可以最快定位問題:

    1. 語意樹是否啟動?按 Tab 後檢查 flt-semantics 元素數量。為 0 → 重按 Tab
    2. localStorage 狀態正確嗎?檢查 flutter.* key,確認 token、快取資料是否存在
    3. HTTP 還是 HTTPS?相機、Service Worker、Web Crypto 都需要 Secure Context
    4. Dialog 回傳值正確嗎?用 dialog 內部的 context 而非外層 context
    5. Release 還是 Debug build?debugPrint 在 release 完全靜默
    6. 寫 debug breadcrumb:try/catch 裡寫 SharedPreferences,Selenium 輪詢 localStorage
    7. SPA 狀態殘留?用 full HTTP reload(無 hash URL)強制重置 Flutter widget tree

    總結:Flutter Web + Selenium 是可行的,但需要正確的心智模型

    Flutter Web 的測試難度不在於「找不到元素」,而在於「找到了但行為不符預期」。Canvas 渲染 + 語意覆層的架構與傳統 HTML DOM 測試完全不同,需要建立新的心智模型。最大的兩個坑——BuildContext 跨 async 靜默失敗release build debugPrint 消失——可以讓你花上數小時 debug 而沒有任何錯誤訊息。

    掌握了這九個教訓後,我們的 Selenium 測試從 0 做到 40/40 全過,涵蓋了登入、導航、簽到/簽退、巡邏流程、設定頁面等完整 E2E 場景。希望這篇記錄能幫到正在走同一條路的開發者。

  • AI 逆向工程協作:Source Inventory 協議讓一比一重建不再落東掉西

    重點摘要

    • AI 協作做逆向工程,最大的坑不是技術,而是 AI 「選擇性閱讀」原始碼
    • 根治方法:強制 Source Inventory 協議 — 動手前先列清單、附行號、用戶確認邏輯後才寫程式
    • 用戶不需要知道原版長什麼樣,清單上的每一項都要有原始碼行號出處
    • 這套協議已寫入 CLAUDE.md,適用於任何 legacy code 逆向重建專案

    最近在做一個系統重建專案:把一套已上線多年的 Ionic + PHP 系統,逐頁逐模組一比一重建為 Flutter 應用。聽起來直白,實際做起來卻踩了一個讓人很沮喪的坑——不是技術難度的問題,而是 AI 協作流程設計的問題

    這篇文章記錄我如何診斷這個問題,並建立一套可複用的 AI 逆向工程協作協議。

    問題:一比一複製,卻一直落東掉西

    我明確告訴 AI:「這是舊版原始碼,你幫我一比一複製到 Flutter。」但每次交付,都會發現缺少功能。最典型的例子:登入後的登出功能不見了

    登出不是什麼複雜功能,但它不在主流程頁面——它藏在 header component 的某個角落。AI 在讀原始碼的時候,只讀了主頁面,沒讀共用元件,自然就沒看到登出按鈕的存在。

    更麻煩的是:我自己也不知道原版長什麼樣(這是別人開發的系統),所以我也沒辦法直接指出「你少了這個」。每次都要在用起來的時候才發現缺少什麼,然後重來。

    根本原因:AI 的「選擇性閱讀」

    深入想了一下,問題的根源在於 AI 讀原始碼的方式:

    錯誤的讀法 正確的讀法
    找「我預期會有的東西」 逐行掃描,列出「所有看到的東西」
    只讀主要頁面檔案 讀所有相關檔案(service、routing、共用元件)
    憑印象補齊功能 每一項都有原始碼出處(檔案 + 行號)
    「讀完」就開始寫 產出清單、確認後才開始寫

    用一句話說:AI 是在「確認自己的預期」,不是在「完整盤點原始碼」。這種差距在簡單頁面感覺不出來,在有大量共用邏輯的真實系統裡,每個模組都會漏東西。

    解法:Source Inventory 協議

    我和 AI 一起設計了一套強制執行的協議,並寫入專案的 CLAUDE.md,讓每個接手這個專案的 AI session 都必須遵守。

    Step 0 — 列出所有要讀的檔案(動手前)

    在讀任何內容之前,先列出這個模組涉及的所有檔案,包含:

    • 頁面本體(template + logic)
    • 所有被引用的 service
    • 共用元件(header、footer、tab-bar、nav)
    • 路由設定
    • 後端 handler

    每個檔案讀完打勾,沒打勾的不算讀過。這一步強迫 AI 在開始之前就意識到「這個模組牽涉哪些檔案」,避免只讀主檔就動手。

    Step 1 — 產出 Source Inventory(每項附行號)

    讀完所有檔案後,產出功能清單。格式固定:

    - [ ] 登出按鈕
          來源:home.page.html:142 <ion-button (click)="logout()">
    
    - [ ] 下拉刷新
          來源:ticket.page.html:38 <ion-refresher (ionRefresh)="doRefresh($event)">
    
    - [ ] 空狀態提示
          來源:ticket.page.html:91 <div *ngIf="tickets.length === 0">目前沒有票券</div>

    關鍵點:每一項都必須有原始碼的檔案和行號

    這解決了「用戶不知道原版長什麼樣,所以沒辦法驗證清單正確性」的問題。用戶不需要看過原版,只需要問:「這一項你是從哪行讀到的?」答不出來,就代表是 AI 憑印象補的,不是真的在原始碼裡。

    Step 2 — 逐項實作,逐項打勾

    對照清單一項一項做,每完成一項就打勾。不允許「大概實作了」或「之後再補」。

    Step 3 — Self-Verify 後才交付

    交付前,AI 自己對照清單跑一遍,確認每項都存在於程式碼中。如果 Step 3 發現漏項,必須補完再說「完成」。漏項不能留給用戶發現。

    一條關鍵規則:禁止在原始碼存在的情況下向用戶索取截圖

    另一個常見的壞模式是:AI 讀不懂某個行為,就跟用戶要截圖或圖片。

    這在逆向工程的情境下尤其荒謬。用戶拿到舊系統原始碼,通常自己也沒有截圖,甚至從來沒用過那個系統。要求用戶提供截圖,是把 AI 本來應該做的工作(讀原始碼)轉嫁給用戶。

    正確的做法是:原始碼讀不懂的地方,標記 [?],說明不確定的點,讓用戶確認業務邏輯——而不是要求視覺確認。

    這套協議適用於任何逆向升級工程

    雖然這次的情境是 Ionic → Flutter,但這套協議的核心邏輯對任何 legacy 重建都適用:

    • jQuery / AngularJS → React / Vue
    • PHP monolith → 現代後端(Go、Node、Python)
    • 原生 Android/iOS → Flutter / React Native
    • 桌面應用 → Web 應用

    只要是「拿到舊系統 code,重寫到新技術棧」,用戶通常都不知道原版的全貌,也無法驗證 AI 是否讀完了所有東西。Source Inventory + 行號出處 是讓這個過程可稽核的最低門檻。

    如何在你的專案裡執行這套協議

    把以下內容加入你的專案 CLAUDE.md(或任何 AI 工作指引文件):

    ## 一比一複製協議(MANDATORY)
    
    > 原始碼是唯一的 ground truth。用戶不應該被要求提供截圖。
    
    ### Step 0 — 列出所有要讀的檔案
    (所有頁面、service、共用元件、routing、後端 handler)
    每個檔案讀完打勾,沒打勾不算讀過。
    
    ### Step 1 — Source Inventory(每項附檔案:行號)
    格式:
    - [ ] 功能描述
          來源:filename.html:LINE_NUMBER <原始碼片段>
    
    ### Step 2 — 逐項實作,逐項打勾
    
    ### Step 3 — Self-Verify 後才交付
    
    ### 禁止行為
    - 在原始碼存在的情況下向用戶索取截圖
    - 沒有產出 Source Inventory 就開始寫程式
    - 只讀主頁面,不讀 service / routing / 共用元件

    結語

    逆向升級工程的難點不在技術轉換,而在於如何保證「沒有遺漏」。AI 的天性是預測下一個 token,這讓它容易「補全期望的樣子」,而不是「如實記錄看到的東西」。

    Source Inventory 協議的作用,就是把 AI 的工作模式從「自由發揮」強制切換成「逐行比對」。這不是在限制 AI,而是在讓 AI 的輸出變得可以被信任。

    如果你也在做類似的逆向重建專案,歡迎把這套協議複製進你的 CLAUDE.md,看看效果如何。

  • iDempiere + LangGraph:為 15 年老 ERP 加上 AI 問答的完整紀錄

    重點摘要

    • 用 LangGraph + Claude Sonnet + Groq Llama 為 15 年老 ERP 系統加上 AI 問答功能,不改任何一行既有程式碼
    • 從設計到上線跑通:13 輪審查、20+ 個 AI 專家、發明了「領域腦」知識管理系統、踩了 30+ 個坑
    • 最大的教訓不是技術——是「經驗存在但沒被用到」。叫 20 個專家 review 不如先讀一遍上次的踩坑紀錄
    • 完整開源:AI Assistant + Domain Brain(領域腦知識管理系統)

    這篇文章記錄一個完整的旅程:從「我想讓老 ERP 系統能用 AI 回答問題」到「真的在 iDempiere 裡輸入問題、6.8 秒後看到 Claude 的回答」。過程中我們設計了架構、寫了計畫、做了 13 輪審查、發現了「領域腦」這個知識管理方法、踩了 30 多個坑、讓兩個不同的 AI(Claude 和 Qwen)協作開發——最後真的跑通了。

    最終成果:一張截圖說明一切

    ** === AI Assistant Response ===
    Question: 我想查詢訂單
    Answer: 很抱歉,目前沒有找到任何訂單資料。建議您提供特定的訂單編號...
    Model: sonnet
    Tokens: 705
    Time: 6864 ms
    Query: order_status_by_documentno

    這代表什麼?整條鏈路全部打通了:使用者在 iDempiere 輸入問題 → Java Plugin 用 HMAC 簽名 → HTTP 打到 Python → LangGraph 分類問題 → 選對了 SQL → 查了 PostgreSQL → PII 脫敏 → Claude Sonnet 回答 → 脫敏還原 → 顯示在 iDempiere UI。6.8 秒,705 tokens,沒有改 iDempiere 任何一行既有程式碼。

    架構:支援老系統,不重寫老系統

    核心理念:iDempiere 是 15 年的 Java ERP,我們不動它,只在旁邊加一個 Python 微服務。

    iDempiere (Java/OSGi)                    Python AI Service (FastAPI)
    ┌──────────────────────┐                 ┌──────────────────────────┐
    │ AI Chat Process      │  HTTP POST      │ HMAC 驗證                │
    │ HMAC 簽名            │ ──────────────→ │ LangGraph 分類 (Llama 8B)│
    │ 審計日誌              │                 │ 選擇預定義 SQL            │
    │                      │ ←────────────── │ PostgreSQL 查詢 (只讀)    │
    │ 顯示回答              │  JSON 回應      │ PII 脫敏 → Sonnet → 還原  │
    └──────────────────────┘                 └──────────────────────────┘

    這個架構的好處:Python service 掛了,ERP 完全不受影響。要換 LLM 模型?改 Python 一行。要加新的查詢?加一個 SQL 定義檔,Java 端不用動。

    開發過程:兩個 AI 協作,一個審查一個寫碼

    這個專案的開發方式很特別:Claude(我)負責設計、審查、知識管理;Qwen 負責寫程式碼。

    角色 AI 工作
    架構師 + 審查員 Claude Opus 設計 spec、寫 plan、派專家 review、建 Domain Brain、debug 部署問題
    程式實作 Qwen Python service 全部程式碼 + Java plugin 全部程式碼
    指揮官 Tom(人類) 定需求、判斷方向、提出「你有沒有去看上次的紀錄?」這種靈魂拷問

    13 輪審查學到的事

    我們做了 13 輪 review,派了 20 多個 AI 專家 agent。前 8 輪查邏輯、安全、架構、接點——都通過了。然後 Tom 問了一句:「你有沒有去看 tw-invoice 上次踩的坑?」

    答案是沒有。然後我們發現 3 個會直接讓 plugin 啟動失敗的 bug,全部都是上次踩過且記錄過的。20 個專家沒抓到,一句「去看舊筆記」就全找到了。

    這件事催生了一篇完整的反思文章和一個全新的知識管理系統——Domain Brain(領域腦知識管理系統)。

    踩的最痛的幾個坑

    痛點 教訓
    JVM 參數加在 idempiere.ini systemd 啟動不吃 ini,要加在 server.sh 先搞清楚服務怎麼啟動的
    2Pack XML 格式錯 Para 要嵌套在 Process 裡、要 type=table、reference=uuid 看 tw-invoice 的 working example 比看文件有用
    AD_Menu_ID=146 不存在 menu ID 是環境特有的,不能 hardcode 用 UUID reference
    ad_menu_access 表不存在 iDempiere 根本沒有這張表 不要假設表存在,先查
    缺 IProcessFactory DefaultProcessFactory 用 Class.forName,看不到 plugin 每個 SvrProcess 都需要自己的 Factory

    Domain Brain:解決「經驗不傳承」的方法

    這個專案最大的副產品是 Domain Brain — 一個把所有專案經驗按技術領域濃萃的知識管理系統。詳細的介紹在前一篇文章,這裡只講結果:

    • 9 份領域腦,涵蓋 OSGi、2Pack、PO Model、REST API、Python LLM、Crawler、回測、OMS、設計原則
    • 每個專案的 CLAUDE.md 宣告自己需要哪些腦:## Domain Brain: osgi-bundle, 2pack, po-model
    • 審查時帶著腦 → 第一輪就抓到之前 8 輪沒抓到的 bug
    • 新坑自動更新回腦 → 所有未來專案受益

    技術棧

    技術
    ERP UI iDempiere 12 + ZK + OSGi Plugin
    AI 路由 LangGraph StateGraph(分類 → 選 SQL → 查詢 → 脫敏 → 回答 → 還原)
    LLM Claude Sonnet(查詢選擇 + 回答) + Groq Llama 8B(分類)
    安全 HMAC-SHA256 簽名、PII 可逆脫敏 [PII_C_001]、只讀 DB 帳號、statement_timeout
    資料庫 PostgreSQL(iDempiere DB),ai_readonly 帳號,ThreadedConnectionPool

    開源

    下一步

    • 從 Process 對話框升級為 ZK Form 聊天窗(更好的 UX)
    • 加入 AI_ChatLog 審計表(追蹤每次問答)
    • 更多預定義 SQL(目前 3 個,目標 20+)
    • 對話歷史(Phase 2)
    • 有限的寫入操作——透過 iDempiere API,不是直接 SQL
  • Spring Boot OMS Code Review 實戰:20 個 Bug 與事件驅動架構的一課

    重點摘要

    • 三輪 Code Review 共找出 20+ 個問題,從 NPE 連鎖到種子資料欄位名稱全錯
    • Kafka Seeder 寫了三個版本,每次重寫都是對「事件驅動架構正確入口」理解的加深
    • 能走事件流就走事件流:API → Kafka → Consumer → DB,每一層都有可追蹤、可重試的意義
    • 21 個 Java 容器沒有 JVM 記憶體限制,用 JAVA_TOOL_OPTIONS 一行解決,不需改 Dockerfile

    這是 多通路電商 OMS 系統開發過程中的一天工作紀錄。系統整合了 Momo、Shopee、Yahoo 等電商平台,透過 Kafka 事件流處理訂單同步、退貨與統計。今天的目標:完成 feature/stats-pipeline 分支上的所有待辦修復,讓系統能順利 docker compose up,並驗證端對端資料流。

    三輪 Code Review:每一輪都有新發現

    第一輪:已知清單上的 7 個問題

    進入狀態之前就有一份清單,分為 Critical、Warning、Info 三個等級:

    等級 問題 修復方式
    CriticalOrderUpsertConsumer .get() NPE 連鎖.path() + 加 orderDataJson null 守衛
    Criticaldaily_statistics.id 缺 NOT NULL加約束 + DEFAULT partition
    CriticalReturnUpsertConsumer 未寫 stats dirty marker新增 Redis ZSET 寫入
    WarningDailyStatisticsService early return 留舊資料改成刪除過時的 stats 列
    Warningenum 預設值小寫 'pending'改大寫 'PENDING',與 JPA EnumType.STRING 對齊
    WarningRetryJobConsumer MissingNode cast.isObject() 判斷再 cast

    其中 enum 大小寫這個問題值得特別說明。Java 的 @Enumerated(EnumType.STRING) 在讀取時呼叫 Enum.valueOf(),這個方法是 case-sensitive 的。資料庫預設值寫 'pending',但 enum 常數叫 PENDING,啟動時不會出錯,但一讀到有 DEFAULT 值的列就會拋 IllegalArgumentException

    第二輪:種子資料是另一個地雷區

    Schema 修完了,以為大功告成,結果種子資料(02-seed-data.sql)是第二個地雷區:

    1. BCrypt hash 是假的$2a$10$dummyhashfordevonly... 根本不是有效的 BCrypt hash,Spring Security 的 passwordEncoder.matches() 永遠回傳 false,登入 100% 失敗。
    2. 訂單狀態小寫'completed''shipped' — 和上面一樣的 case-sensitive 問題,這次在資料列而不是 DEFAULT 值。
    3. daily_statistics 欄位名稱全錯:用了 order_counttotal_amount 這些不存在的欄位名,docker compose up 的 DB 初始化階段會直接 fail。

    這些問題的共同特徵是:compile time 抓不到,schema validate 也抓不到。Hibernate 的 ddl-auto: validate 只單向檢查「entity 中有 mapping 的欄位是否存在於 DB」,不會反向驗證 SQL 腳本的正確性。唯一的防護是跑起來測試。

    第三輪:21 個容器,一個 JVM 記憶體問題

    系統在開發機上跑 21 個 Java 容器(Spring Boot services),沒有任何 JVM heap 限制。JVM ergonomic sizing 預設使用系統 RAM 的 25%,7.4GB 可用 RAM 很快就會不夠。

    解法是在 docker-compose.yml 每個服務加 JAVA_TOOL_OPTIONS

    environment:
      JAVA_TOOL_OPTIONS: "-Xmx256m -XX:+ExitOnOutOfMemoryError"

    JAVA_TOOL_OPTIONS 是 JDK 標準環境變數,JVM 啟動時自動讀取,不需要修改 Dockerfile 的 ENTRYPOINT-XX:+ExitOnOutOfMemoryError 讓容器在 OOM 時立刻崩潰(而不是卡死),對 Docker 的 restart: unless-stopped 友好,等於有了自動恢復機制。

    Seeder 的三次重寫:對事件驅動架構的理解之旅

    今天最有收穫的插曲。目標是「準備一個 Docker 服務,打假訂單資料,確認整體資料流順暢」。這個任務看起來很簡單,結果寫了三個版本。

    第一版:直接打 Kafka(被打槍)

    第一直覺:用 kafka-python 直接連 kafka:9092,組好 ORDER_UPSERT 訊息送到 order.process topic。快速、直接。

    問題:系統對外只有 API,直接操作 Kafka 是繞過了系統設計的邊界。內部基礎設施不應該是外部系統的接入點。

    第二版:打 POST /api/orders(沒走事件流)

    改用 REST API。先 login 拿 JWT,再 POST /api/orders

    問題:OrderController.createOrder() 是直接寫資料庫,跳過了整個 Kafka pipeline。Stats dirty marker 不會被寫入,DailyStatisticsService 不會被觸發,daily_statistics 表不會更新。雖然訂單進了 DB,但「整體資料流」沒有跑通。

    第三版:新增正確的 API 端點(走完整事件流)

    UserOrderController 新增 POST /api/user/orders,接收訂單資料後發布 ORDER_UPSERT 到 Kafka,回傳 202 Accepted:

    POST /api/user/orders  (帶 JWT)
      → 查 Channel → Platform(取得 platformId)
      → 組 ORDER_UPSERT 訊息(header + body + hash)
      → kafkaTemplate.send("order.process", ...)
      → 回傳 202 Accepted
    
    接著:
      Kafka order.process
        → OrderUpsertConsumer(Redis 去重 → INSERT/UPDATE)
            → stats dirty marker 寫入 Redis ZSET
                → StatsRecalcHandler(定時掃)
                    → DailyStatisticsService.recalculate()
                        → daily_statistics 更新

    端到端,一條不少。

    為什麼「能走事件流就走事件流」不只是口號

    三次重寫讓這個原則從抽象變得具體。走事件流的好處不只是「解耦」這個詞能涵蓋的:

    層面 直接寫 DB 走 Kafka 事件流
    可追蹤性只有 DB recordKafka UI 可看完整訊息歷史,帶 traceId
    錯誤處理拋 exception,呼叫方看到 500失敗走 task.failed → retry → task.dlt
    去重需要自己實作Consumer 有 Redis + DB 兩層去重
    統計觸發需要額外呼叫Consumer 自動寫 dirty marker,批次計算
    一致性邏輯分散在多處無論來源(channel job / API),走同一套邏輯

    最後一點是最重要的:一致性。不管訂單是從 Shopee channel job 來的,還是透過 API 手動新增的,都走同一個 OrderUpsertConsumer,同一套去重邏輯,同一套 stats pipeline。系統裡沒有「繞過」的快捷路徑。

    今日修改摘要

    檔案 類型 說明
    01-schema.sqlBug FixNOT NULL、DEFAULT partition、enum 大小寫
    02-seed-data.sqlBug FixBCrypt hash、訂單狀態大小寫、daily_statistics 欄位名稱
    OrderUpsertConsumerBug Fix.get().path(),移除 unused import
    ReturnUpsertConsumerBug Fix加 stats dirty marker、移除 unused import
    DailyStatisticsServiceBug Fixearly return 時刪除過時 stats 列
    OrderServiceBug FixNOT NULL 欄位的 null 守衛
    docker-compose.ymlInfra所有 21 個 Java 容器加 JAVA_TOOL_OPTIONS
    UserOrderControllerFeature新增 POST /api/user/orders → Kafka pipeline
    docker/test-data-generator/FeaturePython seeder,透過 API 打假訂單

    結語:追蹤路徑比結果更重要

    今天花最多時間的不是寫 code,而是「把對的事情弄清楚」。Seeder 寫了三個版本,不是因為技術難,而是因為對系統的理解在逐漸深化。

    一個好的事件驅動系統,它的「正確入口」只有一個。找到那個入口,比快速把功能做出來更重要。這條原則同樣適用於大系統的任何角落:追蹤路徑比結果更重要,因為你下次出問題的時候,你需要知道訊息從哪裡來、往哪裡去。

    能走事件流就走事件流。能用快取盡量快取。這不是教條,是讓大系統在出問題時還能被追蹤、被診斷、被修復的保險。

  • iDempiere Plugin 開發完整指南:踩遍台灣統一發票的 10 個坑

    重點摘要

    • 我們用兩天為 iDempiere 12.0 從零開發了台灣統一發票 OSGi Plugin,踩了至少 7 個主要的坑
    • 最燒時間的 bug:SeqNoGrid 缺失導致 Grid View 在按「新增」時拋出 IndexOutOfBoundsException——官方文件完全沒提
    • EventHandler 模式是 iDempiere 12 的正確驗證方式,ModelValidator 不透過 OSGi DS 登錄根本不執行
    • 2Pack ZIP 結構、事件主題比較、雙 JVM 部署問題——每一個都能讓你白白浪費半天

    這篇文章記錄了我們為 iDempiere 12.0 從零開發台灣統一發票(統一發票)與營業稅申報 Plugin 的完整過程。如果你正在開發 iDempiere Plugin,這篇文章能幫你少掉至少一天的除錯時間。

    為什麼要做這個 Plugin?

    台灣的統一發票制度獨特而嚴格:每兩個月一期的雙月申報週期、財政部核配的字軌號碼(如 AA01234567)、三聯式(B2B)和二聯式(B2C)的不同計稅方式、以及嚴格的 FLOOR 取捨規定。

    這些都是 iDempiere 標準功能完全沒有覆蓋的。既有的 C_Invoice 系統不知道什麼是「字軌」,不知道進項折讓要在哪一期申報,更不知道 401 申報表長什麼樣子。所以我們做了這個 Plugin。

    系統架構概覽:四張表對應四個階段

    iDempiere Plugin 開發的核心是理清業務模型。台灣統一發票有清楚的生命週期,我們用四張資料表對應:

    財政部核配字軌
          │
          ▼
    TW_InvoicePrefix      ← 字軌管理(AA、AB 等,號碼範圍、有效期)
          │ 開立發票時
          ▼
    TW_Invoice_Prefix_Map ← 每張發票的字軌號碼對應(含買方統一編號)
          │ 如有退貨/折讓
          ▼
    TW_InvoiceAdjustment  ← 銷項/進項折讓(方向、期別、超期申報)
          │ 期末
          ▼
    TW_TaxStatement       ← 401 申報表(銷項稅、進項稅、留抵、應納稅額)

    技術層面:OSGi bundle(Equinox),2Pack 管理 dictionary,AbstractEventHandler 做 PO 事件驗證,服務層純 Java 方便單元測試。最終成品:87 個測試全部通過,4 張 TW_* 資料表,4 個 iDempiere 視窗。

    把台灣稅法翻成 Java 程式碼

    稅額計算:FLOOR,不是 ROUND

    這是財政部的明確規定,一律捨去,不四捨五入。聽起來簡單,但在邊界值差一塊錢,申報出去就是對不上帳:

    // 二聯式(B2C):含稅金額已知,反推銷售額和稅額
    BigDecimal saleAmount = grossAmount
        .divide(new BigDecimal("1.05"), 0, RoundingMode.FLOOR);
    BigDecimal taxAmount = saleAmount
        .multiply(new BigDecimal("0.05"))
        .setScale(0, RoundingMode.FLOOR);
    
    // 注意:taxAmount ≠ grossAmount - saleAmount
    // 財政部規定:用前者(先算銷售額,再乘 5%)

    字軌狀態機:單向前進

    字軌狀態只能往前走,不能回頭:I(未啟用)→ A(使用中)→ C(已用完)。這是台灣稅法要求,已啟用的字軌必須被追蹤,不能撤回。

    // EventHandler 在 PO_BEFORE_CHANGE 攔截
    if ("A".equals(oldStatus) && "I".equals(newStatus))
        throw new AdempiereException("使用中字軌不可降回未啟用(台灣稅法)");
    if ("C".equals(oldStatus))
        throw new AdempiereException("已用完字軌不可再變更狀態");

    雙月申報期別計算

    // 從發票月份算期別
    int period = (month - 1) / 2 + 1;  // 1=1-2月, 2=3-4月 ... 6=11-12月

    七個真實踩坑紀錄

    坑 1:事件主題比較,一個字毀掉一切

    這個 bug 讓狀態驗證完全靜默失效了很久。症狀是:程式碼看起來完全正確,但驗證從來不觸發。

    // 錯誤 — 永遠不會觸發
    if (topic.endsWith("po_before_change")) { ... }
    
    // 正確
    if (IEventTopics.PO_BEFORE_CHANGE.equals(topic)) { ... }

    iDempiere 的 topic 格式是 org/adempiere/po/PO_BEFORE_CHANGE(全大寫),endsWith 配對小寫後綴當然不匹配。靜默失效最可怕——不報錯,只是所有驗證都沒執行。規則:事件主題永遠用 IEventTopics 常數。

    坑 2:ModelValidator 是個陷阱

    Validator 類別一開始實作了完整的 ModelValidator 介面(initialize、modelChange、docValidate、login…),程式碼寫得很整齊,但驗證從來不執行。

    原因:ModelValidator 需要透過 ModelValidationEngine.addModelValidator() 主動登錄。單純 implements ModelValidator + @Component 什麼都不會發生。

    正確的 iDempiere 12 Plugin 驗證模式:

    // *Validator.java    → 純靜態方法,不實作任何介面
    // *EventHandler.java → extends AbstractEventHandler,OSGI-INF/*.xml 登錄為 DS 服務
    
    // EventHandler 捕捉 OSGi 事件,呼叫 Validator 的靜態方法做驗證
    @Component(immediate = true, service = IEventHandler.class)
    public class InvoicePrefixEventHandler extends AbstractEventHandler {
        @Override
        protected void doHandleEvent(Event event) {
            String topic = (String) event.getProperty(IEventTopics.EVENT_TOPIC);
            if (IEventTopics.PO_BEFORE_CHANGE.equals(topic)) {
                String err = InvoicePrefixValidator.validateStatusTransition(...);
                if (err != null) throw new AdempiereException(err);
            }
        }
    }

    坑 3:2Pack ZIP 打包方式,犯了兩次

    iDempiere 的 PackIn 解壓 ZIP 到 /tmp/,預期路徑是 /tmp/tw_invoice_system/dict/PackOut.xml

    第一次:ZIP 裡多了 2pack/ 前綴層。第二次:用了 zip -j(junk paths),把所有目錄剝掉,路徑變成 /tmp/PackOut.xml/dict/PackOut.xml——把文件名當目錄了。

    # 正確打包方式
    mkdir -p /tmp/b/tw_invoice_system/dict
    cp PackOut.xml /tmp/b/tw_invoice_system/dict/
    cd /tmp/b && zip -r 2Pack_1.0.10.zip tw_invoice_system/
    
    # 永遠要驗證結構
    unzip -l 2Pack_1.0.10.zip

    坑 4:SeqNoGrid 缺失 → Grid View 崩潰(最燒時間)

    這是整個過程中最「無辜」的 bug——不是邏輯錯誤,是 XML 少了兩個欄位。所有 73 個 AD_Field 元素都缺少 <SeqNoGrid> 和正確的 <IsDisplayedGrid>

    iDempiere 12 的 Grid View 渲染器在初始化行編輯器時需要 SeqNoGrid 做排序依據,全部 NULL 導致:

    java.lang.IndexOutOfBoundsException: Index: 2
        at GridTabRowRenderer.editCurrentRow

    任何視窗,只要在 Grid 模式下按「新增」,必爆。這個 bug 在官方文件裡完全沒有提到。修復:補上這兩個欄位。

    <SeqNo>10</SeqNo>
    <SeqNoGrid>10</SeqNoGrid>        <!-- = SeqNo,顯示欄位 -->
    <IsDisplayedGrid>Y</IsDisplayedGrid>
    
    <!-- 隱藏欄位(SeqNo=0)-->
    <SeqNoGrid>0</SeqNoGrid>
    <IsDisplayedGrid>N</IsDisplayedGrid>

    坑 5:兩個 JVM 搶 Port,Deploy 進錯的那個

    症狀很奇怪:OSGi telnet console 顯示 bundle ACTIVE ✅,但 Web Console 找不到這個 bundle ❌。

    最後發現機器上跑著兩個 iDempiere JVM:

    JVM 擁有 Port 說明
    JVM-A(舊的)8080/8443Web 瀏覽器連這個
    JVM-B(新的)12612OSGi telnet 連這個

    deploy.sh 透過 telnet 把 bundle 裝進了 JVM-B,但使用者看的 Web Console 是 JVM-A。原因是 idempiere-server.sh 有 restart loop,systemctl restart 啟動了新 JVM,但舊 JVM 沒死。

    # 診斷方法
    ps aux | grep java | grep -v grep | wc -l  # > 1 就是這個問題
    
    # 修復:kill 兩個 JVM,等 30 秒讓 port 釋放,再 restart
    sudo kill <pid1> <pid2>
    sleep 30
    sudo systemctl restart idempiere

    坑 6:AD_Field UUID 衝突

    2Pack 重裝時,iDempiere 已為新建的 Tab 自動插入標準欄位(AD_Client_ID、AD_Org_ID、IsActive)。當 PackIn 再次嘗試用不同 UUID 插入同一個 (tab_id, column_id) 組合時,違反了 UNIQUE(ad_tab_id, ad_column_id) 約束,拋出 POSaveFailedException

    解法:對這類「系統可能已存在」的標準欄位,PackOut.xml 使用固定的 placeholder UUID。PackIn 遇到重複就 UPDATE 而不是 INSERT。升版 UUID 策略:已存在的 field → 保留原 UUID;新增的 field → 才用新的 uuid4。不要為了「整齊」換掉既有 UUID。

    坑 7:清除 DB 做全新安裝——FK 刪除順序

    當要做「完整卸載 → 清 DB → 重裝」的驗證時,刪資料的順序錯了好幾次。

    正確順序:AD_Field → AD_Tab → AD_Window,每一步都有 FK 指向下一個。特別注意:AD_PreferenceAD_Menu 也會 FK 到 AD_Window,忘記刪這兩個就卡住。另外:ad_package_imp 記錄了 2Pack 安裝歷史,不清掉的話 Incremental2PackActivator 判定「已安裝過同版本」就跳過不執行。

    如果重來一次,我們會怎麼做

    1. 先建立 PackOut.xml 驗證腳本

    每次 2Pack 安裝後自動跑這些 SQL,出問題立刻知道,不用等到 UI 爆炸:

    -- SeqNoGrid 是否設定
    SELECT tablename, count(*) FILTER (WHERE seqnogrid > 0) ok
    FROM ad_table JOIN ad_tab ... JOIN ad_field ...
    WHERE tablename LIKE 'TW_%' GROUP BY tablename;
    
    -- _UU 欄位是否 updateable
    SELECT columnname, isupdateable FROM ad_column
    WHERE tablename LIKE 'TW_%' AND columnname LIKE '%_UU';

    2. 把 ZIP 結構驗證寫進 Maven build

    <plugin>
      <groupId>org.codehaus.mojo</groupId>
      <artifactId>exec-maven-plugin</artifactId>
      <executions>
        <execution>
          <phase>verify</phase>
          <goals><goal>exec</goal></goals>
          <configuration>
            <executable>bash</executable>
            <arguments>
              <argument>-c</argument>
              <argument>unzip -l resources/META-INF/2Pack_*.zip | grep -q "dict/PackOut.xml"</argument>
            </arguments>
          </configuration>
        </execution>
      </executions>
    </plugin>

    3. 每次 commit 前跑 deploy.sh –check

    加一個 dry-run 模式,只驗證 JAR 可部署、OSGi console 可連線、bundle 存在,不實際更新。讓這個檢查成為 commit hook。

    給下一個要做 iDempiere Plugin 的人

    iDempiere 的文件很少,很多行為只有讀原始碼才能理解。以下是花最多時間搞懂的六件事:

    # 規則 說明
    1ModelValidator 不是 OSGi 服務@Component 不會讓它跑起來,要用 EventHandler
    2SeqNoGrid 是 Grid View 必備欄位缺了不報錯,只在按「新增」時崩潰
    32Pack ZIP 結構有嚴格要求打包後一定要 unzip -l 驗證,絕不用 zip -j
    4_UU 欄位必須 IsUpdateable=Y否則 UUID 永遠 NULL
    5IEventTopics 常數不要用字串字面量比較 topic,靜默失效
    6Incremental2PackActivator 記住安裝歷史同版本不重裝,升版要改 ZIP 檔名

    後記:實作兩個流程(又踩了三個坑)

    文章發完當天,隨即著手實作原本標注為「計劃中功能」的兩支 SvrProcess

    • GenerateTaxStatementProcess — 聚合 TW_Invoice_Prefix_Map 的銷售資料、折讓,產生 TW_TaxStatement 申報記錄
    • ExportTaxReportProcess — 讀取 TW_TaxStatement,輸出財政部格式 CSV

    同時也需要把這兩個流程透過 2Pack 登錄到 iDempiere 的 AD_Process / AD_Menu,讓使用者可以從選單觸發。這一輪又多踩了幾個坑。

    坑 8:AD_Process_Para 的 AD_Process_ID 必須明確寫入

    2Pack 的元素處理器(ElementHandler)有一個隱藏的不一致性

    AD_Tab 時,把它放在 <AD_Window> 元素內,Tab 的 AD_Window_ID 會自動繼承父元素——不需要重複宣告。大多數巢狀元素都這樣工作。

    AD_Process_Para 不同。ProcessParaElementHandler 呼叫 filler.autoFill() 處理子元素,但不從父 <AD_Process> 讀取 context 填入 AD_Process_ID

    結果:2Pack 安裝過程看起來正常執行,沒有任何錯誤訊息,但 Process Para 根本沒有存入。直到手動查 DB 才發現空的。真正的錯誤需要翻 PostgreSQL log:

    tail -f /var/log/postgresql/postgresql-16-main.log
    # ERROR: null value in column "ad_process_id" violates not-null constraint

    修正:每個 <AD_Process_Para> 元素內都必須顯式宣告 AD_Process_ID

    <AD_Process_Para type="table">
      <AD_Process_ID reference="uuid" reference-key="AD_Process">{process-uuid}</AD_Process_ID>
      <Name>Statement Year</Name>
      ...
    </AD_Process_Para>

    坑 9:FieldLength NOT NULL(但 iDempiere log 不說)

    修好 AD_Process_ID 之後,再次安裝,再次失敗,再次查 PostgreSQL log:

    ERROR: null value in column "fieldlength" violates not-null constraint

    FieldLengthad_process_para 資料表中是 NOT NULL 欄位,但如果 PackOut.xml 沒有提供,iDempiere 的 log 只會說 Failed to save ProcessPara——沒有欄位名稱,沒有 SQL,什麼都沒有。

    不同 Reference 型別的預設長度:

    AD_Reference_ID 型別 FieldLength
    11Integer10
    17List1
    10String實際最大長度

    教訓:2Pack 的錯誤訊息有時候刻意模糊。遇到 Failed to save 之類的通用錯誤,直接去查 PostgreSQL log,那裡才有真相。

    坑 10:iDempiere 物理表在 adempiere schema,不在 public

    寫完 clean_reinstall.sh(一個用來清除所有 TW_* 字典並重新部署的腳本)後,發現 DROP TABLE 完全沒作用:

    DROP TABLE IF EXISTS TW_InvoicePrefix;  -- 執行成功,但表還在

    原來 iDempiere 12.0 把物理表建在 adempiere schema,而不是 public。用 information_schema.tables WHERE table_schema='public' 查詢,一張 TW_* 表都找不到。

    正確用法:

    -- 確認表存在
    SELECT tablename FROM pg_tables WHERE schemaname='adempiere' AND tablename ILIKE 'tw_%';
    
    -- 刪除表
    DROP TABLE IF EXISTS adempiere.TW_InvoicePrefix;
    DROP TABLE IF EXISTS adempiere.TW_Invoice_Prefix_Map;

    這也影響 psql 互動查詢——連線後預設 search_path 如果不含 adempiere,直接 SELECT * FROM TW_InvoicePrefix 會找不到表。

    SvrProcess 實作要點

    SvrProcess 的標準結構很直覺——prepare() 讀參數、doIt() 做事。幾個需要注意的地方:

    • 不要呼叫 ps.setAD_Client_ID():那是 PO 類別的 protected 方法,不是 PreparedStatement 的方法。在 SvrProcess 裡用 getAD_Client_ID() 取值,直接 ps.setInt(1, getAD_Client_ID())
    • DB.prepareStatement() 而不是 JDBC 直接連線:iDempiere 的 DB class 管理連線池和事務,不要繞過它。
    • 記得 DB.close(rs, ps):在 finally block 釋放資源。

    clean_reinstall.sh — 開發期間的救命工具

    開發期間反覆修改 PackOut.xml,每次都要手動清資料庫重裝,非常繁瑣。寫了一個腳本自動化整個流程:

    1. 依 FK 順序刪除所有 TW 字典(Window_Access → Process_Access → Field → Tab → Menu → Window → Process_Para → Process)
    2. Drop 物理表(adempiere.TW_*
    3. 清除後設資料(Column → Table → Sequence → Element → Reference → EntityType)
    4. 清 AD_Package_Imp_Detail / AD_Package_Imp(注意:Detail 要先刪,因為有 FK)
    5. 呼叫 deploy.sh 重新部署

    FK 刪除順序是最麻煩的部分——錯一個順序就會碰到 FK 違規,整個腳本失敗。從失敗中整理出來的正確順序如上。

    最終成果(v1.0.11)

    Bundle:    tw.idempiere.invoice.tax v1.0.0 (2Pack v1.0.11)
    Tests:     89 個,全數通過
    Tables:    4 張 TW_* 資料表
    Windows:   4 個 iDempiere 視窗
    Processes: 2 個(GenerateTaxStatement + ExportTaxReport)
    Fields:    73 個 AD_Field(含正確 SeqNoGrid)
    Menu:      7 個項目(1 父選單 + 4 視窗 + 2 流程)
    Status:    ACTIVE,Grid View 正常,流程可從選單觸發
  • 舊系統整合場景下,會用 vs 不會用 Claude Code 的差距

    重點摘要

    • 舊系統整合最大的陷阱:Claude 看不到舊系統,但舊系統的約束決定了新功能能不能用
    • 不會用的人:讓 Claude 在沒有舊系統上下文的情況下做新功能,結果接不上去
    • 會用的人:先讓 Claude 讀關鍵接口,CLAUDE.md 記錄現有約束,設計文件定義接合點
    • 三個場景示範:醫療 HIS 接 LINE 掛號、舊報表接新數據、舊 ERP 接電商平台

    上一篇文章講的是新功能開發。但現實是,大多數人面對的不是一張白紙,而是一個已經在跑的舊系統。

    舊系統整合比全新開發難十倍,不是因為技術更難,而是因為有一大堆隱性約束 Claude 不知道。格式、命名、邊界、不能動的地方,這些都在舊代碼裡,Claude 看不到就無從遵守。

    這篇用三個場景直接模擬:有舊系統在的情況下,會用和不會用 Claude Code 的差距。

    根本差距:Claude 的上下文只有你給它的部分

    Claude Code 非常聰明,但它只知道你告訴它的東西。在全新專案,你說什麼格式就用什麼格式,問題不大。在整合舊系統時,如果你沒有把舊系統的關鍵結構告訴它,它會做出一個邏輯正確但無法接上的東西。

    這不是 Claude 的問題,是你的輸入不完整。

    舊系統整合的核心挑戰是:讓 Claude 在動手之前,先理解它不能動的邊界在哪裡。

    場景一:醫院 HIS 系統加掛 LINE 預約掛號

    情境

    一家地區醫院用了十年的 HIS 系統(Windows Server + MSSQL),所有掛號邏輯都在 stored procedures 裡。現在要加 LINE Chatbot 讓患者能線上預約,但 HIS 系統不能動,只能從外部透過 API 呼叫它。

    不會用的人怎麼做

    幫我寫一個 LINE Chatbot 預約掛號系統,要能讓患者選科別、選醫師、選時段。

    Claude 做出一個完整的 LINE bot,資料庫設計清楚,對話流程順暢。

    開始對接 HIS 系統時,問題一個接一個:

    • HIS 的患者 ID 是 8 位數字,Claude 設計的是 UUID — 整個 primary key 要換
    • HIS 的診次代碼格式是 YYYYMMDD-科別碼-序號(例如 20260325-INT-001),Claude 自己設計了完全不同的格式
    • HIS 只接受 stored procedure 呼叫,不開放直接讀表,Claude 設計的是直接 SELECT
    • 科別代碼是 HIS 裡的維護資料(內科=INT、外科=SUR),Claude 用了自己的命名

    每一個問題單獨看都能修,但修完之後發現下一個格式又不對。最後花了兩天在做格式轉換,而不是做功能。

    結果:開發 3 天,對接 2 天,還有格式轉換層要長期維護。

    會用的人怎麼做

    第一步:先讓 Claude 讀懂舊系統的接口

    把 HIS 系統對外開放的 SP 簽名整理成文件,餵給 Claude:

    請先閱讀以下 HIS 系統的接口文件,
    告訴我你對這個系統的理解,特別是資料格式和呼叫限制,
    再討論 LINE bot 的架構設計。
    
    --- HIS 接口文件 ---
    sp_GetAvailableSessions
      @DeptCode NCHAR(3)      -- 科別碼,參照 tb_Dept.DeptCode
      @DateFrom NCHAR(8)      -- YYYYMMDD 格式
      @DateTo   NCHAR(8)
      回傳: SessionId NCHAR(16), DoctorName, SessionDate, RemainSlots
    
    sp_CreateBooking
      @PatientId NCHAR(8)     -- 8位數字,不足補0
      @SessionId NCHAR(16)    -- 從 sp_GetAvailableSessions 取得
      @Phone     NVARCHAR(20)
      回傳: BookingNo NCHAR(12), Status (SUCCESS/FULL/DUPLICATE)
    
    sp_CancelBooking
      @BookingNo NCHAR(12)
      @Reason    NVARCHAR(100)
    ---

    第二步:CLAUDE.md 記錄現有系統的約束

    ## 整合約束(HIS 系統,不可更動)
    - PatientId:8 位數字字串,不足補 0(例如 "00012345")
    - SessionId:格式為 YYYYMMDD-DeptCode-NNN(例如 "20260325-INT-001")
    - 所有 HIS 呼叫只能透過 stored procedure,不允許直接查表
    - 科別代碼參照 tb_Dept,常用:INT=內科、SUR=外科、PED=小兒科
    
    ## 新系統原則
    - LINE bot 層只做對話邏輯
    - HIS 接口層做格式轉換(HisAdapter class)
    - 不在 LINE bot 層直接呼叫 HIS

    第三步:設計文件定義接合點,不是重新設計格式

    # 功能:LINE 掛號 → HIS 預約對接
    
    ## 接合點(Integration Points)
    LINE 用戶選擇診次 → HisAdapter.getAvailableSessions(deptCode, dateRange)
      → 呼叫 sp_GetAvailableSessions
      → 回傳格式轉為 LINE flex message 可用的結構
    
    用戶確認掛號 → HisAdapter.createBooking(lineUserId, sessionId, phone)
      → 查詢或建立患者檔(PatientId)
      → 呼叫 sp_CreateBooking
      → 處理 FULL / DUPLICATE 回傳狀態
    
    ## 不碰的東西
    - HIS 資料庫 schema 完全不動
    - 所有 PatientId 維持 8 位補零格式
    - SessionId 格式完全繼承 HIS
    
    ## Done When
    - HisAdapter 有完整的 unit test(mock SP 回傳)
    - LINE bot 不包含任何 HIS 格式邏輯(全在 Adapter)

    結果:1.5 天完成,無格式轉換層,後續維護只在 HisAdapter 這一層。

    場景二:舊版股市報表腳本接新即時數據

    情境

    一個量化分析師用了三年的 Python 腳本,每天手動下載 CSV,跑一堆計算,輸出報表。現在要改成自動化:自動爬取數據、自動計算、推播到 Line Notify。但舊腳本的計算邏輯非常複雜(含自訂指標、過去三年調整過的參數),不能改,只能把數據來源換掉。

    不會用的人怎麼做

    幫我把這個股市分析腳本改成自動化,自動抓數據然後推 LINE 通知。

    Claude 看了舊腳本,做了一個新的自動化版本。看起來很整齊,比舊腳本乾淨很多。

    跑了一週,發現結果跟舊版不一樣:

    • 舊腳本的 RSI 計算用的是 Wilder 平滑法,Claude 預設用了 SMA 版本,數值不同
    • 舊腳本對成交量有一個「過去 20 日剔除最高最低各兩天後的平均」的自訂邏輯,新版沒有
    • 舊腳本在除權息日前後有特別處理,新版沒有
    • 欄位名稱被 Claude 重新命名了(舊版 vol_adj 在新版變成 adjusted_volume),下游所有 Excel 公式全壞

    分析師說:「它幫我重寫了,但重寫出來的東西跟我的不一樣,我現在不知道以哪個為準。」

    結果:新舊結果不一致,花了一週在驗算差異,反而比手動下載多花時間。

    會用的人怎麼做

    關鍵原則:計算邏輯一行都不能動,只換數據來源。

    第一步:讓 Claude 讀懂舊腳本,先提取它的業務邏輯

    請閱讀這個舊腳本,告訴我:
    1. 它依賴哪些輸入欄位(欄位名稱和格式)
    2. 有哪些自訂計算邏輯(非標準指標)
    3. 最後輸出哪些欄位
    
    不要修改任何東西,只告訴我你的理解。

    這一步讓 Claude 自己找出所有隱性依賴,避免後面遺漏。

    第二步:CLAUDE.md 把舊腳本的約束凍結

    ## 舊系統相容性約束(不可更動)
    - 所有輸出欄位名稱必須與 legacy_report.py 完全一致
      (vol_adj、rsi_wilder、price_adj 等,不得重新命名)
    - RSI 計算必須使用 Wilder 平滑法(非 SMA),與舊版等價
    - 成交量平均:去掉最高最低各 2 天後的 16 日均量
    - 除權息調整邏輯:參見 legacy_report.py 第 87-134 行,禁止修改
    
    ## 本次修改範圍
    - 只修改數據來源層(DataFetcher class)
    - 所有計算邏輯保持不變
    - 輸出格式和欄位名稱保持不變

    第三步:設計文件只描述「換什麼」,明確說「不換什麼」

    # 功能:數據來源自動化
    
    ## 要換的
    舊:手動下載 CSV 放在 ./data/ 目錄
    新:DataFetcher 自動從 TWSE API 抓取,存成相同格式的 DataFrame
    
    ## 不換的
    - 所有 calculate_*() 函數:一行都不動
    - 所有欄位名稱
    - 輸出的 Excel 格式和公式
    
    ## 驗證方式
    用同一天的歷史數據,新舊兩版並排執行,
    所有欄位數值差異必須在浮點誤差範圍內(< 0.0001)

    「驗證方式」這一段給了 Claude 一個清楚的完成定義:不是「看起來跑得動」,而是「跟舊版數字一樣」。

    結果:1 天完成,新舊並排驗算通過,分析師信心十足上線。

    場景三:舊訂單系統接新電商平台(Shopee / momo)

    情境

    一家中型品牌商有自己的後台訂單系統(自建,PHP + MySQL,跑了七年),現在要同時開 Shopee 和 momo 的店,訂單要自動回拋到自建系統,庫存要即時同步。自建系統的代碼文件不齊全,但不能大改,只能在外面包一層。

    不會用的人怎麼做

    幫我串接 Shopee 和 momo 的訂單,自動同步到我們自己的系統。

    Claude 寫了一個整合服務,讀 Shopee/momo 的 webhook,轉換格式,打進自建系統的 API。

    測試時發現:

    • 自建系統的商品 SKU 格式是 PRD-XXXXXXXX,但 Shopee 上的 SKU 是當初手動輸入的,有些根本對不上
    • 自建系統的訂單狀態只有 5 種,Shopee 有 12 種,Claude 的對照表做了一半,有幾個狀態沒處理
    • momo 的訂單金額包含平台折扣,自建系統的商品價格是定價,兩邊金額對不上帳
    • 自建系統在創建訂單時會觸發一個庫存扣減的觸發器,Claude 不知道這件事,導致庫存被扣兩次

    最後一個問題最嚴重:庫存被扣兩次,一直到實際出貨才發現。

    結果:開發 4 天,測試又 3 天,上線後還是出現庫存問題。

    會用的人怎麼做

    舊系統不熟,先讓 Claude 幫你挖清楚它的邊界。

    第一步:讓 Claude 讀舊系統,主動找隱性行為

    請閱讀這個訂單系統的代碼,特別注意:
    1. 創建訂單時會觸發哪些副作用(觸發器、事件、其他表的更新)
    2. 庫存扣減在哪裡發生(是 API 層還是資料庫層)
    3. 訂單狀態的完整清單和流轉規則
    
    告訴我所有你發現的隱性行為,不要開始寫任何代碼。

    「隱性行為」這個說法很重要。Claude 在讀舊代碼時會主動找資料庫 trigger、事件監聽器、side effect,這些是最容易被遺漏的整合風險。

    第二步:用 Plan Mode 規劃整合架構,重點在「邊界」

    【背景】
    自建訂單系統(PHP + MySQL),創建訂單時資料庫層會自動扣減庫存(trigger)。
    Shopee 和 momo 各有自己的訂單狀態體系。
    
    【目標】
    Shopee/momo 新訂單自動進到自建系統,庫存只扣一次,狀態對照完整。
    
    【約束條件】
    - 自建系統的 trigger 不能改(沒有完整文件,風險太高)
    - SKU 對照表需要人工確認後再上線(不能用 Claude 猜測)
    - momo 的折扣金額要分開記錄,不能直接入自建系統的商品價
    
    請制定整合架構計劃,
    特別說明如何避免庫存被扣兩次,
    等我確認計劃後再開始實作。

    Plan Mode 裡,Claude 提出了三個方案,推薦的是:新建一個 OrderBridge 服務,只負責格式轉換和狀態對照,在呼叫自建系統 API 之前先把庫存檢查做在 Bridge 層(避免 API 呼叫失敗後 trigger 已執行的問題)。

    第三步:設計文件把 SKU 對照、狀態對照、金額拆分全部定義清楚

    ## SKU 對照策略
    - 對照表存在 sku_mapping 資料表,人工審核後才生效
    - 找不到對照的 SKU:訂單進 pending_review 佇列,不自動處理
    - 禁止猜測或模糊匹配
    
    ## 訂單狀態對照(Shopee → 自建)
    UNPAID        → 不同步(等付款)
    READY_TO_SHIP → processing
    SHIPPED       → shipped
    COMPLETED     → completed
    CANCELLED     → cancelled
    其他狀態      → 記錄 log,不同步,發警報
    
    ## momo 金額處理
    momo_original_price → 自建系統 unit_price
    momo_discount       → 另存 discount_record 資料表
    momo_final_price    → 自建系統 actual_amount
    (三個欄位分開記錄,不做合併計算)

    結果:2 天完成 OrderBridge,庫存零重複扣減,SKU 對照人工審核後上線,穩定運行。

    舊系統整合的核心技巧整理

    技巧 怎麼做 目的
    先讓 Claude 讀現有接口提供 API 簽名、schema、關鍵函數,要求 Claude 說出理解後再動手讓 Claude 在正確的上下文下設計,不做無效假設
    要求 Claude 找隱性行為明確說「找出所有 trigger、side effect、副作用,不要開始寫代碼」在整合前把地雷挖出來,不是做完才踩到
    CLAUDE.md 凍結不能動的邊界在 CLAUDE.md 明確寫「不得修改 X 格式 / Y 欄位名稱 / Z 計算邏輯」Claude 不會因為「更整齊」而擅自重新命名或重構
    設計文件定義接合點,不定義格式設計文件描述「新舊系統在哪裡接觸」,格式繼承舊系統,不重新設計避免做一個格式轉換層長期維護
    驗證方式要對比舊系統Done When 裡明確說「與舊系統同樣輸入,輸出結果必須一致」讓 Claude 自己驗算新舊等價性,不是跑起來就算完成

    一個可以直接用的提示模板

    每次要在舊系統上加新功能,用這個模板啟動:

    【現有系統】
    [描述現有系統的技術棧和關鍵接口,附上接口文件或關鍵代碼片段]
    
    【要加的功能】
    [一句話描述]
    
    【不能動的邊界】
    - [現有系統的格式、欄位名稱、計算邏輯等不能更動的項目]
    - [如果有 DB trigger 或其他隱性行為,也列在這裡]
    
    【請先做這兩件事,再開始設計】
    1. 告訴我你對現有接口的理解(特別是你覺得可能影響整合的部分)
    2. 指出你看到的整合風險(格式衝突、重複操作、狀態對照缺口)
    
    確認理解正確後,我們再討論設計。

    這個模板做了三件事:把舊系統的上下文給 Claude、凍結不能動的邊界、要求 Claude 在動手前先說出它的理解和風險判斷。

    常見問題

    Q:舊系統的代碼很亂,文件也沒有,怎麼辦?

    先讓 Claude 讀代碼,問它「這個系統對外暴露了哪些接口?資料庫有哪些關鍵的 table 和 trigger?」讓 Claude 幫你反向整理出一份接口文件,確認沒有遺漏後再進行整合設計。不要跳過這一步,這是整合成功的前提。

    Q:整合的範圍不確定,不知道要動到哪裡?

    先用 Plan Mode,給 Claude 目標和約束,讓它畫出影響範圍。Plan Mode 的價值在複雜整合場景特別高,因為它會列出所有相關的文件和狀態流,讓你確認範圍是否正確,再決定怎麼切入。

    Q:新功能做完,但舊系統有些邏輯說不清楚,怕 Claude 做錯?

    這種情況用「新舊並排驗算」:先在測試環境讓舊版和新版用同樣輸入各跑一次,比較輸出。在設計文件裡明確寫出這個驗證條件,Claude 就會把「新舊等價」列為完成定義的一部分。

    從不會用到會用:舊系統整合的具體升級路徑

    舊系統整合的升級路徑跟全新開發不太一樣:問題不是「我沒說邊界條件」,而是「Claude 根本不知道舊系統長什麼樣」。所以升級的重點是:在動手前把舊系統的關鍵資訊餵給 Claude

    第一步:今天就能做到(一個句子)

    在你的提示前面加上這段:

    我有一個現有系統,新功能必須跟它相容。
    
    現有系統的關鍵格式:
    [把你知道的接口格式、欄位名稱、資料格式貼在這裡]
    
    新功能不能更動上面這些格式。先告訴我你的理解,再開始設計。

    就算你只知道一部分格式,先寫一部分。有比沒有好。

    醫療 HIS 整合:Level 0 → Level 1

    不會用(Level 0) 初步會用(Level 1)
    幫我串接 HIS 系統做 LINE 掛號(見下方)
    我有一個舊的 HIS 系統(不能修改),需要在外面接一個 LINE 掛號 bot。
    
    HIS 現有接口(Stored Procedure):
    sp_GetAvailableSessions(@DeptCode NCHAR(3), @DateFrom NCHAR(8), @DateTo NCHAR(8))
    sp_CreateBooking(@PatientId NCHAR(8), @SessionId NCHAR(16), @Phone NVARCHAR(20))
      回傳 Status: SUCCESS / FULL / DUPLICATE
    
    格式約束(不能改):
    - PatientId 是 8 位數字字串,不足補 0
    - 所有呼叫只能透過 stored procedure,不能直接查表
    
    請先告訴我:
    1. 你對這個 HIS 接口的理解
    2. 你覺得 LINE bot 和 HIS 之間需要什麼樣的轉換層
    
    確認後再討論架構設計。

    舊報表腳本接新數據:Level 0 → Level 1

    我有一個舊的 Python 分析腳本,計算邏輯不能動,只要換掉數據來源(從手動 CSV 改成自動抓取)。
    
    舊腳本的輸入依賴(這些欄位名稱不能改):
    - date (YYYY-MM-DD)
    - open, high, low, close, volume
    - vol_adj(自訂欄位:去掉最高最低各2天的16日均量)
    
    計算邏輯不能動的原因:
    - RSI 用的是 Wilder 平滑法(非標準 SMA),三年來的報告都是這個版本
    - 改了數值會跟歷史記錄不一致
    
    請先告訴我:
    1. 你理解哪些東西不能動
    2. 你打算怎麼讓新舊版本在同樣輸入下輸出一致的結果
    
    確認後再開始實作數據來源層。

    舊 ERP 接電商平台:Level 0 → Level 1

    我有一個舊的訂單系統(PHP + MySQL),要串接 Shopee 的訂單進來。
    舊系統不能大改,只能在外面包一層。
    
    已知的舊系統行為:
    - 創建訂單時,資料庫層有 trigger 會自動扣庫存
    - 訂單狀態:pending / processing / shipped / completed / cancelled(只有這五種)
    
    Shopee 訂單狀態有 12 種,需要做對照。
    
    我擔心的問題:
    - 庫存被扣兩次(trigger + 外面的 API 都扣)
    - 狀態對照不完整,有些 Shopee 狀態沒有對應的舊系統狀態
    
    請先幫我分析這兩個風險,告訴我你的處理方案,再開始設計架構。

    注意最後一段:把你擔心的問題說出來。這讓 Claude 優先處理你知道有風險的地方,而不是從它認為重要的地方開始。

    第二步:下週可以做到(CLAUDE.md 記錄舊系統約束)

    舊系統整合的 CLAUDE.md 要特別加一個「整合約束」區塊:

    # 整合約束(現有系統,不可更動)
    
    ## [系統名稱] 的格式規範
    - [欄位名稱] 的格式:[說明]
    - [接口規範]:[說明]
    - 禁止直接操作資料庫,只能透過 [API / SP / 特定方法]
    
    ## 已知的隱性行為
    - [觸發器 / 事件 / side effect 描述]
    - 注意:[操作 X] 會同時觸發 [Y],不要重複執行
    
    ## 新功能的邊界
    - 只修改 [Adapter / Bridge / 特定模組],不動舊系統
    - 欄位名稱繼承舊系統,不重新命名

    每次發現一個新的隱性行為(觸發器、計算邏輯、格式特例),就加進這個區塊。它會越來越完整,讓你以後的整合工作越來越省力。

    第三步:一個月後的進化

    進化 A:在開始任何整合前,先讓 Claude 找隱性行為

    請閱讀 [現有系統代碼/文件],找出所有:
    1. 資料庫 trigger 和它的作用
    2. 創建/更新/刪除記錄時的 side effect
    3. 對外暴露的接口(API / SP / 事件)和它們的格式
    
    告訴我你找到的所有隱性行為,不要開始設計,先讓我確認。

    這個步驟在整合開始前把地雷挖出來,比做完後踩到省 10 倍時間。

    進化 B:複雜整合用 Plan Mode,重點讓 Claude 畫出影響範圍

    【現有系統】[描述]
    【目標】[一句話]
    【不能動的邊界】[列清楚]
    
    請制定整合計劃,特別標出:
    - 會影響到的現有功能
    - 每個整合點的風險
    - 我需要在上線前人工驗證的項目
    
    等我確認計劃後再開始實作。

    進化 C:設計文件加「新舊等價性驗證」

    舊系統整合的完成定義,永遠要加這一條:

    ## 驗證方式
    用同一份歷史輸入,新舊兩版並排執行,
    [關鍵輸出欄位] 的結果差異必須在 [可接受範圍] 內。
    新版上線前,提供新舊對比報告。
    階段 做什麼 關鍵句
    Level 0直接說要串接什麼
    Level 1(今天)把舊系統格式和約束說清楚「這些格式不能改,告訴我你的理解再開始」
    Level 2(下週)CLAUDE.md 記錄舊系統約束和隱性行為每次整合後補一條「已知隱性行為」
    Level 3(一個月後)先讓 Claude 挖地雷,再 Plan Mode 確認影響範圍「找出所有隱性行為,不要開始設計」
  • 會用 vs 不會用 Claude Code:醫療、股市、電商三場景對比

    重點摘要

    • 差距不在「會不會用 AI」,在「有沒有給 AI 做事的結構」
    • 不會用的人:一句話丟需求 → 結果跑偏 → 多輪修正 → 累積沮喪
    • 會用的人:CLAUDE.md + 設計文件 + 分段驗證 → 第一次就對
    • 三個真實場景(醫療 / 股市 / 電商)示範具體差距

    「Claude Code 對我沒用,我試過,它做出來的東西都不對。」

    這句話我聽過很多次。幾乎每次深入問,問題都不在 Claude,在提問的方式。

    這篇文章用三個真實的軟體開發場景,直接模擬「不會用的人」和「會用的人」各自怎麼做,讓你看清楚差距在哪裡。

    核心差距:有沒有給 AI 做事的結構

    不會用 Claude Code 的人,把它當成「很聰明的搜尋引擎」,問一句,期待完美答案。

    會用的人,把它當成「需要完整簡報才能開工的新進工程師」:你給的資訊越清楚,他做出來的東西越準確。

    差距的本質是:你的輸入有沒有結構

    場景一:醫療預約系統(門診掛號)

    痛點

    診所要做線上掛號,需要處理:醫師排班、假日停診、同時段人數上限、患者重複掛號防止。每個規則都有例外,例外裡還有例外。

    不會用的人怎麼做

    直接開口:

    幫我寫一個門診預約系統的 API,要能掛號、取消、查詢。

    Claude 給了一個乾淨的 REST API,CRUD 完整,code 看起來不錯。

    開始整合後發現問題一個接一個:

    • 沒處理同時段上限(診所每診次只收 20 人,API 沒有 capacity 邏輯)
    • 沒有重複掛號檢查(同一個患者可以掛同一診次兩次)
    • 沒有假日判斷(系統不知道什麼是醫院休診日)
    • 時區沒處理(台灣 UTC+8,stored as UTC,前端顯示全錯)

    開始補需求:「喔對,要加上每診次人數上限」→ Claude 修改。「還有重複掛號要擋掉」→ Claude 再改。「假日要停診」→ 再改。每次修改都可能動到之前改好的邏輯,三輪之後代碼開始難以追蹤。

    結果:8 小時,仍有未發現的 bug,信心不足。

    會用的人怎麼做

    第一步:CLAUDE.md 裡早就寫好領域規則

    ## 醫療領域規則
    - 所有時間以 UTC+8 儲存,API 回應包含 timezone 欄位
    - 患者 ID 採用身分證字號格式,需驗證格式(字母+9位數字)
    - 診次 (session) 為最小預約單位,每診次有 capacity 上限
    - 相同患者在同一診次只能有一筆有效預約
    
    ## 錯誤處理標準
    - 業務邏輯錯誤:HTTP 422 + { code, message, field }
    - 驗證錯誤:HTTP 400 + 同上格式

    第二步:先寫設計文件(20 分鐘)

    # 功能:門診掛號 API
    
    ## 輸入
    { patientId: string, sessionId: string }
    
    ## 驗證順序(按此順序,遇錯即停)
    1. patientId 格式是否合法
    2. sessionId 是否存在且為未來時間
    3. 該診次是否已達 capacity
    4. 該患者是否已有此診次的有效預約
    
    ## 邊界條件
    - capacity 滿:422,code: SESSION_FULL
    - 重複掛號:422,code: DUPLICATE_BOOKING
    - 患者格式錯:400,code: INVALID_PATIENT_ID
    - 診次已過去:422,code: SESSION_EXPIRED
    
    ## Done When
    - 所有邊界條件有單元測試
    - integration test 驗證完整掛號流程
    - 0 TypeScript errors

    第三步:分段給任務

    請先閱讀 CLAUDE.md 和這份設計文件。
    閱讀完告訴我你的理解,特別是驗證順序的邏輯,再開始實作。
    先只實作 validateBooking() 函數和對應單元測試,不要動 API 層。

    確認驗證邏輯正確後,再說「現在依照這個驗證函數實作 POST /bookings endpoint」。

    結果:2.5 小時,第一次就通過所有測試,零修改。

    場景二:股市回測系統

    痛點

    想驗證一個交易策略:當 5 日均線上穿 20 日均線時買進,死亡交叉時賣出。聽起來簡單,但台股有漲跌停、只能在交易日交易、最小交易單位是 1000 股、手續費和證交稅要扣掉,每個細節都會影響回測結果。

    不會用的人怎麼做

    幫我寫一個台股回測程式,策略是 5 日均線上穿 20 日均線買進,死叉賣出。

    Claude 給了一個 Python 回測,用 pandas 計算均線,邏輯清楚。跑了一下,報酬率看起來很漂亮。

    仔細看才發現:

    • 買賣都用收盤價,但實際上收盤價買不到(要用隔日開盤)
    • 沒有漲跌停限制,假設任何價格都能成交
    • 沒扣手續費(0.1425%)和證交稅(0.3%)
    • 沒有最小交易單位,0.1 張也買
    • 跑到非交易日(週末)的資料也在交易

    這些問題加起來,讓回測結果虛報了約 30–40%。一個看起來賺錢的策略,修正後可能是虧損的。

    結果:6 小時修修改改,最後不確定結果是否可信。

    會用的人怎麼做

    設計文件把所有台股規則先寫清楚:

    # 功能:台股均線策略回測引擎
    
    ## 市場規則(必須完整實作)
    - 交易日:排除週末 + 台灣國定假日(用 holidays-tw 套件)
    - 成交價:訊號發生在收盤,執行在「隔一個交易日開盤價」
    - 漲跌停:每日最大漲跌幅 ±10%,超過範圍無法成交,標記 LIMIT_HIT
    - 最小交易單位:1000 股,不足整張無法下單
    - 手續費:買賣各 0.1425%(可設定),最低 20 元
    - 證交稅:賣出 0.3%(ETF 為 0.1%)
    
    ## 回測輸出格式
    {
      total_return_pct: float,    # 扣除所有費用後
      trades: [{ date, action, price, shares, cost, tax, net_pnl }],
      unfilled: [{ date, reason }],   # LIMIT_HIT 等無法成交紀錄
      sharpe_ratio: float,
      max_drawdown_pct: float
    }
    
    ## 邊界條件
    - 漲停無法賣出(LIMIT_HIT_SELL)
    - 跌停無法買入(LIMIT_HIT_BUY)
    - 資金不足一張:跳過,記錄 INSUFFICIENT_FUNDS
    - 最後持倉:回測結束日強制以收盤價平倉

    提示方式:

    請先閱讀這份設計文件,告訴我:
    1. 成交時間點的邏輯(訊號日 vs 執行日)你的理解
    2. 漲跌停時的處理流程
    
    確認理解正確後,先只實作 TaiwanMarketRules 類別和測試,
    包含漲跌停判斷、交易日判斷、手續費計算三個方法。
    不要實作策略邏輯或回測引擎。

    結果:3 小時,回測結果可信,且有完整的無法成交紀錄供分析。

    場景三:電商金流串接(綠界 ECPay)

    痛點

    要串接台灣最常用的金流服務商,支援信用卡一次付清 + ATM 虛擬帳號。牽涉到加密簽章驗證、非同步回調(ReturnURL / OrderResultURL)、以及各種付款失敗情境。

    不會用的人怎麼做

    幫我串接綠界金流,支援信用卡和 ATM 付款。

    Claude 寫了一個串接,看起來完整。測試環境跑起來。上線後三天,開始收到客訴:

    • 付款成功但訂單沒更新(沒有正確處理綠界的非同步通知)
    • ATM 超時未付款,訂單卡在「待付款」沒有自動取消
    • CheckMacValue 驗簽偶發失敗(特殊字元的 URL encode 方式不對)
    • 退款流程完全沒實作(以為付款和退款是同一組 API)

    每個問題單獨看都能修,但修一個又會影響另一個。金流 bug 是最難測試的,因為要模擬真實付款行為。

    結果:上線後 bug,緊急修補兩天,損失用戶信任。

    會用的人怎麼做

    先用 Plan Mode 讓 Claude 梳理所有狀態流:

    【背景】
    我要串接綠界 ECPay,使用 Node.js + PostgreSQL,
    CLAUDE.md 中規定所有金流操作要記 audit log。
    
    【目標】
    支援信用卡一次付清和 ATM 虛擬帳號,訂單狀態要與付款狀態完全同步。
    
    【約束條件】
    - 不能有「已付款但訂單未更新」的狀態
    - ATM 超過 3 天未付款自動取消訂單並釋放庫存
    - CheckMacValue 驗簽失敗必須記錄並告警
    
    請制定實作計劃,列出所有需要處理的狀態流轉,
    等我確認計劃後再開始實作。

    Claude 在計劃中列出了 14 個狀態節點,包含「非同步通知重複到達」、「驗簽失敗的處理」這些不明顯的情境。確認計劃後再開始做。

    設計文件特別標明的邊界條件:

    ## 邊界條件(金流特有)
    - ReturnURL(非同步)和 OrderResultURL(同步)可能「都」被呼叫
      → 用 idempotency key 確保同一筆交易只更新一次訂單
    - CheckMacValue 驗簽:URL encode 順序必須按綠界規範(非標準 encodeURIComponent)
      → 測試案例需包含含特殊字元的 MerchantTradeNo
    - ATM 逾期:用 pg-cron 排程,不要依賴前端觸發
    - 退款:獨立 API,與付款 API 完全分離,需要人工審核 flag

    結果:4 小時,上線零 bug,audit log 完整。

    差距總結

    面向 不會用的人 會用的人
    提示方式「幫我做 X」「背景 + 目標 + 約束條件 + 邊界條件」
    領域規則每次用到才補說寫進 CLAUDE.md,一次設定永久生效
    邊界條件做完後才發現沒處理設計文件裡最前面就列清楚
    複雜任務一次丟,結果跑偏再修Plan Mode 確認方向,分段執行
    驗證方式全部做完再整體測試每段完成後先確認,錯誤早期發現
    對話輪數8–15 輪2–4 輪
    結果品質能跑,但邊界不完整完整,測試覆蓋所有邊界

    為什麼大多數人卡在「不會用」

    不是因為懶,是因為直覺錯了。

    我們習慣搜尋引擎:問一句,得到答案。Claude Code 不是搜尋引擎,它是一個需要簡報才能開工的工程師。你給的資訊越完整,它做出來的東西越準確。

    轉換這個直覺需要時間,但一旦轉過來,你就不會想回頭了。

    從下一個任務開始,試著在開口前先回答三個問題:

    1. 這個功能的輸入和輸出各是什麼格式?
    2. 哪些邊界條件會讓它失敗?
    3. 我怎麼確認它做對了?

    把這三個問題的答案寫下來,你的 Claude Code 使用效果會立刻不一樣。

    常見問題

    Q:每次都要先寫設計文件,不會很花時間嗎?

    一份最小版設計文件 20 分鐘。省下的多輪修改至少 2–4 小時。這筆帳很好算。

    小功能(一個函數、修一個 bug)不需要設計文件,直接做。設計文件是為了「有多個邊界條件、或跨越兩層以上架構」的任務。

    Q:CLAUDE.md 不是每個專案都要重寫一遍嗎?

    寫一次,維護成本低。每次發現 Claude 做了一個你不喜歡的決定,把那條規則加進 CLAUDE.md,下次就不會再發生。它是越用越省力的資產。

    Q:我的需求很複雜,文件要寫到多詳細?

    詳細到「可以讓另一個工程師依照這份文件實作,不需要問你問題」。如果你寫完文件還是覺得有地方模糊,那就是還有沒想清楚的需求,先想清楚再開始比較省事。

    從不會用到會用:各情境的具體升級路徑

    看完上面三個場景,你可能知道差距在哪,但還不知道今天要改什麼。這一節給你一條明確的路,從第一步到進階,每步都有可以直接複製的東西。

    第一步:今天就能做到(不需要任何準備)

    在你現在的任何提示最後加上這一句:

    先告訴我你的理解和假設,再開始實作。

    就這一句。它會讓 Claude 在動手前先說出它的假設,你可以在它寫出一堆錯誤代碼前糾正它。成本 0,效果立竿見影。

    三個情境的具體升級示範:

    醫療場景:Level 0 → Level 1

    不會用(Level 0) 初步會用(Level 1)
    幫我寫門診預約系統(見下方)
    幫我寫門診掛號的 API。
    
    輸入:{ patientId: string, sessionId: string, phone: string }
    輸出:{ bookingNo: string, status: "SUCCESS" | "FULL" | "DUPLICATE" }
    
    必須處理:
    - 同一診次超過容量上限 → 回傳 FULL
    - 同一患者重複掛同一診次 → 回傳 DUPLICATE
    - 所有時間以 UTC+8 處理
    
    先告訴我你的理解,特別是這三種情況你打算怎麼處理,再開始實作。

    改變了什麼:加了輸入輸出格式、三個邊界條件、確認步驟。這 5 分鐘的準備,省掉事後發現「沒處理重複掛號」的 2 小時修改。

    股市場景:Level 0 → Level 1

    幫我寫台股均線回測。策略:5日均線上穿20日均線買進,死叉賣出。
    
    台股特有規則(全部都要實作):
    - 訊號發生在收盤,執行在隔交易日開盤價(不是同日收盤)
    - 漲跌停 ±10%,超過無法成交,記錄 LIMIT_HIT
    - 手續費:買賣各 0.1425%,最低 20 元
    - 證交稅:賣出 0.3%
    - 最小交易單位 1000 股,不足不下單
    
    先告訴我這些規則你的理解,特別是成交時間點的邏輯,再開始寫。

    電商金流場景:Level 0 → Level 1

    幫我串接綠界 ECPay,支援信用卡一次付清。
    
    必須處理的情況:
    - ReturnURL(非同步)和 OrderResultURL(同步)可能都會被呼叫,
      同一筆交易只能更新訂單一次(要有 idempotency 機制)
    - CheckMacValue 驗簽失敗:記 log,不更新訂單,回傳 0|Error
    - 付款成功後訂單狀態必須立刻更新
    
    先告訴我你打算怎麼處理「非同步和同步通知都到達」這個情況,再開始實作。

    第二步:下週可以做到(CLAUDE.md)

    把你的領域規則寫成一個檔案,放在專案根目錄,命名 CLAUDE.md。之後每次對話都不用再重複說這些規則。

    先從最小版開始,15 分鐘寫完:

    # 專案規則
    
    ## Tech Stack
    [填你用的技術]
    
    ## 這個領域的特殊規則
    [把你每次都要補充說明的規則寫在這裡]
    例:
    - 台股交易日排除週末 + 台灣國定假日
    - 醫療系統所有時間 UTC+8
    - 金流驗簽失敗一律記 log 不更新訂單
    
    ## 代碼標準
    [你每次都要糾正的習慣寫在這裡]
    例:
    - 禁止 any 類型
    - 每個函數必須有對應測試
    - API 錯誤格式統一:{ code, message, field }
    
    ## 邊界條件規則
    [這個專案特有的邊界處理方式]

    寫好之後,下次開口前說「請先讀 CLAUDE.md」或什麼都不用說(Claude Code 會自動載入)。

    第三步:一個月後的進化(設計文件 + Plan Mode)

    當你習慣 Level 1 之後,下一步是在動手前多寫一份設計文件。這份文件不是給人看的,是給 Claude 看的,20 分鐘寫完,節省的是事後 4–6 小時的修改。

    設計文件的最小格式:

    # 功能:[名稱]
    
    ## 輸入 / 輸出
    輸入:[JSON 範例]
    輸出:[JSON 範例]
    
    ## 邊界條件(必須處理)
    - [條件 A] → [預期行為]
    - [條件 B] → [預期行為]
    
    ## 完成定義
    - [ ] 所有邊界條件有對應單元測試
    - [ ] TypeScript 0 errors

    有了設計文件之後,任務的啟動提示變成:

    請先閱讀 CLAUDE.md 和 [設計文件名]。
    閱讀完告訴我你的理解,特別是邊界條件部分。
    確認後先只實作 [最小單元],不要動其他層。

    當任務更複雜(跨 5 個以上文件、不可逆的架構改動),改用 Plan Mode 啟動:

    請先制定實作計劃,列出方案選項和取捨,等我確認後再開始執行。
    階段 做什麼 時間投資 節省的來回
    Level 0直接說需求00
    Level 1(今天)加輸入/輸出/邊界 + 確認步驟5 分鐘/次2–4 輪
    Level 2(下週)CLAUDE.md 寫入領域規則15 分鐘(一次性)每次省 1–2 輪
    Level 3(一個月後)設計文件 + Plan Mode20 分鐘/功能第一次就對
  • 舊系統不死,AI 讓它進化:不重寫也能持續成長

    重點摘要

    • 舊系統不是問題,缺乏 AI 輔助才是問題——AI 讓「不重寫、持續演進」成為可行選項
    • 歷史證明:超過 80% 的「重寫計畫」以失敗或兩個系統都要維護收場
    • AI 最確定的價值是讀懂舊代碼、補文件、補測試,讓團隊敢繼續開發
    • 「AI 能不能讓大規模翻新變安全」——這是尚待驗證的命題,不宜過度樂觀

    你的公司有一套跑了十年的系統。它能動,它撐起了整個業務,但沒有人敢碰它。文件不齊、邏輯散落在各處、原始開發者早就離職了。每次有人提議「重寫」,討論就會陷入沉默——因為大家心裡都知道,這條路走過很多次,沒幾次是成功的。

    AI 的出現,讓這個困境有了新的解法。但不是你想像中的那種解法。

    為什麼重寫這麼難?歷史給了殘酷的答案

    軟體界有個著名的現象叫做 Second System Effect(第二系統效應),由《人月神話》作者 Fred Brooks 提出:工程師在重寫時,往往會把所有「本來想做卻沒做」的功能都塞進去,結果新系統比舊系統更複雜、更難維護。

    但更根本的問題是:舊系統裡有隱藏的業務邏輯,它們沒有寫在文件裡,只存在於代碼的行為中——某個奇怪的判斷式、某個例外處理、某個只在特定情境才觸發的路徑。這些邏輯,是十年來無數個「為什麼這樣做?」的答案。

    重寫計畫的典型失敗模式

    • 兩個系統並行期拉太長:新舊系統同時維護,工程師精力分散,bug 在兩邊都出現
    • 上線那天發現漏了邏輯:舊系統某個角落的行為,新系統根本沒有對應
    • 商業壓力中斷重寫:計畫進行到一半,業務需求改變,只能把新舊系統黏在一起
    • 重寫後反而更慢:新架構雖然「漂亮」,但少了十年累積的效能優化細節

    這不是悲觀,這是現實。重寫不是不可能,但它的成功率遠低於大多數人的預期。在 AI 出現之前,面對舊系統,企業只有兩條路:硬撐,或者賭一把。

    AI 帶來了第三條路:輔助成長

    AI 最確定的價值,不是幫你重寫系統,而是讓「不重寫、持續演進」這件事變得可持續。

    過去,舊系統的最大問題不是代碼本身,而是「沒有人理解它」。原始開發者離職了,知識沒有傳承;文件過時了,沒有人有時間更新;要改一個功能,必須花三天理解上下文,才敢動一行代碼。

    AI 改變了這個方程式。

    AI 能為舊系統做什麼?

    挑戰 過去的困境 AI 輔助後
    理解舊代碼 要花幾天甚至幾週閱讀 AI 幾分鐘內產出架構圖和流程說明
    補充文件 沒時間寫、寫了也快過時 AI 根據現有代碼生成,每次修改後更新
    補充測試 舊系統通常 0 測試,改動沒有安全網 AI 針對現有行為補寫測試,改動有保護
    修復 bug 怕改了 A 壞了 B,只敢最小化改動 AI 追蹤影響範圍,降低連帶破壞風險
    新人上手 要跟著老人學幾個月才敢動 AI 隨時解釋任何一段代碼的邏輯和背景
    新增功能 不知道該插在哪裡,怕破壞現有邏輯 AI 建議最小侵入式的擴充點

    實際做法:AI 如何輔助舊系統成長

    第一步:讓 AI 讀懂系統,產出活的文件

    不要急著改代碼。第一件事是讓 AI 理解現有系統,然後把理解結果固化成文件。

    # 讓 AI 讀整個 codebase,產出架構說明
    # 在 Claude Code 中,直接描述你的需求:
    
    「請閱讀這個專案的所有代碼,並產出:
    1. 系統架構圖(模組與模組之間的關係)
    2. 核心業務流程說明(從使用者角度描述主要流程)
    3. 高風險區域列表(邏輯最複雜、最不敢動的地方)
    4. 技術債清單(有哪些地方明顯需要改善)」

    這份文件不是給外部人看的,是給你的團隊每天使用的工作手冊。它會隨著系統改動而更新——這一點很重要,因為過去文件之所以沒用,是因為沒有人有時間維護它。AI 讓維護文件的成本降低了 90%。

    第二步:為現有行為補測試,建立安全網

    舊系統的問題不是「代碼爛」,而是「沒有測試保護」。任何一個修改都是在沒有安全網的情況下走鋼絲。

    AI 可以閱讀現有代碼,理解它的行為,然後為這些行為寫測試——即使這些行為從來沒有文件。

    # 範例:讓 AI 為現有函式補測試
    「這個函式 calculateDiscount() 已經跑了八年,
    請分析它的所有分支條件,為每個分支寫一個測試案例,
    包括正常情況、邊界值和異常情況。
    不要改動現有邏輯,只補充測試。」

    有了測試,團隊才敢改動。改動有安全網,系統才能持續演進而不是不斷累積技術債。

    第三步:最小侵入式地新增功能

    新增功能不等於重構整個模組。AI 擅長找到「最小侵入式的擴充點」——在不動現有邏輯的前提下,把新功能插進去。

    這個原則來自 Open/Closed Principle(開放封閉原則):對擴充開放,對修改封閉。即使舊系統沒有遵循這個原則,AI 也可以建議如何在外圍包一層,讓新功能不影響舊邏輯。

    第四步:漸進式現代化,而非大爆炸式重寫

    如果真的有部分需要改善,AI 輔助的方式是:一次只動一個模組,改完之後讓它穩定跑一段時間,確認沒有問題再動下一個。

    這不是「重寫」,這是「漸進式現代化」。兩者的關鍵差異:

    重寫 漸進式現代化
    範圍 全部 一次一個模組
    風險 集中在上線日 分散,每步都可以回滾
    業務中斷 長期並行維護兩個系統 系統持續運作,局部更新
    AI 的角色 「幫我重新實作這一切」 「幫我安全地改善這一塊」

    那麼,「重寫」這條路呢?

    這是一個需要誠實面對的問題。

    過去的答案很清楚:重寫計畫成功率低,風險高,通常不是好選擇。大多數成功的案例,仔細看都是「漸進式替換」而不是「一次性重寫」。

    AI 會改變這個答案嗎?

    這是一個尚待驗證的命題。AI 確實讓理解舊系統更容易,讓知識遷移成本降低,理論上應該讓重寫的準備工作做得更完整。但「做得更完整的準備」不等於「執行時不會出問題」。隱藏的業務邏輯、時序問題、效能細節——這些在代碼裡只有在跑了幾百萬筆資料之後才會浮現。

    更誠實的說法是:

    • AI 讓「輔助成長」這條路變得可行——這是現在就可以驗證的事
    • AI 讓「重寫」變得更安全——這是有可能的,但還需要更多實際案例來驗證
    • AI 能取代「漸進式替換」的謹慎原則——不太可能,這個原則的價值在於限制風險暴露,而不是技術能力

    所以,如果有人告訴你「有了 AI,重寫就不危險了」——保持懷疑。如果有人告訴你「AI 讓你不需要擔心舊系統的技術債了」——同樣保持懷疑。

    如何判斷你的系統需要什麼?

    面對舊系統,用這個框架來判斷方向:

    優先考慮 AI 輔助成長,如果:

    • 系統仍然在提供商業價值,只是難以維護
    • 核心業務邏輯複雜,沒有人完整理解
    • 團隊規模小,無法支撐兩個系統並行
    • 業務需求變化頻繁,不能停下來等重寫完成

    可以考慮漸進式替換(不是重寫),如果:

    • 某個模組已經明顯成為瓶頸,且邊界清晰
    • 有足夠的測試保護現有行為
    • 可以部署影子流量,讓新舊模組並行驗證
    • 有明確的回滾機制

    謹慎考慮大規模重寫,只有當:

    • 現有系統在技術上已經無法繼續擴充(不只是難,而是真的不可能)
    • 有足夠的資源支撐至少 18 個月的並行期
    • 有完整的行為規格文件(或 AI 幫你產出的等效文件)
    • 組織願意接受在過渡期期間功能停滯

    結語:舊系統不是問題,缺乏支援才是

    回到最初的問題:那套跑了十年的系統,它的問題不是年齡,而是孤立。沒有人理解它,沒有測試保護它,沒有文件說明它,改動它需要承擔巨大的個人風險。

    AI 能做的,是讓這個系統不再孤立。它可以成為每個工程師的「老前輩」——隨時解釋任何一段邏輯,隨時分析改動的影響,隨時生成測試保護現有行為。

    這不是重寫的故事,這是陪伴成長的故事。

    至於重寫——如果未來真的需要,AI 會讓你準備得更充分。但那是另一個故事,而且它的結局還沒有寫完。