標籤: PostgreSQL

  • 拔掉 RLS 的兩天:AI 時代規模化 Day One 為何從奢侈變預設

    重點摘要

    • 540 萬包裹壓測,警衛的社區包裹列表要 7330 毫秒。病根是 RLS 兩條 PERMISSIVE policy 用 OR 合併,讓查詢規劃器用不到 community_id 索引。
    • 全面拔除 173 條 policy、85 張表的 RLS,租戶隔離 100% 改由應用層帶 community_id。同一條查詢從 7330ms 降到 1.34ms,約 5500 倍。
    • 但拔掉 RLS 不是終點——第二波跨社區洩漏被「單租戶壓測」蓋住了:對照社區在多數表是 0 筆,洩漏判準(本社區數=全平台數)自動成立。要每張表都灌 ≥2 個社區才測得出;而且漏帶 community_id 不只洩漏,更是少了索引,同一個病根換地方又長。
    • AI 把規模準備的「建造成本」打掉了,但有兩個成本它打不掉:分區帶來的「複雜度稅」(維護照繳),與「取決於規模實測才知道對錯」的經驗決策(RLS 自己就是反例)。
    • 結論:AI 時代「規模化 Day One」從奢侈變預設,但「準備什麼」的判斷力更值錢——有把握的提前上,取決於壓測的,老實留給壓測。

    一個 V2 產品、兩天、一個 AI 協作者,把一套多租戶系統從「靠資料庫兜底」推進到「應用層自己負責」。這篇記錄的不是 commit log,是技術選型的隱性成本,怎麼在特定規模與特定哲學下才現形——以及 AI 怎麼悄悄改寫了「該不該提前準備規模化」這道老題目。

    先講結論:MVP 智慧是成本算出來的啟發法,不是定律

    傳統智慧說:MVP 先做,別過度工程,規模化以後再說。這句話我一直信,直到這兩天我意識到——它是一條成本驅動的啟發法,不是定律

    它成立的前提是「規模準備很貴」。以前要做分區、多租戶隔離、idempotency、離線佇列這整套規模機械,往往要多三到四倍的人力、好幾個月。那個成本,才讓「先別做、等需要再說」變成理性選擇。但兩件事同時改變了這個算式:

    1. AI 把建造成本打掉了。那套規模機械,AI 輔助下生成 + 維護的人力崩塌。
    2. 這是 V2,不是 try。前一版已經證明了領域——住戶、警衛、包裹是真需求,而且會有很多社區。規模不是猜測,是 near-certainty。「You Ain’t Gonna Need It」的前提「你可能不需要」,直接是假的。

    所以對一個 AI 輔助、領域已驗證的 V2,Day One 就準備規模化,比經典 MVP 智慧主張的更該做。但這篇真正有料的地方,是兩個 AI 也打不掉的成本。後面講。

    當初為什麼這樣選技術?

    這套系統的技術選型,是三股力量的合力:領域驅動 + 規模前瞻 + 不重蹈前版覆轍

    • Flutter 單一 codebase:住戶用手機、警衛用平板,功能大半重疊靠角色 gate。兩份原生 = 兩倍維護;一份 + responsive 是省力的對。加上離線優先是硬需求(警衛平板斷網也要能收件、拍照、印通知條)。代價老實說:CanvasKit 的 web 端很痛,e2e 自動化一路在跟它的無障礙語意樹搏鬥。
    • Go + GraphQL(型別契約):Go 單一 binary 好部署、並發強,對著「1000+ RPM / 1000 社區」的目標。GraphQL 用型別嚴謹的契約——這是對前版的反動:舊版用 category/method 字串路由 + PHP 的型別強制轉換怪招,踩過坑。型別世界 = runtime 少驚喜。
    • PostgreSQL + RLS(Row-Level Security):這個最值得講,因為我們親手拆了它

    RLS 的故事:為安全而選,為清亮而拆

    RLS 當初為什麼選?防禦縱深。理由聽起來無懈可擊:「把租戶隔離放在資料庫層,app 出 bug 也繞不過。」每個社區的資料用 policy 鎖死,誰都別想跨社區看到別人的包裹。

    然後規模化壓測來了。我們灌了 540 萬包裹、5.4 萬戶、202 個社區,模擬一個 V2 跑兩三年後的樣子。一量——警衛的社區包裹列表,7330 毫秒。七秒。

    病根不是索引沒建,是 RLS 的兩條 PERMISSIVE policy 用 OR 合併(是系統管理員) OR (社區=X)。community_id 只出現在 OR 的一邊,查詢規劃器就用不到 community_id 索引,只能 Seq Scan 掃完整張表的所有分區。這個病,設計時看不出來,要到 5.4M 才現形

    業主的反應很關鍵。他不要「在 DB 加更多限制」來繞,他要的是更乾淨:「我不要我的資料庫跟我的 AP 有這樣的依賴。本來就該做到零洩漏,怎麼會依賴 RLS?」

    於是我們全面拔除 RLS——173 條 policy、85 張表,全部 DROP。租戶隔離 100% 改由應用層:每一條碰租戶資料的查詢,自己帶上 community_id = current_setting('app.community_id')

    拆的過程,反過來證明「本來就該零洩漏」當時是假的

    審計時我抓到,授權中介層對「同社區範圍」的操作寫死了一行註解:「RLS 負責租戶隔離,這裡不另外檢查。」也就是說,有好幾個改別人資料的後端進入點(停用警衛、在別社區建戶、復活別社區的軟刪戶),它們的隔離正確性,本來就完全押在 RLS 上。RLS 一拔,這些就是跨社區提權漏洞。

    派去掃的 AI 子代理一開始判了 23 個缺口;我逐條人工驗證,收斂到 3 個真缺口——其餘有的早被別的守衛擋著、有的是子代理幻覺出根本不存在的函式。這也是個教訓:AI 的清單要逐條驗,不能盲信。

    拆完,效能呢?同一條查詢,帶上 community_id:

    查詢 RLS 開(OR 合併擋索引) RLS 拔除 + 應用層 community_id
    警衛社區包裹列表(5.4M 列) 7330 ms(Seq Scan 全分區) 1.34 ms(Index Scan)
    包裹改動歷程鑽取 0.2 ms

    從 7330ms 到 1.34ms,約 5500 倍。反諷的地方:當初選 RLS 是「為了安全」。它不但變成效能地雷,還讓 app 層偷懶——「反正 DB 會兜底」,於是 app 層的隔離就不嚴謹了。兜底機制養出了被兜底者的鬆懈。

    拔完 RLS 之後:洩漏沒結束,是換了個地方躲

    拔掉那天修了 3 個提權,我以為收工了。錯。RLS 一拔,等於把「DB 兜底」這層拿掉,所有原本偷懶的查詢全部裸奔——真正的第二波,要再跑一輪壓測才現形。

    一個包裹計數查詢,給某社區警衛回了 280 萬——那是整個平台的數字,不是他那個社區的。病根:這個 count 自己手寫 WHERE,繞過了會自動補 community_id 的中央 helper。同一類的還有一票:社區統計(*Stats)、各種計數(*Count)、住戶/包裹 picker、以及「先 load-by-id 再改」的 mutation——全是各自為政手寫查詢、漏帶租戶欄位的高風險地帶。

    但這篇最該記住的,是為什麼第一次壓測沒抓到。答案很反直覺:單租戶結構上測不出跨租戶洩漏。

    我第一次灌的 540 萬,只灌在 parcelshouseholds 兩張表,其他維度(使用者、訪客、公告、picklist)對照社區根本 0 筆。而洩漏的判準是「本社區查到的數字 == 全平台的數字」——當對照組是 0,這條恆等式自動成立,洩漏被 0 資料蓋得死死的。修法不只是補 WHERE,是補資料形狀:每一張要驗的表,都灌進 ≥2 個社區的資料,動態探針才有對照組去抓「你回給我的,有沒有混進別人的」。

    還有一層,業主一句話點破:漏帶 community_id 不只是洩漏,是效能。「為什麼還有功能沒戴上 ID,這不只會洩漏,重點是效能會變差,沒用到 index。」少了那個欄位,查詢就跟當初 RLS 的 OR 一樣用不到索引、掃全表。防洩漏和上索引,是同一個動作——community_id 既是隔離邊界,也是索引前綴。當初那個「OR 擋索引」的病根,拔了 RLS 之後,在每一條手寫查詢裡換個地方又長了出來。

    規模下另外兩個 Day One 就得拍的板

    同一輪還拍了兩個一旦上線就很難回頭的板,都是「規模 + 時間」逼出來的。

    • 快照 vs 正規化:歷史標籤不准 join。包裹上的物流公司、儲位、包裹類型、收件人姓名,全部存成當下的文字快照,不是外鍵。為什麼不正規化?因為這些是歷史事實。若做成外鍵去 join 現行字典,哪天某家物流公司改名或下架,過去的包裹紀錄會被回頭改寫——未來的公司出現在過去的資料上。原則一句話:身分用外鍵,歷史標籤用快照。這跟「證據鏈不可竄改」是同一個底層要求。
    • 控制面狀態用單例快取,不是每頁查 DB。社區可被系統管理員停用,每個請求都得確認「這社區還活著嗎」。最笨的做法是每頁打一次 DB——那又是另一種 RLS 式的過度防禦。做法是一個程序級單例快取(map + 鎖),10 分鐘 TTL,miss 才查 DB,停用/復活時主動逐出;隔離檢查收斂在一個 chokepoint(進交易時查一次),不撒在每條查詢。順手修了個誠實問題:社區停用後登入原本回「帳密錯誤」會誤導用戶,改成帳密對的人才告知「社區暫停服務中」——既不誤導本人,也不洩漏社區存在給攻擊者。

    兩個 AI 也打不掉的成本

    回到開頭的 thesis。AI 讓規模準備「便宜到值得 Day One 就做」——但有兩個成本它打不掉。

    成本 AI 打掉了什麼 AI 打不掉什麼
    複雜度稅 建造成本(生成分區/隔離/佇列) 維護稅:每次改動/debug 都要付。例:pg_total_relation_size 對分區母表回 0,最大的表都報 0,要展開分區層才量得到
    經驗決策 實作速度 壓測經驗:RLS 的「OR 擋索引」要 5.4M 才看得見,連 AI 都沒辦法 Day One 告訴你對的選擇

    成本一:AI 打掉「建造成本」,沒打掉「複雜度稅」。月分區讓程式更難推理。這兩天我們親自繳了這個稅:監測資料量時,pg_total_relation_size 對分區母表只算母表本身(回 0);還有 FK 要複合鍵、分區不繼承 RLS、清測試資料時被自己剛上的 append-only trigger 擋住……這個複雜度,是每一次未來改動、每一次 debug 都要付的稅,連 AI 也付。「AI 讓規模準備好建」是真的,「規模準備免費」是假的——稅照繳,只是從建造繳給了維護。

    成本二:有些規模決策是「經驗的」,Day One 就是準備不了——RLS 自己就是鐵證。RLS 本來就是 Day One 的規模準備(多租戶隔離),結果它是錯的 Day One 選擇。為什麼當初設計看不出來?因為「OR 擋索引」這個病要 5.4M 壓測才看得見。有些決策,正確答案取決於規模行為,而那行為你預測不了——那是壓出來的,不是設計出來的。而且如前面那一節的教訓:光有規模還不夠,要對的「資料形狀」——對照社區是 0 筆的那次壓測,把第二波洩漏整個蓋住了。經驗決策不只需要壓力,需要對的壓力

    AI 時代,「規模化 Day One」從奢侈變預設。但「準備什麼」的判斷力反而更值錢:有把握的結構提前上(便宜了);取決於規模實測的決策,老實留給壓測。

    有把握的(會有很多社區 → 按社區/時間分區;離線不可妥協 → 佇列),提前做。沒把握的(隔離機制、索引策略),別假裝設計階段就能拍板。這跟我之前寫過的 企業 AI 落地為什麼失敗 是同一個底層觀念:方法論不能照抄,要看你的前提條件還成不成立。

    方法:規格化、決策留證、以及「我自己打臉自己」的報告

    這兩天還做了第二件大事——把包裹做成可被法庭級檢驗的證據鏈。改動寫進不可竄改的 log(鑽一顆包裹的歷程,5.4M 下 0.2 毫秒)、簽名與照片在儲存層鎖死不可刪、系統管理員調查要「破窗」且每次都留審計。但比功能更值得分享的是方法

    • 規格先行。每個設計決定都先寫進規格、辯論清楚、鎖進文件,才動手。對話會被壓縮遺忘,規格不會。
    • 決策留證,不靠主觀評分。業主不信任 AI 加工過的「成本/風險」評分,他要看真實檔案、真實行數、schema 影響。所以需要他拍板的時刻,我生的是事實卡片,不是紅黃綠燈。
    • 三份報告的誠實檢驗。最後他要一份評分報告。但他要的不是一個數字——他要三份做比較:原始版、我「現在的預估」(鎖時間戳,不准事後改)、和「全部做完 + 全面測試後的真實量測」。核心是拿我的主觀預估去對真實量到的,差多少 = 我的評估可信度。

    結果:我預估綜合 7.5,真實約 7.8,誤差約 0.3 分;方向性預測(「dev 環境的測試污染會讓部分整合測試紅、但那不是回歸」「forensic 能力做完會升」「效能會守住」)全中。唯一校準:我對「系統穩定度」過度保守——實際零回歸。這種「逼 AI 先承諾預測、再用真實數據打臉」的設計,把「AI 的話可不可信」變成一個可量測的問題。

    人機協作:最好的部分不是分工,是辯論

    這兩天的分工大概是:業主出領域知識與方向決策,AI 出執行、驗證與反思。但最好的部分不是分工,是辯論。RLS 該不該拆、拆了 sysadmin 怎麼查日誌、照片資料夾要不要鏡射分區、孤兒清理會不會變成刪證據的後門——這些不是「下指令、執行」,是來回推。

    業主用領域常識把我拉回現實(「哪有警衛不分社區的,警衛 A 在四個社區工作就是四個警衛」),我用壓測數據和審計把假設證偽(「你說本來就零洩漏,但我們現有 code 就漏了 3 個」)。AI 不會累、能掃完每個呼叫點、能把 23 個候選逐條驗到剩 3 個真的;但判斷「準備什麼」「什麼時候該停下來問人」,還是要人。

    結語

    如果只能留一句:老的 RD 有老的包袱,所以才有「MVP、先別管規模」的智慧。但那個智慧是成本算出來的。當 AI 把建造成本打掉、當你做的是領域已驗證的 V2——規模化就是 Day One 的事。只是別忘了,AI 打不掉複雜度稅,也替不了你壓測;有把握的提前上,沒把握的,老實壓出來——而且要用對的資料形狀去壓,單租戶測不出跨租戶的洩漏。

    RLS 是我們「Day One 準備了錯的東西、規模化才發現」的活標本。它沒有讓這個決定變壞——它讓這個決定有了教材

    技術細節:Flutter + Go(gqlgen) + PostgreSQL 16;月分區、River 佇列、MinIO。壓測 540 萬包裹 / 202 社區 / RLS-off:熱查詢 1.34ms、改動 log 鑽歷程 0.2ms。

  • 把 ERP 變成 AI 的執行單元:iDempiere OData × MCP Server 整合策略

    重點摘要(TL;DR)

    • iDempiere(開源 ERP)的 REST/OData API 包裝成 MCP server,任何支援 MCP 的 AI 工具(Claude / ChatGPT / Cursor / Claude Code / VS Code)都能直接呼叫 ERP。
    • Microsoft 已經做了 Dynamics 365 ERP MCP server(2026/4 文件更新),三類工具設計:Data tools(OData CRUD)、Form tools(模擬使用者操作)、Action tools(直接呼叫 business class)。這個設計可以直接借鏡到 iDempiere
    • iDempiere REST 已經提供 api/v1/auth(JWT)、api/v1/models(OData CRUD)、api/v1/windowsapi/v1/processes 四類 endpoint — 剛好對應 Microsoft 的三類工具
    • 整合到腦子系統:LLM Gateway + MCP Server 雙軌設計,Gateway 管 LLM 流量,MCP 管 ERP tool calls,各自有 audit log,iDempiere 內建的 AD_Role 直接當權限層,不用自寫 ABAC。
    • 本文是腦子系統四部曲的第五篇延伸(ERP 整合層)。前四篇:Why / How / Scale / Tools

    一、為什麼 iDempiere OData 是腦子系統缺的拼圖

    前四篇文章把 AI 治理系統蓋好了:LLM Gateway、雙引擎、Harness、Chat-native Agent。但有個關鍵問題沒解決 — AI 怎麼安全地讀寫公司的真實業務資料?

    大部分公司現況:業務資料躺在 ERP 裡,AI 透過 prompt 拿不到;或者員工自己貼資料給 AI(踩到 A 級資料禁令)。第一篇的核心哲學「AI 時代不做 UI,做給 AI 安全取資料的入口」需要一個具體載體。

    iDempiere(開源 ERP)的 REST/OData API 剛好就是這個載體 — 它本來就是「給機器讀的標準介面」,而且整套權限、Audit、租戶隔離都已經 30 年累積在 iDempiere 的 AD(Application Dictionary)裡。不用重新造輪子,直接接到 AI

    二、事實核對:iDempiere REST API 真實狀態(2026/5)

    本文涉及的 iDempiere 技術細節都來自官方來源,以下是 2026/5 撰文時的驗證結果:

    事實 驗證來源
    REST plugin 由 BX Service GmbH 維護,GPLv2,used in production iDempiere Wiki
    支援 iDempiere release 12 及 master,plugin 已運作於 v9/v10 GitHub Repo
    官方文件站 idempiere-rest-docs
    Swagger UI 互動式 API 探索 hengsin/idempiere-rest-swagger-ui

    2.1 四個主要 API 端點

    • POST/PUT api/v1/auth/tokens — JWT 認證(token 1 小時有效)
    • api/v1/models/{tableName} — PO(Persistent Object)CRUD,支援 OData filter
    • api/v1/windows/{windowSlug}/tabs/{tabSlug} — Window/Tab 互動(對應 ERP UI 的視窗結構)
    • api/v1/processes/{processSlug} — Process 呼叫(DocAction、報表、自動化流程)

    以及附加端點:檔案存取、Reference 資料、Cache 管理、Workflow、Scheduler 資訊。[來源]

    三、業界典範:Microsoft Dynamics 365 ERP MCP Server

    Microsoft 在 2026/4/27 發表了 Dynamics 365 Finance & Operations 的 MCP server 完整文件 — Microsoft Learn這是目前業界最完整的 ERP × AI 整合範例,值得借鏡。

    3.1 Microsoft 的三類工具設計

    類別 用途 代表工具(Microsoft)
    Data tools OData CRUD operations data_find_entities、data_create_entities、data_update_entities、data_delete_entities
    Form tools 模擬使用者在 UI 上的操作(點按鈕、填表、開分頁) form_click_control、form_set_control_values、form_save_form
    Action tools 直接呼叫 ERP 內部 business logic class api_find_actions、api_invoke_action

    3.2 三個關鍵設計原則(直接可借鏡)

    1. 動態 context:MCP server 每次 tool call 都根據 agent 安全角色和環境配置「動態」回傳 context — 「the security role of the authenticated user for the agent determines which objects are returned in the view model」(原文)
    2. 角色限制 = scope 限制:Agent 只看到自己角色能存取的 menu / entities / API,既是安全也是 prompt 效率(context 不會塞太多無關資訊)
    3. Allowed MCP Clients:Microsoft 預設只允許 Copilot Studio 和 VS Code 兩個 client ID 存取 MCP,其他 agent platform 必須在 Microsoft Entra ID 註冊後加入白名單 — 不是「誰來都能接」

    四、把這個設計搬到 iDempiere

    關鍵 insight:iDempiere REST 的四個 endpoint,剛好對應 Microsoft 的三類工具設計,直接 mapping:

    Microsoft 三分類 iDempiere REST 對應 endpoint 說明
    Data tools api/v1/models/{table} PO CRUD + OData filter,直接套
    Form tools api/v1/windows/{slug}/tabs/{slug} Window/Tab 結構,可模擬「打開視窗、切分頁、設欄位」
    Action tools api/v1/processes/{slug} Process 呼叫(DocAction、報表、自動化)

    結論:你不用設計 MCP server 的工具分類,直接複製 Microsoft 的三分類,把 iDempiere REST 端點包裝進去即可。

    五、MCP 是什麼,為什麼是關鍵

    Model Context Protocol 是 Anthropic 2024/11 發布的開源協議,定義 AI 應用怎麼跟外部資料來源、工具、工作流溝通。官方比喻:「USB-C port for AI applications」。[來源]

    5.1 為什麼是 ERP × AI 的關鍵

    • 標準協議,一次寫多處用:同一個 MCP server 可以同時被 Claude Desktop / Claude Code / Cursor / VS Code / ChatGPT 接([來源])
    • 不是 prompt engineering 的小聰明,是基礎建設層
    • 已成 industry standard:Anthropic / OpenAI / Microsoft 都採納

    5.2 寫 MCP server 的工具(2026/5 驗證)

    • Python SDK:modelcontextprotocol/python-sdk v1.x stable(v2 pre-alpha 開發中)
    • 安裝:uv add "mcp[cli]"pip install "mcp[cli]"
    • Transport:stdio、SSE、Streamable HTTP 三種
    • 認證:OAuth 2.1 resource server 標準

    六、實作範例:iDempiere MCP server v0

    下面是用 FastMCP + httpx 實作的最小可行版本,展示三類工具的骨架。注意:這是教學範例,production 版需要加上錯誤處理、重試、token refresh、審計 log 等。

    # idempiere_mcp_server.py
    from mcp.server.fastmcp import FastMCP
    import httpx
    from typing import Optional
    
    mcp = FastMCP("iDempiere-MCP")
    IDEMPIERE_BASE = "https://idempiere.example.com/api/v1"
    
    # ───────── Auth ─────────
    @mcp.tool()
    async def authenticate(
        username: str,
        password: str,
        client_id: int,
        role_id: int,
        organization_id: int = 0,
        warehouse_id: int = 0,
        language: str = "en_US"
    ) -> dict:
        """One-shot authentication with all parameters.
        Returns session token valid for 1 hour."""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{IDEMPIERE_BASE}/auth/tokens",
                json={
                    "userName": username,
                    "password": password,
                    "parameters": {
                        "clientId": client_id,
                        "roleId": role_id,
                        "organizationId": organization_id,
                        "warehouseId": warehouse_id,
                        "language": language,
                    }
                }
            )
            resp.raise_for_status()
        return resp.json()
    
    # ───────── Data Tools (OData CRUD) ─────────
    @mcp.tool()
    async def query_records(
        token: str,
        table_name: str,
        filter_expr: Optional[str] = None,
        top: int = 50
    ) -> dict:
        """Query iDempiere PO records via OData.
    
        Filter examples (note iDempiere uses 'neq' not 'ne'):
          - "IsCustomer eq true and contains(Name, 'Acme')"
          - "Created gt 2026-04-01T00:00:00Z"
        """
        params = {"$top": top}
        if filter_expr:
            params["$filter"] = filter_expr
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{IDEMPIERE_BASE}/models/{table_name}",
                params=params,
                headers={"Authorization": f"Bearer {token}"}
            )
            resp.raise_for_status()
        return resp.json()
    
    @mcp.tool()
    async def create_record(token: str, table_name: str, data: dict) -> dict:
        """Create a PO record. Caller must include all mandatory fields.
        Tip: query AD_Column WHERE IsMandatory='Y' to discover them first."""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{IDEMPIERE_BASE}/models/{table_name}",
                json=data,
                headers={"Authorization": f"Bearer {token}"}
            )
            resp.raise_for_status()
        return resp.json()
    
    # ───────── Action Tools (Process call) ─────────
    @mcp.tool()
    async def run_process(
        token: str,
        process_slug: str,
        parameters: dict
    ) -> dict:
        """Execute an iDempiere Process (e.g. DocAction, scheduled job, report).
    
        Parameters must be FLAT top-level keys, NOT a 'parameters' array:
          Correct:  {"StatementYear": 2026, "StatementPeriod": "2"}
          Wrong:    {"parameters": [{"parameterName": ..., "value": ...}]}
        """
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{IDEMPIERE_BASE}/processes/{process_slug}",
                json=parameters,
                headers={"Authorization": f"Bearer {token}"}
            )
            resp.raise_for_status()
        return resp.json()
    
    if __name__ == "__main__":
        mcp.run(transport="streamable-http")

    這支 script 跑起來後,任何支援 MCP 的 client(Claude Desktop / Claude Code / Cursor / VS Code)都可以連到 http://localhost:8000 並使用上述工具。

    6.1 範例對話(架構驗證)

    員工(在 chat 工具中):
      「幫我查最近 10 筆訂單金額大於 100 萬的客戶」
    
    AI agent(透過 MCP 自動執行):
      1. authenticate(...) → 拿到 session token
      2. query_records(
           token=...,
           table_name="C_Order",
           filter_expr="GrandTotal gt 1000000",
           top=10
         )
      3. 解析結果,回給員工
    
    員工看到:
      「最近 10 筆大於 100 萬的訂單列表如下:...」

    注意:第 1 步的 authenticate 只執行一次,session token 1 小時有效,後續 query 都用同一個 token。

    七、整合進腦子系統:雙軌架構

    員工 chat app (LINE / Mattermost / Telegram / Slack)
        ↓
    Chat-native Agent (QwenPaw) 或 Coding Agent (Claude Code)
        │
        ├─ LLM 流量 ───→ 公司 LLM Gateway (LiteLLM + Portkey)
        │                ├─ 分級/脫敏/路由
        │                └─ → 雲端 frontier 或本地 Ollama
        │
        └─ Tool calls ─→ iDempiere MCP Server (自製)
                          ├─ OAuth 2.1 / Allowed Clients 白名單
                          ├─ Data tools (OData CRUD)
                          ├─ Form tools (Window/Tab 互動)
                          ├─ Action tools (Process call)
                          └─ Audit log → SIEM
                          ↓
                      iDempiere REST API (api/v1/*)
                          ↓ (內建 AD_Role 過濾)
                      iDempiere PostgreSQL

    關鍵設計:

    • LLM Gateway 跟 MCP Server 是兩條平行軌道:Gateway 管 prompt,MCP 管 tool calls。兩者都要 audit log,可獨立縱深防禦
    • 權限不重複設計:iDempiere 內建 AD_Role 直接當權限層,MCP server 帶 user 的 token 進去,iDempiere 自動套 role 過濾資料 — 不用自寫 ABAC 規則
    • Allowed MCP Clients 白名單:借鏡 Microsoft 設計,只允許特定 agent platform 接 MCP server,不是「誰來都能接」

    八、權限層的對應(這是最大紅利)

    員工從 chat app 問問題時的完整權限路徑:

    1. 員工 LINE/Slack ID → Agent 認 ALLOWED_USERS 白名單
    2. Agent → MCP Server,帶員工的 iDempiere session token
    3. MCP Server → iDempiere REST,帶 token
    4. iDempiere 自動套員工的 AD_Role 過濾資料:
       - 業務員角色 → 只看自己的 SalesRep 訂單
       - CFO 角色 → 看全公司
       - RD 角色 → 完全看不到業務資料
    5. 回應只含「員工角色應該看到」的資料

    iDempiere 30 年累積的 AD_Role / AD_Window_Access / AD_Column 權限設計直接拿來用。這比自己在 Gateway 寫 ABAC 簡單一個量級

    九、為什麼這比 Dynamics 365 / NetSuite MCP server 適合中小規模製造業

    特性 Dynamics 365 ERP MCP iDempiere + 自製 MCP
    License Microsoft 訂閱 + Copilot 點數 GPLv2 開源
    Hosting Cloud only(Tier 2+) self-host / air-gapped 可
    Tool 計費 0.1 Copilot Credits per tool call(非 Copilot Studio 環境) 0(自架)
    A 級資料 需透過 Cloud,法規場景受限 完全本地處理
    客製化 透過 ICustomAPI + AI tool framework 直接改 plugin / 加 process

    對製造業中小集團、要 air-gapped 的法規場景、預算有限的公司:iDempiere + 自製 MCP server 是唯一既可離線又能整合 AI 的開源路徑

    十、工程藍圖:漸進式 v0 → v1 → v2

    v0:Read-only Data Tools(2-4 週,1 RD)

    • 實作 authenticate + query_records(本文範例)
    • 支援 5-10 個常用 table:C_BPartner、C_Order、M_Product、M_InOut、M_Movement、AD_User、R_Request 等
    • OData filter 支援 eq / neq / contains / gt / lt
    • 串接 Claude Desktop 或 Claude Code 測試

    v1:加入 Action Tools(2-4 週)

    • 實作 run_process(DocAction、報表、自動化)
    • 實作 create_record / update_record(POST/PUT)
    • 處理 mandatory field 偵測(自動查 AD_Column WHERE IsMandatory=’Y’)
    • token 自動 refresh(1 小時過期)
    • 串接公司 LLM Gateway,流量都過 audit

    v2:Form Tools + 進階 Window 互動(4-8 週)

    • 包裝 api/v1/windows/{slug}/tabs/{slug}
    • 讓 AI 模擬「打開視窗、切分頁、設欄位、按按鈕」
    • 處理複雜流程(發票核銷、應收沖帳等)
    • 整合 Allowed MCP Clients 白名單機制

    對中小企業:v0 可能就夠用 80%。對中大型集團:v0 → v1 → v2 漸進式投資,12-16 週完整版可上線。

    十一、結語:把 AI 變成 ERP 的執行單元

    前四篇腦子系統的 AI 仍然是「跟業務資料分開的工具」 — 員工問問題,AI 回答。

    加上 iDempiere MCP Server,AI 變成能直接動 ERP 的執行單元:查訂單、開請款單、跑 process、生報表。員工從 chat app 一句話完成原本要打開 ERP 點 5 個選單的工作。

    這才是「AI 時代不做 UI,做給 AI 安全取資料的入口」的真實落地。RD 不再被 UI 工單吃掉,而 80% 不寫 code 的員工終於能用一句中文操作 ERP。

    對企業 IT 主管的具體下一步:

    1. 裝 bxservice/idempiere-rest plugin 到既有 iDempiere(若還沒)
    2. 用 Postman 測 4 個主要 endpoint(repo 內有 collection)
    3. 用本文 v0 範例寫 MCP server,跑在開發機 localhost
    4. 掛到 Claude Desktop / Claude Code 試用,驗證權限層運作
    5. 確認可用後,搬上公司內網,接入 LLM Gateway

    延伸閱讀:腦子系統四部曲 + 本篇

    可運作的 Reference Links(2026/5 撰文時驗證)

    iDempiere 官方資源

    MCP 官方資源

    業界 ERP MCP server 參考

    OData 標準

  • 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 寫了三個版本,不是因為技術難,而是因為對系統的理解在逐漸深化。

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

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

  • schemaSpy

    結論

    這套工具可以掃描資料庫來產資料庫文件,But前提是你的DB 表欄位的關聯要做好,其次他產出後的文件並沒有BI需要後續整理,但是以第一步來說,沒問題

    (閱讀全文…)

  • DB備份還原

    pgsql 備份

    全備份全回朔

    資料備份

    “`bash~
    sudo mkdir -p /opt/backup/idempiere
    sudo chown postgres:postgres /opt/backup/idempiere
    sudo nano /opt/backup/backup_idempiere.sh
    sudo chmod +x /opt/backup/backup_idempiere.sh
    sudo chown postgres:postgres /opt/backup/backup_idempiere.sh

    (閱讀全文…)