---
title: "FR-063 Nuitka 落地版打包（Linux 容器版）— 設計文件"
brand: "Guidant AI · **FR-063** Nuitka 落地版打包"
eyebrow: "FR-063 · Nuitka Packaging — 設計定案 · 2026-08-14"
h1: "設計定案：單 binary 兩模式，六步管線，四棒拆分"
lede: "本文件是 [討論稿](./discussion.html) 的正式化——**D1–D11 十一項決策全數定案**，內容以討論稿為準。核心：統一入口 `RUN_MODE` 切模式、DI 靜態清單雙軌、`resource_path()` 資源收斂、config 三層重整、六步 build 管線、非 root production image、七項 smoke 探針。拆分為 .1 程式整理 → .2 Build 管線 → .3 Production image → .4 socketio 驗證四棒，執行順序與依賴關係見 §6。"
chips: [
  {text: "設計定案待實作", kind: ok},
  {text: "D1–D11 全定案", kind: ok},
  {text: "範圍：Linux 容器版", kind: accent},
  {text: "Notion CM-1187", kind: plain}
]
footer: "FR-063 · Nuitka 落地版打包 — 設計文件 · 2026-08-14 · 三階段戰線第一棒（FR-063 編譯 → FR-064 防竄改 → FR-065 Installer）· Notion 母案 CM-1187"
---

> 狀態：**設計定案待實作**（D1–D11 已拍板）｜建立日期：2026-08-14｜三階段戰線第一棒：**FR-063 編譯 → FR-064 防竄改 → FR-065 Installer**
> 討論稿（唯一素材來源，user 已審）：[`discussion.html`](./discussion.html)｜Notion 母案：CM-1187

## 變更紀錄 {#changelog nav="-"}

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-08-14 | 初版設計定案，D1–D10 全數拍板（D7–D10 為原待確認 Q1–Q4 升格） | FR-063 母案 CM-1187 |
| 2026-08-14 | 新增 D11 config 三層重整＋死項清理（子任務 .1g），§5.7 詳細設計 | FR-063 母案 CM-1187 |

## 需求背景與端到端流程 {#why nav="背景"}

產品進入落地版（on-premise）交付階段——**客戶主機上不可再出現一行可讀原始碼**。本案把 BE 整包用 **Nuitka standalone 編成機器碼 binary**、包進 Linux Docker image 交付，與 FR-062 既有 license 執法構成雙保險。範圍限定 Linux 容器版；防竄改（FR-064）、Installer 與 Windows 支援（FR-065，傾向 Docker Desktop / WSL2 跑同一 Linux image）是後兩棒。

**選型脈絡（前期已定）**：比較過 .pyc（compileall）／ Cython ／ PyArmor ／ Nuitka 四條路。Python 3.11 反編譯工具鏈已斷裂（uncompyle6 / decompyle3 停在 3.8 前後），純 `.pyc` 其實已有基礎保護力，但決策者拍板**採 Nuitka standalone 取得最高保護等級**，對齊 Grafana / HashiCorp 等 Go binary 陣營的交付形態（源碼可見陣營如 GitLab / Sentry 僅靠 license 執法）。

**2026-08-13 夜間實測（STG 機 `/opt/guidant_ai`，Nuitka 4.1.3 / Python 3.11，4 core / 32GB）**：冷 build 1h27m、產物 `main_app.dist` 808MB、8,613 支 C 檔全編、`pymupdf.mupdf.c` 單檔 34 分鐘（平行化不可壓縮地板）。試跑 import 期即炸 `SystemError: dynamic module 'dependency_injector.providers' not initialized properly from def`——根因與修法已定案為 D2。

```{.mermaid cap="圖 1 — 端到端：源碼 → build 管線 → image → 客戶主機"}
%%{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
  SRC["原始碼樹<br/>（統一入口＋<br/>resource_path 收斂後）"] --> PIPE["Build 管線六步<br/>（build 機 24c/32GB，<br/>全 script 化）"]
  PIPE --> BIN["Nuitka dist<br/>單 binary 兩模式<br/>＋隨附資料檔"]
  BIN --> IMG["Production image<br/>非 root · gunicorn 內嵌<br/>fc-match 斷言"]
  IMG --> SMOKE["Smoke 七項探針"]
  SMOKE --> CUST["客戶主機<br/>容器內無 .py 業務原始碼<br/>＋FR-062 license 雙保險"]
```

## 決策定案表 D1–D11 {#decisions nav="決策"}

十一項**均已定案**（細節與完整理由見討論稿「決策定案 D1–D11」節）：

| # | 決策 | 理由摘要 | 被排除方案 |
|---|------|----------|------------|
| **D1** | api 模式先驗，socketio 模式隨後（單一 binary 兩模式，配 D9） | api 路徑完全不碰 eventlet（已掃描確認），先驗它＝90% 業務程式碼到位；兩模式是獨立 process（8000/8002）可分開驗。**保底**：eventlet 卡關則 socketio 換 async 承載（gevent 等，flask-socketio 支援換 `async_mode`），業務模組仍全數受編譯保護 | 兩支入口各自編譯（被 D9 統一入口取代） |
| **D2** | dependency-injector 私房 wheel，rebuild 納入管線前置步驟 | 官方 wheel 走 Limited API（abi3）→ 強制 PEP 489 多相初始化 → 與 Nuitka 靜態嵌入不相容。sdist（1MB，BSD）可現場 cythonize 重編：候選①不開 Limited API（預設即不開，15 分鐘可驗）②CFLAGS `-DCYTHON_PEP489_MULTI_PHASE_INIT=0`。**版本感知快取**＋sdist 留檔，升版自動重編零漂移 | 換掉 DI 套件（整個 DI 體系地基，不可行） |
| **D3** | 建 `resource_path()` 統一資源定位入口 | 開發模式回原始碼樹、打包模式回 dist 根，所有資料檔讀取收斂一個入口；順帶清理 catalog seed 搬出 `docs/`、下載範本移出 `static/`、三處死 Blueprint static 宣告、`load_routes()` 死碼 | 各處各自判斷路徑（維持現狀，脆且散） |
| **D4** | 證據分類 docker CLI 依賴：落地版功能開關降級停用 | BE 自己容器化後跑 `cmmc-classifier:latest` 變成 DinD 問題，安裝複雜度暴增 | 掛 `docker.sock` / 改分類器執行形態（留日後客戶真有需求再議） |
| **D5** | 專用 build 機 24 core / 32GB（user 準備），100GB+ SSD | 24 core 預估冷 build ~35–40 分（pymupdf 34 分是地板），ccache 熱 build 10 分內；流程全 script 化，build 機只是算力、搬家零成本 | 沿用 STG 機 4 core（1h27m 不可接受） |
| **D6** | 版本號＋git commit hash build 期 bake 進產物 | `/api/1.0/version` 現讀 `pyproject.toml`，產物內無→回 `unknown`。改 build 期生成 `version.py` 常數檔；**commit hash 一併 bake**——編譯後 traceback 無行號，log 版本資訊必須足以唯一鎖定 commit（同版號可能 hotfix 重 build） | 只 bake 版號不 bake commit（不足以定位） |
| **D7** | main_app 承載換 gunicorn **`BaseApplication` API 內嵌**（原 Q1） | Flask dev server 不上 production；Nuitka 下不能走 `gunicorn main:app` CLI import（產物無可外部 import 的模組），改在程式內起 master/worker，gunicorn 作為依賴編入，worker 數走環境變數。開發模式保留 dev server（開關切換） | gunicorn CLI 模式（binary 產物做不到） |
| **D8** | production image 非 root 執行（原 Q2） | 合規產品客戶會掃 image，root 執行是標準紅字；亦降低被攻破後權限面。固定 uid 專用使用者＋entrypoint chown volume＋安裝文件寫明（FR-065 承接細節） | root 執行（賣合規產品自己被掃紅字說不過去） |
| **D9** | 統一入口＋`RUN_MODE` 切模式＋按模式裁剪載入，三支舊入口全退役（原 Q3） | 骨架以 `main_app.py` 邏輯為主（較新、註解完整）；`main.py`（會 `create_all`）/ `main_app.py` / `main_socketio.py` 全退役。單 binary build 一次、容器 command 只差環境變數，無縫接「一 image 兩 entrypoint」慣例；裁剪解掉 scheduler 雙跑、socketio 進程瘦身。PyCharm debug 不回歸（見 §5.1） | 維持兩支入口各自編譯（build ×2、產物 ×2） |
| **D10** | jedi-file-upload 硬編 LibreOffice 路徑修正（原 Q4） | 套件內 `local_file_adapter.py` / `minio_adapter.py` 硬編 platform 判斷、不吃 `LIBREOFFICE_CMD`，與主專案不一致。改同主專案邏輯（`LIBREOFFICE_CMD` env → PATH → mac fallback）。jedi-* 異動照規範：開發期 poetry path dependency、完成後 pin 版發佈 | 主專案側 workaround（違反套件異動規範，髒） |
| **D11** | Config 三層重整＋死項清理（新增子任務 .1g） | binary 化後「改 config＝重編譯交付」，設定放錯層代價放大；六套環境 class 是雲端多環境時代設計，落地版每客戶都是新環境。三刀：①六套環境 class 塌成一套 Config、差異值 env 化、`ENV` 降級純顯示標籤 ②Secret 拆 JSON 包裝＋`config_loader.py` 啟動期集中驗證（向後相容舊 `.env`）③營運參數（登入鎖定／密碼政策／JWT 效期／MFA）遷 DB system_config 內建預設＋DB 覆寫。另附 2026-08-14 盤查定案的死項清理（config 14 項＋SQLALCHEMY 系＋APISPEC 系＋`.env` 4 項）。詳見 §5.7 | 維持六套 class（為客戶加 class 要重編譯，不可行）；一次大搬 DB（第一批刻意保守） |

## 現況盤點摘要 {#inventory nav="盤點"}

三路掃描（動態 import／`__file__` 路徑／外部依賴）結果總表——**完整明細（#4 四處資料檔、#10 volume 清單、#14 jedi-* 環境變數清單）見討論稿「現況盤點」節，此處不重複貼**：

| 級 | # | 項目 | 位置 | 風險 | 對策 | 歸屬子需求 |
|----|---|------|------|------|------|-----------|
| 🔴 | 1 | DI auto-scan `rglob('*.py')` | `config/di_modules.py:49,72` | 產物無 `.py` 掃空 → 所有 route `@inject` 失效，啟動不報錯、第一個 request 才炸 | build 期 gen 靜態清單 `config/di_modules_static.py`，開發動態掃／release 用靜態（§5.2） | .1a |
| 🔴 | 2 | dependency-injector abi3 | 套件本身 | import 期 `SystemError` | D2 私房 wheel rebuild | .2b |
| 🟠 | 3 | blueprint 動態載入吞錯 | `main_app.py:26` 等三處 | `except ImportError: warning` 靜默——缺模組整組 API 消失 | 改 fail-fast＋靜態 import 補強驗證 | .1e |
| 🟠 | 4 | 資料檔 ×4（`__file__` 定位） | translations `.mo`／SSP docx 範本／`cmmc_l1_canon.json`／`cmmc_l1_aos.json` | Nuitka 不自動打包非 `.py` 檔，四處全滅（`canon.json` 缺檔回 `None` 最陰） | D3 `resource_path()`（§5.3） | .1b |
| 🟠 | 5 | 中文字型 | production image | 缺字型 PDF 回 200 但中文整段消失 | Dockerfile 裝 `fonts-noto-cjk`＋build 期 `fc-match` 斷言 | .3 |
| 🟠 | 6 | version 讀 `pyproject.toml` | `api/version/routes/version_route.py:17` | 產物內無 → 回 `unknown` | D6 version＋commit bake | .1e / .2c |
| 🟡 | 7 | `log/app.log` cwd 相對路徑 | jedi_common（import `jedi_issue` 期 `makedirs`） | cwd 不對就在奇怪位置長 log/ | entrypoint 先 `chdir` 固定工作目錄 | .3 |
| 🟡 | 8 | `static/` 雙重身分 | 上傳落地處 vs 範本下載處 | volume 可寫 vs 隨版唯讀直接打架 | 範本移出 static（併 D3） | .1b |
| 🟡 | 9 | 上傳根目錄 fallback `/tmp/upload/` | `STORAGE_CONFIG.base_dir` 未設時 | 容器重啟即丟 | 落地版必須明確設定並掛載 | .3 |
| 🟡 | 10 | volume 清單 ×6 | 上傳／log／`.cm-jobs`／LibreOffice profile／`TMPDIR`／agent 憑證 | 未宣告即遺失或不可寫 | Dockerfile / compose 明確宣告（§5.5） | .3 |
| 🟡 | 11 | 死 static 宣告 ×3＋壞下載 | `core/app_factory.py:35`、`api/module_frame/__init__.py:83` 等 | `module_frame_import_route.py:32` 的下載**現在就是壞的** | D3 一併清理 | .1b |
| 🟣 | 12 | 外部 binary ×4 | LibreOffice／cinc-auditor／docker CLI／tesseract | 套件內 LibreOffice 路徑不吃 env（不一致）；docker CLI 見 D4 | D10 修套件；D4 功能開關停用；其餘 image 裝或降級 | .1f / .3 |
| 🟣 | 13 | WeasyPrint 系統庫 | import 期 dlopen pango 等 | Nuitka 不自動收 dlopen 的 `.so` | image apt 裝齊（同現行做法），不打進 binary | .3 |
| 🟣 | 14 | 環境變數 60+（含 jedi-* 16 個） | `DB_SECRET` 等 import 期 `json.loads` 缺了直接炸 | 正式進入點不載 `.env` 全靠外部注入；jedi-* 16 個主專案 grep 不到 | .1 收斂時產完整對照表，.3 文件化 | .1 / .3 |
| 🟣 | 15 | APScheduler 4 jobs 雙跑 | 兩進入點都起，靠 CAS 冪等擋 | 編譯面無風險，但雙跑是體質債 | D9 裁剪收斂為 api 模式單一持有 | .1c |

掃過確認安全（getattr/registry 字串、`inspect.getsource` 零命中、BytesIO 串流匯出、`__import__("uuid")` 常量、runtime `listdir`）——見討論稿「✅ 掃過確認安全」。

## 詳細設計 {#design nav="設計"}

### 5.1 統一入口（D9 + D7 + D1）

新增統一入口（骨架以 `main_app.py` 邏輯為主），`RUN_MODE` 環境變數切換 `api`（預設）/ `socketio`；`main.py` / `main_app.py` / `main_socketio.py` 三支全退役。單一 binary、容器 command 只差環境變數。

**模式裁剪載入表**（現況兩入口幾乎孿生，socketio 背著全部 REST blueprint＋4 個 scheduler job 雙跑）：

| 載入項 | api 模式 | socketio 模式 |
|--------|----------|---------------|
| REST blueprints（REGISTERED_APPS） | 全載 | 不載（僅留 health check） |
| socketio init + Redis message queue | 不載 | 載 |
| APScheduler 4 jobs | 載（唯一持有者） | **不載——解掉雙跑** |
| eventlet monkey_patch | 無 | 有 |
| DI container | 全量 | 第一版照全量（求穩），瘦身留第二步 |
| SocketIO namespaces | 不註冊 | 註冊（集中清單，見下） |
| 承載 | gunicorn 內嵌（D7） | socketio.run（eventlet） |

- **SocketIO namespace 集中註冊（FR-063.4 補）**：兩個 namespace（`/socket/notification`、`/socket/fill-survey`）原本註冊在各模組 `create_module()` 內，被本表「socketio 模式不載 REST blueprint」一併裁掉，導致即時功能全失效（api 模式有定義沒端點、socketio 模式有端點沒 namespace）。修法為抽離至 `config/socketio_namespaces.py` 集中清單，由 `create_app(enable_socketio=True)` 在 **DI wiring 之後**統一註冊（handler 走 `@inject`，早於 wiring 建構會安靜拿到佔位物件）。該清單同時是 Nuitka 顯式 include 的來源，心智模型同 `REGISTERED_APPS` 與 DI 靜態清單。
- **eventlet 條件塊**：`eventlet.monkey_patch()` 收進 `if mode == "socketio":`，且必須在其他 import 之前——Python import 執行到才發生，Nuitka 保留模組執行順序語意，編譯後行為不變。
- **gunicorn 內嵌（D7）**：api 模式 production 承載採 gunicorn `BaseApplication` API——在我們的程式內起 master/worker，入口仍是我們的 binary；worker 數走環境變數。開發模式保留 Flask dev server（開關切換，開發體驗不變）。
- **PyCharm debug 保障**（歷史脈絡：當初拆兩檔正是 eventlet monkey_patch × pydevd 衝突）：`RUN_MODE` 不設或 `=api` 時 `import eventlet` 那行**根本不執行**、stdlib 零觸碰，debug 體驗與現行 `main_app.py` 完全等價；patch 收進條件塊後誤觸面比現狀更小。
- **工程注意**：socketio 模式 DI 最小集邊界要實際盤（socket handler 依賴回追），第一版「不載 blueprint、不起 scheduler、DI 照全量」求穩——裁過頭會踩隱性依賴。
- 紅利：socketio 進程瘦身（啟動快／記憶體省／8002 不再暴露整套 REST API）、scheduler 收斂單一持有、eventlet × Nuitka 實測覆蓋面縮小。

### 5.2 DI 靜態清單機制（盤點#1）

- **gen script**：從現行 `config/di_modules.py` 的 rglob 掃描結果 dump 產 `config/di_modules_static.py`（build 期執行，Step 3）。
- **雙軌**：開發模式照舊動態掃（加檔即生效，開發體驗不變）；release 模式吃 build 期凍結的靜態清單。
- **一致性驗證**：gen script 是兩軌之間唯一的橋——掃描結果 dump 下來就是靜態清單，**不存在第二套手維護名單**；同一份程式碼兩模式行為等價。

```{.mermaid cap="圖 2 — 開發模式 vs release 模式雙軌"}
%%{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
  subgraph DEV["開發模式（現況不變）"]
    D_SRC["原始碼樹 .py"] --> D_SCAN["di_modules.py<br/>rglob 動態掃"]
    D_SRC --> D_RES["resource_path()<br/>回原始碼樹"]
  end
  subgraph REL["release 模式（Nuitka 產物）"]
    R_BIN["統一入口 dist<br/>binary＋隨附資料檔"] --> R_STATIC["di_modules_static.py<br/>build 期產的靜態清單"]
    R_BIN --> R_RES["resource_path()<br/>回 dist 根"]
  end
  D_SCAN -. "gen script<br/>build 期 dump" .-> R_STATIC
```

### 5.3 `resource_path()` 設計（D3，盤點#4/#8/#11）

單一入口：開發模式回原始碼樹、打包模式回 dist 根。**全部要收斂的讀取點**：

| 讀取點 | 檔 | 現況壞法 |
|--------|-----|----------|
| `config/config.py:46` `TRANSLATIONS_DIR` | `config/translations` 下的 `.mo` | i18n 全滅；`.mo` 是 build 產物不入版控，管線要先 `pybabel compile` |
| `app/oscal/service/export/ssp_docx_generator.py:27-29` | `app/oscal/templates/ssp/ssp_cmmc_template.docx` | SSP 匯出壞；`../../` 相對跳躍定位特別脆 |
| `app/evidence_classification/service/report/report_common.py:16` | `resources/cmmc_l1_canon.json` | 缺檔回 `None` 靜默降級，四處裡最陰 |
| `app/evidence_classification/service/catalog_builder.py:11` | `docs/reference/.../cmmc_l1_aos.json` | 讀 `docs/` 下的檔，現行 image 可能已經是壞的 |

**順帶搬遷／清理項**（同棒做掉）：

1. catalog seed 搬出 `docs/`（release 產物不該讀 docs/）
2. 下載範本移出 `static/`（解盤點#8 雙重身分衝突）
3. 清掉三處指向不存在目錄的 Blueprint static 宣告（盤點#11，含 `module_frame_import_route.py:32` 現在就壞的下載路徑）
4. 刪 `api/__init__.py` 死碼 `load_routes()`

### 5.4 Build 管線六步（D2 / D5 / D6）

| Step | 動作 | 備註 |
|------|------|------|
| 1 | dependency-injector 私房 wheel rebuild | D2：照 `pyproject.toml` 鎖定版本現場拉 sdist 重編；**版本感知快取**（wheel 檔名含版號，同版直接用快取）＋ sdist tar.gz 留檔防 PyPI 不可用；升版自動觸發重編，零漂移 |
| 2 | poetry install ＋ 覆蓋私房 wheel | |
| 3 | gen 靜態 DI 清單 ＋ version/commit bake | 盤點#1 / D6；另含 `pybabel compile` 產 `.mo` |
| 4 | Nuitka 編譯統一入口 | 單 binary 兩模式（D9）；api 模式先驗（D1） |
| 5 | production Dockerfile 打 image | apt 依賴＋fc-match 斷言（§5.5） |
| 6 | smoke 驗證 | 七項探針（§5.6） |

全 script 化——build 機只是算力，搬家零成本（D5）。

### 5.5 Production image 規格（D8，盤點#5/#7/#9/#10/#13/#14）

- **非 root 固定 uid**（如 1000）專用使用者；**entrypoint**：先 `chdir` 固定工作目錄（解盤點#7 log 路徑）→ `chown` volume 目錄（解掛載 uid/gid 不合老問題）→ 起 binary。
- **OS 依賴**：沿用現行 E2E Dockerfile apt 清單（LibreOffice / WeasyPrint 系統庫 pango·harfbuzz·gobject·fontconfig / `fonts-noto-cjk` 等）；WeasyPrint 的 `.so` 走 image apt 裝齊、不打進 binary（盤點#13）。**build 期 `fc-match 'Noto Sans CJK TC'` 斷言**——缺字型 PDF 回 200 但中文整段消失（盤點#5）。
- **volume 佈局**：

| 掛載點 | 用途 | 備註 |
|--------|------|------|
| 上傳目錄（`STORAGE_CONFIG.base_dir`） | 使用者上傳檔 | 必須明確設定，未設 fallback `/tmp/upload/` 重啟即丟（盤點#9） |
| `log/` | app log | 配合 entrypoint chdir |
| `$HOME/.cm-jobs` | job 狀態 | |
| `~/.config/libreoffice` | LibreOffice profile | 首次轉檔會建 |
| `TMPDIR` | LibreOffice 轉檔中繼 | 容量要夠，大檔轉檔會吃 |
| agent 憑證目錄 | `AGENT_*` env 指的憑證檔 | `pki/` 在 dockerignore，檔案只能外部掛入 |

- **環境變數注入**：正式進入點**不載 `.env`，全靠外部注入**；`DB_SECRET` / `JWT_SECRET` / `REDIS_SECRET` import 期就 `json.loads`——缺了 import 階段直接炸。主專案 60+ 變數以各 Config class 為準、.1 收斂時產完整對照表；**jedi-* 套件內另有 16 個主專案 grep 不到的變數**（`ENABLE_MULTI_TENANT`、`DB_USERNAME` 等，完整清單見討論稿盤點#14），落地版部署文件必列。

### 5.6 Smoke 驗收清單（七項探針）

「編得出來」≠「功能活著」——本案地雷多為**靜默失效型**，逐雷探針缺一不可（FR-064/065 復用）：

| 探針 | 探的雷 |
|------|--------|
| 登入 | 基本 API／DI wire 全量 |
| 任一分頁列表 | blueprint 載入完整 |
| i18n 中文回應 | `.mo` 檔有打包（盤點#4） |
| SSP docx 匯出 | 範本檔有打包（盤點#4） |
| 摘要報告 PDF 開檔驗中文 | WeasyPrint 系統庫＋字型（盤點#5/#13） |
| 問卷上傳 | static／上傳目錄可寫（盤點#8/#9） |
| socketio 通知 | eventlet 編譯後可用（D1/D9） |

### 5.7 Config 三層重整（D11）

**分層判準**——「誰、多常、需要改它」：

| 層 | 判準 | 改動代價 | 例 |
|----|------|----------|-----|
| 編譯進 binary | 產品常數 | 改＝出新版 | 上傳目錄常數、內建預設值 |
| 環境變數 | 每部署點不同 | 裝機定一次 | `DB_HOST`、`CORS_ALLOWED_ORIGINS`、`LOG_DIR` |
| DB `system_config` | 營運中會調 | 線上改即生效 | 登入鎖定、密碼政策、JWT 效期、MFA |

**第 1 刀：六套環境 class 塌成一套 Config**

- 保留單一 Config（以現 `BaseConfig` 為底）；DevelopmentPremise / StagingPremise / StagingAwsBillowS / ProductionAwsBillowS / StagingAwsNics / ProductionAwsNics 六套子 class 全刪。
- 差異值 env 化：`WEBSITE_URL`→刪（死項）、`CORS_ALLOWED_ORIGINS`→env 逗號分隔（預設 `*`）、`DB_PORT`→env（預設 5432）、`LOGGING_LOCATION`→env `LOG_DIR`（預設 `./log`）、`SQLALCHEMY_TRACK_MODIFICATIONS`→死項刪、JWT 效期→第 3 刀進 DB。
- `config_util.py` ENV→class 對照表退役；`ENV` 變數降級為純顯示標籤（log 印一行，不驅動行為），不破壞現有部署腳本。檔頭死值 `AWD_DEFAULT_URL`、`BaseConfig.DB_NAME="audit_manager"` 一併清。
- 遷移配套：產「六 class 差異→env 對照表」，三環境 `.env` 翻譯是機械操作；STG / POC 實際更新等部署放行（環境鐵律）。

**第 2 刀：Secret 拆 JSON 包裝＋啟動期集中驗證**

- 新增 `config/config_loader.py`：新格式平鋪變數（`DB_USER` / `DB_PASSWORD` / `REDIS_USER` / `REDIS_PASSWORD` / `JWT_SECRET_KEY`），向後相容舊格式（偵測 `DB_SECRET` 存在照舊 `json.loads`，現有 `.env` 零改動）。
- 開機必填檢查，缺項一次列全（「缺少：DB_HOST, JWT_SECRET_KEY（或 JWT_SECRET）」），取代 import 期 `json.loads` 直接 traceback；`json.loads` 移進 loader，由統一入口（D9 / .1c）最早階段呼叫——**loader 與統一入口同檔作業**。
- 附帶效益：新格式命名與 jedi-* 期待的 `DB_USERNAME` / `DB_PASSWORD` 命名分歧一併對齊收斂（盤查發現 jedi 讀的變數名與主專案 `.env` 是兩套）。

**第 3 刀：營運參數遷移 DB `system_config`（內建預設＋DB 覆寫）**

- 第一批（刻意保守）：`LOGIN_MAX_LOCK_COUNT` / `LOGIN_USER_LOCK_TIME`、`CHANGE_PASSWORD_THRESHOLD_DAYS`、`LOGIN_INACTIVITY_THRESHOLD_DAYS`、`JWT_ACCESS/REFRESH_TOKEN_EXPIRES`、`MFA_REQUIRED`。
- 機制：沿用既有 system_config root reader pattern（`infra/system_config/system_config_root_reader.py` 現成）；編譯內建預設值，DB 有值覆寫，讀不到照預設跑（無啟動依賴）。本期不做 UI（root 管理介面留後續）；migration 一支 seed（`ON CONFLICT DO NOTHING`），只套 DEV。
- 風險註記：JWT 效期改 DB 後的快取生效時機，與既有 system_config reader 快取行為一致，不另發明。

**死項清理（2026-08-14 盤查定案）**

| 類別 | 項目 |
|------|------|
| config.py 直接刪 ×14 | `TOKEN_TTL`、`USER_LOCK_COUNT`、`USER_PASSWORD_LIMIT_TIME`（舊代，新代 `LOGIN_*` / `CHANGE_PASSWORD_*` 活著）、`PROJECT_EXCEL_UPLOAD_DIR` / `PROJECT_EXCEL_SAMPLE_DIR` / `PROJECT_EXCEL_SAMPLE_FILENAME`、`USER_EXCEL_SAMPLE_DIR` / `USER_EXCEL_SAMPLE_FILENAME`、`ANSWER_SAMPLE_DIR` / `ANSWER_SAMPLE_FILENAME`、`LOGGING_FORMAT`、`FEEDBACK_ALLOWED_EXTENSIONS`（另兩處同名模組常數是別的東西不動）、`SCHEDULER_API_ENABLED`（裝的是純 apscheduler 非 flask-apscheduler）、`WEBSITE_URL`（唯一用途餵 CORS，inline 後刪） |
| `SQLALCHEMY_*` 四支 ×7 份全刪 | 未裝 flask_sqlalchemy、app_factory 自組 URI，`SQLALCHEMY_DATABASE_URI` / `BINDS` / `POOL_RECYCLE` / `TRACK_MODIFICATIONS` 全無消費者 |
| `APISPEC_*` 三支刪 | `docs.init_app` 被註解、Swagger 從未啟用；落地版不應對客戶暴露 Swagger。Swagger 補齊列 follow-up 不進本案 |
| `.env` 死變數 ×4 | `REDIS_PORT`（port 硬寫 6379）、`UPLOAD_DIR` / `UPLOAD_FILE_DIR` / `UPLOAD_STATIC_DIR`（僅 test fixture 用） |
| `.env` 整理 | `TEST_*` / `FR0*_SEED_*` 六項測試帳密移 `.env.test`；`NOTION_API_TOKEN` 留（工具鏈用）加註釋分區；`LICENSE_ENFORCEMENT_ENABLED` / `LICENSE_READONLY_GATE_ENABLED` 顯式寫入 `.env`（現靠預設全開，曾是 DEV 踩雷點）並列入安裝設定清單必填 |
| 確認活著不可刪 | `BUNDLE_ERRORS`（flask_restful 隱式讀）、`PROPAGATE_EXCEPTIONS`（Flask core 讀）、`CHANGE_PASSWORD_REQUEST_EXPIRATION_DAYS`（餵 jedi-login DTO）、`USER_EXCEL_UPLOAD_DIR`、`ANSWER_UPLOAD_DIR`、`REDIS_URL`（flask_redis 讀） |

**「讀寫分離未生效」事實註記**：SQLALCHEMY read replica 綁定無人消費，`DB_READ_HOST` 設了未生效——讀寫分離從未實際運作。落地版單機 PG 無 replica 需求；真要讀寫分離是獨立 feature 不夾帶。`DB_READ_HOST` 保留 env 定義，文件標註「目前未生效」。jedi-* 套件不讀 Flask `app.config`（已驗證），只吃 env＋DTO，死項判定無漏掃風險。

**影響面**：讀 `app.config[...]` 的呼叫點全不用動（key 名不變，值來源變）；要動：config.py 大改、config_util.py 退役對照表、新增 config_loader.py、統一入口接線、一支 migration、密碼政策 / JWT 讀取點改走 system_config reader（約 3–5 個 service 檔）。

## 拆分（4 子需求，執行順序與依賴） {#phases nav="拆分"}

::: {.callout .crit}
**🔴 排序要點（拆卡與派工必守）**

1. **.1a / .1b 是編譯硬前置**——DI 靜態清單與 resource_path 不到位，編出來的 binary 必死（盤點#1/#4），其他 .1 子項才是體質改善。
2. **.2b 提前風險驗證**——build 機一到位**最優先單獨跑 wheel rebuild**，15 分鐘驗 dependency-injector 炸點解不解，提前排除最底層風險，免得 .1 做完才發現雷排不掉。
3. **.1 整包有獨立價值**——統一入口／gunicorn／資源收斂／解 scheduler 雙跑是體質改善，即使 Nuitka 路線意外中止也不白費。
4. **平行機會**——.1f（jedi-file-upload）完全獨立可平行；.2 的 script 撰寫不必等 .1 完成；build 機環境準備（.2a）與 .1 開發時程重疊。
:::

```{.mermaid cap="圖 3 — 子需求依賴與執行順序"}
%%{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 P1[".1 程式整理（開發環境即可驗證）"]
    A1["a DI 靜態清單<br/>🔴 編譯硬前置"]
    A2["b resource_path＋搬遷<br/>🔴 編譯硬前置"]
    A3["c 統一入口 RUN_MODE<br/>＋舊入口退役＋裁剪"]
    A4["d gunicorn 內嵌<br/>（與 c 同檔一起做）"]
    A5["e fail-fast＋死碼<br/>＋version bake 接點"]
    A6["f jedi-file-upload D10<br/>（獨立可平行）"]
    A7["g config 三層重整 D11<br/>（.1b 後，與 c 平行；<br/>loader 與 c 同檔）"]
  end
  subgraph P2[".2 Build 管線（等 build 機；script 可先寫）"]
    B1["a build 機環境建置"]
    B2["b wheel rebuild script<br/>⚠️ build 機到位最優先跑<br/>15 分驗 D2 炸點"]
    B3["c Nuitka build script"]
  end
  P3[".3 Production image<br/>Smoke 七項探針"]
  P4[".4 socketio 模式驗證<br/>（驗證棒非開發棒）"]
  A1 --> B3
  A2 --> B3
  A2 --> A7
  A3 --> A4
  B1 --> B2
  B2 --> B3
  B3 --> P3
  P3 --> P4
```

### FR-063.1 程式整理 — 依賴：無（全部可在開發環境完成驗證，不需 build 機）

| 子任務 | 內容 | 對應 |
|--------|------|------|
| .1a | DI 靜態清單機制（gen script＋開發動態掃／release 靜態雙軌） | 🔴 編譯硬前置｜盤點#1｜§5.2 |
| .1b | `resource_path()` 統一資源定位＋資料檔搬遷（四讀取點收斂、catalog seed 搬出 docs/、範本移出 static/、清三處死 static 宣告） | 🔴 編譯硬前置｜D3｜盤點#4/#8/#11｜§5.3 |
| .1c | 統一入口（`RUN_MODE`）＋三支舊入口退役（D9）＋模式裁剪載入 | §5.1 |
| .1d | gunicorn `BaseApplication` 內嵌（D7）——**與 .1c 同檔作業一起做** | §5.1 |
| .1e | blueprint fail-fast（盤點#3）＋死碼清理（`load_routes()`）＋version bake 接點（D6） | §5.1/§5.4 |
| .1f | jedi-file-upload 修 `LIBREOFFICE_CMD`（D10）——**獨立可平行**；涉套件異動走 path dependency 規範 | 盤點#12 |
| .1g | config 三層重整＋死項清理（D11）——**排序 .1b 之後、與 .1c 可平行**；第 2 刀 loader 與統一入口（.1c）同檔作業。**驗收**：①三環境用舊 `.env` 原封不動起服務行為零差異（向後相容）②新格式 `.env` 起服務行為相同 ③故意漏設必填→啟動一次列全缺項非 traceback ④DB system_config 改鎖定次數生效、刪除回內建預設 ⑤既有測試全綠 | D11｜§5.7 |

**收口 gate**：開發模式行為零變化——既有測試綠、PyCharm debug 不變、`RUN_MODE` 兩模式開發環境都起得來；`grep` 全 codebase 無殘留 `__file__` 相對資料檔定位；blueprint 缺模組時啟動即炸而非 warning。

### FR-063.2 Build 管線 — 依賴：build 機到位（D5，user 準備）；script 撰寫可與 .1 平行

| 子任務 | 內容 | 對應 |
|--------|------|------|
| .2a | build 機環境建置（gcc/g++/patchelf/ccache/docker/100GB+ SSD） | D5 |
| .2b | wheel rebuild script（D2 含版本感知快取＋sdist 留檔）——**⚠️ build 機一到位最優先單獨跑這個**，15 分鐘驗 dependency-injector 炸點解不解，提前排除最底層風險，免得 .1 做完才發現雷排不掉 | D2｜盤點#2 |
| .2c | Nuitka build script（串 DI 清單生成＋version bake＋編譯） | §5.4 Step 3–4 |

**驗收**：build 機一鍵跑完 Step 1–4 產出可啟動的統一入口 dist；私房 wheel 快取命中時跳過重編；binary 產出後 api 模式起得來、打得到 API（import 期不炸、DI wire 全量）。

### FR-063.3 Production image — 依賴：.2 binary 產出

production Dockerfile：非 root 固定 uid（D8）／volume 佈局（盤點#10）／fc-match 斷言（盤點#5）／entrypoint chdir＋chown（盤點#7/D8）／環境變數文件化（盤點#14 完整對照表）。

**驗收**：Smoke 七項探針全過（§5.6）；重啟後上傳檔不丟；容器內找不到任何 `.py` 業務原始碼；process 以非 root uid 執行。

### FR-063.4 socketio 模式驗證與裁剪 — 依賴：.3

裁剪程式碼在 .1c 已寫掉；**本棒主要是編譯後實測，是驗證棒不是開發棒**：eventlet monkey-patch × Nuitka 相容性實測；卡關 → 啟動 D1 保底（換 gevent 等 `async_mode`，入口薄殼十幾行無祕密，業務模組仍全數受編譯保護）。

**驗收**：`RUN_MODE=socketio` 進程可建立連線並收發事件（socketio 通知端到端通）、不載 REST blueprint、scheduler 不雙跑；或保底方案落地且業務模組仍在編譯保護內。

## 端到端驗收 {#acceptance nav="驗收"}

1. **Smoke 七項探針全過**（§5.6：登入／列表／i18n／SSP docx／PDF 中文／上傳／socketio）。
2. **容器內無 `.py` 業務原始碼**——客戶主機上找不到任何可讀業務原始碼，與 FR-062 license 執法構成雙保險。
3. **開發模式零回歸**——既有測試綠、PyCharm debug 體驗與現行 `main_app.py` 完全等價、動態 DI 掃描與資源定位行為不變（加檔即生效）。
4. **Config 向後相容（D11）**——三環境用舊 `.env` 原封不動起服務，行為零差異；新格式 `.env` 行為相同。
5. 其他逐棒驗收見 §6 各子需求（.1 收口 gate／.2 binary 可啟動／.3 image 規格／.4 socketio 通）。
