架構手冊 · 插件模式 · 2026-09-01

外部套件怎麼「插」進主產品——註冊、設定、通知的運作模式

這頁用白話與圖解回答三個問題:插件怎麼掛上主產品設定值歸誰管像「寄通知」這種跨套件的合作怎麼運作。看完你可以判斷「新產品要接這些能力,要做哪些事」。2026-08-31 增補:使用流程、接線 QA、資料關聯三律與查詢實戰問答。2026-09-01 增補:申報制(插件自己來報到)與「申報書是選配」鐵則,以及收官整理的 D6 設計規則六條(arc-review 正面清單)。

給 PM 的白話版 詳細規格見 design.md
§1

一句話:什麼是「插件模式」

想像主產品是一間房子,插件是家電:冷氣(身分登入)、對講機(通知)、保險箱(檔案儲存)。家電自己就是完整的功能,房子只要有插座(註冊點)和電線(接線方式),插上就能用;拔掉某台家電,房子照樣能住,只是少了那個功能。

比喻 對應到我們的系統 白話說明
房子 Guidant AI 主產品 住人的地方——真正面對客戶的產品
家電 jedi-* 套件(插件) 各自完整的功能包:身分登入、通知、檔案…
插座 模組清單裡的一行註冊 家電要插哪裡——一行一台,加一行就多一台
電線 port(後面 §3 詳細講) 家電和房子之間約定好的接線方式

這不是空想——身分套件已經通過「拔掉測試」

身分套件(jedi-iam)已在 2026-08-30 完成合併,並實際做過拔掉測試:把註冊那一行註解掉,產品照常開機,只是登入功能整組消失(呼叫會得到 404),其他功能完全不受影響。這證明插件真的是「插上就有、拔掉就沒有」,不會牽一髮動全身。

§2

插件怎麼掛上主產品(註冊)

主產品開機時,會照著一份模組清單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/>功能上線]
圖 1 — 開機時的註冊流程:主產品照清單點名,插件各自掛上自己的功能

呼叫 register() 時,主產品可以塞四個「插槽」進去,決定這個插件在這個產品裡長什麼樣:

插槽 白話解釋 例子
config(參數值) 每個產品不一樣的數字與開關 OTP 驗證碼幾分鐘過期、密碼要幾碼
adapters(行為差異) 寄信這件事你們家怎麼做」——同一件事各產品做法不同,由主產品提供做法 A 產品用內建通知系統寄、B 產品接客戶的郵件主機
schema_extensions(欄位差異) 你們家要多記什麼欄位」——套件基本表之外,本產品額外要存的資料 這個產品的使用者要多記「員工編號」
mount_api(API 總開關) 要不要對外開放這個插件的 API 端點 有些產品只想用它的內部邏輯、不開放端點

全文唯一的一段示意代碼——註冊真的就是一行:

register(app, adapters=..., config=...)

新產品要接一支插件,要做的事就三件

步驟 做什麼 白話
① 加一行 在模組清單加上這個插件 「插上插座」
② 接線 實作它宣告的 port(通常 2–4 張,見 §3) 「照插頭形狀接電線」
③ 建表 (插件有自己資料表的話)跑它隨包附的資料庫遷移 「家電要的專屬櫃子照說明書組起來」
§3

申報制:插件自己來報到,平台不去認識功能

前面講的是「插件怎麼掛上去」。還有一個反過來的問題:平台怎麼知道有哪些功能?

錯誤的做法是平台自己列一張清單(「任務型別有:一般、問卷、檢測工具…」)。那等於 平台認識了每一個插件——每加一種功能就要回頭改平台,插件也就不再是「插上就有、 拔掉就沒有」了。

正解是反過來:平台只開一個登記處,插件自己來報到。這叫「申報制」。

平台自己列清單(❌) 申報制(✅)
誰知道有哪些型別 平台寫死 每個插件申報自己的
加一種新功能 要改平台的程式碼 只加一支插件,平台零改動
拔掉某功能 清單裡的死項目留著 選單自動少一項

實例(FR-069 4.2 第 3 步/CM-1490):任務型別登記表只保留 general(任務骨幹), 問卷型別由問卷插件申報、檢測工具型別由檢測插件申報。實測拔掉問卷插件的申報, 下拉選單當場少一項——沒有殘留的死選項

🔴 申報書是選配——核心能力不得依賴任何插件有沒有申報

規則:平台的核心能力不可以寫成「要先有人申報才能運作」。沒有任何插件的空 平台,也必須跑得起來、跑得完整——只是選單上少了那些型別而已。

為什麼要明文寫死這條:申報制很容易滑向「平台反過來依賴申報」。典型的滑法是 平台某段程式寫成「查登記表拿到型別 → 依型別分岔」,一旦登記表是空的就走進沒人 處理的分支,於是空平台反而壞掉。那等於平台把自己的正常運作押在「有插件」上, 插件從選配變成必要,前面拆的耦合又長回來。

先例:問卷(survey)就是這樣運作的——它是選配插件,拿掉之後任務平台照常派工、 留言、看儀表板,只是不能派問卷任務。這個先例在 2026-09-01 由決策者定調為通則, 往後所有 audit 型積木必須依附平台,而 survey 型核心能力必須能獨立存在。

怎麼驗(不是宣稱,是可執行的檢查):

  1. 拔掉測試:把插件的申報註解掉,平台開機、核心端點照常回 200/401(不是 500)
  2. 空登記表測試:登記表只有 general 時,平台的每條核心路徑都要走得通
  3. 兩者都要實跑——「應該沒問題」不算數,靜默降級的症狀正是看起來一切正常
§4

port 是什麼——插件之間「不直接認識」的合作方式

這是整個模式最重要的一個觀念:插件之間不互相打電話,全部透過主產品這個「總機」轉接。

用寄 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/>(實際負責寄信)"]
圖 2 — 兩個套件互不知道對方存在,全靠主產品的接線盤轉接(全文最重要的一張圖)

這樣繞一圈的好處,白話講有三條:

  1. 換掉通知系統,身分套件一行都不用改——只要新系統也能包成同一個插頭形狀。
  2. 新產品沒裝通知套件,身分套件照樣能用——信寄不出去,但登入照常運作(這叫「優雅降級」:缺一角不會整組垮)。
  3. 哪天要接 LINE 通知,只要多寫一個轉接頭(adapter),不用動任何套件。

「安靜降級」有風險——所以配了守衛測試

降級的另一面是:忘了接線不會報錯,信就是默默不寄。為此我們有「接線守衛測試」:哪張 port 忘了接,CI(自動測試)直接亮紅燈,不會等到客戶抱怨收不到信才發現

目前定下的標準 port 有四張(規格編號 D16),每支插件只准跟這四種標準插頭與地基套件(jedi-common)打交道,不准私下認識別的套件:

標準 port 白話用途
身分名冊 「這個 user id 是誰?叫什麼名字?」
INotifier(通知) 「幫我把這則訊息寄出去/推出去」
檔案存取 「幫我存這個檔/把那個檔拿來」
設定讀取 「這個租戶的某某設定值是多少?」
§5

D6 設計規則六條——寫新套件時照這個做

FR-069 抽了 12 支套件之後,arc-review 從實際做出來的東西裡挑出六個值得當成標準的做法 (不是事後想的原則,是已經在跑的碼)。寫新套件或改既有套件時照這六條。

① 守門三件套:無預設 + 建構期拒絕掛載 + mount_api=False 逃生門

需要認證/授權/加密才安全的插槽,三件事一起做

  1. 不給預設值——auth_required / license_guard / crypto 這類欄位不寫 = None 的預設。
  2. register() 當場檢查,缺就拋例外拒絕掛載——不是「有就用、沒有就跳過」。
  3. 另給 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) 的問卷共編整組失效,而宿主端無論怎麼接線都補不出來。

② ABC vs Protocol 的選型規則

兩種寫法在 codebase 裡都有,不是隨便挑

用哪個 什麼時候 實例
ABCabstractmethod 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

④ 兩個方法不要合成一個回 tuple

同一個 port 裡,「取 A」與「取 B」分兩支方法,不要合成一支回 (A, B)

jedi-task-platform 的原文理由:get_workflow_execution_idget_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)檔頭寫三樣東西:

  1. 沿革表——這支從哪來、原本叫什麼、哪張卡搬的。
  2. 不留轉發殼的理由(如果舊名死了)——「留殼等於舊名沒退場,之後新寫的 code 兩個名字都 import 得動,半年後又變成同一個東西兩條路徑」。
  3. 守衛座標——哪幾支測試在守這個邊界,讓下一個人知道改壞了誰會叫。

範本:jedi-compliance-auditjedi-task-platform__init__ / README 檔頭。

這六條的共同精神:FR-069 arc-review 的主題是「綠著的守衛不代表在守」。 六條裡有四條(①②③⑤)都在講同一件事的不同面向——讓錯誤大聲地壞,而不是安靜地壞

§6

設定(config)歸誰管——「規格書進套件、值留主產品」

原則一句話:套件管規格書,主產品管實際的值。

  • 套件知道:「有哪些設定、什麼型別、預設多少、填錯怎麼擋」——這是規格書(schema),隨套件出貨。
  • 主產品知道:「值存在哪張表、目前填多少」——存在主產品的 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["③ 都沒設 → 用套件內建預設值"]
圖 3 — 設定的分工與讀取順序:規格書隨套件、值存主產品,讀取時三層 fallback

設定分三種作用域(誰的設定、誰能改):

作用域 描述的是什麼 例子 誰改
系統級 這個部署(整套裝在客戶機房的產品) LDAP 連線、儲存後端 平台管理員
租戶級 這個客戶(同一套產品裡的某一家公司) 通知偏好 租戶管理員
租戶可覆寫系統級 平台給預設、客戶可以換自己的 SMTP 信箱伺服器——客戶可用自己的,沒設就用平台預設 兩者皆可

已成正式規格(D18,2026-08-31 拍板)——第四階段起照辦

此模式已有第一個落地實例(安全政策七個設定鍵,2.5 階段完成),並於 2026-08-31 升格為正式規格 D18 設定 schema 申報制:schema 進套件、值與存放留宿主、模組開關表永遠留宿主(「哪些模組對這個租戶開啟」是宿主的組裝決策,套件不該知道自己有沒有被啟用)。

三項執行細節同時定案:①作用域是 schema 的必填欄位(上表三種),儲存機制現成、不需新表;②預設活在 code、DB 只存差異——讀無列就回套件內建預設,套件裝上就有預設值可用、不必等 seed;真要 seed 也只補缺鍵、絕不覆蓋(覆蓋會把客戶調過的設定打回原廠);③上圖的三層 fallback 由套件申報、宿主執行,全鏈皆無則明確報錯,不靜默回空值。

附帶好處仍在:設定頁可以直接從申報的規格書自動長出表單,不必每加一個設定就手刻一次畫面(加分項,非必辦)。既有套件於第三階段補殼時回頭套用(有設定的才做)。全文見 design.md §3 D18。

§7

實際走一遍:使用者登入收到 OTP 信,背後發生什麼

把前面三節串起來,看一次真實流程:

%%{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: 回「請輸入驗證碼」
圖 4 — 登入寄 OTP 驗證信的完整流程:身分套件全程只對著兩個插頭講話

一句收攏:身分套件全程不知道「信怎麼寄、SMTP 設定存在哪」——它只對著兩個插頭講話。寄信的細節在通知套件,設定的存放在主產品,三方各管各的、靠插頭形狀合作。

§8

插件的使用流程——從拿到套件到上線

每支插件唯一「露在外面」的,是一個叫 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 就能用"]
圖 5 — 從拿到套件到上線的五步流程:讀一個檔、寫幾個轉接頭、加一行、跑遷移

一句實證:身分套件的獨立測試環境(harness,套件自己帶的迷你主產品)就是照這五步接的——空資料庫十分鐘跑起來

接好之後,開機時有兩道自動檢查

塞進來的東西形狀不對 → 開機當場報錯,不會等到執行期才炸;該塞的沒塞 → 接線守衛測試紅燈。所以接錯或漏接都不會默默溜到客戶手上——細節見下一節 Q3。

§9

口袋裡裝的是誰?——接線的四個常見疑問

前面講完整套模式,這節整理四個最常被問到的問題(Q1–Q3 是 2026-08-31 與決策者實際問答的整理,Q4 補於 2026-09-01——由一次真實故障逼出來的契約)把「接線」這件事講到底。

Q1:套件怎麼知道要用哪一個通知服務?

它不知道,是主產品開機時「指名塞給它」的。

套件只留一個空口袋(建構子參數——建立套件服務時預留的欄位);主產品的接線盤(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
圖 6 — 接線分兩段:開機期把東西放進口袋,執行期只管摸口袋按按鈕

Q2:套件不就還是用到了別的套件的功能嗎?

用到「功能」,但不認識「人」。

中間永遠隔一層主產品寫的皮(adapter,轉接頭)。所以:通知套件改版、換成 LINE、甚至這個產品根本沒裝通知套件——都只動那層皮,或者什麼都不用動;身分套件一行不改

「用到功能」和「認識套件本身」是一字之差,但正是拔插自由的來源:依賴的是「插頭形狀」(port),不是插上來的那台電器。

Q3:忘了塞會怎樣?

不會炸,會「安靜降級」——信默默不寄、人名顯示成帳號。方便,但也危險:最嚴重的實際案例是設定口袋沒塞,人機驗證(CAPTCHA)會退回測試模式、對任何人都放行

所以配了兩道閘

驗什麼 白話
① 開機驗形狀 塞進來的東西形狀對不對 插頭孔數不對,開機當場報錯,不讓你帶病上線
② 接線守衛測試 該塞的口袋都塞了沒 哪張 port 忘了接,CI(自動測試)直接紅燈

兩道閘都過,漏接在 CI 就被抓到,不會等客戶抱怨收不到信才發現。

Q4:「不開放 API 端點」的模式,套件還能正常運作嗎?

能,而且這一點是硬性契約——2026-09-01 才被一次真實故障逼著補齊的。

前面提過面板上有個 mount_api 開關:關掉就代表「我只想用這個套件的內部邏輯,不要 對外開放它的 API 端點」。聽起來只是少掛幾條網址,實際上曾經藏了一個很難自救的坑。

問題出在套件的執行期口袋(前面 Q1 說的那個口袋,套件靠它拿到主產品塞進來的東西) ——原本的實作把「把東西放進口袋」這個動作,綁在掛 API 端點的那一步上。於是關掉 API 端點時,口袋根本沒被放東西進去,套件內部一去摸就摸到空的。

「不開放端點」與「內部功能可用」變成綁死的二選一,而且主產品端無論怎麼接線都 補不出來——因為那個口袋只有套件自己放得進去。

真實故障(問卷套件,2026-09-01):主產品的即時協作服務刻意不載入任何 REST 端點 (減少對外暴露面),而問卷的多人共編處理程序需要摸口袋拿服務。結果每一次「加入共編」 「更新答案」都在後端當場出錯,使用者看到的是「更新失敗,請稍後再試」——問卷多人 共編整組失效,而系統健康檢查一路綠燈。

所以定為契約:

關掉 API 端點時,套件一樣要把東西放進執行期口袋。

「不開放端點」與「內部功能可用」是兩件事,不可以綁在一起。主產品端補不了這個洞, 只有套件自己能負責。

放的語意也要與掛端點那條路一致:後放的蓋掉先放的(同一個應用先後接兩份設定時, 兩條路要給出相同結果;若寫成「已經有就跳過」,同一件事會因為走哪條路而有不同答案, 是那種平常看不出、換場景才爆的分歧)。

八支插件已全數補齊並各配一條契約測試(問卷、身分、檔案、檢測、通知、系統選單、 議題、API 日誌)。測試都做過「故意寫壞看會不會紅」的驗證,確認它們真的守得住。

§10

資料表的關聯怎麼辦?——三條判斷律

前面講的口袋、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/>(連編號都不存)"]
圖 7 — 兩張表有關聯時的判斷樹:先問要不要住一起,再問要不要記得對方

一句收攏:該住一起就合併、真跨界就記編號、只是通知就走事件——三條律把「外鍵塞不進口袋」這件事收乾淨。

已成正式規格(D17,2026-08-31 拍板)——第四階段每支候選依此判疆界

三律於 2026-08-31 升格為正式決策 D17 資料關聯三律,第四階段每支候選(人員指派/問卷合併/flow_control 拆出/OSCAL)判斷疆界切分時一律依此。既有的跨界外鍵不回頭全面拆除——隨各疆界抽取時逐案處理(拆到誰才判誰)。

軟參照有代價:資料庫不再幫你擋「指到已刪除的資料」。這由兩件事收拾——「查不到必降級」(顯示編號、絕不炸,已寫進律②)與定期清理孤兒資料(背景作業,比照 GitLab loose foreign keys+清理器;本案尚未建置,隨第四階段各疆界落地時補)。

反悔條件:若未來出現「跨疆界一致性必須由資料庫保證」的硬需求(如金額結算類),該處回到單一疆界內處理,而不是把外鍵加回跨界。全文見 design.md §3 D17。

§11

那查詢會不會變慢、變複雜?——六個實戰問答

上一節講完「資料怎麼拆」,最自然的下一個疑問是:拆完之後,查東西會不會變慢、變麻煩? 這節整理六個實際被問到的查詢情境(2026-08-31 與決策者實際問答的整理),從最簡單到最複雜逐一走過。

先立一個貫穿整節的比喻:把系統想成美食街——每個插件是一個檔口(各有自己的廚房=自己的資料表),主產品是櫃檯。規則只有一條:櫃檯不進別人廚房炒菜(不直接摸插件的表),要菜就跟檔口點(呼叫插件的服務);檔口自己廚房內愛怎麼炒怎麼炒(疆界內的表隨便 JOIN)

Q1:查「某人的日誌」怎麼查?

日誌檔口自己就能出餐。 日誌表本來就存著操作者的編號——「查編號 17 的日誌」是單表查詢,跟以前一模一樣。九成的查詢是這型,完全無感。

唯一的小步是「雷門」這個名字要先換成編號——問一次身分名冊(port)就有。而且業界日誌系統的標配是寫入當下就把名字快照存進日誌(日誌本來就該記「當時他叫什麼」,之後改名不影響歷史紀錄),存了快照連名冊都不用問。

Q2:要用「對面檔口的條件」過濾怎麼辦?(如:查資安部所有人的操作)

兩步——先問名冊「資安部有誰」拿回一串編號,再用編號查自己的表。原本的「連表過濾」改寫成「先換算、再過濾」,還是兩句查詢的事。

%%{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["結果"]
圖 8 — 跨疆界條件的兩步查法:先把「部門名」換算成編號清單,再回自己的表過濾

Q3:首頁儀表板要湊 N 個模組的數字,不會超慢嗎?

儀表板歸櫃檯(宿主)管,做法是「各檔口出各自的統計小菜、櫃檯擺盤」——每個插件對自己疆界提供摘要,宿主拼裝。不慢,三個原因:

# 原因 白話
各插件算的是自己的小範圍 每盤小菜都好算,沒有誰要炒全場的菜
儀表板標配快取 算一次,大家看 60 秒,不是每個人開頁面都重算
量大時預先算好存一張統計表 業界所有 BI/GA 類產品都這樣做

補一句業界視角:以往「一句 500 行 JOIN 的巨型報表 SQL」才是慢的元凶——拆成疆界後每段查詢變小,反而好加索引、好診斷

Q4:深查詢呢?「專案內指派給雷門且未完成的任務+控制項群組+控制項+AO+名稱過濾」——跨這麼多東西不會拆成 N 段嗎?

不會,因為這些東西根本沒有被拆開。 任務、控制項、AO 全是「稽核執行」同一個生活圈——疆界劃分的第一原則就是「常一起查的資料住同一個檔口」。所以這條查詢在檔口內一句連表照舊,速度跟以前完全相同。唯一跨界的只有「雷門→編號」那一小步。

收攏成一條鐵則:熱門查詢不准跨疆界——老是要跨,代表疆界劃錯了,正解是重劃(合併),不是硬寫慢查詢(業界 Shopify、GitLab 同做法)。

Q5:真的跨疆界的組合頁呢?(如「我的問卷任務」:指派給我的任務+我填答的題目)

三步舞——①任務檔口查「指派給我的」;②收集問卷編號,一次批量跟問卷檔口點「這批問卷+我的作答」(它廚房內自己連表);③櫃檯拼盤回給畫面。總共兩句查詢,不是 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/>回給畫面"]
圖 9 — 跨疆界組合頁的三步舞:各拿各的、批量點菜、櫃檯拼盤——總共兩句查詢

「放櫃檯」是什麼意思?就是主專案裡一支普通的組裝程式——不是新機制,現有系統裡本來就有這層,架構收斂後它的角色被正名而已。ORM 從頭用到尾,被禁止的只有「櫃檯直接連表查插件的表」這一件事。

Q6:跨疆界的「深」查詢呢?也是主產品聚合嗎?

對,但「深」要拆開看:深的部分留在各檔口、櫃檯永遠只做淺拼裝——①A 檔口深查自己的(內部多表連查,愛多深多深)②B 檔口拿 A 的編號批量深查自己的③櫃檯拼裝回傳。「深」發生在①②,主產品只做③。

如果「拼裝本身」也變深呢?(過濾/排序條件橫跨兩邊,例:按問卷完成率排序任務清單)——按嚴重度三個出口,依序試:

順位 出口 適用
條件先換算成編號(Q2 模式) 大多數場景夠用
預先同步一張投影表,查詢單表(讀模型) 跨界排序/統計的常規解
重劃疆界——這種查詢是天天跑的熱路徑?代表兩塊資料該住一起(三律第一條) 訊號,回決策桌

永遠不做的一項

主產品直接跨疆界寫巨型連表查詢當日常手段——那是留給一次性重報表的合法後門,不是常規路徑。常規永遠是:各檔口深查自己+櫃檯淺拼裝;拼裝變深就照上表逐級升級。

總表:哪種查詢的 API 開在哪、誰來查?

查詢類型 API 開在哪 查詢寫在哪 例子
簡易(單一檔口) 插件自己開——client 直接打插件掛出的 API,主產品零參與 插件內 日誌清單、問卷 CRUD、登入(身分插件的 39 條 API 就是實例)
看似複雜、實為同檔口的深查詢 該檔口自己 檔口內一句連表 任務+控制項+AO(同屬稽核疆界)
真跨檔口的組合 主產品開「套餐窗口」(聚合 API) 主產品組裝——首選調各插件的服務拼盤,直接連表查是留給重報表的合法後門 「我的問卷任務」、儀表板

一句話:各檔口的菜,檔口自己賣(client 直接跟檔口買);要湊成套餐的,才由櫃檯開一個套餐窗口。 如果連簡易查詢都要主產品代寫,「隨插即用」就假了——裝了插件還得幫它寫查詢。

查詢問答一句收攏

自己疆界=照舊;跨疆界的條件=先換算成編號;跨疆界的組合=各拿各的、批量、櫃檯拼盤。會慢的訊號=熱查詢老跨界=疆界劃錯,重劃。

§12

這個模式對產品的意義(給 PM 的收益表)

情境 沒有插件模式 有插件模式
新產品上市 登入、通知、檔案這些「每個產品都要」的功能重寫一遍 裝套件+接線(三步驟,見 §2),核心功能直接到位
客製交付 客戶要換通知管道 → 動到產品裡面的代碼,風險大 加一個轉接頭(adapter)就好,套件本身一行不動
品質 共用邏輯散在產品裡,改壞了要等出事才知道 每支套件自帶獨立測試環境天天跑,主產品改壞當天知道
退場 功能下架要小心翼翼拆,怕扯斷別的東西 拔掉註冊那一行即可,「拔掉測試」保證其他功能無感

各套件的抽取進度看 路線圖;技術規格(D6 插件契約、D16 port 通則)見 design.md §3。