---
title: "v1.14.0 全景部署手冊 — 主產品 BE / FE ＋ License Center"
brand: "Guidant AI · **FR-062** License 控管機制"
eyebrow: "FR-062 · v1.14.0 部署順序書 · 2026-08-10"
h1: "v1.14.0 全景部署手冊"
lede: "這一版部署橫跨 **三個 repo（主產品 BE／FE ＋ License Center）**，且金鑰體系有**跨系統的順序依賴**——LC 產鑰 → 公鑰進 BE 程式碼 → 該環境 BE 重新部署，漏一步整條照全數拒收。本文件是**全景與順序**手冊：告訴你先做什麼、後做什麼、哪一步錯了會出現什麼症狀。LC 單機安裝細節不在此重寫，一律指向 License Center repo 的部署手冊。"
chips: [
  {text: "三 repo × 七階段", kind: accent},
  {text: "🔴 公鑰回填是最容易漏的一步", kind: crit},
  {text: "STG／POC 已依此順序實戰完成", kind: ok},
  {text: "Troubleshooting 為 2026-08-10 實戰全記錄", kind: plain}
]
footer: "FR-062 · v1.14.0 全景部署手冊 · 2026-08-10 · LC 單機細節見 license_center/docs/deployment-guide.html"
---

> 適用版本：**v1.14.0**（BE／FE 同版號對齊）｜撰寫日期：2026-08-10
> 上游：[`design.md`](./design.md) §4.2／§4.7／§4.9、LC 部署手冊 `license_center/docs/deployment-guide.html`（LC repo 內）
> 本文件與 [`existing-tenant-license-migration-sop.md`](./existing-tenant-license-migration-sop.html) 的關係：後者是 **T-7.2 腳本**的 DEV 演練 SOP，**STG／POC 一律不走該腳本**（見 §10）。

---

## 1. 為什麼這一版部署比較複雜 {#why nav="為什麼複雜"}

過去幾版的部署都是「拉 BE、拉 FE、套 migration、重啟」四步就完事。v1.14.0 不是，成因有三個，而且**三個會互相纏在一起**：

**① 跨三個 repo。** 主產品 BE（`compliance-manager-be`）、主產品 FE（`compliance-manager-fe`）、License Center（`license_center`）。前兩者是既有部署流程的延伸，第三者是**這一版才第一次出現在部署清單上的新系統**。

**② License Center 是獨立系統、獨立 DB，不是主產品的一個模組。** 它有自己的 repo、自己的 `.env`、自己的 DB（`license_center_{env}`）、自己的 systemd 服務、自己的後台登入帳密。主產品的 `poetry install` 不會裝到它，主產品的 migration 不會建它的表。這是**刻意的隔離設計**——私鑰、客戶名單、訂單、抽佣資料不進主產品；兩邊只透過「簽好的照」與內部簽發 API 交互，無 DB 層共用。部署時的代價就是：**它得從零裝一次**。

**③ 金鑰體系有跨系統的順序依賴——這是真正的坑。** 每個環境有**自己獨立的一把簽發鑰**（決策者要求 STG／POC 不得與 DEV 同鑰），私鑰永不離開該機。流程是：

```
LC keygen（該環境產鑰）
   ↓  公鑰 + kid
BE common/license/public_keys.py 的 PUBLIC_KEYS 陣列 append 一項
   ↓  commit
該環境的 BE 部署必須帶到這個 commit
```

::: {.callout .crit}
**🔴 漏掉中間任何一步的症狀：LC 簽發成功，產品端回「未知的 kid」，該環境所有照全數拒收。**

這不是「部分功能壞掉」——是**授權系統整條死**。而且症狀出現的位置（產品端拒收）離根因（BE 沒帶到公鑰 commit）很遠，第一次遇到很容易往「照是不是簽壞了」的方向查。2026-08-10 STG／POC 實戰時就在這裡卡了一次。

[**因此**：Phase 3「公鑰回填」被獨立成一個階段，而不是併進 BE 部署裡順手做。]{.rec}
:::

---

## 2. 部署全景圖 {#topology nav="全景圖"}

### 2.1 拓撲：誰跟誰講話

```{.mermaid cap="圖 1 — 單一環境內的拓撲。私鑰只在 LC 那台機器上，公鑰編譯進 BE 程式碼；兩系統各自獨立 DB，無共用。"}
%%{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'}}}%%
graph LR
  subgraph 主產品["主產品 Guidant AI"]
    FE["FE<br/>nginx :80"]
    BE["BE :8000<br/>PUBLIC_KEYS 編譯在程式碼內"]
    PDB[("guidant_ai_&lt;env&gt;<br/>config.tenant_licenses<br/>config.tenant_license_events")]
  end
  subgraph LCsys["License Center（獨立系統）"]
    LC["LC :5062<br/>Flask + Tabler 後台"]
    KEYS["keys/<br/>私鑰（passphrase 加密）<br/>永不離開該機"]
    LDB[("license_center_&lt;env&gt;<br/>plans / license_issuance<br/>api_tokens")]
  end

  FE -->|"REST /api/1.0"| BE
  BE --> PDB
  BE -->|"POST /api/internal/issue｜extend<br/>Bearer LICENSE_CENTER_API_TOKEN"| LC
  LC --> LDB
  LC --- KEYS
  KEYS -.->|"keygen 產出公鑰 + kid<br/>人工回填進 BE 原始碼"| BE
```

要點三條：

- **私鑰只在 LC 那台機器的 `keys/` 目錄**，不入版控、不進主產品、不跨環境複製。
- **公鑰編譯在 BE 程式碼**（`common/license/public_keys.py`），不放設定檔、不放 DB——理由見 §7。
- **BE → LC 是唯一的系統間呼叫**，帶 API token；LC 從不主動打產品端。

### 2.2 順序：先做什麼後做什麼

```{.mermaid cap="圖 2 — 七階段部署順序。虛線框住的 Phase 2→3→4 是那條跨系統的金鑰依賴鏈，順序不可交換。"}
%%{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 OP as 部署人員
    participant DB as DB server
    participant LC as License Center（目標機）
    participant SRC as BE 原始碼（本機 repo）
    participant BE as 產品 BE（目標機）
    participant FE as 產品 FE（目標機）

    Note over OP,DB: Phase 0 — 前置盤點（唯讀，不改任何東西）
    OP->>DB: 比對 schema_migrations（DEV vs 目標環境）
    DB-->>OP: 落差清單 → 決定這次套哪幾支

    Note over OP,DB: Phase 1 — DB migration
    OP->>DB: 主產品 7 支 FR-062 migration
    OP->>DB: 建 license_center_&lt;env&gt; ＋ LC 001／002 ＋ seed-plans

    Note over OP,LC: Phase 2 — LC 部署
    OP->>LC: 拉 code／.env／poetry install
    OP->>LC: keygen（該環境專屬一把）
    LC-->>OP: kid ＋ 公鑰（keys/public_keys.json）

    rect rgb(246, 235, 213)
    Note over OP,BE: 🔴 Phase 3 — 公鑰回填（跨系統，最容易漏）
    OP->>SRC: PUBLIC_KEYS append 新 kid ＋ 公鑰
    SRC-->>OP: commit
    end

    Note over OP,BE: Phase 4 — BE 部署
    OP->>BE: 拉 code（必須含 Phase 3 那個 commit）
    OP->>BE: .env 三項 ＋ 重啟

    Note over OP,FE: Phase 5 — FE 部署
    OP->>FE: 拉 code → build → 佈署

    Note over OP,LC: Phase 6 — API token 發放
    OP->>LC: 後台新增 token（明文只顯示一次）
    OP->>BE: 貼進 .env → 再重啟一次 BE

    Note over OP,BE: Phase 7 — 既有租戶發照
    OP->>BE: 產品後台「向 LC 請照」
    BE->>LC: POST /api/internal/issue
    LC-->>BE: 簽好的照 → BE 驗章落地
```

::: {.callout .warn}
**Phase 2 → 3 → 4 這條鏈不可交換順序。**

Phase 6（token）之所以排在 BE 部署之後，是因為 token 明文只在 LC 後台產出的當下顯示一次——必須等 LC 起來、後台能登入了才發得出來。代價是 **BE 要重啟兩次**（Phase 4 一次、Phase 6 貼完 token 再一次）。想省一次的話可以把 Phase 6 提前到 Phase 4 之前做（LC 在 Phase 2 就活了），但 Phase 4 的 `.env` 就得等 token 到手才寫完。兩種都可以，**不可以做的是 Phase 3 跟 Phase 4 對調**。
:::

---

## 3. Phase 0：前置盤點 {#phase0 nav="Phase 0 盤點"}

**這一階段全部是唯讀操作**——`SELECT`、`pg_dump`、看服務狀態、讀設定檔。對任何環境（含 POC）都可以直接做、不需請示。會改變環境狀態的動作從 Phase 1 才開始。

### 3.1 主產品 migration 落差比對

```bash
# 兩邊各撈一次已套清單（密碼查 .env DB_SECRET）
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -Atc "SELECT filename FROM public.schema_migrations ORDER BY 1" > /tmp/mig-dev.txt

psql -h <目標 DB_HOST> -p 25432 -U cmmgr -d <目標 DB_NAME> \
  -Atc "SELECT filename FROM public.schema_migrations ORDER BY 1" > /tmp/mig-target.txt

# DEV 有、目標環境沒有的（左欄獨有）
comm -23 /tmp/mig-dev.txt /tmp/mig-target.txt
```

### 3.2 目標環境現有服務／DB 狀態

```bash
ssh jedi@<目標主機> 'systemctl is-active license-center; \
  curl -s -o /dev/null -w "LC:%{http_code}\n" http://127.0.0.1:5062/; \
  curl -s -o /dev/null -w "BE:%{http_code}\n" http://127.0.0.1:8000/swagger-ui/'

# LC DB 是否已存在
psql -h <目標 DB_HOST> -p 25432 -U cmmgr -d postgres \
  -Atc "SELECT datname FROM pg_database WHERE datname LIKE 'license_center%'"
```

### 3.3 決策點：哪些 migration 這次要套

比對出來的落差清單**不等於待辦清單**。落差裡會混進「不屬於本版範圍」的東西，這些要**明確決定不套**，而不是順手一起套掉。

v1.14.0 的實際決策（2026-08-10，決策者裁示）：

| migration | 決策 | 理由 |
|---|---|---|
| 七支 `*-fr062-*`（見 §4.1） | **套** | 本版範圍 |
| `2026-07-20-ssp-shared-metadata-DEV-cleanup.sql` | **不動** | DEV 專用清理腳本，STG／POC 有各自對應的 `*-STG-cleanup` / `*-POC-cleanup` |
| `2026-08-01-fr058-22-openscap-content-path-advanced.sql` | **不動** | FR-058 範圍，非本版 |
| `2026-08-03-fr060-1-detection-profile-split.sql` | **不動** | FR-060 範圍，非本版 |

::: {.callout .decided}
**「三環境 schema 零落差」是上版前的驗收條件，不是每次部署的目標。**

開發期 DEV 領先屬正常狀態。把不屬於本版的 migration 一起帶上去，等於偷渡了未經驗收的變更——FR-058 就發生過「11 支 migration 全同步進三環境，其中 4 支 seed 讓 POC 顯示四個 `available` 但 agent 無對應 connector」的事故。
:::

---

## 4. Phase 1：DB migration {#phase1 nav="Phase 1 DB"}

::: {.callout .crit}
**🔴 環境異動鐵律：開發期間只套 DEV。STG／POC 必須有決策者「當次」明確放行才可執行。**

「只是加個欄位應該無害」正是出事的起點。派工單或交接文件若寫「三環境都套」，**該指令本身可能就是錯的，停下問決策者**。誤套了就停下回報，不要自行補救——回滾也是異動。
:::

### 4.1 主產品：七支 FR-062 migration

依日期順序套（同日的照下表順序，後三支對前面建立的物件做調整，不可提前）：

| # | 檔名 | 做什麼 |
|---|---|---|
| 1 | `2026-08-08-fr062-2-tenant-licenses.sql` | 建 `config.tenant_licenses`（租戶級 RLS）＋ 全域時鐘回撥浮水印 |
| 2 | `2026-08-08-fr062-2-license-menu-route.sql` | `ui_routes` 登記 License 管理頁＋授權狀態頁 |
| 3 | `2026-08-09-fr062-tenant-license-events.sql` | 建 `config.tenant_license_events`（append-only 生命週期事件表） |
| 4 | `2026-08-10-fr062-8-license-menu-restructure.sql` | 選單結構調整：`license.read` 能力點（`is_platform=true`）＋ `group-license` 群組 ＋ 關閉舊儀表板入口 |
| 5 | `2026-08-10-fr062-9-license-manual-suspend.sql` | `tenant_licenses` 加 `suspended_at` / `suspend_reason`，人為停權與到期管線拆成兩個正交軸 |
| 6 | `2026-08-10-fr062-10-license-lc-events.sql` | `tenant_license_events.event_type` 值域加 `lc_issue` / `lc_extend` |
| 7 | `2026-08-10-fr062-license-group-sort-last.sql` | `group-license` 選單群組沉底（sort 45→90） |

```bash
cd /path/to/compliance-manager-be
for f in \
  scripts/sql/2026-08-08-fr062-2-tenant-licenses.sql \
  scripts/sql/2026-08-08-fr062-2-license-menu-route.sql \
  scripts/sql/2026-08-09-fr062-tenant-license-events.sql \
  scripts/sql/2026-08-10-fr062-8-license-menu-restructure.sql \
  scripts/sql/2026-08-10-fr062-9-license-manual-suspend.sql \
  scripts/sql/2026-08-10-fr062-10-license-lc-events.sql \
  scripts/sql/2026-08-10-fr062-license-group-sort-last.sql ; do
  echo "=== $f"
  psql -h <DB_HOST> -p 25432 -U cmmgr -d <DB_NAME> \
       --single-transaction -v ON_ERROR_STOP=1 -f "$f" || break
done
```

帳號一律 `cmmgr`（系統管理員繞 RLS；`cm_app` 受 RLS 會 silent fail）。**密碼請查 `.env` 的 `DB_SECRET`**，任何情況下都不要寫進指令稿或文件。port 必帶 `-p 25432`（非預設 5432，漏了症狀長得像 connection refused）。

每支腳本收尾都會 `INSERT public.schema_migrations`（`ON CONFLICT DO NOTHING`），所以**重跑是安全的**，但整支腳本本身不見得冪等——出錯時看 `--single-transaction` 已自動回滾該支，修完再跑。

### 4.2 License Center：建 DB ＋ 兩支 migration ＋ seed

```bash
# 1) 建 DB（命名新制：license_center_{dev|stg}；prod 不帶後綴）
psql -h <DB_HOST> -p 25432 -U cmmgr -d postgres -c 'CREATE DATABASE license_center_stg'

# 2) 套 LC repo 的兩支 migration（依序）
cd /opt/license_center
psql -h <DB_HOST> -p 25432 -U cmmgr -d license_center_stg \
  --single-transaction -v ON_ERROR_STOP=1 -f scripts/sql/001_create_plans_and_license_issuance.sql
psql -h <DB_HOST> -p 25432 -U cmmgr -d license_center_stg \
  --single-transaction -v ON_ERROR_STOP=1 -f scripts/sql/002_create_api_tokens.sql

# 3) seed 預設方案（basic / professional / enterprise）
#    ⚠️ 需 .env 先就緒（Phase 2 §5.2），CLI 走同一組 DB 連線設定
PYTHONPATH=src poetry run python -m license_center.cli.main seed-plans
```

DB user 目前沿用 `cmmgr`（上 PRD 前要換成 LC 專屬帳號，見 LC 手冊安全備忘）。

---

## 5. Phase 2：License Center 部署 {#phase2 nav="Phase 2 LC"}

單機安裝的**完整細節在 LC 部署手冊**（`license_center/docs/deployment-guide.html`），此處只給順序與本手冊視角的重點。

| 步驟 | 一句話 | LC 手冊對應 |
|---|---|---|
| 5.1 拉 code | `git clone` / `git pull` 到 `/opt/license_center` | §2 前置需求 |
| 5.2 `.env` | 放 repo 根目錄（程式 `load_dotenv()` 自讀）。**`LICENSE_CENTER_DB_NAME` 要指對環境** | §4 .env 設定 |
| 5.3 `poetry install` | 裝依賴、建 venv | §2 |
| 5.4 **keygen** | 每環境獨立一把，私鑰不離開該機 | §5 金鑰對產生 |
| 5.5 systemd | `license-center.service` 常駐 | §6 systemd 常駐 |
| 5.6 啟動驗證 | `curl` 回 302、後台可登入、passphrase 自驗通過 | §9 驗收 checklist |

### 5.2 `.env`：兩個最容易寫錯的欄位

::: {.callout .warn}
**`LICENSE_CENTER_DB_NAME` 指錯環境 = 在別的環境的 DB 上簽照。**

從別台機器 `scp` 過來的 `.env` 範本最容易留著上一個環境的 DB 名。改完務必回頭 `grep` 一次。同理 `LICENSE_CENTER_BIND_HOST`——預設 `127.0.0.1` 只綁本機，要對外服務才設 `0.0.0.0`（且**必須在內網防火牆內**，不應直接曝露公網）。
:::

密碼類欄位（`LICENSE_CENTER_DB_PASSWORD` / `ADMIN_PASSWORD` / `SECRET_KEY` / `KEY_PASSPHRASE`）**一律查密碼管理器或部署文件，不寫進任何會 commit 的檔案**。

### 5.4 keygen：passphrase 先產先存，再貼

順序很重要——**先產 passphrase 存進密碼管理器，再拿去 keygen**，不要 keygen 完才回頭補記：

```bash
cd /opt/license_center && export PYTHONPATH=src
python3 -c "import secrets; print(secrets.token_hex(32))"   # 先產、先存密碼管理器
poetry run python -m license_center.cli.main keygen          # 互動輸入兩次 passphrase
cat keys/public_keys.json                                    # 取得 kid + pubkey → Phase 3 要用
```

::: {.callout .decided}
**passphrase 遺失，且該鑰「還沒簽過任何照」時，重產的成本是零。**

2026-08-10 STG 首把鑰 `ef99f508c74e43fd` 就是因 passphrase 對不上而作廢重產成 `04b1e65f100c6c8f`——因為當時一張照都還沒簽，直接重來最快。

[**但一旦簽過照就完全不同**：私鑰遺失 = 該環境存量照無法補發重簽，只能換鑰（重 keygen + 更新 `PUBLIC_KEYS` + 全部照重簽）。所以「先存密碼管理器」這個順序在正式環境是硬要求。]{.rec}
:::

### 5.5 systemd：`PYTHONPATH=src` 為什麼不能放 `.env`

unit 檔內必須寫 `Environment=PYTHONPATH=src`，**放進 `.env` 無效**——Python 的模組解析（import `license_center`）發生在程式執行 `load_dotenv()` **之前**，等 `.env` 被讀進來時 import 早就失敗了。症狀是 `ModuleNotFoundError: license_center`。

同理 `ExecStart` 要用 venv 的**絕對路徑**（venv 不在專案內時用 `poetry env info --path` 查），`WorkingDirectory` 要指對（`.env` 靠它才找得到）。

---

## 6. Phase 3：公鑰回填 BE 🔴 {#phase3 nav="Phase 3 公鑰回填"}

::: {.callout .crit}
**🔴 這是整套部署最容易漏、漏了整條死的一步。**

症狀：**LC 簽發成功（後台看得到那筆 `license_issuance`），但產品端上傳／落地時回「未知的 kid: xxxx（公鑰列表無對應項）」，該環境所有照全數拒收。**

因為 LC 那端一切正常，很容易往「照簽壞了」的方向查。判準很簡單：**去 BE 目標機上 `grep` 那個 kid，找不到就是這一步沒做完。**
:::

### 6.1 三個動作

```bash
# ① 在 LC 機器上取 kid + 公鑰
cat /opt/license_center/keys/public_keys.json
```

② 加進 BE 的 `common/license/public_keys.py`——**陣列 append，不是取代**：

```python
PUBLIC_KEYS: list[dict] = [
    {   # DEV 簽發鑰（開發者本機 LC，2026-08-08 起）
        "kid": "2ce3bb59a6f5a2ce",
        "pubkey": "/WVn5Kvbc6EOnl2L3m7qOwlR5xL7gMWtznjlKEOYvUQ=",
    },
    # ... 既有各環境的鑰全部保留 ...
    {   # ← 新環境的鑰 append 在最後，附註解說明是哪個環境、哪天產的
        "kid": "<新 kid>",
        "pubkey": "<新 pubkey>",
    },
]
```

::: {.callout .warn}
**多把共存是必要的，不可「換成新的」。**

`PUBLIC_KEYS` 是**全域共用**的一份程式碼，同一份 BE 原始碼會部署到 DEV／STG／POC 三個環境。把舊 kid 換掉，等於讓其他環境的存量照全部驗不過。金鑰輪替也是靠這個機制過渡（新舊雙鑰並存，等舊照全數換發完再移除）。
:::

③ **commit**，然後——

::: {.callout .crit}
**該環境的 BE 部署必須帶到這個 commit。**

Phase 4 拉 code 時如果拉到的是這個 commit 之前的版本，前面兩步全部白做。每次 keygen ⇒ 一個 BE commit ⇒ 該環境 BE 重新部署，**這三件事是一組的**。
:::

### 6.2 為什麼公鑰放程式碼，不放設定檔或 DB

這是刻意的設計決策（design.md §4.2／§4.9），不是圖方便：

**放設定檔或 DB 的話，能改到設定檔／DB 的人就能換掉公鑰，換掉公鑰就能用自己的私鑰簽假照。** 而落地部署（host 版）的整個威脅模型，就是假設客戶對自己機房裡的機器有完整存取權。公鑰編譯進程式碼，把「偽造授權」的門檻從「改一行設定」提高到「改 Python 原始碼並重啟服務」——後者留得下痕跡，也不是靠部署權限就能無聲完成的。

::: {.callout .crit}
**🔴 客戶落地版（Docker image）的連帶要求：正式環境的簽發鑰必須在 build image 之前就進 `PUBLIC_KEYS`。**

image 一旦封裝出去，公鑰列表就凍結了——事後才產的鑰簽出的照，那個 image 一張都驗不過，只能重 build 重出貨。
:::

---

## 7. Phase 4：BE 部署 {#phase4 nav="Phase 4 BE"}

### 7.1 拉 code

必須包含 Phase 3 那個公鑰 commit。部署後先驗一次：

```bash
grep -c '"kid"' /opt/compliance-manager-be/common/license/public_keys.py   # 應等於已部署環境數
grep '<本環境 kid>' /opt/compliance-manager-be/common/license/public_keys.py
```

### 7.2 `.env` 三項

| 變數 | 值 | 說明 |
|---|---|---|
| `DEPLOYMENT_MODE` | `saas` 或 `host` | 部署形態語意，供 license 驗證引擎區分行為（host 才綁機器指紋）。預設 `saas`。**請照時 BE 會把這個值帶給 LC**（見 §10.3） |
| `LICENSE_ACTIVATION_SERVER_URL` | `http://<LC 位址>:5062` | 指向**該環境自己的** LC。LC 與 BE 同機時用 `http://127.0.0.1:5062` |
| `LICENSE_CENTER_API_TOKEN` | Phase 6 產出後回填 | 打 LC 內部簽發 API 用的 Bearer token。此時可先留空 |

另有兩個保險絲（出廠都是開，一般不動）：`LICENSE_ENFORCEMENT_ENABLED`（執法總開關）、`LICENSE_READONLY_GATE_ENABLED`（只關「過期後全域擋寫入」、保留模組守門）。

### 7.3 重啟

```bash
# BE 重啟必 kill -9——既有紀律，普通 kill 會留孤兒 PID 佔住 8000
pkill -9 -f main_app.py
cd /opt/compliance-manager-be && nohup python main_app.py > /dev/null 2>&1 &
tail -50 log/app.log            # 確認起得來、無 traceback
```

---

## 8. Phase 5：FE 部署 {#phase5 nav="Phase 5 FE"}

拉 code → `npm ci && npm run build` → 佈署到該環境既有位置（STG／POC 皆為 nginx `/var/www/html/audit-manager`）。流程與過去各版相同，本版沒有特殊步驟。

本版 FE 的 License 相關改動（`compliance-manager-fe`，版號同步 v1.14.0）：

- **「向 LC 請照」dialog**（新簽／展延，含照型態選項——admin 可發 Trial 照）
- **停權文案與狀態快取失效**、SaaS 版到期橫幅與唯讀提示改用不提「上傳照」的文案、SaaS 版隱藏客戶側開通／續約入口
- **登出修復**：登出後不再誤打 `/license/status`（`afterEach` 排除未登入態＋登出 reset store），並在送 `LOGOUT` 前先等 chatbot session 清完
- 授權歷程事件標籤補 `lc_issue` / `lc_extend` 翻譯

---

## 9. Phase 6：API token 發放 {#phase6 nav="Phase 6 Token"}

供產品 BE 呼叫 LC 內部簽發 API（`/api/internal/issue`｜`/extend`｜`/plans`）用。

1. LC 後台登入 → **API Token 管理** → **新增**：名稱用 `product-{env}` 命名慣例（STG 現況名為 `Staging`、POC 為 `GuidantAI-POC`），選對 environment
2. **複製明文 token**（見下方警示）
3. 貼進產品 BE `.env` 的 `LICENSE_CENTER_API_TOKEN`
4. **重啟 BE 才生效**（config 在啟動時讀入）

::: {.callout .warn}
**明文只顯示一次——當場複製。**

關掉那個畫面就再也拿不回來（DB 只存雜湊），只能作廢重發。
:::

撤銷 = 在 LC 後台停用該列，**即時生效**（不必等 BE 重啟）。

---

## 10. Phase 7：既有租戶發照 {#phase7 nav="Phase 7 發照"}

### 10.1 走產品後台「向 LC 請照」，不走 T-7.2 腳本

::: {.callout .decided}
**FR-062.10 上線後，發照的正解是產品後台的「向 LC 請照」按鈕。**

`scripts/` 下的 T-7.2 發照 migration 腳本（SOP 見 [`existing-tenant-license-migration-sop.md`](./existing-tenant-license-migration-sop.html)）**護欄只認 `guidant_ai_dev`**，且刻意不提供 `--db-name` 逃生門——它是 DEV 演練用的，**STG／POC 一律不用**。2026-08-10 的 STG／POC 發照就是走「向 LC 請照」完成的。
:::

操作路徑：產品 root 後台 → 授權管理（選單最下方 **授權管理** 群組）→ 選租戶 → **向 LC 請照** → 選方案（basic／professional／enterprise）、照型態、天數 → 送出。

底層流程：BE 帶 API token 打 LC `POST /api/internal/issue` → LC 簽章回照 → BE 驗章後走既有 `upload_license` 漏斗落地，事件記為 `lc_issue`（展延為 `lc_extend`）。

### 10.2 每個頂層客戶租戶各發一張

照綁在**頂層租戶**上（子租戶透過租戶樹 resolve 到頂層的照），所以逐一為每個頂層客戶租戶發一張即可，子租戶不必也不能各發。root tenant **無條件豁免 license 執法、永遠不持照**——這是設計如此，不是漏發。

### 10.3 `deployment_mode` 會自動帶站台 config

::: {.callout .warn}
**commit `8c82e8e6` 之後，請照未指定 `deployment_mode` 時會自動帶該站台的 `DEPLOYMENT_MODE`。**

在**該 commit 之前**部署的環境，請照時若沒明確指定，LC 會用自己的預設值猜——POC 首發那張照就因此被簽成 `saas`（POC 應為 `host`），只能連同事件清空重發。

判準：發完照去產品後台看那張照的 `deployment_mode` 欄位，跟該環境的 `DEPLOYMENT_MODE` 對不上就是踩到了。
:::

---

## 11. 部署驗收 checklist {#checklist nav="驗收 checklist"}

逐項勾，不要跳。前四項驗 LC 自身，後四項驗端到端。

- [ ] **LC 活著**：`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5062/` 回 **302**
- [ ] **LC 後台可登入**（用 `.env` 設的管理帳密）
- [ ] **passphrase 自驗通過**：LC 手冊 §5「passphrase 驗證一行」印出「passphrase 正確」
- [ ] **token 白名單生效**：不帶 token 打 `/api/internal/plans` 回 **401**；帶 token 回 **200**
- [ ] **產品後台看得到授權管理頁**（root 登入，側邊選單**最下方**的「授權管理」群組）
- [ ] **請照端到端成功**：後台「向 LC 請照」簽出一張照，產品端驗簽通過、落地為現行照
- [ ] **授權歷程出現 `lc_issue`**（該租戶詳情 → 授權歷程時間線）
- [ ] **登出無錯誤 toast**（登出後不應再打 `/license/status`）

---

## 12. Troubleshooting {#troubleshooting nav="Troubleshooting"}

以下全部是 2026-08-10 STG／POC 實戰時真的踩到的，按「症狀 → 原因 → 解法」排。

| 症狀 | 原因 | 解法 |
|---|---|---|
| `ModuleNotFoundError: license_center` | 沒設 `PYTHONPATH=src` | 手動跑要 `export PYTHONPATH=src`；systemd 要用 `Environment=PYTHONPATH=src`（**放 `.env` 無效**，模組解析早於 dotenv 載入） |
| LC 簽發回 **503「尚未就緒」** | `LICENSE_CENTER_KEY_PASSPHRASE` 未設，或改了 `.env` 沒重啟 | 補設後 `systemctl restart license-center` |
| LC 簽發回 **503「私鑰解鎖失敗」** | passphrase 與 keygen 當下設的不符 | 先跑驗證 one-liner 確認（見下方 ⚠️）。救不回且**該鑰未簽過任何照**時，重 keygen 成本為零，直接重產＋回填新公鑰 |
| 產品端「**未知的 kid**」 | Phase 3 沒做，或 BE 沒重新部署到含公鑰的 commit | 去 BE 目標機 `grep` 該 kid；缺就補 §6 三步驟 |
| 請照回 **400** | 參數或值域問題 | 查 BE log：`grep 'license.lc' log/app.log` |
| 請照回 **401** | API token 對不上——`.env` 貼錯，或貼完沒重啟 BE | 重貼 token → **重啟 BE**（token 在啟動時讀入） |
| 照記載的 `deployment_mode` 錯 | 見 §10.3 | **重新「新簽」一張**——rebind 不會修，rebind 是同內容換綁定 |

::: {.callout .crit}
**⚠️ passphrase 驗證 one-liner 必須先 `load_dotenv()`。**

漏了這行，指令會讀不到 `.env` 裡的 passphrase 而回失敗，把「**沒讀到**」誤判成「**填錯**」——2026-08-10 就因此多繞了一圈，差點把一把其實正確的鑰判死。

```bash
cd /opt/license_center
poetry run python -c "
from dotenv import load_dotenv; load_dotenv()
from license_center.web.keys import unlock_private_key, passphrase_from_env
unlock_private_key(passphrase_from_env() or ''); print('passphrase 正確')"
```
:::

**log 位置**：LC → `journalctl -u license-center -f`；產品 BE → `log/app.log`（repo 根目錄下）。

---

## 13. 環境現況對照表 {#envs nav="環境現況"}

| | **DEV** | **STG** | **POC** |
|---|---|---|---|
| 主機 | 開發者本機 | `192.168.50.188` | `192.168.50.189` |
| BE | `~/Projects/.../compliance-manager-be`（:8000，`main_app.py`） | `/opt/compliance-manager-be`（:8000） | `/opt/compliance-manager-be`（:8000） |
| FE | `npm run dev`（:5180） | nginx `/var/www/html/audit-manager`（:80） | nginx `/var/www/html/audit-manager`（:80） |
| LC 位置 | `~/Projects/.../license_center`（:5062） | `/opt/license_center`（:5062） | `/opt/license_center`（:5062） |
| LC 啟動方式 | 手動 `PYTHONPATH=src poetry run python -m license_center.web.app` | **systemd** `license-center.service`（enabled+active，`BIND_HOST=0.0.0.0`） | 已起（:5062） |
| 產品 DB | `guidant_ai_dev`（188:25432） | `guidant_ai_stg`（188:25432） | `guidant_ai_poc`（189:25432） |
| LC DB | `license_center_dev`（188:25432） | `license_center_stg`（188:25432） | `license_center_poc`（189:25432） |
| 簽發鑰 kid | `2ce3bb59a6f5a2ce` | `04b1e65f100c6c8f` | `f3b562a6d9755527` |
| LC API token 名稱 | （本機自行發放） | `Staging`（env=stg） | `GuidantAI-POC`（env=production） |
| v1.14.0 部署狀態 | 全部完成（開發環境） | **BE／FE／LC 部署完成**；兩租戶正式發照由決策者手動執行 | **BE／FE／LC 部署完成**；現行照 1 張（102 Billows Tech，host 模式） |

::: {.callout .crit}
**🔴 POC 等同 production**（對外 demo／客戶試玩）。**唯讀操作（SELECT／`pg_dump`／看容器／讀設定）可自由做、不需請示；任何寫入（含 migration、重啟、部署、改設定）一律停下問決策者。**

STG 首把簽發鑰 `ef99f508c74e43fd` 因 passphrase 遺失重產，**未簽過任何照即作廢，不保留**——`PUBLIC_KEYS` 內只有上表三把。
:::

---

## 14. 相關文件 {#refs nav="相關文件"}

| 文件 | 內容 |
|---|---|
| [`design.md`](./design.html) | FR-062 設計定稿（§4.2 簽章與驗證／§4.7 送照時序／§4.9 金鑰生命週期） |
| `license_center/docs/deployment-guide.html` | **LC 單機部署細節手冊**（DB／`.env`／keygen／systemd／token／安全備忘）——本手冊 §5 全部指向它 |
| [`existing-tenant-license-migration-sop.md`](./existing-tenant-license-migration-sop.html) | T-7.2 發照腳本 SOP——**僅適用 DEV**，STG／POC 走 §10 的「向 LC 請照」 |
| `docs/user-manual/license-guide.md` | 授權管理使用指南（平台管理員＋租戶使用者兩視角） |
| [`handoff/fr062-STATE.md`](./handoff/fr062-STATE.html) | FR-062 當下狀態（living） |
