架構手冊 · 插件模式 · 2026-09-01
這頁用白話與圖解回答三個問題:插件怎麼掛上主產品、設定值歸誰管、像「寄通知」這種跨套件的合作怎麼運作。看完你可以判斷「新產品要接這些能力,要做哪些事」。2026-08-31 增補:使用流程、接線 QA、資料關聯三律與查詢實戰問答。2026-09-01 增補:申報制(插件自己來報到)與「申報書是選配」鐵則,以及收官整理的 D6 設計規則六條(arc-review 正面清單)。
想像主產品是一間房子,插件是家電:冷氣(身分登入)、對講機(通知)、保險箱(檔案儲存)。家電自己就是完整的功能,房子只要有插座(註冊點)和電線(接線方式),插上就能用;拔掉某台家電,房子照樣能住,只是少了那個功能。
| 比喻 | 對應到我們的系統 | 白話說明 |
|---|---|---|
| 房子 | Guidant AI 主產品 | 住人的地方——真正面對客戶的產品 |
| 家電 | jedi-* 套件(插件) | 各自完整的功能包:身分登入、通知、檔案… |
| 插座 | 模組清單裡的一行註冊 | 家電要插哪裡——一行一台,加一行就多一台 |
| 電線 | port(後面 §3 詳細講) | 家電和房子之間約定好的接線方式 |
這不是空想——身分套件已經通過「拔掉測試」
身分套件(jedi-iam)已在 2026-08-30 完成合併,並實際做過拔掉測試:把註冊那一行註解掉,產品照常開機,只是登入功能整組消失(呼叫會得到 404),其他功能完全不受影響。這證明插件真的是「插上就有、拔掉就沒有」,不會牽一髮動全身。
主產品開機時,會照著一份模組清單(config/app_modules.py,一行代表一個模組)逐一「點名」:對清單上的每個插件呼叫它的 register()(自我介紹+掛載),插件就把自己的東西掛進來。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
A[主產品開機] --> B[讀模組清單<br/>一行=一個模組]
B --> C[對每個模組<br/>呼叫套件的 register]
C --> D[套件把自己的<br/>API 路由/預設接線/<br/>資料表定義掛進來]
D --> E[完成<br/>功能上線]
呼叫 register() 時,主產品可以塞四個「插槽」進去,決定這個插件在這個產品裡長什麼樣:
| 插槽 | 白話解釋 | 例子 |
|---|---|---|
| config(參數值) | 每個產品不一樣的數字與開關 | OTP 驗證碼幾分鐘過期、密碼要幾碼 |
| adapters(行為差異) | 「寄信這件事你們家怎麼做」——同一件事各產品做法不同,由主產品提供做法 | A 產品用內建通知系統寄、B 產品接客戶的郵件主機 |
| schema_extensions(欄位差異) | 「你們家要多記什麼欄位」——套件基本表之外,本產品額外要存的資料 | 這個產品的使用者要多記「員工編號」 |
| mount_api(API 總開關) | 要不要對外開放這個插件的 API 端點 | 有些產品只想用它的內部邏輯、不開放端點 |
全文唯一的一段示意代碼——註冊真的就是一行:
register(app, adapters=..., config=...)新產品要接一支插件,要做的事就三件
| 步驟 | 做什麼 | 白話 |
|---|---|---|
| ① 加一行 | 在模組清單加上這個插件 | 「插上插座」 |
| ② 接線 | 實作它宣告的 port(通常 2–4 張,見 §3) | 「照插頭形狀接電線」 |
| ③ 建表 | (插件有自己資料表的話)跑它隨包附的資料庫遷移 | 「家電要的專屬櫃子照說明書組起來」 |
前面講的是「插件怎麼掛上去」。還有一個反過來的問題:平台怎麼知道有哪些功能?
錯誤的做法是平台自己列一張清單(「任務型別有:一般、問卷、檢測工具…」)。那等於 平台認識了每一個插件——每加一種功能就要回頭改平台,插件也就不再是「插上就有、 拔掉就沒有」了。
正解是反過來:平台只開一個登記處,插件自己來報到。這叫「申報制」。
| 平台自己列清單(❌) | 申報制(✅) | |
|---|---|---|
| 誰知道有哪些型別 | 平台寫死 | 每個插件申報自己的 |
| 加一種新功能 | 要改平台的程式碼 | 只加一支插件,平台零改動 |
| 拔掉某功能 | 清單裡的死項目留著 | 選單自動少一項 |
實例(FR-069 4.2 第 3 步/CM-1490):任務型別登記表只保留 general(任務骨幹), 問卷型別由問卷插件申報、檢測工具型別由檢測插件申報。實測拔掉問卷插件的申報, 下拉選單當場少一項——沒有殘留的死選項。
規則:平台的核心能力不可以寫成「要先有人申報才能運作」。沒有任何插件的空 平台,也必須跑得起來、跑得完整——只是選單上少了那些型別而已。
為什麼要明文寫死這條:申報制很容易滑向「平台反過來依賴申報」。典型的滑法是 平台某段程式寫成「查登記表拿到型別 → 依型別分岔」,一旦登記表是空的就走進沒人 處理的分支,於是空平台反而壞掉。那等於平台把自己的正常運作押在「有插件」上, 插件從選配變成必要,前面拆的耦合又長回來。
先例:問卷(survey)就是這樣運作的——它是選配插件,拿掉之後任務平台照常派工、 留言、看儀表板,只是不能派問卷任務。這個先例在 2026-09-01 由決策者定調為通則, 往後所有 audit 型積木必須依附平台,而 survey 型核心能力必須能獨立存在。
怎麼驗(不是宣稱,是可執行的檢查):
general 時,平台的每條核心路徑都要走得通這是整個模式最重要的一個觀念:插件之間不互相打電話,全部透過主產品這個「總機」轉接。
用寄 OTP 驗證信當例子。身分套件(jedi-iam)要寄信,但它不認識通知套件(jedi-notification)——它只公開宣告:「我需要一個會寄信的東西」。這個宣告就叫 port(可以想成「插頭形狀」:規定好幾個孔、什麼電壓,但不管插上來的是誰家的電器)。主產品開機時,把通知套件包裝成符合這個形狀的插頭(這層包裝叫 adapter,轉接頭),塞給身分套件。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
A["jedi-iam 身分套件<br/>(只知道:我需要<br/>一個會寄信的東西)"] -. 宣告 INotifier port<br/>(插頭形狀) .-> B["主產品<br/>宿主接線盤"]
B -. 注入 adapter<br/>(符合形狀的轉接頭) .-> C["jedi-notification<br/>通知套件<br/>(實際負責寄信)"]
這樣繞一圈的好處,白話講有三條:
「安靜降級」有風險——所以配了守衛測試
降級的另一面是:忘了接線不會報錯,信就是默默不寄。為此我們有「接線守衛測試」:哪張 port 忘了接,CI(自動測試)直接亮紅燈,不會等到客戶抱怨收不到信才發現。
目前定下的標準 port 有四張(規格編號 D16),每支插件只准跟這四種標準插頭與地基套件(jedi-common)打交道,不准私下認識別的套件:
| 標準 port | 白話用途 |
|---|---|
| 身分名冊 | 「這個 user id 是誰?叫什麼名字?」 |
| INotifier(通知) | 「幫我把這則訊息寄出去/推出去」 |
| 檔案存取 | 「幫我存這個檔/把那個檔拿來」 |
| 設定讀取 | 「這個租戶的某某設定值是多少?」 |
FR-069 抽了 12 支套件之後,arc-review 從實際做出來的東西裡挑出六個值得當成標準的做法 (不是事後想的原則,是已經在跑的碼)。寫新套件或改既有套件時照這六條。
mount_api=False 逃生門需要認證/授權/加密才安全的插槽,三件事一起做:
auth_required / license_guard / crypto 這類欄位不寫 = None 的預設。register() 當場檢查,缺就拋例外拒絕掛載——不是「有就用、沒有就跳過」。mount_api=False——真的不想要 API 的宿主走這個明確的出口,而不是靠「不接線」偷偷關掉。為什麼(jedi-detection 的 plugin.py 原文寫得最清楚):預設放行會讓「忘記接線」變成 一組無聲的公開端點——服務照常起得來、健康檢查照樣綠燈,而任何人都能讀寫別的租戶的 檢測工具設定(含連線憑證)。crypto 缺了更陰:明文落庫與明文下發,而功能表面上完全正常。
🔴 判準:這個插槽缺了之後,壞掉的樣子是「安靜」還是「大聲」? 安靜 → 三件套; 大聲(當場 500)→ 可以給預設值降級。
⚠️ 逃生門要看實作不看簽名(CM-1500 的教訓):mount_api=False 這個分支也要把 runtime context 寫進 app.extensions。jedi-survey 曾經只做 _configure_runtime() 就返回, 於是「不掛路由」與「拿得到 context」在套件裡變成綁死的二選一——socketio 模式(不載 REST) 的問卷共編整組失效,而宿主端無論怎麼接線都補不出來。
兩種寫法在 codebase 裡都有,不是隨便挑:
| 用哪個 | 什麼時候 | 實例 |
|---|---|---|
ABC(abstractmethod) |
fail-closed 的守門類、以及「缺了就該炸」的必要能力 | jedi-detection 的 ICrypto / IEvidenceSink、task-platform 的 ILicenseGuard / IProjectRoleGuard |
| Protocol(結構型別) | 純降級輔助、以及只是「跟宿主要個資料」的名冊型 port | jedi-participant 的 IUserDirectory / IJobNotifier、evidence-classification 的 IProjectDirectory |
理由:ABC 會在建構的當下就因為沒實作抽象方法而炸——這正是守門類要的(不合格的 adapter 根本進不了門)。Protocol 是鴨子型別,宿主拿任何長得像的東西都能塞——對「缺了只是 少個功能」的輔助 port 很方便,但守門類用它等於把不合格的守衛放進來,而它會在 第一次被呼叫時才壞,那時已經在線上了。
實況(收官盤點):compliance-audit / detection / task-platform / survey / integrity / license-runtime 走 ABC;participant / evidence-classification 走 Protocol。 兩制並存本身沒問題,問題是以前沒有選型規則,本條補上。
bool 出口形問「這個人是不是管理者」的 port,回 bool,不要回 None 也不要直接拋例外:
def is_project_manager(self, project_id: int, user_id: int) -> bool: ...
def is_any_project_manager(self, user_id: int) -> bool: ...為什麼:拋什麼例外、回哪個 error code、要不要降級——那是呼叫端的語意決策, 不是守衛的。port 只負責回答事實,判斷怎麼處理留給用它的人。 canonical 形見 jedi_evidence_classification/domain/ports.py。
同一個 port 裡,「取 A」與「取 B」分兩支方法,不要合成一支回 (A, B)。
jedi-task-platform 的原文理由:get_workflow_execution_id 與 get_internal_id 分開兩支而不是回一個 tuple,是因為兩處呼叫端各只要其中一個,合成一支會逼 repo 層去 認識它不需要的欄位。
順帶一提同一段的第二條規則:不存在就回 None,不要在 port 裡拋例外—— 「拋什麼例外是呼叫端的語意決策」(留言拋 GRC_JOB_NOT_FOUND,別的呼叫端可能想靜默跳過)。
套件需要「產生一個宿主的東西」時(例如檢測套件要產生一份佐證), 不要 import 宿主的實體類別,改在 port 上開一支工廠方法:
@abstractmethod
def build_evidence(self, **fields: Any) -> Any:
...套件把它知道的事(哪個任務、哪個檔、雜湊、來源是 DETECTION_TOOL)交出去, 由宿主決定裝進什麼型別。
效果:「佐證實體長什麼樣」完全留在宿主,套件不建構也不認得它的型別。 抽出前這裡是直接 from domain.flow_engine.entity.job_evidence_entity import JobEvidenceEntity ——那一行就是套件反向依賴宿主。
__init__.py 檔頭三件套:沿革表 + 不留殼理由 + 守衛座標每支套件的 __init__.py(或 plugin.py)檔頭寫三樣東西:
範本:jedi-compliance-audit 與 jedi-task-platform 的 __init__ / README 檔頭。
這六條的共同精神:FR-069 arc-review 的主題是「綠著的守衛不代表在守」。 六條裡有四條(①②③⑤)都在講同一件事的不同面向——讓錯誤大聲地壞,而不是安靜地壞。
原則一句話:套件管規格書,主產品管實際的值。
system_configs 資料表。比喻:家電說明書寫「電壓 110V、定時可設 1–12 小時」——說明書隨家電來;但你家定時實際設幾小時,記在你家,不會寫回說明書。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
A["套件申報 schema(規格書)<br/>鍵名/型別/預設值/驗證規則"] --> B["主產品 system_configs 表(值)<br/>一張表兩層:系統級+租戶級"]
B --> C{"讀取時怎麼挑?"}
C --> D["① 租戶有設 → 用租戶的"]
C --> E["② 租戶沒設 → 用系統級"]
C --> F["③ 都沒設 → 用套件內建預設值"]
設定分三種作用域(誰的設定、誰能改):
| 作用域 | 描述的是什麼 | 例子 | 誰改 |
|---|---|---|---|
| 系統級 | 這個部署(整套裝在客戶機房的產品) | LDAP 連線、儲存後端 | 平台管理員 |
| 租戶級 | 這個客戶(同一套產品裡的某一家公司) | 通知偏好 | 租戶管理員 |
| 租戶可覆寫系統級 | 平台給預設、客戶可以換自己的 | SMTP 信箱伺服器——客戶可用自己的,沒設就用平台預設 | 兩者皆可 |
已成正式規格(D18,2026-08-31 拍板)——第四階段起照辦
此模式已有第一個落地實例(安全政策七個設定鍵,2.5 階段完成),並於 2026-08-31 升格為正式規格 D18 設定 schema 申報制:schema 進套件、值與存放留宿主、模組開關表永遠留宿主(「哪些模組對這個租戶開啟」是宿主的組裝決策,套件不該知道自己有沒有被啟用)。
三項執行細節同時定案:①作用域是 schema 的必填欄位(上表三種),儲存機制現成、不需新表;②預設活在 code、DB 只存差異——讀無列就回套件內建預設,套件裝上就有預設值可用、不必等 seed;真要 seed 也只補缺鍵、絕不覆蓋(覆蓋會把客戶調過的設定打回原廠);③上圖的三層 fallback 由套件申報、宿主執行,全鏈皆無則明確報錯,不靜默回空值。
附帶好處仍在:設定頁可以直接從申報的規格書自動長出表單,不必每加一個設定就手刻一次畫面(加分項,非必辦)。既有套件於第三階段補殼時回頭套用(有設定的才做)。全文見 design.md §3 D18。
把前面三節串起來,看一次真實流程:
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
participant U as 使用者
participant M as 主產品 API
participant I as jedi-iam(身分套件)
participant S as 設定 port
participant N as INotifier port
participant T as jedi-notification(通知套件)
U->>M: 登入(帳號+密碼)
M->>I: 交給身分套件處理
I->>I: 密碼對、這個帳號該走 MFA
I->>S: 這個租戶 SMTP 用哪組?
Note over S: 租戶沒設 → 回系統級的
I->>N: 把這封驗證碼信寄出去
N->>T: (主產品的接線)實際寄信
I->>U: 回「請輸入驗證碼」
一句收攏:身分套件全程不知道「信怎麼寄、SMTP 設定存在哪」——它只對著兩個插頭講話。寄信的細節在通知套件,設定的存放在主產品,三方各管各的、靠插頭形狀合作。
每支插件唯一「露在外面」的,是一個叫 plugin.py 的檔——可以想成套件的「插座面板+說明書」。套件裡其他幾百個檔都是電器內部,新產品要接入,理論上只需要讀這一個檔。
面板上有什麼:
| 面板上的東西 | 白話說明 |
|---|---|
| 幾個常數 | 掛上後叫什麼名字、API 網址前綴是什麼 |
| 四張 port 宣告 | 「我需要外界提供這幾種形狀的服務」——插頭規格書(見 §3) |
| 四個插槽 | config(參數值)/adapters(把 port 的實作塞進來,皆選填)/schema_extensions(要多記的欄位)/mount_api(要不要開放 API 端點) |
| register() | 唯一入口,開機時呼叫一次 |
拿到套件之後,接入流程五步:
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
A["① 裝套件<br/>(poetry add,一行指令)"] --> B["② 讀 plugin.py<br/>看它要哪幾張 port"]
B --> C["③ 照規格寫 adapter<br/>通常 2–4 個小類別<br/>(例:「會寄信的東西」)"]
C --> D["④ 模組清單加一行<br/>開機時呼叫 register()<br/>把 config 與 adapters 塞進去"]
D --> E["⑤(有資料表的)<br/>跑它隨包附的資料庫遷移"]
E --> F["完成——API 就能用"]
一句實證:身分套件的獨立測試環境(harness,套件自己帶的迷你主產品)就是照這五步接的——空資料庫十分鐘跑起來。
接好之後,開機時有兩道自動檢查
塞進來的東西形狀不對 → 開機當場報錯,不會等到執行期才炸;該塞的沒塞 → 接線守衛測試紅燈。所以接錯或漏接都不會默默溜到客戶手上——細節見下一節 Q3。
前面講完整套模式,這節整理四個最常被問到的問題(Q1–Q3 是 2026-08-31 與決策者實際問答的整理,Q4 補於 2026-09-01——由一次真實故障逼出來的契約)把「接線」這件事講到底。
它不知道,是主產品開機時「指名塞給它」的。
套件只留一個空口袋(建構子參數——建立套件服務時預留的欄位);主產品的接線盤(DI 容器,dependency injection,統一管「誰用誰」的接線總表)在開機時,把包好的通知器放進這個口袋。之後套件每次要寄信,動作都一樣:摸口袋、按按鈕——按下去實際走的是 SMTP 郵件還是 LINE 推播,它不知道也不在乎。
要換通知管道?改接線盤那一行就好,套件零改動。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph BOOT["開機期(接線一次)"]
A["主產品接線盤<br/>(DI 容器)"] -->|"把包好的通知器<br/>放進口袋"| B["套件的空口袋<br/>(建構子參數)"]
end
subgraph RUN["執行期(每次寄信)"]
C["套件要寄信"] -->|"摸口袋、按 send"| D["口袋裡的通知器<br/>實際寄出<br/>(SMTP?LINE?套件不知道)"]
end
BOOT --> RUN
用到「功能」,但不認識「人」。
中間永遠隔一層主產品寫的皮(adapter,轉接頭)。所以:通知套件改版、換成 LINE、甚至這個產品根本沒裝通知套件——都只動那層皮,或者什麼都不用動;身分套件一行不改。
「用到功能」和「認識套件本身」是一字之差,但正是拔插自由的來源:依賴的是「插頭形狀」(port),不是插上來的那台電器。
不會炸,會「安靜降級」——信默默不寄、人名顯示成帳號。方便,但也危險:最嚴重的實際案例是設定口袋沒塞,人機驗證(CAPTCHA)會退回測試模式、對任何人都放行。
所以配了兩道閘:
| 閘 | 驗什麼 | 白話 |
|---|---|---|
| ① 開機驗形狀 | 塞進來的東西形狀對不對 | 插頭孔數不對,開機當場報錯,不讓你帶病上線 |
| ② 接線守衛測試 | 該塞的口袋都塞了沒 | 哪張 port 忘了接,CI(自動測試)直接紅燈 |
兩道閘都過,漏接在 CI 就被抓到,不會等客戶抱怨收不到信才發現。
能,而且這一點是硬性契約——2026-09-01 才被一次真實故障逼著補齊的。
前面提過面板上有個 mount_api 開關:關掉就代表「我只想用這個套件的內部邏輯,不要 對外開放它的 API 端點」。聽起來只是少掛幾條網址,實際上曾經藏了一個很難自救的坑。
問題出在套件的執行期口袋(前面 Q1 說的那個口袋,套件靠它拿到主產品塞進來的東西) ——原本的實作把「把東西放進口袋」這個動作,綁在掛 API 端點的那一步上。於是關掉 API 端點時,口袋根本沒被放東西進去,套件內部一去摸就摸到空的。
「不開放端點」與「內部功能可用」變成綁死的二選一,而且主產品端無論怎麼接線都 補不出來——因為那個口袋只有套件自己放得進去。
真實故障(問卷套件,2026-09-01):主產品的即時協作服務刻意不載入任何 REST 端點 (減少對外暴露面),而問卷的多人共編處理程序需要摸口袋拿服務。結果每一次「加入共編」 「更新答案」都在後端當場出錯,使用者看到的是「更新失敗,請稍後再試」——問卷多人 共編整組失效,而系統健康檢查一路綠燈。
所以定為契約:
關掉 API 端點時,套件一樣要把東西放進執行期口袋。
「不開放端點」與「內部功能可用」是兩件事,不可以綁在一起。主產品端補不了這個洞, 只有套件自己能負責。
放的語意也要與掛端點那條路一致:後放的蓋掉先放的(同一個應用先後接兩份設定時, 兩條路要給出相同結果;若寫成「已經有就跳過」,同一件事會因為走哪條路而有不同答案, 是那種平常看不出、換場景才爆的分歧)。
八支插件已全數補齊並各配一條契約測試(問卷、身分、檔案、檢測、通知、系統選單、 議題、API 日誌)。測試都做過「故意寫壞看會不會紅」的驗證,確認它們真的守得住。
前面講的口袋、port,解的都是程式碼的解耦。但資料庫還有一種綁法:表與表之間的外鍵(FK,foreign key——資料庫層的強制關聯:A 表指著 B 表,B 的資料不能隨便刪,刪了 A 會指到空的)。外鍵是資料庫自己執行的規則,沒辦法「塞口袋」。
所以資料關聯改用三條判斷律來拆——而且這不是我們發明的,業界(Shopify、GitLab、微服務、DDD)走的全是同一套:
| 律 | 白話判準 | 本案實例 | 業界對應 |
|---|---|---|---|
| ① 想建外鍵=兩張表該住一起 | 天天要連表查、要同生共死的資料,代表本來就是同一個能力被切錯了——正解是合併疆界,不是硬拆 | 問卷設計表與作答表有跨界外鍵 → 裁定合併成問卷疆界套件(D10) | DDD:「兩個領域老要互摸對方的表=邊界劃錯」 |
| ② 真的跨疆界=只存編號、不建外鍵(軟參照) | A 表記 B 的編號就好;要顯示名字透過名冊 port 問,查不到就降級顯示編號、絕不炸 | 日誌套件的表存使用者帳號但不外鍵到 users 表;「禁止 JOIN 宿主身分表」是明文禁令 | GitLab 的 loose foreign keys(還配背景清孤兒)、微服務 database-per-service、DDD reference-by-ID、Shopify 禁跨模組 JOIN(CI 工具強制掃) |
| ③ 只是「B 發生事、A 要知道」=連編號都不用存 | 那是流程通知不是資料關聯——走 port 呼叫或事件,事過境遷不留關聯 | 任務完成要發通知 = INotifier port | 事件驅動架構(event-driven) |
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
A{"兩張表有關聯?"} --> B{"天天連表查/<br/>同生共死?"}
B -->|是| C["律①:合併疆界<br/>(外鍵留著——本來就是一家人)"]
B -->|否| D{"只是要指到<br/>對方的資料?"}
D -->|是| E["律②:存編號+透過 port 問<br/>(軟參照,查不到就降級)"]
D -->|否| F["律③:事件通知<br/>(連編號都不存)"]
一句收攏:該住一起就合併、真跨界就記編號、只是通知就走事件——三條律把「外鍵塞不進口袋」這件事收乾淨。
已成正式規格(D17,2026-08-31 拍板)——第四階段每支候選依此判疆界
三律於 2026-08-31 升格為正式決策 D17 資料關聯三律,第四階段每支候選(人員指派/問卷合併/flow_control 拆出/OSCAL)判斷疆界切分時一律依此。既有的跨界外鍵不回頭全面拆除——隨各疆界抽取時逐案處理(拆到誰才判誰)。
軟參照有代價:資料庫不再幫你擋「指到已刪除的資料」。這由兩件事收拾——「查不到必降級」(顯示編號、絕不炸,已寫進律②)與定期清理孤兒資料(背景作業,比照 GitLab loose foreign keys+清理器;本案尚未建置,隨第四階段各疆界落地時補)。
反悔條件:若未來出現「跨疆界一致性必須由資料庫保證」的硬需求(如金額結算類),該處回到單一疆界內處理,而不是把外鍵加回跨界。全文見 design.md §3 D17。
上一節講完「資料怎麼拆」,最自然的下一個疑問是:拆完之後,查東西會不會變慢、變麻煩? 這節整理六個實際被問到的查詢情境(2026-08-31 與決策者實際問答的整理),從最簡單到最複雜逐一走過。
先立一個貫穿整節的比喻:把系統想成美食街——每個插件是一個檔口(各有自己的廚房=自己的資料表),主產品是櫃檯。規則只有一條:櫃檯不進別人廚房炒菜(不直接摸插件的表),要菜就跟檔口點(呼叫插件的服務);檔口自己廚房內愛怎麼炒怎麼炒(疆界內的表隨便 JOIN)。
日誌檔口自己就能出餐。 日誌表本來就存著操作者的編號——「查編號 17 的日誌」是單表查詢,跟以前一模一樣。九成的查詢是這型,完全無感。
唯一的小步是「雷門」這個名字要先換成編號——問一次身分名冊(port)就有。而且業界日誌系統的標配是寫入當下就把名字快照存進日誌(日誌本來就該記「當時他叫什麼」,之後改名不影響歷史紀錄),存了快照連名冊都不用問。
兩步——先問名冊「資安部有誰」拿回一串編號,再用編號查自己的表。原本的「連表過濾」改寫成「先換算、再過濾」,還是兩句查詢的事。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
A["條件:資安部<br/>(部門名)"] --> B["問身分名冊 port:<br/>資安部有誰?"]
B --> C["拿回編號清單<br/>(17、42、58…)"]
C --> D["用編號清單<br/>過濾自己的日誌表"]
D --> E["結果"]
儀表板歸櫃檯(宿主)管,做法是「各檔口出各自的統計小菜、櫃檯擺盤」——每個插件對自己疆界提供摘要,宿主拼裝。不慢,三個原因:
| # | 原因 | 白話 |
|---|---|---|
| ① | 各插件算的是自己的小範圍 | 每盤小菜都好算,沒有誰要炒全場的菜 |
| ② | 儀表板標配快取 | 算一次,大家看 60 秒,不是每個人開頁面都重算 |
| ③ | 量大時預先算好存一張統計表 | 業界所有 BI/GA 類產品都這樣做 |
補一句業界視角:以往「一句 500 行 JOIN 的巨型報表 SQL」才是慢的元凶——拆成疆界後每段查詢變小,反而好加索引、好診斷。
不會,因為這些東西根本沒有被拆開。 任務、控制項、AO 全是「稽核執行」同一個生活圈——疆界劃分的第一原則就是「常一起查的資料住同一個檔口」。所以這條查詢在檔口內一句連表照舊,速度跟以前完全相同。唯一跨界的只有「雷門→編號」那一小步。
收攏成一條鐵則:熱門查詢不准跨疆界——老是要跨,代表疆界劃錯了,正解是重劃(合併),不是硬寫慢查詢(業界 Shopify、GitLab 同做法)。
三步舞——①任務檔口查「指派給我的」;②收集問卷編號,一次批量跟問卷檔口點「這批問卷+我的作答」(它廚房內自己連表);③櫃檯拼盤回給畫面。總共兩句查詢,不是 N 句。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
A["① 任務檔口:<br/>查「指派給我的任務」<br/>(第 1 句查詢)"] --> B["② 收集問卷編號,<br/>一次批量點菜:<br/>「這批問卷+我的作答」<br/>(第 2 句查詢,<br/>它廚房內自己連表)"]
B --> C["③ 櫃檯拼盤<br/>回給畫面"]
「放櫃檯」是什麼意思?就是主專案裡一支普通的組裝程式——不是新機制,現有系統裡本來就有這層,架構收斂後它的角色被正名而已。ORM 從頭用到尾,被禁止的只有「櫃檯直接連表查插件的表」這一件事。
對,但「深」要拆開看:深的部分留在各檔口、櫃檯永遠只做淺拼裝——①A 檔口深查自己的(內部多表連查,愛多深多深)②B 檔口拿 A 的編號批量深查自己的③櫃檯拼裝回傳。「深」發生在①②,主產品只做③。
如果「拼裝本身」也變深呢?(過濾/排序條件橫跨兩邊,例:按問卷完成率排序任務清單)——按嚴重度三個出口,依序試:
| 順位 | 出口 | 適用 |
|---|---|---|
| ① | 條件先換算成編號(Q2 模式) | 大多數場景夠用 |
| ② | 預先同步一張投影表,查詢單表(讀模型) | 跨界排序/統計的常規解 |
| ③ | 重劃疆界——這種查詢是天天跑的熱路徑?代表兩塊資料該住一起(三律第一條) | 訊號,回決策桌 |
永遠不做的一項
主產品直接跨疆界寫巨型連表查詢當日常手段——那是留給一次性重報表的合法後門,不是常規路徑。常規永遠是:各檔口深查自己+櫃檯淺拼裝;拼裝變深就照上表逐級升級。
| 查詢類型 | API 開在哪 | 查詢寫在哪 | 例子 |
|---|---|---|---|
| 簡易(單一檔口) | 插件自己開——client 直接打插件掛出的 API,主產品零參與 | 插件內 | 日誌清單、問卷 CRUD、登入(身分插件的 39 條 API 就是實例) |
| 看似複雜、實為同檔口的深查詢 | 該檔口自己 | 檔口內一句連表 | 任務+控制項+AO(同屬稽核疆界) |
| 真跨檔口的組合 | 主產品開「套餐窗口」(聚合 API) | 主產品組裝——首選調各插件的服務拼盤,直接連表查是留給重報表的合法後門 | 「我的問卷任務」、儀表板 |
一句話:各檔口的菜,檔口自己賣(client 直接跟檔口買);要湊成套餐的,才由櫃檯開一個套餐窗口。 如果連簡易查詢都要主產品代寫,「隨插即用」就假了——裝了插件還得幫它寫查詢。
查詢問答一句收攏
查自己疆界=照舊;跨疆界的條件=先換算成編號;跨疆界的組合=各拿各的、批量、櫃檯拼盤。會慢的訊號=熱查詢老跨界=疆界劃錯,重劃。
| 情境 | 沒有插件模式 | 有插件模式 |
|---|---|---|
| 新產品上市 | 登入、通知、檔案這些「每個產品都要」的功能重寫一遍 | 裝套件+接線(三步驟,見 §2),核心功能直接到位 |
| 客製交付 | 客戶要換通知管道 → 動到產品裡面的代碼,風險大 | 加一個轉接頭(adapter)就好,套件本身一行不動 |
| 品質 | 共用邏輯散在產品裡,改壞了要等出事才知道 | 每支套件自帶獨立測試環境天天跑,主產品改壞當天知道 |
| 退場 | 功能下架要小心翼翼拆,怕扯斷別的東西 | 拔掉註冊那一行即可,「拔掉測試」保證其他功能無感 |