---
title: "Nuitka 落地版打包 — 需求討論稿 (FR-063)"
brand: "Guidant AI · **FR-063** Nuitka 落地版打包"
eyebrow: "FR-063 · Nuitka Packaging（Linux 容器版）— 需求討論稿 · 2026-08-14"
h1: "客戶主機上不能再有一行可讀的原始碼"
lede: "產品進入落地版（on-premise）交付階段。本案用 **Nuitka standalone 把整個 BE 編成機器碼**，配合 FR-062 既有的 license 執法形成雙保險。2026-08-13 夜間已在 STG 機完成第一次冷 build 實測（1h27m / 808MB 產物），並撞出第一個 import 期炸點（dependency-injector）。本稿整理**十一項已定案決策（D1–D11，全數定案、無待確認項）**、三路掃描的現況盤點（不修就死／靜默失效／路徑類／外部依賴）、build 管線設計與階段拆分草案。"
chips: [
  {text: "D1–D11 全定案", kind: ok},
  {text: "夜間實測已完成一輪", kind: ok},
  {text: "範圍：Linux 容器版", kind: accent},
  {text: "後續：FR-064 防竄改 · FR-065 Installer", kind: accent}
]
footer: "FR-063 · Nuitka 落地版打包 — 需求討論稿 · 2026-08-14 · 三階段戰線第一棒（FR-063 編譯 → FR-064 防竄改 → FR-065 Installer）· 實測環境：STG 機 /opt/guidant_ai（Nuitka 4.1.3 / Python 3.11，4 core / 32GB）· Notion 母案 CM-1187"
---

## 需求背景：為什麼要編譯 {#why nav="背景"}

產品進入落地版（on-premise）交付階段——**客戶主機上不可再出現可讀原始碼**。這是一條三階段戰線，本案是第一棒：

::::::: grid2
::: {.card .ok}
#### FR-063 Nuitka 編譯（本案）

把 BE 整包編成機器碼 binary，客戶主機上沒有 `.py` 可讀。**範圍限定 Linux 容器版**。
:::

::: {.card .warn}
#### FR-064 防竄改 → FR-065 Installer

下兩棒：防竄改機制、安裝器。**Windows 支援也在下一階段**——傾向用 Docker Desktop / WSL2 跑同一個 Linux image，不另編 Windows binary。
:::
:::::::

### 1.1 方案選型脈絡（前期討論已定）

比較過四條路：**.pyc（compileall）／ Cython ／ PyArmor ／ Nuitka**。值得記一筆的是：Python 3.11 的反編譯工具鏈已經斷裂（uncompyle6 / decompyle3 都停在 3.8 前後），純 `.pyc` 交付其實已有基礎保護力。但決策者拍板**採 Nuitka standalone 編成機器碼，取得最高保護等級**，對齊 Go binary 陣營的交付形態。

業界對照：

| 陣營 | 代表 | 交付形態 | 保護手段 |
|------|------|----------|----------|
| 源碼可見 | GitLab、Sentry | Ruby / Python 原始碼直接落地 | 靠 license 執法 |
| 編譯 binary | Grafana、HashiCorp | Go 編譯後 binary | 機器碼天然不可讀 |
| **本案** | Guidant AI 落地版 | **Nuitka binary** | **license 執法（FR-062 既有）＋ binary 交付，雙保險** |

## 2026-08-13 夜間實測 {#probe nav="實測"}

環境：STG 機 `/opt/guidant_ai`，Nuitka 4.1.3 / Python 3.11，4 core / 32GB。

::::::: statgrid
::: stat
[1h27m]{.v}[冷 build 時間（4 core）]{.k}
:::

::: stat
[808MB]{.v}[產物 main_app.dist]{.k}
:::

::: stat
[8,613]{.v}[C 檔全編]{.k}
:::

::: {.stat .warn}
[34min]{.v}[pymupdf.mupdf.c 單檔耗時（平行化不可壓縮地板）]{.k}
:::
:::::::

::: {.callout .crit}
**🔴 試跑結果：import 期就炸**

```
SystemError: dynamic module 'dependency_injector.providers'
not initialized properly from def
```

炸點是 `dependency-injector` 套件本身——整個 DI 體系的地基。根因分析與修法已定案為 D2（見下節）。
:::

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

以下十一項**均已定案**，不是 pending。每項附理由。D7–D10 為原「待確認 Q1–Q4」拍板後升格；D11 為 2026-08-14 config 盤查後新增拍板。

::: {.callout .decided}
**D1 api 模式先驗，socketio 模式隨後（單一 binary 兩模式，見 D9）**

api 模式路徑完全不碰 eventlet（已掃描確認）——先驗它就等於 90% 業務程式碼到位。socketio 模式隨後跟進，唯一的增量風險是 **eventlet monkey-patch × Nuitka 相容性**，必須實測。

兩模式是**獨立 process**（port 8000 / 8002），不是線程，可以分開驗證。依 D9 統一入口決策，兩模式編在**同一個 binary**、由 `RUN_MODE` 環境變數切換，不再分兩支入口各自編譯。

[**保底方案**：若 eventlet 實測卡關，socketio 模式換 async 承載（gevent 等——flask-socketio 支援換 `async_mode`）。入口薄殼只有十幾行、無祕密，業務模組仍全數受編譯保護。]{.rec}
:::

::: {.callout .decided}
**D2 dependency-injector 私房 wheel，rebuild 納入 compile 管線前置步驟**

炸因：官方 wheel 以 **Limited API（abi3）模式**編譯 → 強制走 PEP 489 多相初始化 → 與 Nuitka 靜態嵌入環境不相容。

已驗證：PyPI 有 sdist（1MB，BSD 授權），`setup.py` 會現場 cythonize 從 `.pyx` 重生 C 碼。兩條修法候選：

1. 從 sdist 重編、**不開 Limited API**（預設即不開，可能已直接相容，15 分鐘可驗）
2. CFLAGS 帶 `-DCYTHON_PEP489_MULTI_PHASE_INIT=0` 強制關多相初始化

[**管線設計**：每次 build 照 `pyproject.toml` 鎖定版本現場拉 sdist 重編；**版本感知快取**（wheel 檔名含版號，同版直接用快取）＋ sdist tar.gz 留檔防 PyPI 不可用。升版自動觸發重編，零漂移。]{.rec}
:::

::: {.callout .decided}
**D3 建 `resource_path()` 統一資源定位入口**

開發模式回原始碼樹、打包模式回 dist 根，所有資料檔讀取收斂到這一個入口。影響範圍＝盤點表 #4 的四處資料檔（translations `.mo`／SSP docx 範本／`cmmc_l1_canon.json`／`cmmc_l1_aos.json`）。

順帶清理四件事：

- catalog seed 搬出 `docs/`（release 產物不該讀 docs/）
- 下載範本移出 `static/`（見盤點 #8 雙重身分衝突）
- 清掉三處指向不存在目錄的 Blueprint static 宣告（盤點 #11）
- 刪 `api/__init__.py` 死碼 `load_routes()`
:::

::: {.callout .decided}
**D4 證據分類 docker CLI 依賴：落地版功能開關降級停用**

`classifier_container_runner.py` 跑 `cmmc-classifier:latest` 需要 docker CLI——BE 自己容器化之後這變成 **DinD（Docker-in-Docker）問題**。

落地版第一批以功能開關停用此能力，避免安裝複雜度暴增。日後客戶真有需求再議：掛 `docker.sock` 或改分類器執行形態。
:::

::: {.callout .decided}
**D5 專用 build 機 24 core / 32GB（user 準備）**

24 core 預估冷 build **~35–40 分鐘**（pymupdf 單檔 34 分鐘是不可壓縮地板），配 ccache 熱 build **10 分內**。需 100GB+ SSD。

build 流程**全 script 化**——build 機只是算力，搬家零成本。
:::

::: {.callout .decided}
**D6 版本號＋git commit hash build 期 bake 進產物**

`/api/1.0/version` 現在讀 `pyproject.toml`——產物內沒有這個檔，會回 `unknown`。改為 build 期生成 `version.py` 常數檔一起編進去。

**擴充（定案）**：release build 把 **git commit hash 一併 bake 進 version 資訊**，`/api/1.0/version` 回 version + commit。動機：編譯後 traceback 只剩函式名、無原始碼行號，support 只能靠「版號 → git tag 原始碼」對照定位問題——log 內的版本資訊必須足以**唯一鎖定 commit**，光有版號不夠（同版號可能有 hotfix 重 build）。
:::

::: {.callout .decided}
**D7 main_app 承載換 gunicorn 內嵌**（原 Q1）

Flask dev server 不上 production——這是落地版對外交付，執行形態在此定案。但 Nuitka 下**不能走 `gunicorn main:app` 的 CLI import 模式**：binary 產物內沒有可供 gunicorn 外部 import 的模組。改採 gunicorn **`BaseApplication` API 內嵌**——在我們的程式內起 master/worker，入口仍是我們的 binary，gunicorn 作為依賴一起編入。worker 數走環境變數。開發模式保留 dev server（開關切換，開發體驗不變）。
:::

::: {.callout .decided}
**D8 production image 非 root 執行**（原 Q2）

定案非 root。理由：合規產品的客戶會掃 image，「root 執行」是標準紅字，賣合規產品自己先被掃出紅字說不過去；非 root 亦降低容器被攻破後的權限面。

做法：image 內建固定 uid（如 1000）專用使用者＋entrypoint 啟動時 chown volume 目錄（解掛載目錄 uid/gid 不合的老問題）＋安裝文件寫明。此為 **FR-065 installer 要一起承接的細節**。
:::

::: {.callout .decided}
**D9 統一入口＋按模式裁剪載入，三支舊入口全退役**（原 Q3，拍板並加碼裁剪需求）

新增**統一入口**（骨架以 `main_app.py` 邏輯為主——它較新、註解完整），`RUN_MODE` 環境變數切換 `api`（預設）/ `socketio` 模式；`main.py`（舊版，會 `create_all` 建表）/ `main_app.py` / `main_socketio.py` **三支全退役**。單一 binary，build 一次，容器 command 只差環境變數，無縫接現行「一 image 兩 entrypoint」部署慣例。

eventlet monkey_patch 收進 `if mode == "socketio":` 條件塊，且必須在其他 import 之前——Python import 是執行到才發生，Nuitka 保留模組執行順序語意，此 pattern 編譯後行為不變。

**PyCharm debug 痛點不回歸**（歷史脈絡，決策時特別確認過）：當初拆兩檔正是因為 eventlet monkey_patch 與 pydevd debugger 衝突（斷點中斷／exception）。統一入口下 `RUN_MODE` 不設或 `=api` 時，`import eventlet` 那行**根本不執行**、stdlib 零觸碰，debug 體驗與現行 `main_app.py` 完全等價；且 patch 收進條件塊後，誤 import 觸發 patch 的面比現狀更小。

**模式裁剪載入表**——現況是 `main_socketio.py` 與 `main_app.py` 幾乎孿生：socketio 進程背著全部 REST blueprint＋全部 4 個 APScheduler job **雙跑**，靠 CAS 冪等擋。統一入口順勢裁剪：

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

紅利：socketio 進程瘦身（啟動快／記憶體省／8002 不再暴露整套 REST API）、scheduler 雙跑收斂到 api 進程單一持有、eventlet × Nuitka 的實測覆蓋面縮小。

**工程注意**：socketio 模式的 DI 最小集邊界要實際盤（socket handler 依賴回追），第一版採「不載 blueprint、不起 scheduler、DI 照全量」求穩——裁過頭會踩隱性依賴。
:::

::: {.callout .decided}
**D10 jedi-file-upload 硬編 LibreOffice 路徑修正**（原 Q4）

套件內 `local_file_adapter.py` / `minio_adapter.py` 硬編 platform 判斷、**不吃 `LIBREOFFICE_CMD`**，與主專案解析邏輯不一致（盤點#12）。定案修正：改為與主專案同邏輯（`LIBREOFFICE_CMD` env → PATH → mac fallback）。

屬 jedi-* 套件異動，照規範走：開發期 poetry path dependency、完成後 pin 版發佈。**納入 .1 清障子需求**。
:::

::: {.callout .decided}
**D11 Config 三層重整＋死項清理（新增子任務 .1g）**

動機：打包成 binary 後「改 config＝重編譯交付」，設定放錯層代價放大；現行六套環境 class 是雲端多環境時代的設計，落地版每個客戶都是新環境，不可能為客戶加 class 重編譯。分層判準是「**誰、多常、需要改它**」：

| 層 | 判準 | 改動代價 |
|----|------|----------|
| 編譯進 binary | 產品常數 | 改＝出新版 |
| 環境變數 | 每部署點不同 | 裝機定一次 |
| DB `system_config` | 營運中會調 | 線上改即生效 |

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

- 保留單一 Config（以現 `BaseConfig` 為底），六套子 class（DevelopmentPremise / StagingPremise / StagingAwsBillowS / ProductionAwsBillowS / StagingAwsNics / ProductionAwsNics）全刪
- 差異值 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
- import 期 `json.loads` 移進 loader，由統一入口（D9 / .1c）最早階段呼叫
- 附帶效益：新格式命名與 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 份全刪（user 拍板）**：盤查發現專案未裝 flask_sqlalchemy，app_factory 自組 URI，`SQLALCHEMY_DATABASE_URI` / `BINDS` / `POOL_RECYCLE` / `TRACK_MODIFICATIONS` 全無消費者——**連帶事實：read replica 綁定無人消費，`DB_READ_HOST` 設了未生效，讀寫分離從未實際運作**。落地版單機 PG 無 replica 需求；真要讀寫分離是獨立 feature 不夾帶。`DB_READ_HOST` 保留 env 定義，文件標註「目前未生效」
- **`APISPEC_*` 三支刪（user 拍板）**：`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 讀）
- jedi-* 套件不讀 Flask `app.config`（已驗證），只吃 env＋DTO，死項判定無漏掃風險

**.1g 驗收條件**：①三環境用舊 `.env` 原封不動起服務，行為零差異（向後相容）②新格式 `.env` 起服務行為相同 ③故意漏設必填→啟動一次列全缺項非 traceback ④DB system_config 改鎖定次數生效、刪除回內建預設 ⑤既有測試全綠

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

### Smoke 驗收清單（.3 子需求驗收條件，FR-064/065 復用） {#smoke nav="-"}

「編得出來」≠「功能活著」——本案盤點出的地雷多為**靜默失效型**（不炸但功能悄悄壞），驗收必須逐雷探針，缺一不可：

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

## 現況盤點：三路掃描結果 {#inventory nav="盤點"}

以下是「動態 import／`__file__` 路徑／外部依賴」三路掃描的完整結果，按嚴重度分級。

### 🔴 不修就整站死

| # | 位置 | 問題 | 修法 |
|---|------|------|------|
| 1 | `config/di_modules.py:49,72` | `rglob('*.py')` 掃 DI wire 模組——產物內沒有 `.py`，掃空後 wire 只剩 2 支固定模組 → **所有 route 的 `@inject` 失效**。最陰的是啟動不報錯，第一個 request 才炸 | build 期 dump 靜態模組清單：gen script 從現行掃描結果產 `config/di_modules_static.py`；開發模式照掃、release 模式用靜態清單 |
| 2 | `dependency_injector` 套件 | import 期 `SystemError`（abi3 × Nuitka 不相容） | 見 D2 私房 wheel |

### 🟠 靜默失效（不炸但功能悄悄壞）

| # | 位置 | 問題 | 修法 |
|---|------|------|------|
| 3 | `main_app.py:26` / `main_socketio.py:28` / `main.py:21` | `importlib.import_module` 動態載 `REGISTERED_APPS` blueprint，`except ImportError: logger.warning` **靜默吞錯**——編譯後若缺 blueprint 只噴一行 warning，整個模組的 API 消失 | 改 fail-fast；並以靜態 import 補強（`--include-package=api` 理論上已涵蓋，但要驗證） |
| 4 | 資料檔 ×4（`__file__` 相對定位） | Nuitka **不自動打包非 `.py` 檔**，四處全滅（明細見下） | 併入 D3 `resource_path()` |
| 5 | 中文字型 | `fc-match` build 期斷言要在 production Dockerfile 重建——缺字型時 **PDF 照樣回 200 但中文整段消失** | Dockerfile 裝 `fonts-noto-cjk` ＋ build 期 `fc-match 'Noto Sans CJK TC'` 斷言 |
| 6 | `api/version/routes/version_route.py:17` | 讀 `pyproject.toml`，產物內無 → 回 `unknown` | 見 D6 version bake |

#4 的四處資料檔明細：

| 位置 | 讀的檔 | 壞法 |
|------|--------|------|
| `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` | `parents[3]` + `docs/reference/CMMC-Level 1-Evidences/cmmc_l1_aos.json` | 讀 `docs/` 下的檔——**現行 image 可能已經是壞的**；要搬出 docs/（D3 順帶） |

### 🟡 路徑類

| # | 位置 | 問題 | 修法 |
|---|------|------|------|
| 7 | `log/app.log` | cwd 相對路徑；且 **import `jedi_issue` 期就 `makedirs('log')`**（在 jedi_common 內）——cwd 不對就在奇怪位置長出 log/ | entrypoint 必先 `chdir` 到固定工作目錄 |
| 8 | `static/` 雙重身分 | 同一目錄同時是**上傳落地處**（要 volume、可寫）與**範本下載處**（要隨版本、唯讀）——容器化後兩個需求直接打架 | 拆開：範本移出 static（併入 D3） |
| 9 | 上傳根目錄 | DB `STORAGE_CONFIG.base_dir` 未設時 fallback `/tmp/upload/` → **容器重啟即丟** | 落地版必須明確設定並掛載 |
| 10 | volume 清單 | 見下表 | Dockerfile / compose 明確宣告 |
| 11 | `core/app_factory.py:35` `template_folder='../templates'` 指向不存在目錄；`api/module_frame/__init__.py:83` 等**三處** Blueprint `static_folder='static'` 指向不存在目錄，且 `module_frame_import_route.py:32` 拿它 join 下載範本路徑（**這條現在就是壞的**） | 死宣告＋壞功能 | D3 一併清理 |

#10 需要外掛 volume 的完整清單：

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

### 🟣 外部依賴與環境變數

| # | 項目 | 說明 |
|---|------|------|
| 12 | 外部 binary ×4 | **LibreOffice**（解析順序 `LIBREOFFICE_CMD` → PATH → mac 絕對路徑；⚠️ jedi-file-upload 套件內另有一份硬編路徑**不吃 `LIBREOFFICE_CMD`**，不一致要注意）；**cinc-auditor**（275MB，`CINC_AUDITOR_CMD`）；**docker CLI**（見 D4 停用）；**tesseract**（優雅降級，現行不裝） |
| 13 | WeasyPrint 系統庫 | import 期 `dlopen` pango / harfbuzz / gobject / fontconfig——**Nuitka 不自動收 dlopen 的 `.so`**。image 內 apt 裝齊即可（與現行 Dockerfile 相同做法），不必打進 binary |
| 14 | 環境變數 60+ 個 | `DB_SECRET` / `JWT_SECRET` / `REDIS_SECRET` **import 期就 `json.loads`——缺了 import 階段直接炸**；jedi-* 套件內另有 16 個主專案 grep 不到（清單見下）；`AGENT_*` 值是憑證檔**路徑**，檔案要外部掛載；正式進入點**不載 `.env`，全靠外部注入** |
| 15 | APScheduler 4 jobs | `drive_sync_worker`（5s）/ `webhook_channel_renewer`（6h）/ `framework_parse_job_cleanup`（每日）/ `license_expiry_state_machine`（每日）——**現況兩個進入點都會起（雙跑靠 CAS 冪等擋）**，無額外 binary 依賴，編譯面無風險。統一入口後收斂為 api 模式單一持有（見 D9） |

#14 jedi-* 套件內、主專案 grep 不到的環境變數（落地版部署文件必列）：

| 類別 | 變數 |
|------|------|
| 執行環境 | `ENABLE_MULTI_TENANT`、`RUN_ENV`、`TZ` |
| DB | `DB_USERNAME`、`DB_PASSWORD`、`DB_SCHEMA`、`DEFAULT_SCHEMA` |
| Redis | `REDIS_DB`、`REDIS_SSL` |
| i18n | `DEFAULT_LOCALE` |
| 外部整合 | `GITLAB_*`、`GITHUB_PRIVATE_TOKEN` |
| Auth | `OTP_COOLDOWN_SECONDS`、`AUTH_CHANGE_SECRET_COOLING_HOURS` |

（主專案自身的 60+ 個以現行各 Config class 為準，.1 子需求收斂時產完整對照表。config 死項盤查結論見 D11。）

### ✅ 掃過確認安全

- getattr / registry 字串解類：零風險
- `inspect.getsource`：零命中
- 匯出多走記憶體串流（BytesIO），不落地也不讀源
- `__import__("uuid")` 常量參數，Nuitka 可靜態解析
- `scheduler_report_route` 的 `listdir` 掃的是 runtime 目錄，安全

## Build 管線設計 {#pipeline nav="管線"}

```{.mermaid cap="圖 1 — FR-063 build 管線六步"}
%%{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
  S1["Step 1<br/>dependency-injector<br/>私房 wheel rebuild<br/>（D2，版本感知快取）"] --> S2["Step 2<br/>poetry install<br/>＋覆蓋私房 wheel"]
  S2 --> S3["Step 3<br/>gen 靜態 DI 清單<br/>＋ version bake<br/>（盤點#1 / D6）"]
  S3 --> S4["Step 4<br/>Nuitka 編譯統一入口<br/>（單 binary 兩模式，D9；<br/>api 模式先驗，D1）"]
  S4 --> S5["Step 5<br/>production Dockerfile<br/>打 image<br/>apt 依賴＋fc-match 斷言"]
  S5 --> S6["Step 6<br/>smoke 驗證<br/>啟動＋關鍵 API 巡檢"]
```

Step 5 的 OS 依賴清單沿用現行 E2E Dockerfile 的 apt 清單（LibreOffice / WeasyPrint 系統庫 / fonts-noto-cjk 等）＋ `fc-match` 斷言；執行形態＝**gunicorn 內嵌（D7）**、**非 root 執行（D8）**，均已定案。Step 6 的 smoke 巡檢照上節「Smoke 驗收清單」逐雷探針。

```{.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＋隨附資料檔（D9）"] --> 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
```

雙軌的原則：**同一份程式碼，兩個模式行為等價**——開發模式維持動態掃描（加檔即生效，開發體驗不變），release 模式吃 build 期凍結的靜態產物。gen script 是兩軌之間唯一的橋，掃描結果 dump 下來就是靜態清單，不存在第二套手維護名單。

## 階段拆分草案 {#phases nav="拆分"}

給後續拆卡參考，粗粒度四個子需求：

::::::: grid2
::: {.card .ok}
#### .1 清障與程式碼改造

DI 靜態清單機制（盤點#1）、`resource_path()` 重構含全部影響面（D3、盤點#4/#8/#11）、blueprint fail-fast（盤點#3）、死碼清理、version + commit bake（D6）、**統一入口實作（D9，三支舊入口退役）**、**jedi-file-upload LibreOffice 路徑修正（D10）**、**g. config 三層重整＋死項清理（D11）——排序 .1b 之後、與 .1c 可平行；第 2 刀 loader 與統一入口（.1c）同檔作業**。

**驗收草案**：開發模式行為零變化（既有測試綠、PyCharm debug 體驗不變）；`grep` 全 codebase 無殘留 `__file__` 相對資料檔定位；blueprint 缺模組時啟動即炸而非 warning；`RUN_MODE` 兩模式各自可起。
:::

::: {.card .ok}
#### .2 Build 管線

wheel rebuild script（D2 含快取）、Nuitka build script、build 機環境建置（D5）。

**驗收草案**：build 機上一鍵跑完 Step 1–4 產出可啟動的統一入口 dist；私房 wheel 快取命中時跳過重編；產物 import 期不炸、DI wire 全量。
:::

::: {.card .ok}
#### .3 Production Dockerfile 與 image

volume 佈局（盤點#10）、環境變數文件化（盤點#14 完整對照表）、fc-match 斷言（盤點#5）、entrypoint chdir（盤點#7）、gunicorn 內嵌承載（D7）、非 root 執行＋entrypoint chown（D8）。

**驗收草案**：image 啟動後過完整「Smoke 驗收清單」（見決策節，逐雷探針：登入／列表／i18n／SSP docx／PDF 中文／上傳／socketio）；重啟後上傳檔不丟；容器內找不到任何 `.py` 業務原始碼；process 以非 root uid 執行。
:::

::: {.card .warn}
#### .4 socketio 模式驗證與裁剪

單 binary 已含 socketio 模式（D9），本階段重點不是編譯而是：eventlet monkey-patch × Nuitka 相容性**實測**＋**裁剪載入實作**（不載 blueprint、不起 scheduler，照 D9 載入表）；卡關則啟動 D1 保底（換 async 承載）。

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