---
title: "Google Drive 應用程式設定 — 需求討論稿 (FR-110)"
brand: "Guidant AI · **FR-110** Google Drive 應用程式設定"
eyebrow: "FR-110 · Drive OAuth 憑證從環境變數搬進 UI · 需求討論稿 · 2026-09-17（v1 · 待審）"
h1: "讓 Drive 憑證用畫面填，不必進主機改設定檔"
lede: "目前要讓客戶的 Guidant AI 接得上 Google Drive，工程師得登進主機、編輯 `.env`、再重啟服務——落地版客戶端沒有這條動線。本案新開一頁「Google Drive 應用程式設定」，把 Google 那組應用程式帳號（`client_id` / `client_secret`）與兩個對外網址改成**畫面上填、存資料庫、密鑰加密落庫**，並附一顆「驗證憑證」按鈕當場告訴你填對沒有。做法整套照抄 **FR-107 T-5.5 的 AI 服務設定**，不發明新機制。"
chips: [
  {text: "已拍板 7 項（D1–D7）", kind: ok},
  {text: "待決策 D8–D12", kind: warn},
  {text: "跨 2 repo：BE／FE ＋ 選單 migration", kind: accent},
  {text: "前身：FR-070 白話需求（Drive 半邊）", kind: accent},
  {text: "先例：FR-107 T-5.5 AI 金鑰 DB 化", kind: accent},
  {text: "陷阱：背景排程無租戶脈絡，必走繞 RLS 讀取", kind: crit}
]
footer: "FR-110 · Google Drive 應用程式設定 — 需求討論稿 · 2026-09-17 v1 · 前身：FR-070（AI／Drive 憑證 DB 化白話需求，AI 半邊已隨 FR-107 T-5.5 落地）· 現況依據：`config/config.py`、`di_containers/cloud_integration/`、`api/cloud_integration/`、`app/system_config/` 實檔查證 · 沿革見 LOG 與 git log"
---

## 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**把「本系統在 Google 登記的那組應用程式帳號」從主機的設定檔搬到畫面上，讓不會 ssh 的人也設定得了，而且填完當場知道對不對。**

給非工程讀者的背景：Google Drive 整合要能運作，需要兩層授權。第一層是「Guidant AI 這個軟體本身，在 Google 那邊登記過、拿到一組應用程式帳號」——這組帳號全站共用一份，目前寫死在主機的設定檔裡。第二層才是「每位客戶拿自己的 Google 帳號按下授權，把自己的雲端硬碟接過來」——這層早就有畫面（雲端空間整合頁）。**本案只做第一層**，第二層不動。

::: {.callout .decided}
**四階段，先後順序不可換**

前一階段沒完成，後一階段連東西可測都沒有。
:::

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者親手檢查法） |
|---|---|---|---|
| **① 存讀與解析** | 資料庫存得下這組設定、密鑰加密、服務端讀得到（資料庫優先、設定檔後備）、依賴注入接線改成每次呼叫都重新解析 | 後端 service ＋ resolver ＋ DI 改線 | 用資料庫工具直接塞一筆設定進去 → 去雲端空間整合頁按「連接 Google Drive」→ **不必重啟服務**就走到 Google 授權畫面 |
| **② 驗證與 API** | 三支端點：讀設定（密鑰遮罩）、存設定、驗證憑證 | 後端 API ＋ 守門 | Swagger 或 curl 打驗證端點：故意填錯的 `client_secret` 要回「憑證錯誤」，填對的要回「憑證正確」 |
| **③ 畫面與選單** | 新頁面「Google Drive 應用程式設定」放進「系統設定」群組；四個欄位＋複製鈕＋驗證鈕 | 前端頁面 ＋ 選單 migration | 用平台管理員登入 → 側邊選單「系統設定」底下看得到新頁 → 填完存檔 → 重新整理，密鑰欄顯示「已設定」而不是明文；**用一般租戶管理員登入則完全看不到這一頁** |
| **④ 設定檔退場** | 更新 `.env.sample`、部署文件、安裝包的 `guidant.env` 說明，標明這四項改由畫面設定 | 文件 | 打開 `.env.sample`，Drive 那段有一行白話寫「這幾項改到畫面上設，此處僅開發機後備」 |

依賴：①是全案地基，②③可在①完成後平行；④等③驗收通過才寫（不然文件會描述還沒上線的畫面）。

---

## 需求背景與現況 {#background nav="背景"}

### 為什麼現在不夠用

落地版（客戶自己機房裝一套）裝完之後，要開通 Google Drive 功能得有人登進主機、編輯 `/srv/guidant-ai` 的環境設定檔、重啟容器。這條動線有三個問題：

- **客戶端沒有人做得到**——交付動線設計上是「裝完機 → 管理員登入 → 在畫面上完成設定」，跟上傳授權檔（License）同一條路。Drive 卻脫隊。
- **填錯了不會有人告訴你**——目前 `client_secret` 填錯，唯一症狀是使用者按下「連接 Google Drive」之後，走到 Google 那邊被擋，錯誤訊息還是 Google 的英文頁面。沒有任何一個地方會說「是你的應用程式帳號填錯了」。
- **改一次要重啟整個服務**——設定值在服務啟動時讀進記憶體，改了不重啟不生效。

### 目前的變數清單與各自去向

`config/config.py` 目前跟 Drive 有關的共七項，本案只搬其中四項：

| 變數 | 用途（白話） | config.py 行號 | 本案去向 |
|---|---|---|---|
| `GOOGLE_DRIVE_OAUTH_CLIENT_ID` | Guidant AI 在 Google 登記的應用程式編號 | 160 | **搬進畫面**（手填） |
| `GOOGLE_DRIVE_OAUTH_CLIENT_SECRET` | 上述應用程式的密鑰 | 161 | **搬進畫面**（手填，加密落庫） |
| `GOOGLE_DRIVE_OAUTH_REDIRECT_URI` | 使用者在 Google 按完同意後，Google 要把人送回哪個網址 | 162 | **搬進畫面**（自動算、唯讀顯示、可進階覆寫） |
| `DRIVE_WEBHOOK_PUBLIC_BASE_URL` | Google 要主動通知「檔案有變動」時，從外網打進來的站台位址 | 170 | **搬進畫面**（自動算、唯讀顯示、可進階覆寫） |
| `DRIVE_TOKEN_ENCRYPTION_KEY` | 加密用的鑰匙本身 | 163 | **留在設定檔**（裝機時生成，密文與鑰匙必須分開放，同時落在資料庫就失去意義） |
| `DRIVE_SYNC_WORKER_POOL_SIZE` | 同步工作執行緒數量 | 236 | **留在設定檔**（部署調校參數，不是憑證） |
| `DRIVE_FILE_SIZE_LIMIT_MB` | 單檔大小上限 | 255 | **留在設定檔**（同上） |
| `FRONTEND_BASE_URL` | 前端站台位址 | 257 | **留在設定檔**（非 Drive 專屬，多處共用；但可能成為自動算網址的來源，見 D8） |

::: {.callout .warn}
**`.env.sample` 目前漏列 `DRIVE_WEBHOOK_PUBLIC_BASE_URL`**

實查 `.env.sample` 只有 `GOOGLE_DRIVE_OAUTH_*` 三項與加密鑰、上限、執行緒數，**沒有這一項**。而 `config.py:164-170` 的註解記著這個坑：FR-016 實作計畫列了這項但落地時漏在 config 定義，症狀是授權走到註冊通知頻道那步拋 `'NoneType' object has no attribute 'rstrip'`，而且**在設定檔補這個變數也無效**（當時沒有任何程式碼會去讀）。config 那邊已補上，但 `.env.sample` 至今沒補——本案第④階段要一起收掉。
:::

---

## 兩層設定怎麼分工 {#two-layers nav="兩層分工"}

新頁面與既有的「雲端空間整合」頁**不重疊也不取代**，是上下兩層：

```{.mermaid cap="圖 1 — 平台層（本案新頁）與租戶層（既有頁）的分工，以及誰讀誰"}
%%{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 P["平台層 — 本案新頁「Google Drive 應用程式設定」"]
    P1["誰能設：平台（ROOT）管理員"]
    P2["設什麼：本系統在 Google 登記的應用程式帳號<br/>client_id / client_secret / 兩個對外網址"]
    P3["存哪：system_configs 的 ROOT 租戶那一列<br/>全站共用一份"]
  end

  subgraph T["租戶層 — 既有頁「雲端空間整合」"]
    T1["誰能設：各租戶管理員"]
    T2["設什麼：拿自己的 Google 帳號按下授權"]
    T3["存哪：tenant_drive_integrations<br/>一個租戶一列"]
  end

  R["憑證解析器<br/>ROOT 設定 → 設定檔後備"]
  U["實際用到憑證的四處"]

  P3 --> R
  R --> U
  U --> T2
  T2 -.->|"授權拿到的通行證<br/>加密後存這裡"| T3
```

白話說：**平台層填一次、全站受用；租戶層每家客戶各自按一次授權。** 沒有平台層那組帳號，租戶層的「連接 Google Drive」按下去必然失敗——本案順帶讓這個失敗變得看得懂（見 D11 下方與風險段）。

---

## 決策定案（D1–D7） {#decided nav="已定案"}

::: {.callout .decided}
**以下七項決策者已拍板，本段為記錄，不是待議項。**
:::

| # | 決策 | 內容 | 為什麼 |
|---|---|---|---|
| **D1** | FR 編號 | 新開 **FR-110**，只做 Drive 半邊 | 前身是 FR-070 白話需求（AI 金鑰 ＋ Drive 憑證兩件一起提）。AI 那半邊已隨 **FR-107 T-5.5** 落地，剩下的 Drive 半邊獨立成案 |
| **D2** | 頁面形式 | **新開一頁**「Google Drive 應用程式設定」，掛「系統設定」群組（`pid=47`，`sort=39`），與「AI 服務設定」並列同守門 | 與租戶層的「雲端空間整合」頁受眾不同、權限不同，混在同一頁會讓租戶管理員看到不該看的欄位。並列 AI 服務設定則是因為兩者本質相同：都是「填連線資訊與金鑰」 |
| **D3** | 畫面四項 | ① `client_id` 手填 ② `client_secret` 手填 ③ **回呼網址**自動算、唯讀顯示 ＋ 複製鈕 ④ **通知用對外網址**自動算、唯讀顯示 ＋ 複製鈕。③④ 另有「進階」摺疊區可手動覆寫，**預設摺疊** | 回呼網址必須跟客戶在 Google 後台登記的**一字不差**，所以要能一鍵複製貼過去；但它其實是算得出來的（對外站台位址 ＋ 固定路徑），讓人手打只會打錯。留覆寫是給反向代理、非標準路徑等特殊部署用 |
| **D4** | 密鑰加密 | `client_secret` 用**既有**的 `DRIVE_TOKEN_ENCRYPTION_KEY` 加密落庫，**不新增環境變數**。鑰匙是空的就直接拋錯，**沒有存明文這條路** | Drive 這條線本來就強制要有這把鑰匙（`FernetCrypto.__init__` 空值直接 `ValueError`），使用者的授權通行證也是用它加密的。刻意與 AI 金鑰那把不同：AI 那把允許「空鑰匙＝不加密」給開發機方便，這裡不給——因為這條線沒有「鑰匙可以不設」的狀態 |
| **D5** | 驗證憑證按鈕 | **要做**。拿一個假的授權碼去打 Google 的換票端點：Google 回 `invalid_client` 代表憑證填錯、回 `invalid_grant` 代表**憑證是對的**（只是那個授權碼本來就假）。一支 POST 端點 | 這是全案最有感的一顆按鈕。目前填錯要等使用者去按授權才發現，而且看到的是 Google 的錯誤頁；有了它，填完當場就知道 |
| **D6** | 換憑證的處理 | **只警告，不自動改**各租戶的連線狀態。畫面在存檔前若偵測到 `client_id` 有變更，跳確認框說明「所有租戶需重新授權」 | 換了應用程式帳號，舊的授權通行證在新帳號底下是無效的。但自動把所有租戶標成「未連接」風險太大（改錯一個字就把全站連線清掉），交由人確認 |
| **D7** | 守門 | 路由只掛 `@jwt_required`，**守門下沉到 service 層**呼叫 `require_platform_admin()`（軸①，`common/authz/platform.py`）。選單能力點**沿用 `storage-config.{read,update}`，不新開** | 完全照 AI 服務設定的做法。不新開能力點的理由寫在 `scripts/sql/2026-09-17-fr107-ai-service-config-menu.sql`：新開一顆要 seed 到每個既有租戶的管理員角色，**漏掉的症狀是那個租戶永遠 403 且沒有任何錯誤訊息**。而沿用的那顆已配給每個租戶管理員，所以能力點之後還要再套一層平台管理員判定 |

### 讀取解析順序（隨 D1–D7 一併定案）

兩層，**不做租戶層**：

1. **資料庫的 ROOT 租戶設定**（`system_configs`，平台管理員在新頁填的）
2. **環境變數後備**（開發機方便；正式環境以資料庫為準）

形狀照抄 `app/system_config/service/ai_provider_key_resolver.py:145-151`——那支是三層（租戶 → ROOT → 設定檔），本案砍掉租戶層那一圈，因為應用程式帳號本來就全站一份。

::: {.callout .crit}
**🔴 背景排程讀設定必須走繞過隔離的專用讀取器**

`core/scheduler.py` 有兩支排程會用到 Drive 憑證：`drive_sync_worker`（每 5 秒）與 `webhook_channel_renewer`（每 6 小時）。它們以系統服務身分執行，**沒有登入者、沒有租戶脈絡**，一般的設定查詢路徑在這種情況下挑到哪一列是不確定的（看資料列 id 排序）。

必走 `infra/system_config/system_config_root_reader.py` 的 `read_root_config_value()`，**不可裸呼叫依賴隔離機制收斂的單筆查詢**。

這不是理論風險：2026-07-29 POC 的檔案上傳就是這樣把某租戶的檔存進別租戶的儲存桶，而另一個環境「沒重現」純粹是資料列 id 排序恰好對了——骰子點數不同而已。詳見 `docs/claude/memory/feedback_background_job_storage_config_no_context_trap.md`。
:::

---

## 待決策（D8–D12） {#pending nav="待決策"}

::: {.callout .pending}
**D8 — 自動算出來的網址，要從哪裡推導？**

回呼網址與通知用對外網址都是「對外站台位址 ＋ 固定路徑」，問題是「對外站台位址」從哪來。

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 用既有的 `FRONTEND_BASE_URL` 環境變數 | 語意不對——那是**前端**站台位址，而 Google 要打的是**後端** API。兩者同網域時剛好對，分開部署時靜默錯 |
| **B** | 用當次請求的 `Host` / `X-Forwarded-Host` 標頭 | 不必多填欄位，但值取決於「管理員是從哪個網址進來設定的」。走反向代理、走內網 IP 進來設定時，算出來的網址對外不可達，而且**看起來完全正常** |
| **C** | 新增一個 ROOT 設定「系統對外網址」，由本頁一起填 | 多一個欄位要填 |

[**建議：C**。部署的對外網址是一件明確的事實，不該靠推導——A 與 B 算錯時都不會報錯，症狀是使用者按授權後被 Google 擋、或是檔案變動通知永遠收不到，兩者都極難追。多一個欄位換掉兩種靜默失敗划算。但要點明：選 C 就是本頁從四個欄位變五個，且該欄位可能被其他功能共用（那是好事，但要想清楚它該不該叫「Drive 的」設定）。]{.rec}
:::

::: {.callout .pending}
**D9 — 這組設定存在哪張表？**

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 進共用的 `system_configs` 表，新開一個群組 `GOOGLE_DRIVE_APP_CONFIG` | 沿用既有的讀寫殼、遮罩機制、守門機制，零新表 |
| **B** | 新開一張專屬表 | 欄位型別明確、可加約束，但要新 migration、新 repository、新 domain service，且與 AI 金鑰的做法分岔 |

[**建議：A**。AI 金鑰（`AI_PROVIDER_CONFIG`）、SMTP、議題整合設定都在這張表，遮罩與守門的殼 `GuardedSystemConfigService` 已經長好，本案只要加一個群組名與對應的遮罩規則。選 B 等於為了四個欄位開一整套 DDD 分層。]{.rec}
:::

::: {.callout .pending}
**D10 — 密鑰怎麼回給前端、怎麼寫回去？**

照 `guarded_system_config_service.py:174-200` 的既有規則（`_merge_ai_provider_secrets`）：

- **讀**：密鑰欄位一律不回明文，換成 `is_set: true/false` 旗標——前端只要顯示「已設定 / 未設定」
- **寫**：前端這次**沒送**密鑰（欄位不存在、空字串、或把 `is_set` 原樣送回來）＝**保留舊值**；送了非空值＝換新
- **清除**：光送空字串永遠清不掉，要顯式帶 `clear: true`

[**建議：照抄，一字不改。** 這三條規則不是風格選擇，是踩出來的：底層寫入是整格覆寫，不補「缺的從舊值補回」這一步，使用者只改網址按存檔，密鑰就被抹掉，**而畫面顯示「儲存成功」**，要等下次有人按授權失敗才浮現。列在這裡是要讓決策者知道這條路徑存在、不是新發明的。]{.rec}
:::

::: {.callout .pending}
**D11 — 安裝版裝機時要不要把設定檔的值寫進資料庫一次？**

FR-107 T-5.5 的原廠 AI 金鑰是「裝機時寫進資料庫」。本案要不要比照？

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 裝機時把環境變數的值 seed 進資料庫 | 裝完就「已設定」，但沒人填過也顯示「已設定」，分不出「原廠給的」與「客戶自己填的」 |
| **B** | 不 seed，純靠環境變數後備 | 環境變數有值就用得到，功能正常；資料庫有值才代表「有人在畫面上設過」 |

[**建議：B**。兩者功能上等價（後備那層照樣讀得到），但語意差很多——「資料庫裡有＝有人設過」是個乾淨的判準，日後要做「未開通」提示、要判斷該不該跳引導畫面，都靠它。而且 Drive 這組帳號與 AI 金鑰的性質不同：AI 金鑰是原廠掏錢買來送客戶用的，Drive 應用程式帳號通常是客戶自己去 Google 申請的。]{.rec}
:::

::: {.callout .pending}
**D12 — 現有的依賴注入單例怎麼改？**

`di_containers/cloud_integration/cloud_integration_containers.py:107-112` 目前把 `GoogleOAuthClient` 註冊成**單例**，三個參數在服務啟動時從設定檔灌進去、之後永不改變。憑證改成資料庫可調之後，這個單例會拿著開機當下的舊值。

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 改成每次取用都重新建立（Factory） | 呼叫端拿到的物件生命週期變了，要確認沒有人把它存起來重複用 |
| **B** | 保留單例，**內部**改成每次呼叫方法時才去解析器拿當下的憑證 | 呼叫端一字不用改 |

**受影響的呼叫端（已 grep 全庫確認）**：

| 檔案 | 用法 |
|---|---|
| `app/cloud_integration/service/google_drive_integration_service.py:53,61` | 注入後存成 `self._oauth` |
| `domain/cloud_integration/service/google_drive_token_manager.py:41,45` | 注入後存成 `self._oauth` |
| `di_containers/cloud_integration/cloud_integration_containers.py:132,267` | 兩處往下傳 |
| `scripts/evidence/classify/classify_evidence_drive.py:190` | **離線腳本，自己 `GoogleOAuthClient(...)` 直讀環境變數**，不走依賴注入 |

[**建議：B**。上表前兩個呼叫端都是「注入後存成成員變數」——選 A 改成每次建立，這兩處仍然只在建構時拿一次，等於白改，還得連它們一起動。選 B 把「去哪拿憑證」關進 `GoogleOAuthClient` 內部，外面四個接點一字不動。至於那支離線腳本，見下方接入點表的處置建議。]{.rec}
:::

---

## 現況接入點盤點 {#touchpoints nav="接入點"}

::: {.callout .warn}
**下表每一列都經過實檔查證**（行號為 2026-09-17 當下）。本案動到的每一處都在這張表上；沒在表上的就是不動。
:::

| 元件 | 檔案／位置 | 現況 | 本案動作 |
|---|---|---|---|
| 設定值宣告 | `config/config.py:160-170` | 四項憑證從環境變數讀，預設空字串 | **保留**（降為後備層），註解改寫說明現在的真相來源是資料庫 |
| 依賴注入單例 | `di_containers/cloud_integration/cloud_integration_containers.py:107-112` | `GoogleOAuthClient` 註冊成單例，三參數開機時灌入 | **改**，做法待 D12 定案 |
| OAuth 客戶端 | `infra/cloud_integration/google_drive/google_oauth_client.py:33` | `__init__(client_id, client_secret, redirect_uri)` 三個值存成成員 | 若採 D12-B：內部改成呼叫時解析 |
| 通行證管理 | `domain/cloud_integration/service/google_drive_token_manager.py:41,45` | 注入 `GoogleOAuthClient` 後存成 `self._oauth` | D12-B 下**不動** |
| 整合服務 | `app/cloud_integration/service/google_drive_integration_service.py:53,61` | 同上 | D12-B 下**不動** |
| 同步排程 | `core/scheduler.py:155-167` | `drive_sync_worker` 每 5 秒跑，`system_context("drive_sync_worker")` | **驗證**它讀憑證時走的是繞過隔離的讀取器（見上方紅框） |
| 通知頻道續約 | `core/scheduler.py:187-204` | `webhook_channel_renewer` 每 6 小時，明說要掃全部租戶 | 同上 |
| 授權網址端點 | `api/cloud_integration/__init__.py:34` → `/api/1.0/integrations/google-drive/auth-url` | 產生 Google 授權網址 | **不動**（憑證由下層解析） |
| 回呼端點 | `api/cloud_integration/__init__.py:35` → `/api/1.0/integrations/google-drive/callback` | 接 Google 送回的授權碼 | **不動**；此路徑即回呼網址的固定尾段 |
| 離線分類腳本 | `scripts/evidence/classify/classify_evidence_drive.py:190-194` | 自己 `GoogleOAuthClient(...)`，三個值 `os.environ[...]` **直讀且無預設**（缺了直接 KeyError） | **建議跟改**成走解析器——否則資料庫設好之後這支腳本仍然只認環境變數，而且缺變數的失敗訊息是 `KeyError`，看不出所以然。**列為子任務，不是必做**（該腳本屬 FR-107 待刪的舊 Drive 分類線，見 `followup_fr107_remove_legacy_drive_classification_line`） |
| 前端整合卡片 | `compliance-manager-fe/src/components/integrations/GoogleDriveIntegrationCard.vue` | 租戶層的「連接 Google Drive」入口 | **建議加**：平台層憑證未設定時顯示「系統尚未完成 Google Drive 應用程式設定，請聯繫平台管理員」，而不是讓人按下去撞 Google 的錯誤頁 |
| 平台管理員旗標 | `common/constant/ai_service_config.py` ↔ `compliance-manager-fe/src/config/aiServiceConfig.js` | AI 服務設定的開放範圍旗標，前後端各一份、**必須成對改** | 本案比照新增一組同型常數（Drive 版）；兩邊一起建、一起改 |
| 選單登記 | `scripts/sql/2026-09-17-fr107-ai-service-config-menu.sql`、`...-system-menu-regroup.sql` | AI 服務設定掛 `pid=47`、`sort=37`；儲存設備設定 `sort=36` | **新增一支 migration**：本頁掛 `pid=47`、`sort=39`，能力點沿用 `storage-config.{read,update}`（read=ALL / update=ANY） |
| 環境變數範本 | `.env.sample:145-153` | 有 `GOOGLE_DRIVE_OAUTH_*` 三項，**缺 `DRIVE_WEBHOOK_PUBLIC_BASE_URL`** | 補上缺的那項 ＋ 四項加註「改由畫面設定，此處為開發機後備」 |

---

## 資料模型與 API 草案 {#model-api nav="資料與 API"}

### 資料庫存的東西長什麼樣

假設 D9 選 A（進 `system_configs`），一列即可：

| 欄位 | 值 |
|---|---|
| `group` | `GOOGLE_DRIVE_APP_CONFIG` |
| `key` | `CONFIG` |
| `tenant_id` | ROOT 租戶（全站共用一份） |
| `value` | 見下方 JSON |

```json
{
  "client_id": "<Google 給的應用程式編號，明文>",
  "client_secret": "<加密後的密文>",
  "redirect_uri_override": "",
  "webhook_base_url_override": ""
}
```

兩個 `_override` 欄位空著＝用自動算的值，填了＝以填的為準（對應 D3 的「進階」摺疊區）。

::: {.callout .crit}
**🔴 `client_secret` 存的是密文，且沒有存明文的路**

加密用 `infra/cloud_integration/crypto/fernet_crypto.py` 的 `FernetCrypto`，鑰匙是 `DRIVE_TOKEN_ENCRYPTION_KEY`。該類別建構時鑰匙為空就 `ValueError`——這是既有行為，本案刻意沿用而非放寬。

**密文在資料庫、鑰匙在主機設定檔，兩件分開放**：單獨拿到資料庫備份解不開，單獨拿到設定檔也沒有密文可解。
:::

### 三支端點

| 方法 | 路徑 | 做什麼 | 守門 |
|---|---|---|---|
| `GET` | `/api/1.0/system/config/GOOGLE_DRIVE_APP_CONFIG/CONFIG` | 讀設定。**`client_secret` 換成 `is_set: true/false`**；另回自動算出的兩個網址供畫面顯示與複製 | route `@jwt_required` ＋ service 層 `require_platform_admin()` |
| `PUT` | 同上 | 存設定。缺的密鑰從舊值補回；`clear: true` 才清除 | 同上 |
| `POST` | `/api/1.0/integrations/google-drive/verify-credentials` | 驗證憑證（D5） | 同上 |

讀取回應範例（密鑰已遮罩）：

```json
{
  "status": true,
  "data": {
    "client_id": "1234567890-abcdefg.apps.googleusercontent.com",
    "client_secret": { "is_set": true },
    "redirect_uri": "https://guidant.example.com/api/1.0/integrations/google-drive/callback",
    "redirect_uri_is_override": false,
    "webhook_base_url": "https://guidant.example.com",
    "webhook_base_url_is_override": false
  }
}
```

### 驗證憑證怎麼判（D5 的實作原理）

拿一個**故意造假**的授權碼去打 Google 的換票端點 `https://oauth2.googleapis.com/token`，看 Google 回哪一種錯：

| Google 回什麼 | 代表 | 畫面顯示 |
|---|---|---|
| `invalid_client` | 應用程式帳號或密鑰**錯了** | ❌ 憑證錯誤，請確認 client_id 與 client_secret |
| `invalid_grant` | 帳號密鑰**是對的**（Google 認得它），只是那個授權碼假的 | ✅ 憑證正確 |
| 連不上 / 逾時 | 主機出不了外網 | ⚠️ 無法連線至 Google，請確認主機對外網路 |

::: {.callout .warn}
**這顆按鈕驗不到回呼網址**

Google 在換票這一步不會去比對回呼網址是否已登記——那是在**產生授權網址**那一步才檢查的。所以「憑證正確」只代表帳號密鑰對，不代表客戶已經把回呼網址貼進 Google 後台。畫面上驗證成功的提示要一併寫明「別忘了把下方回呼網址貼進 Google Cloud Console 的已授權重新導向 URI」。
:::

### 端到端時序

```{.mermaid cap="圖 2 — 從工程師填設定，到租戶授權時憑證被讀出來的完整路徑"}
%%{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 A as 平台管理員
    participant FE as 設定頁面
    participant BE as 後端設定服務
    participant DB as 資料庫
    participant G as Google
    participant T as 租戶管理員

    A->>FE: 開啟「Google Drive 應用程式設定」
    FE->>BE: GET 讀設定
    BE->>BE: 檢查是不是平台管理員
    BE->>DB: 讀 ROOT 那一列
    BE-->>FE: client_id 明文／密鑰只回「已設定」<br/>回呼網址（自動算）
    A->>FE: 貼上 client_id 與 client_secret
    A->>FE: 按「驗證憑證」
    FE->>BE: POST 驗證
    BE->>G: 拿假授權碼換票
    G-->>BE: invalid_grant（＝帳號密鑰是對的）
    BE-->>FE: ✅ 憑證正確
    A->>FE: 複製回呼網址 → 貼進 Google Cloud Console
    A->>FE: 按「儲存」
    FE->>BE: PUT 存設定
    BE->>BE: 密鑰加密（Fernet）
    BE->>DB: 寫入 ROOT 那一列

    Note over T,G: 之後任何租戶要接自己的 Drive
    T->>BE: 按「連接 Google Drive」
    BE->>DB: 解析憑證（ROOT → 設定檔後備）
    BE->>G: 帶著 client_id 產生授權網址
    G-->>T: 顯示 Google 同意畫面
    T->>G: 同意
    G->>BE: 帶授權碼回呼
    BE->>G: 換通行證
    BE->>DB: 通行證加密後存租戶那張表
```

---

## 拆分草案 {#breakdown nav="拆分草案"}

::: {.callout .warn}
**本段是草案，等 D8–D12 定案後才開卡。** 子任務顆粒度、依賴關係可能隨決策調整。
:::

| 子需求 | 子任務 | 做什麼 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| **.1 存讀地基** | T-1.1 | 設定群組 ＋ 遮罩規則 ＋ 守門（`GuardedSystemConfigService` 加 `GOOGLE_DRIVE_APP_CONFIG` 分支） | 用平台管理員讀設定回 `is_set`；用租戶管理員讀回 403 | — |
| | T-1.2 | 憑證解析器（ROOT → 設定檔後備）＋ 加密解密 | 資料庫塞值 → 解析器回資料庫的；清空 → 回設定檔的 | T-1.1 |
| | T-1.3 | 依賴注入接線改造（D12） | 改資料庫設定後**不重啟**，授權網址帶的是新 `client_id` | T-1.2 |
| | T-1.4 | 背景排程讀取路徑驗證（走繞隔離讀取器） | 停掉環境變數只留資料庫，同步排程照常運作 | T-1.2 |
| **.2 API** | T-2.1 | 讀 / 存兩支端點（含「缺的密鑰從舊值補回」與 `clear` 旗標） | 只改網址存檔，密鑰不被抹掉 | T-1.1 |
| | T-2.2 | 驗證憑證端點（D5） | 錯的密鑰回「憑證錯誤」，對的回「憑證正確」 | T-1.2 |
| | T-2.3 | 自動算網址（D8 定案後） | 回呼網址與端點實際路徑一致 | D8 |
| **.3 畫面** | T-3.1 | 設定頁面（四／五欄 ＋ 複製鈕 ＋ 驗證鈕 ＋ 進階摺疊區） | 平台管理員看得到、租戶管理員看不到 | T-2.1 |
| | T-3.2 | 換 `client_id` 的確認框（D6） | 改動 `client_id` 按存檔會跳確認 | T-3.1 |
| | T-3.3 | 選單 migration（`pid=47`、`sort=39`、沿用能力點） | 側邊選單「系統設定」底下出現新頁 | T-3.1 |
| | T-3.4 | 租戶層卡片的「尚未設定」提示 | 清空平台憑證後，租戶頁顯示提示而非讓人撞錯誤頁 | T-1.2 |
| **.4 設定檔退場** | T-4.1 | `.env.sample` 補缺項 ＋ 四項加註 ＋ 部署文件 ＋ 安裝包 `guidant.env` 說明 | 打開範本看得懂哪些改到畫面設 | .3 驗收通過 |
| | T-4.2（選配） | 離線分類腳本改走解析器 | 只在資料庫設定、不設環境變數時腳本仍可跑 | T-1.2 |

---

## 風險與陷阱 {#risks nav="風險"}

::: {.callout .crit}
**🔴 一：背景排程讀 tenant-scoped 設定會非確定性挑到別人的**


`drive_sync_worker`（5 秒一次）與 `webhook_channel_renewer`（6 小時一次）都沒有登入者脈絡。裸呼叫依賴隔離機制收斂的單筆查詢，挑到哪一列**看資料列 id 排序**。

本案的憑證雖然只存 ROOT 一份、不像儲存設定那樣每租戶一列，但**讀取路徑必須明確指定 ROOT**，不能仰賴「反正只有一列」——那是資料現況不是程式約束，日後有人加第二列就靜默錯。

實作一律走 `read_root_config_value()`。驗收方式：把排程的讀取路徑打開來看，不是看功能有沒有壞（壞不壞看骰子）。
:::

::: {.callout .crit}
**🔴 二：換了 `client_id`，所有租戶的既有授權全部失效**

Google 的授權通行證是綁應用程式帳號發的。換帳號＝舊通行證在新帳號底下無效，**但資料庫裡那些通行證還在、狀態還顯示「已連接」**。症狀是同步排程開始大量失敗、使用者以為還連著。

D6 決定只警告不自動改狀態，所以確認框的文案要寫得夠白話：不是「確定要儲存嗎」，而是「更換應用程式帳號後，**所有已連接 Google Drive 的客戶都需要重新授權一次**，確定要更換嗎？」
:::

::: {.callout .warn}
**三：回呼網址與 Google 後台登記的不一致**

Google 會**逐字比對**。差一個結尾斜線、差 `http` 與 `https`、走反向代理後對外網址與後端自己算出來的不同——都會在使用者按下授權後被 Google 擋，錯誤頁是 Google 的英文頁，看不出是我們這邊設定的問題。

這正是 D3 要「自動算 ＋ 複製鈕」而不是讓人手打的原因，也是 D8 建議選 C（明確填對外網址）而非推導的原因。
:::

::: {.callout .warn}
**四：`.env.sample` 缺 `DRIVE_WEBHOOK_PUBLIC_BASE_URL`**

實查確認缺。`config.py:164-170` 記著同型的坑：這個變數曾經「config 沒定義」，於是依賴注入永遠取到 `None`，症狀是註冊通知頻道時拋 `'NoneType' object has no attribute 'rstrip'`，而且**在設定檔補這個變數也無效**——排查時極易誤判成環境沒設好。config 已修，範本至今沒補，第④階段一起收。
:::

::: {.callout .warn}
**五：平台管理員旗標前後端必須成對改**

AI 服務設定用一組前後端各一份的常數控制「這頁只給平台管理員」：後端 `common/constant/ai_service_config.py` 的 `AI_PROVIDER_CONFIG_PLATFORM_ADMIN_ONLY`，前端 `src/config/aiServiceConfig.js` 的 `AI_SERVICE_CONFIG_PLATFORM_ADMIN_ONLY`。

**只改前端＝畫面開了但 API 仍 403；只改後端＝API 開了但畫面進不去。** 本案新增同型的一組（Drive 版），兩邊一起建，並在各自檔頭互指對方。
:::

::: {.callout .warn}
**六：能力點不新開，但別忘了它擋不住租戶管理員**

沿用 `storage-config.{read,update}` 的代價是：那顆能力點**已經配給每個租戶的管理員角色**，單看能力點租戶管理員是過關的。所以能力點之後必須再套一層平台管理員判定（D7），**這一層才是真正的門**。

FR-107 的 CM-1867 第二輪驗收就是在這裡被退回的——以為掛了能力點就安全。
:::

