---
title: "Google Drive 應用程式設定 — 設計文件 (FR-110)"
brand: "Guidant AI · **FR-110** Google Drive 應用程式設定"
eyebrow: "FR-110 · Drive OAuth 憑證從環境變數搬進 UI · 設計文件 · 2026-09-17（D1–D12 全數定案）"
h1: "Drive 憑證搬進畫面 — 設計定案"
lede: "把「本系統在 Google 登記的那組應用程式帳號」從主機設定檔搬到畫面上：**畫面填、存資料庫、密鑰加密落庫**，外加一顆「驗證憑證」按鈕當場告訴你填對沒有。整套機制照抄 **FR-107 T-5.5 的 AI 服務設定**，不發明新做法。本頁是設計決策的落點——十二項決策各自寫明選了什麼、為什麼選、排除了什麼。"
chips: [
  {text: "D1–D12 全數定案", kind: ok},
  {text: "4 子需求 · 11 子任務", kind: accent},
  {text: "跨 2 repo：BE／FE ＋ 選單 migration", kind: accent},
  {text: "先例：FR-107 T-5.5 AI 金鑰 DB 化", kind: accent},
  {text: "陷阱：背景排程無租戶脈絡，必走繞 RLS 讀取", kind: crit},
  {text: "陷阱：平台管理員旗標前後端成對改", kind: crit}
]
footer: "FR-110 · Google Drive 應用程式設定 — 設計文件 · 2026-09-17 · 前身 FR-070（Drive 半邊）· 先例 FR-107 T-5.5 · 沿革見 LOG 與 git log"
---

> 狀態：**設計定案（D1–D12 全數拍板）**｜日期：2026-09-17｜前身：FR-070 白話需求（Drive 半邊）
> 討論稿（含選項與被排除方案的完整推演）：[`discussion.html`](./discussion.html)
> 先例：FR-107 T-5.5 AI 服務設定（`docs/features/FR-107-2609-evidence-classification-v2/`）

> 🔴 **這份是設計決策，不是現況。** 內文的行號與待辦停在 2026-09-17 定稿當時，之後實作改動的結果**不回頭改寫這裡**——現況一律看 `FINAL-SPEC.md`（實作完成後產出）。

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

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-09-17 | 初版設計定案。D1–D7 隨討論稿拍板，D8–D12 於本日一併裁定（全採討論稿建議案）。含資料模型、解析器、三支端點、前端版型、選單 migration、設定檔退場與 11 張子任務拆分。 | FR-110 母案 |

---

## 1. 需求背景與端到端流程 {#background nav="背景"}

### 為什麼現在不夠用

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

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

### 兩層授權，本案只做上面那層

Google Drive 整合要能運作需要兩層授權：

- **第一層（本案）**：「Guidant AI 這個軟體本身，在 Google 那邊登記過、拿到一組應用程式帳號」。這組帳號**全站共用一份**，目前寫死在主機設定檔。
- **第二層（不動）**：「每位客戶拿自己的 Google 帳號按下授權，把自己的雲端硬碟接過來」。這層早就有畫面（雲端空間整合頁）。

沒有第一層，第二層的「連接 Google Drive」按下去必然失敗。本案順帶讓這個失敗變得看得懂（見 §5.6 與 §8 風險二）。

### 哪幾項搬、哪幾項留

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

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

### 端到端流程

```
平台管理員登入 → 側邊選單「系統設定 → Google Drive 應用程式設定」
  → 填 client_id / client_secret / 系統對外網址
  → 按「驗證憑證」→ 當場知道帳號密鑰對不對
  → 複製畫面上算好的回呼網址 → 貼進 Google Cloud Console
  → 按「儲存」→ 密鑰加密落庫（ROOT 那一列）
  → 之後任何租戶按「連接 Google Drive」→ 憑證由解析器讀出（資料庫優先、設定檔後備）
  → 不必重啟服務
```

---

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

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

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

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

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

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

---

## 3. 決策定案（D1–D12） {#decisions nav="決策定案"}

::: {.callout .decided}
**十二項全部拍板**

下表每一列都寫明：定案內容、選它的理由、被排除的方案與排除原因。
:::

### D1 — FR 編號

| 項目 | 內容 |
|---|---|
| **定案** | 新開 **FR-110**，只做 Drive 半邊 |
| **理由** | 前身 FR-070 是「AI 金鑰 ＋ Drive 憑證」兩件一起提的白話需求。AI 那半邊已隨 FR-107 T-5.5 落地，剩下的 Drive 半邊性質完整、可獨立驗收，續掛 FR-070 會讓一個已完成一半的需求永遠處於未結案狀態 |
| **被排除** | 續作 FR-070 — 該案的文件與卡片已按 AI 金鑰的脈絡寫成，Drive 半邊塞回去會讓兩件事的驗收條件混在同一張母卡 |

### D2 — 頁面形式

| 項目 | 內容 |
|---|---|
| **定案** | **新開一頁**「Google Drive 應用程式設定」，掛「系統設定」群組（`pid=47`、`sort=39`），與「AI 服務設定」並列同守門 |
| **理由** | 與租戶層的「雲端空間整合」頁受眾不同、權限不同。並列 AI 服務設定是因為兩者本質相同：都是「填連線資訊與金鑰」 |
| **被排除** | 併進既有的「雲端空間整合」頁加一個分頁 — 那頁是租戶管理員的地盤，平台層欄位混進去會讓租戶管理員看到不該看的設定，且要在同一頁維護兩套守門 |

### D3 — 畫面欄位

| 項目 | 內容 |
|---|---|
| **定案** | ① `client_id` 手填 ② `client_secret` 手填 ③ **系統對外網址**手填（D8 新增）④ **回呼網址**自動算、唯讀顯示 ＋ 複製鈕 ⑤ **通知用對外網址**自動算、唯讀顯示 ＋ 複製鈕。④⑤ 另有「進階」摺疊區可手動覆寫，**預設摺疊** |
| **理由** | 回呼網址必須跟客戶在 Google 後台登記的**一字不差**，所以要能一鍵複製貼過去；但它其實是算得出來的（對外站台位址 ＋ 固定路徑），讓人手打只會打錯。留覆寫是給反向代理、非標準路徑等特殊部署用 |
| **被排除** | 回呼網址也讓人手填 — Google 逐字比對，差一個結尾斜線就被擋，而錯誤只在使用者按授權那一刻才浮現、且顯示的是 Google 的英文頁 |

### D4 — 密鑰加密

| 項目 | 內容 |
|---|---|
| **定案** | `client_secret` 用**既有**的 `DRIVE_TOKEN_ENCRYPTION_KEY` 加密落庫，**不新增環境變數**。鑰匙是空的就直接拋錯，**沒有存明文這條路** |
| **理由** | Drive 這條線本來就強制要有這把鑰匙（`FernetCrypto.__init__` 空值直接 `ValueError`），使用者的授權通行證也是用它加密的。刻意與 AI 金鑰那把不同：AI 那把允許「空鑰匙＝不加密」給開發機方便，這裡不給——因為這條線沒有「鑰匙可以不設」的狀態 |
| **被排除** | 新開一把 Drive 應用程式專用金鑰 — 多一把要多一次裝機生成、多一處文件、多一個忘了設的機會，而它保護的東西與既有那把在同一條信任邊界內；沿用既有的「空鑰匙＝不加密」寬容路徑 — 那會產生「資料庫裡是明文但沒人知道」的狀態 |

### D5 — 驗證憑證按鈕

| 項目 | 內容 |
|---|---|
| **定案** | **要做**。拿一個假的授權碼去打 Google 的換票端點，依 Google 回的錯誤碼判三態。一支 POST 端點 |
| **理由** | 這是全案最有感的一顆按鈕。目前填錯要等使用者去按授權才發現，而且看到的是 Google 的錯誤頁 |
| **被排除** | 不做驗證、靠文件叮嚀 — 等於把「填錯了怎麼辦」整個留給客戶，而症狀出現的地方（租戶按授權）離原因發生的地方（平台填設定）隔了好幾天與好幾個人 |

### D6 — 換憑證的處理

| 項目 | 內容 |
|---|---|
| **定案** | **只警告，不自動改**各租戶的連線狀態。畫面在存檔前若偵測到 `client_id` 有變更，跳確認框說明「所有租戶需重新授權」。後端只存不擋 |
| **理由** | 換了應用程式帳號，舊的授權通行證在新帳號底下是無效的。但自動把所有租戶標成「未連接」風險太大——改錯一個字就把全站連線清掉，且這個動作不可逆 |
| **被排除** | 存檔時自動把所有租戶的連線狀態標成失效 — 破壞力與誤觸機率不成比例；完全不提醒 — 症狀是同步排程開始大量失敗而畫面還顯示「已連接」，沒人知道為什麼 |

### D7 — 守門

| 項目 | 內容 |
|---|---|
| **定案** | 路由只掛 `@jwt_required`，**守門下沉到 service 層**呼叫 `require_platform_admin()`（軸①，`common/authz/platform.py` → `jedi_iam.authz.platform`）。選單能力點**沿用 `storage-config.{read,update}`，不新開** |
| **理由** | 完全照 AI 服務設定的做法。不新開能力點：新開一顆要 seed 到每個既有租戶的管理員角色，**漏掉的症狀是那個租戶永遠 403 且沒有任何錯誤訊息** |
| **被排除** | 新開 `drive-app-config.{read,update}` 能力點 — 為四個欄位增加一次全租戶 seed 的風險；只靠能力點不加平台管理員判定 — 沿用的那顆已配給每個租戶管理員，單看能力點租戶管理員是過關的（見 §8 風險六） |

### D8 — 自動算出來的網址從哪裡推導

| 項目 | 內容 |
|---|---|
| **定案** | **新增一個欄位「系統對外網址」**（`public_base_url`），由本頁一起填，存在同一列設定內。回呼網址與通知用對外網址都由它推導 |
| **理由** | 部署的對外網址是一件明確的事實，不該靠推導。推導錯的兩種方式**都不會報錯**：症狀是使用者按授權後被 Google 擋、或是檔案變動通知永遠收不到，兩者都極難追。多一個欄位換掉兩種靜默失敗划算 |
| **被排除** | **A：用既有的 `FRONTEND_BASE_URL`** — 語意不對，那是**前端**站台位址，而 Google 要打的是**後端** API；同網域時剛好對，分開部署時靜默錯。**B：用當次請求的 `Host` / `X-Forwarded-Host` 標頭** — 值取決於「管理員是從哪個網址進來設定的」，走反向代理或用內網 IP 進來設定時算出來的網址對外不可達，而且**看起來完全正常** |
| **附帶約束** | 這個欄位在本案屬於 `GOOGLE_DRIVE_APP_CONFIG` 這組設定。日後若有第二個功能也需要「系統對外網址」，應把它升格成獨立的系統設定群組再讓兩邊共用，**而不是各自複製一份** |

### D9 — 這組設定存在哪張表

| 項目 | 內容 |
|---|---|
| **定案** | 進共用的 `system_configs` 表，**新開一個群組 `GOOGLE_DRIVE_APP_CONFIG`** |
| **理由** | AI 金鑰（`AI_PROVIDER_CONFIG`）、SMTP、議題整合設定都在這張表，遮罩與守門的殼 `GuardedSystemConfigService` 已經長好，本案只要加一個群組名與對應的遮罩規則。零新表、零新 repository、零新 domain service |
| **被排除** | 新開一張專屬表 — 欄位型別明確、可加約束，但要新 migration、新 repository、新 domain service，且與 AI 金鑰的做法分岔；為四個欄位開一整套 DDD 分層不划算 |

### D10 — 密鑰怎麼回給前端、怎麼寫回去

| 項目 | 內容 |
|---|---|
| **定案** | **照抄 AI 金鑰的三條規則，一字不改**：讀＝密鑰換成 `is_set` 旗標；寫＝沒送密鑰就沿用舊值；清除＝必須顯式帶 `clear_client_secret: true` |
| **理由** | 這三條不是風格選擇，是踩出來的。底層寫入是整格覆寫、不補「缺的從舊值補回」這一步，使用者只改網址按存檔密鑰就被抹掉，**而畫面顯示「儲存成功」**，要等下次有人按授權失敗才浮現。而只送空字串就清除的設計，會讓「使用者清空輸入框但其實只想改別的欄位」變成一次不可逆的刪除 |
| **被排除** | 讀取時回明文（方便前端顯示） — 密鑰一旦進了瀏覽器就等於在每一台管理員電腦上多存一份；空字串即清除 — 與「沒送＝沿用」語意衝突，兩者無法並存 |

### D11 — 安裝版裝機時要不要把設定檔的值寫進資料庫一次

| 項目 | 內容 |
|---|---|
| **定案** | **不 seed**，純靠環境變數後備 |
| **理由** | 兩者功能上等價（後備那層照樣讀得到），但語意差很多——「資料庫裡有＝有人設過」是個乾淨的判準，日後要做「未開通」提示、要判斷該不該跳引導畫面，都靠它。而且 Drive 這組帳號與 AI 金鑰的性質不同：AI 金鑰是原廠掏錢買來送客戶用的，Drive 應用程式帳號通常是客戶自己去 Google 申請的 |
| **被排除** | 裝機時把環境變數的值 seed 進資料庫（AI 金鑰的做法） — 裝完就顯示「已設定」，但沒人填過也顯示「已設定」，分不出「原廠給的」與「客戶自己填的」，於是「未開通提示」這個功能失去判斷依據 |

### D12 — 現有的依賴注入單例怎麼改

| 項目 | 內容 |
|---|---|
| **定案** | **保留單例**，把「去哪拿憑證」關進 `GoogleOAuthClient` 內部：建構時注入解析器，每支對外方法一開頭先向解析器拿當次憑證 |
| **理由** | 四個呼叫端一字不用改。而且改法把「憑證從哪來」收斂成一個檔內的一件事，不是散在四個注入點 |
| **被排除** | **改成每次取用都重新建立（Factory）** — 兩個主要呼叫端（整合服務、通行證管理）都是「注入後存成成員變數」，改成每次建立它們仍然只在建構時拿一次，**等於白改**，還得連它們一起動 |

---

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

::: {.callout .warn}
**下表每一列都經過實檔查證**

行號為 2026-09-17 當下。本案動到的每一處都在這張表上；沒在表上的就是不動。
:::

| 元件 | 檔案／位置 | 現況 | 本案動作 |
|---|---|---|---|
| 設定值宣告 | `config/config.py:159-170` | 四項憑證從環境變數讀，預設空字串 | **保留**（降為後備層），註解改寫說明真相來源是資料庫 |
| 依賴注入單例 | `di_containers/cloud_integration/cloud_integration_containers.py:107-112` | `GoogleOAuthClient` 註冊成單例，三參數開機時灌入 | **改**：三個參數換成注入解析器（D12） |
| 加密服務 | 同上 `:100-104` | `token_crypto_service` 已是 `FernetCrypto` 單例，鑰匙為 `DRIVE_TOKEN_ENCRYPTION_KEY` | **沿用**，本案的密鑰加解密直接取它 |
| OAuth 客戶端 | `infra/cloud_integration/google_drive/google_oauth_client.py:33` | `__init__(client_id, client_secret, redirect_uri)` 三個值存成成員 | **改**：內部改成呼叫時解析（D12） |
| 通行證管理 | `domain/cloud_integration/service/google_drive_token_manager.py:41,45` | 注入 `GoogleOAuthClient` 後存成 `self._oauth` | **不動** |
| 整合服務 | `app/cloud_integration/service/google_drive_integration_service.py:53,61` | 同上 | **不動**（唯一例外：加「憑證未設定」前置檢查，見 §5.5） |
| 同步排程 | `core/scheduler.py:155-167` | `drive_sync_worker` 每 5 秒跑，`system_context("drive_sync_worker")` | **驗證**它讀憑證時走的是繞 RLS 的讀取器（見 §8 風險一） |
| 通知頻道續約 | `core/scheduler.py:187-204` | `webhook_channel_renewer` 每 6 小時，明說要掃全部租戶 | 同上 |
| 通知頻道註冊 | `app/cloud_integration/service/webhook_channel_manager.py:52-69,111-127` ＋ `di_containers/cloud_integration/cloud_integration_containers.py:151-157` | 組給 Google 的通知網址，對外位址於服務啟動時由環境變數灌入、之後不再變 | **改**：改成呼叫時向解析器要；位址未設定時擋下註冊 |
| 授權網址端點 | `api/cloud_integration/routes/google_drive_integration_route.py:67-83` → `POST /api/1.0/integrations/google-drive/auth-url` | 產生 Google 授權網址 | **不動路由**；service 層加前置檢查回 412 |
| 回呼端點 | `api/cloud_integration/__init__.py:35` → `/api/1.0/integrations/google-drive/callback` | 接 Google 送回的授權碼 | **不動**；此路徑即回呼網址的固定尾段 |
| 設定讀寫殼 | `app/system_config/service/guarded_system_config_service.py`（478 行） | 已有 `AI_PROVIDER_CONFIG` 的遮罩／合併／平台管理員判定三段 | **加一組同型分支**給 `GOOGLE_DRIVE_APP_CONFIG` |
| ROOT 繞 RLS 讀取器 | `infra/system_config/system_config_root_reader.py` | `read_root_config_value(group, key)` 已存在 | **沿用**（解析器與背景排程都走它） |
| 泛用設定端點 | `api/system_config/routes/system_config_route.py:117-160` → `/api/1.0/system/config/<group>/<key>` | GET／PUT／DELETE 三個方法，PUT 走 `assert_config_capability(group, "update")` | **沿用**，不新開讀寫端點（見 §5.5） |
| 能力點分流表 | `core/plugins/system_core.py:62-76` | `_GROUP_CAPABILITY_RESOURCE` **沒有收 `AI_PROVIDER_CONFIG`** | **照 AI 現況不掛表**（詳見 §5.5 的紅框） |
| 離線分類腳本 | `scripts/evidence/classify/classify_evidence_drive.py:190-194` | 自己 `GoogleOAuthClient(...)`，三個值 `os.environ[...]` **直讀且無預設**（缺了直接 KeyError） | **選配子任務**跟改成走解析器（該腳本屬待刪的舊 Drive 分類線） |
| 前端整合卡片 | FE `src/components/integrations/GoogleDriveIntegrationCard.vue` | 租戶層的「連接 Google Drive」入口 | **加**：平台層憑證未設定時顯示提示，不讓人按下去撞 Google 的錯誤頁 |
| 平台管理員旗標 | `common/constant/ai_service_config.py` ↔ FE `src/config/aiServiceConfig.js` | AI 服務設定的開放範圍旗標，前後端各一份、**必須成對改** | **新增同型的一組**（Drive 版），兩邊一起建、檔頭互指 |
| 前端路由 | FE `src/config/router/index.js:256-272` | AI 服務設定掛 `meta.requiresPlatformAdmin: true` | **加一段同型路由** |
| 前端 i18n 註冊 | FE `src/config/locales/index.js:77,153,235,313` | AI 服務設定的 zh-tw／en 各一支專屬 json，於此 import 並展開 | **加一組同型** |
| 選單登記 | `scripts/sql/2026-09-17-fr107-ai-service-config-menu.sql` ＋ `...-system-menu-regroup.sql`（`sort=37`）＋ `...-cloud-integrations-regroup.sql`（`sort=38`） | 系統設定群組 `pid=47` 目前已排到 `sort=38` | **新增一支 migration**：本頁掛 `pid=47`、`sort=39` |
| 環境變數範本 | `.env.sample:145-153` | 有 `GOOGLE_DRIVE_OAUTH_*` 三項，**缺 `DRIVE_WEBHOOK_PUBLIC_BASE_URL`** | 補上缺的那項 ＋ 四項加註「改由畫面設定」 |

---

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

### 5.1 資料模型

一列即可，存在共用的 `system_configs` 表：

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

```json
{
  "client_id": "Google 給的應用程式編號（明文）",
  "client_secret_encrypted": "Fernet 加密後的密文",
  "public_base_url": "https://guidant.example.com",
  "redirect_uri_override": null,
  "webhook_base_url_override": null
}
```

**推導規則**（兩個 `_override` 為 `null` 或空字串時生效）：

| 推導值 | 公式 | 實際結果範例 |
|---|---|---|
| 回呼網址 | `public_base_url` 去掉結尾斜線 ＋ `/api/1.0/integrations/google-drive/callback` | `https://guidant.example.com/api/1.0/integrations/google-drive/callback` |
| 通知用對外網址 | `public_base_url` 去掉結尾斜線 | `https://guidant.example.com` |

::: {.callout .crit}
**🔴 回呼路徑是一個字串常數，不可兩處各寫一份**

`/api/1.0/integrations/google-drive/callback` 這串同時是**路由註冊的值**（`api/cloud_integration/__init__.py`）與**推導公式的尾段**。兩處各寫一份的話，日後有人改路由而忘了改推導，症狀是畫面上顯示的回呼網址與實際能接的網址不同——而使用者把畫面上那串貼進 Google 後台，於是**Google 逐字比對永遠過不了**，錯誤頁還是英文的。

實作時把尾段抽成一個模組層常數，路由註冊與推導函式都引用它。
:::

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

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

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

⚠️ 欄位名刻意帶 `_encrypted` 尾綴，與 AI 金鑰那邊的裸 `api_key` 不同——看到這個名字的人就知道裡面不是明文，不會寫出「直接把這個值拿去打 Google」的程式碼。
:::

### 5.2 憑證解析器 `GoogleDriveAppConfigResolver`

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

**兩層，順序固定**：

1. **資料庫的 ROOT 租戶設定** — 走 `read_root_config_value("GOOGLE_DRIVE_APP_CONFIG", "CONFIG")`
2. **環境變數後備** — 從 `config` 讀四項原有變數

::: {.callout .crit}
**🔴 兩層之間是「整組切換」，不是逐欄位各自後備**

**定案：資料庫那一列只要有 `client_id`，整組就用資料庫的**——資料庫裡缺的欄位視為「未設定」，**不去環境變數撿**。資料庫沒有 `client_id` 時才整組退回環境變數。

**為什麼不逐欄位各自後備**：混用時畫面上顯示的與實際生效的會不一致。設想資料庫有 `client_id` 而 `public_base_url` 空著、環境變數裡卻有一組舊的回呼網址——畫面顯示「回呼網址：（未設定）」，實際送給 Google 的卻是環境變數那串舊的。一線人員看著畫面完全推不出系統在用什麼值，而**這種不一致不會有任何錯誤訊息**。

整組切換的代價是「資料庫填一半」時功能會壞，但那個壞是**看得出來的**——畫面上就是空的，而且驗證憑證按鈕會當場說話。
:::

**對外介面**（呼叫端只認這三個）：

| 方法 | 回什麼 | 備註 |
|---|---|---|
| `resolve()` | 一個解好的設定物件：`client_id` / `client_secret`（已解密明文）/ `redirect_uri`（已套推導或覆寫）/ `webhook_base_url` | 四項都解不出來時回 `None` |
| `is_configured()` | 布林 | 供「憑證未設定」前置檢查用（§5.5） |
| `effective_urls()` | 推導後的兩個網址 ＋ 各自的 `is_override` 旗標 | 供讀取端點回給畫面顯示與複製用 |

::: {.callout .crit}
**🔴 解析器讀資料庫一律走繞 RLS 的 ROOT 讀取器**

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

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

實作一律走 `read_root_config_value()`，**不可裸呼叫依賴隔離機制收斂的單筆查詢**。同型事故：2026-07-29 POC 的檔案上傳就是這樣把某租戶的檔存進別租戶的儲存桶，而另一個環境「沒重現」純粹是資料列 id 排序恰好對了。
:::

### 5.3 加密

| 項目 | 做法 |
|---|---|
| 加密器來源 | DI 容器已有的 `token_crypto_service`（`FernetCrypto` 單例，`di_containers/cloud_integration/cloud_integration_containers.py:100-104`） |
| 鑰匙 | `DRIVE_TOKEN_ENCRYPTION_KEY`（不新增環境變數） |
| 鑰匙為空時 | **直接拋錯**（`FernetCrypto.__init__` 既有行為），不走明文路徑 |
| 寫入時機 | 存設定端點在寫入資料庫前加密 |
| 讀取時機 | 解析器在回傳明文前解密；**讀取端點永不解密**（它只回 `is_set` 旗標） |

::: {.callout .warn}
**與 AI 金鑰的解密容錯刻意不同**

AI 金鑰的 `decrypt_api_key()` 解不開時回**原值**，理由是「升級前寫進去的明文金鑰解不開是正常的，當成 None 會讓既有客戶的 AI 功能在升級當下全部失效」。

本案**沒有這個歷史包袱**——`GOOGLE_DRIVE_APP_CONFIG` 是新開的群組，裡面不可能存在加密機制之前寫的明文。故解不開就是解不開，**讓它拋出來**。抄 AI 那邊的容錯反而會把「鑰匙被換掉了」這種嚴重狀況變成「拿密文去打 Google」的無聲失敗。
:::

### 5.4 `GoogleOAuthClient` 改法（D12 落地）

**改動只在這一個檔內**，四個呼叫端一字不動。

| 現況 | 改後 |
|---|---|
| `__init__(client_id, client_secret, redirect_uri)`，三個值存成成員 | `__init__(config_resolver)`，存成 `self._resolver` |
| `build_auth_url()` 直接讀 `self.client_id` / `self.redirect_uri` | 方法開頭先 `cfg = self._resolver.resolve()`，之後讀 `cfg.client_id` / `cfg.redirect_uri` |
| `exchange_code()` 直接讀三個成員 | 同上 |
| `refresh_access_token()` 直接讀 `client_id` / `client_secret` | 同上 |
| `get_userinfo()` / `revoke()` | **不動**（這兩支只用 access token，不碰應用程式帳號） |

DI 那邊對應改成：

| 現況 | 改後 |
|---|---|
| `google_oauth_client = providers.Singleton(GoogleOAuthClient, client_id=..., client_secret=..., redirect_uri=...)` | `google_oauth_client = providers.Singleton(GoogleOAuthClient, config_resolver=drive_app_config_resolver)` |

**呼叫端清單（已 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(...)` 直讀環境變數** | **會壞**——建構簽章變了。列為選配子任務 T-4.2 |

::: {.callout .crit}
**🔴 依賴注入的簽章漂移是靜默的**

DI 的 `Singleton` / `Factory` 是 lazy——建構參數對不上**不會在啟動時炸**，只在該物件第一次被取用時才炸。改了 `__init__` 簽章必須同步改 `di_containers`，而且**驗收要實際打一次端點**（按「連接 Google Drive」），不能只看服務起得來。

同理，`classify_evidence_drive.py:190` 那支直接建構的離線腳本是**編譯期看不出來的破壞**——它不走 DI，簽章改了它就壞，而且要等有人跑那支腳本才知道。T-4.2 不做的話，至少要在該行加一句說明指向本案。
:::

### 5.5 API

三支端點，**前兩支沿用既有的泛用設定端點，不新開路由**。

| 方法 | 路徑 | 做什麼 |
|---|---|---|
| `GET` | `/api/1.0/system/config/GOOGLE_DRIVE_APP_CONFIG/CONFIG` | 讀設定（密鑰遮罩 ＋ 回推導後的網址） |
| `PUT` | 同上 | 存設定（缺的密鑰從舊值補回；`clear_client_secret: true` 才清除） |
| `POST` | `/api/1.0/integrations/google-drive/verify-credentials` | 驗證憑證（新開路由，掛在 cloud_integration 模組） |

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

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

**寫入規則**（`GuardedSystemConfigService` 加一組同型分支，照抄 `_merge_ai_provider_secrets` 的三條）：

| 前端送什麼 | 結果 |
|---|---|
| 沒有 `client_secret` 這個鍵 / 空字串 / 把 `{"is_set": true}` 原樣送回來 | **沿用舊值** |
| `client_secret` 帶非空字串 | 換新（加密後落庫） |
| `clear_client_secret: true` | **清除**（`is_set` 與 `clear_client_secret` 兩個旗標本身都不落地） |
| `client_id` 與舊值不同 | **只存，不擋**——警告由前端確認框負責（D6） |

::: {.callout .crit}
**🔴 能力點分流表的現況與直覺不同，本案照抄現況**

實查 `core/plugins/system_core.py:62-76` 的 `_GROUP_CAPABILITY_RESOURCE`：**`AI_PROVIDER_CONFIG` 並不在表內**。也就是說 AI 服務設定的**選單**綁的是 `storage-config.{read,update}`（`route_capabilities` 那一列），但**泛用設定端點**走的是 fallback 的 `system_config.{read,update}`——兩者是不同的兩道門，不是同一顆能力點。

這條路徑目前成立，是因為 `AI_PROVIDER_CONFIG_PLATFORM_ADMIN_ONLY = True`：真正的門是 service 層那句 `require_platform_admin()`，而 ROOT 的 Administrator 角色實查持有 `system_config.*`。

**本案照抄同一形狀**：`GOOGLE_DRIVE_APP_CONFIG` 同樣**不掛進分流表**，選單綁 `storage-config.{read,update}`，真正的門是 service 層的平台管理員判定。

⚠️ 這件事必須實測進驗收清單（見 §7 第 2、3 項）：用 **ROOT 平台管理員**與**一般租戶管理員**各打一次 GET/PUT，確認前者通、後者 403。照抄一個沒完全看懂的形狀是本案最可能出錯的地方。
:::

**守門**：

| 層 | 做什麼 |
|---|---|
| route | `@jwt_required()`（既有泛用端點已有）；PUT 另有既有的 `assert_config_capability(group, "update")` |
| service | `GuardedSystemConfigService` 在讀寫 `GOOGLE_DRIVE_APP_CONFIG` 時呼叫 `require_platform_admin()`，由新增的 `DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY` 旗標控制 |
| 列表隱藏 | 讀整組 / 讀列表時，非平台管理員看不到這一列（照 `_ai_provider_row_hidden` 同型處理） |

**驗證憑證端點**（D5 實作原理）：拿一個**故意造假**的授權碼去打 Google 的換票端點 `https://oauth2.googleapis.com/token`，看 Google 回哪一種錯：

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

::: {.callout .warn}
**三態一律回 HTTP 200，不用錯誤碼表達**

「憑證填錯」是這支端點的**正常結果**之一，不是請求失敗。用 4xx 表達會讓前端得先分辨「是驗證結果不好，還是我這支請求本身壞了」，而且 4xx 在瀏覽器主控台會被記成紅字，讓人以為程式出錯。

真正該拋錯誤碼的只有一種：**根本還沒填 `client_id` 就按驗證**（見下方 error code 表的 400 那列）。
:::

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

Google 在換票這一步不會去比對回呼網址是否已登記——那是在**產生授權網址**那一步才檢查的。所以「憑證正確」只代表帳號密鑰對，**不代表客戶已經把回呼網址貼進 Google 後台**。

畫面上驗證成功的提示要一併寫明：「別忘了把下方回呼網址貼進 Google Cloud Console 的『已授權的重新導向 URI』」。
:::

**擬新增 error code**（放 `common/code/flow_control_error_code.py`，與既有 `GRC_DRIVE_*` 同檔；實查該檔 400 系列已用到 `GRC_400121`、412 系列已用到 `GRC_412052`）：

| 常數 | 訊息 | 碼 | 何時拋 |
|---|---|---|---|
| `GRC_DRIVE_APP_CREDENTIAL_INCOMPLETE` | Google 應用程式設定不完整，請先填寫 client_id 與 client_secret | `GRC_400122` | 按「驗證憑證」但設定裡沒有 `client_id` 或 `client_secret` |
| `GRC_DRIVE_APP_CONFIG_NOT_SET` | 系統尚未完成 Google Drive 應用程式設定，請聯繫平台管理員 | `GRC_412053` | 租戶按「連接 Google Drive」（`auth-url` 端點）而平台憑證未設定 |

::: {.callout .crit}
**🔴 後端新增 error code 必須同步前端的 i18n 對照檔**

前端靠 `error-code.json` 把碼翻成使用者看得懂的句子。**只加後端不加前端的症狀是畫面上跳出一串代碼**，而且不會有任何錯誤——因為那就是查不到翻譯時的預設行為。兩支 code 都要在 FE 的 zh-tw 與 en 各補一行。
:::

### 5.6 前端

| 項目 | 座標 | 做什麼 |
|---|---|---|
| 頁面 | `src/views/google-drive-app-config/GoogleDriveAppConfigForm.vue` | 照 `src/views/ai-service-config/AiServiceConfigForm.vue`（253 行）的版型 |
| 服務方法 | `src/service/CloudIntegrationService.js` | 加三個方法：讀設定、存設定、驗證憑證 |
| API 常數 | `src/config/api/api.js` | 讀／存沿用既有的 `API.SYSTEM_CONFIG`（拼 `/GOOGLE_DRIVE_APP_CONFIG/CONFIG`）；**驗證端點要新增一個常數** |
| 路由 | `src/config/router/index.js` | 加一段同型路由，`meta.requiresPlatformAdmin: true`（照 `ai-service-config` 那段，`:256-272`） |
| 旗標常數 | `src/config/aiServiceConfig.js` 的同型新檔 `src/config/driveAppConfig.js` | `DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY` / `ROUTE_NAME` / `URL` 三個常數，檔頭指向後端那份 |
| i18n | `src/config/locales/i18n/zh-tw/google-drive-app-config.json` ＋ `en/` 同名 ＋ 兩份 `menu.json` 各加標題與說明 ＋ `src/config/locales/index.js` import 並展開 | 照 AI 服務設定那組（`index.js:77,153,235,313` 四處） |

**版面**：

| 區 | 內容 |
|---|---|
| 頁首說明 | 一段白話：這裡設定的是「本系統在 Google 登記的應用程式帳號」，全站共用一份；各客戶自己接 Drive 是另一頁 |
| 主要欄位 | `client_id`（一般文字框）、`client_secret`（Password 元件，已設定時顯示「已設定」與清除鈕）、系統對外網址（一般文字框，帶格式提示） |
| 唯讀顯示區 | 回呼網址、通知用對外網址，各配一顆**複製鈕**；覆寫生效時標示「（已手動覆寫）」 |
| 進階（預設摺疊） | 回呼網址覆寫、通知用對外網址覆寫兩個輸入框；清空即回到自動算 |
| 動作列 | 「驗證憑證」（不存檔，當場打 Google）、「儲存」 |
| 儲存前攔截 | `client_id` 與載入時的值不同 → 跳確認框（D6），文案：「更換應用程式帳號後，**所有已連接 Google Drive 的客戶都需要重新授權一次**，確定要更換嗎？」 |

**租戶層卡片**（`src/components/integrations/GoogleDriveIntegrationCard.vue`）：後端 `auth-url` 回 `GRC_412053` 時，卡片顯示「系統尚未完成 Google Drive 應用程式設定，請聯繫平台管理員」，並把「連接 Google Drive」按鈕停用——而不是讓人按下去撞 Google 的英文錯誤頁。

### 5.7 選單 migration

檔名：`scripts/sql/2026-09-XX-fr110-google-drive-app-config-menu.sql`（`XX` 取實作當天）。範本照 `scripts/sql/2026-09-17-fr107-ai-service-config-menu.sql`。

| 項目 | 值 | 依據 |
|---|---|---|
| `pid` | `47`（系統設定群組） | 實查 `2026-09-17-fr107-system-menu-regroup.sql`：群組 47 ＝「設定系統本身怎麼運作與對外連線」 |
| `sort` | `39` | 實查同群現況：儲存設備 36、AI 服務 37、雲端空間整合 38 → 39 為下一個空位 |
| `name` | `drive-app-config` | FE 以此取 i18n |
| `url` | `/system/drive-app-config` | FE 以此對路由 |
| `icon` | `pi pi-fw pi-google`（需先在本專案的 primeicons 版本比對確認存在） | 見下方紅框 |
| 能力點 | 沿用 `storage-config.read`（`ALL`）＋ `storage-config.update`（`ANY`） | D7 |
| 繞 RLS | 檔頭 `SET LOCAL app.is_super_admin = 't'` | `ui_routes` / `route_capabilities` 是系統層級資料 |
| 收尾 | `INSERT public.schema_migrations` | SQL migration 鐵則 |
| manifest | `scripts/sql/manifest.tsv` 加一列，`phase=active`、`envs=*` | 見下方紅框 |
| 套哪些環境 | **只套 DEV**。STG / POC 等決策者明示放行 | 環境異動鐵律 |

::: {.callout .crit}
**🔴 兩個會靜默失效的登記，一個都不能漏**

**① manifest.tsv 沒登記＝客戶端永遠套不到。** 新客戶裝的是長好的結構檔，升級客戶走 migrate 模式照 manifest 逐支套。漏登記的症狀是**開發機上一切正常、客戶那邊選單就是不出現**，而且沒有任何錯誤訊息。FR-107 的四支選單 migration 就是漏了才補登記的。

**② icon 名稱寫錯＝無聲破圖。** PrimeIcons 對不存在的 class **不報錯、只渲染成空字元**。FR-107 那支寫了 `pi-sparkles`（7.0 才加的），本專案是 6.0.1，於是選單左邊就是一塊空白。**實作時必須逐字比對 `node_modules/primeicons/primeicons.css` 的 `.pi-xxx:before` 確認存在**，不要憑印象寫。
:::

::: {.callout .warn}
**出貨基線待重產**

本檔是主線 migration（`phase=active` / `envs=*`）。新客戶裝的是 `scripts/init/02-schema.sql` 這份長好的結構、不是重放 migration——不同步則新裝客戶缺這列且無錯誤訊息。**重產基線屬決策者裁示，runner 只回報不自己動。**
:::

### 5.8 設定檔退場

| 檔案 | 動作 |
|---|---|
| `.env.sample`（Drive 段 `:145-153`） | **補上缺的 `DRIVE_WEBHOOK_PUBLIC_BASE_URL`**；四項憑證各加一行白話註明「改由畫面設定（系統設定 → Google Drive 應用程式設定），此處僅開發機後備」 |
| `docs/claude/` 部署相關文件 | 同步這四項的新設定路徑 |
| 安裝包 `guidant.env` 說明 | 同上；裝機時這四項留空是正常的（D11 不 seed） |

::: {.callout .warn}
**`.env.sample` 缺項本身就是一個踩過的坑的殘留**

`config.py:164-170` 記著：`DRIVE_WEBHOOK_PUBLIC_BASE_URL` 曾經「config 沒定義」，於是依賴注入永遠取到 `None`，症狀是註冊通知頻道那步拋 `'NoneType' object has no attribute 'rstrip'`，而且**在設定檔補這個變數也無效**（沒有任何程式碼會去讀它），排查時極易誤判成環境沒設好。config 那邊已修，範本至今沒補——本階段一起收掉。
:::

### 5.9 端到端時序

```{.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'}}}%%
sequenceDiagram
    participant A as 平台管理員
    participant FE as 設定頁面
    participant BE as 後端設定服務
    participant R 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->>FE: client_id 有變更 → 跳確認框
    FE->>BE: PUT 存設定
    BE->>BE: 密鑰加密（Fernet）
    BE->>DB: 寫入 ROOT 那一列

    Note over T,G: 之後任何租戶要接自己的 Drive（不必重啟服務）
    T->>BE: 按「連接 Google Drive」
    BE->>R: 要當次憑證
    R->>DB: 繞 RLS 讀 ROOT 那一列
    R-->>BE: client_id ＋ 解密後的密鑰 ＋ 回呼網址
    BE->>G: 帶著 client_id 產生授權網址
    G-->>T: 顯示 Google 同意畫面
    T->>G: 同意
    G->>BE: 帶授權碼回呼
    BE->>G: 換通行證
    BE->>DB: 通行證加密後存租戶那張表
```

---

## 6. 拆分（4 子需求 × 11 子任務） {#breakdown nav="拆分"}

::: {.callout .decided}
**每張卡都寫成「新 session 不讀其他文件就能開工」的厚度**

做什麼、動哪些檔、驗收怎麼判、依賴誰，四項都寫。單張預估一個 session 內可完成。
:::

### FR-110.1 存讀與解析（地基）— 依賴：無

#### T-1.1 設定群組 ＋ 遮罩 ＋ 守門

- **做什麼（白話）**：讓資料庫存得下這組設定，而且讀出來時密鑰不會變成明文跑到瀏覽器上；同時確保只有平台管理員碰得到。
- **檔案座標**：
  - `app/system_config/service/guarded_system_config_service.py` — 加 `GOOGLE_DRIVE_APP_CONFIG` 的三段同型分支（遮罩 `_mask_*`、合併 `_merge_*`、平台管理員判定 `_assert_*`），照現有 `AI_PROVIDER_*` 那三段抄
  - `common/constant/drive_app_config.py`（新檔）— `DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY = True`，檔頭寫明前端有一份對應常數、兩邊要一起改
- **驗收條件**：① 用 ROOT 平台管理員打 `GET /api/1.0/system/config/GOOGLE_DRIVE_APP_CONFIG/CONFIG` 回 `client_secret: {"is_set": ...}` 而非明文；② 用一般租戶管理員打同一支回 403；③ 讀整組設定列表時，租戶管理員看不到這一列。
- **依賴**：無。

#### T-1.2 憑證解析器 ＋ 加解密

- **做什麼（白話）**：寫一支「去哪拿 Drive 憑證」的解析器——先看資料庫的 ROOT 設定，沒有才回頭看主機設定檔；密鑰存進去要加密、拿出來要解密。
- **檔案座標**：
  - `app/system_config/service/google_drive_app_config_resolver.py`（新檔）— 形狀照 `ai_provider_key_resolver.py`，但**砍掉租戶層**、**改成整組切換**（見 §5.2 紅框）
  - 讀資料庫一律走 `infra/system_config/system_config_root_reader.read_root_config_value`
  - 回呼路徑常數：抽出來讓路由註冊與推導公式共用（見 §5.1 紅框）
- **驗收條件**：① 資料庫塞一筆 → 解析器回資料庫的值；② 清掉資料庫那列 → 解析器回設定檔的值；③ 資料庫只有 `client_id` 沒有 `public_base_url` → 回呼網址是空的而**不是**設定檔那串（驗證「整組切換」）；④ 加密後落庫的值肉眼看不是明文，解出來與原值相同。
- **依賴**：T-1.1。

#### T-1.3 `GoogleOAuthClient` 與依賴注入改造（D12）

- **做什麼（白話）**：讓 Google OAuth 客戶端每次要用憑證時才去問解析器，而不是開機時拿一次就記到關機。改完後改設定不必重啟服務。
- **檔案座標**：
  - `infra/cloud_integration/google_drive/google_oauth_client.py` — `__init__` 改收解析器；`build_auth_url` / `exchange_code` / `refresh_access_token` 三支方法開頭各加一次解析（`get_userinfo` / `revoke` 不動）
  - `di_containers/cloud_integration/cloud_integration_containers.py:107-112` — 三個參數換成注入解析器
- **驗收條件**：① 服務起得來；② **實際打一次** `POST /api/1.0/integrations/google-drive/auth-url`，回的授權網址帶的是資料庫裡那個 `client_id`；③ 直接改資料庫的 `client_id` → **不重啟**再打一次，授權網址跟著變。
- **依賴**：T-1.2。
- ⚠️ DI 是 lazy，簽章對不上不會在啟動時炸 —— 驗收②③不可省（見 §5.4 紅框）。

#### T-1.4 背景排程讀取路徑驗證

- **做什麼（白話）**：確認兩支背景排程（每 5 秒的同步、每 6 小時的通知續約）讀憑證時走的是「明確指定平台那一列」的讀法，而不是碰運氣。
- **檔案座標**：`core/scheduler.py:155-167`（`drive_sync_worker`）、`:187-204`（`webhook_channel_renewer`）；往下追到解析器的呼叫路徑
- **驗收條件**：① **開檔看讀取路徑**確認走 `read_root_config_value()`（不是看功能有沒有壞——壞不壞看資料列排序的運氣）；② 把環境變數的四項清空、只留資料庫設定，同步排程照常運作不報錯。
- **依賴**：T-1.2。

### FR-110.2 驗證與 API — 依賴：FR-110.1

#### T-2.1 讀 / 存端點 ＋ 網址推導

- **做什麼（白話）**：讓畫面讀得到設定（密鑰只顯示「已設定」）、存得進去（沒動密鑰就不要把它弄丟），並且把兩個網址算好一起回給畫面顯示與複製。
- **檔案座標**：
  - 沿用既有泛用端點 `api/system_config/routes/system_config_route.py:117-160`，**不新開路由**
  - `guarded_system_config_service.py` — 讀取時把推導後的兩個網址與 `is_override` 旗標塞進回應（`effective_redirect_uri` / `effective_webhook_base_url`）
  - 推導邏輯呼叫 T-1.2 的解析器 `effective_urls()`
- **驗收條件**：① 只改 `public_base_url` 按存檔，密鑰**沒有**被抹掉；② 帶 `clear_client_secret: true` 才清得掉；③ 把 `{"is_set": true}` 原樣送回去也視為「沿用」不當成新值；④ 讀回的 `effective_redirect_uri` 與實際 callback 路由路徑一字不差。
- **依賴**：T-1.1、T-1.2。

#### T-2.2 驗證憑證端點（D5）

- **做什麼（白話）**：做那顆「驗證憑證」按鈕的後端——拿一個假授權碼去問 Google，依它回的錯誤判「憑證對／憑證錯／連不上」三態。
- **檔案座標**：
  - `api/cloud_integration/routes/` 新增 route，註冊到 `api/cloud_integration/__init__.py`：`POST /api/1.0/integrations/google-drive/verify-credentials`
  - `app/cloud_integration/service/` 對應 app service 方法，守門呼叫 `require_platform_admin()`
  - `common/code/flow_control_error_code.py` — 新增 `GRC_400122`
- **驗收條件**：① 故意填錯 `client_secret` → 回 `{"result": "invalid_credential"}`；② 填對的 → 回 `{"result": "ok"}`；③ 三態**一律 HTTP 200**；④ 沒填 `client_id` 就按 → 拋 `GRC_400122`。
- **依賴**：T-1.2。

### FR-110.3 畫面與選單 — 依賴：FR-110.2

#### T-3.1 設定頁面（含確認框）

- **做什麼（白話）**：刻出「Google Drive 應用程式設定」這一頁——兩個手填欄位、一個對外網址欄位、兩個唯讀網址配複製鈕、一個進階摺疊區、驗證鈕與儲存鈕；換應用程式帳號時要跳確認框。
- **檔案座標**（repo：`compliance-manager-fe`）：
  - `src/views/google-drive-app-config/GoogleDriveAppConfigForm.vue`（新檔，版型照 `src/views/ai-service-config/AiServiceConfigForm.vue`）
  - `src/service/CloudIntegrationService.js` — 加三個方法
  - `src/config/api/api.js` — 驗證端點常數（讀／存沿用 `API.SYSTEM_CONFIG`）
  - `src/config/router/index.js` — 照 `:256-272` 加一段，`meta.requiresPlatformAdmin: true`
  - `src/config/driveAppConfig.js`（新檔）— 三個常數，檔頭指向後端 `common/constant/drive_app_config.py`
  - i18n：`src/config/locales/i18n/{zh-tw,en}/google-drive-app-config.json` 兩支新檔 ＋ 兩支 `menu.json` 各加標題與說明 ＋ `src/config/locales/index.js` 四處註冊
  - FE `error-code.json` 的 zh-tw 與 en 各補 `GRC_400122`、`GRC_412053`
- **驗收條件**：① 平台管理員進得去、填完存檔、重新整理頁面後密鑰欄顯示「已設定」而非明文；② 一般租戶管理員手打網址進不去（路由守衛擋）；③ 複製鈕按下去剪貼簿拿到的是完整回呼網址；④ 改 `client_id` 按儲存會跳確認框，文案寫明「所有已連接的客戶都需要重新授權」；⑤ 進階區預設是摺疊的。
- **依賴**：T-2.1、T-2.2。
- ⚠️ 前後端旗標成對：`DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY` 兩邊值必須一致（見 §8 風險五）。

#### T-3.2 選單 migration

- **做什麼（白話）**：讓這一頁出現在側邊選單的「系統設定」底下。
- **檔案座標**：`scripts/sql/2026-09-XX-fr110-google-drive-app-config-menu.sql`（範本照 `2026-09-17-fr107-ai-service-config-menu.sql`）＋ `scripts/sql/manifest.tsv` 加一列（`active` / `*`）
- **內容**：`ui_routes` 一列（`pid=47`、`sort=39`、`name=drive-app-config`、`url=/system/drive-app-config`、icon 需實查 primeicons 6.0.1 確認存在）；`route_capabilities` 綁 `storage-config.read`（`ALL`）＋ `storage-config.update`（`ANY`）；檔頭 `SET LOCAL app.is_super_admin = 't'`；結尾 `INSERT public.schema_migrations`；內建驗證 `DO $$` 區塊。
- **驗收條件**：① 只套 DEV，`psql --single-transaction -v ON_ERROR_STOP=1` 跑過無錯；② 平台管理員重新登入，側邊選單「系統設定」底下出現這一頁且**圖示不是空白**；③ `manifest.tsv` 有登記；④ 回報「出貨基線待重產」。
- **依賴**：T-3.1。
- ⚠️ 兩個靜默失效點見 §5.7 紅框（manifest 漏登記、icon 名稱不存在）。

#### T-3.3 租戶層卡片的「尚未設定」提示

- **做什麼（白話）**：平台還沒填憑證時，租戶那頁的「連接 Google Drive」不要讓人按下去撞 Google 的英文錯誤頁，改成直接說「請聯繫平台管理員」。
- **檔案座標**：
  - BE：`app/cloud_integration/service/google_drive_integration_service.py` 的 `build_auth_url` 加前置檢查，未設定拋 `GRC_412053`
  - BE：`common/code/flow_control_error_code.py` 新增 `GRC_412053`
  - FE：`src/components/integrations/GoogleDriveIntegrationCard.vue` 接住這個碼顯示提示、停用按鈕
- **驗收條件**：清空平台憑證（資料庫那列與環境變數都清）→ 租戶管理員開雲端空間整合頁，看到提示文字且按鈕停用，**不會**被導去 Google。
- **依賴**：T-1.2（BE 側）、T-3.1（FE i18n 落點）。

### FR-110.4 設定檔退場與收尾 — 依賴：FR-110.3 驗收通過

#### T-4.1 `.env.sample` 與部署文件

- **做什麼（白話）**：把設定檔範本與部署文件改成「這四項改到畫面上設」，順便補上一直漏掉的那一項。
- **檔案座標**：`.env.sample`（Drive 段 `:145-153`）、`docs/claude/` 部署相關文件、安裝包 `guidant.env` 說明
- **驗收條件**：① `.env.sample` 有 `DRIVE_WEBHOOK_PUBLIC_BASE_URL` 這一行；② 四項憑證各有一行白話註明改由畫面設定、此處僅開發機後備；③ 憑證值本身不入版控（只寫「請查部署文件或詢問管理員」）。
- **依賴**：FR-110.3 全部驗收通過（不然文件會描述還沒上線的畫面）。

#### T-4.2（選配）離線分類腳本跟改

- **做什麼（白話）**：讓那支離線的證據分類腳本也走新的解析器，不然資料庫設好之後它仍然只認環境變數，而且缺變數時的失敗訊息看不出所以然。
- **檔案座標**：`scripts/evidence/classify/classify_evidence_drive.py:190-194`
- **驗收條件**：只在資料庫設定、不設環境變數時腳本仍可跑。
- **依賴**：T-1.2。
- ⚠️ **選配而非必做**：該腳本屬 FR-107 待刪的舊 Drive 分類線（見 `followup_fr107_remove_legacy_drive_classification_line`）。**但 T-1.3 會改掉 `GoogleOAuthClient` 的建構簽章，這支不改就會壞。** 若決定不做，至少要在該行加一句說明指向本案，讓下一個跑這支腳本的人知道為什麼壞。

---

## 7. 端到端驗收清單（決策者親手可做） {#acceptance nav="驗收"}

| # | 做什麼 | 通過的樣子 |
|---|---|---|
| 1 | 用平台管理員登入，看側邊選單「系統設定」 | 底下出現「Google Drive 應用程式設定」，**圖示不是空白** |
| 2 | 用一般租戶管理員登入 | 選單裡**沒有**這一頁；手打網址也進不去 |
| 3 | 用一般租戶管理員直接打 `GET /api/1.0/system/config/GOOGLE_DRIVE_APP_CONFIG/CONFIG` | 回 403 |
| 4 | 開設定頁，填 `client_id` ／ `client_secret` ／ 系統對外網址 | 回呼網址自動出現，與 Google Cloud Console 要登記的格式相同 |
| 5 | 按「驗證憑證」（密鑰故意打錯一個字） | ❌ 憑證錯誤 |
| 6 | 改回正確的密鑰再按一次 | ✅ 憑證正確，並提醒「別忘了把回呼網址貼進 Google 後台」 |
| 7 | 按回呼網址旁邊的複製鈕，貼到記事本 | 拿到完整網址，沒有截斷 |
| 8 | 按「儲存」，重新整理頁面 | 密鑰欄顯示「已設定」，**不是明文** |
| 9 | 只改系統對外網址、不動密鑰，再按存檔、重新整理 | 密鑰仍然是「已設定」（**沒有被抹掉**） |
| 10 | 改 `client_id` 按存檔 | 跳確認框，寫明「所有已連接的客戶都需要重新授權」 |
| 11 | **不重啟服務**，用租戶管理員去雲端空間整合頁按「連接 Google Drive」 | 走到 Google 授權畫面，網址裡的 `client_id` 是剛剛填的那組 |
| 12 | 清空平台憑證（資料庫那列與環境變數都清），租戶再按一次 | 顯示「系統尚未完成 Google Drive 應用程式設定，請聯繫平台管理員」，按鈕停用 |
| 13 | 只留資料庫設定、清空環境變數，觀察 10 分鐘 | 同步排程沒有報錯（背景排程讀得到 ROOT 那一列） |
| 14 | 打開 `.env.sample` 的 Drive 段 | 有 `DRIVE_WEBHOOK_PUBLIC_BASE_URL`；四項憑證各有一行「改由畫面設定」的白話說明 |

---

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

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

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

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

實作一律走 `read_root_config_value()`。驗收方式：**把排程的讀取路徑打開來看**，不是看功能有沒有壞（壞不壞看骰子）。同型事故：2026-07-29 POC 的檔案上傳把某租戶的檔存進別租戶的儲存桶，另一個環境「沒重現」純粹是 id 排序恰好對了。
:::

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

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

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

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

後端 `common/constant/drive_app_config.py` 的 `DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY`，前端 `src/config/driveAppConfig.js` 的同名常數。

**只改前端＝畫面開了但 API 仍 403；只改後端＝API 開了但畫面進不去。** 兩邊一起建，並在各自檔頭互指對方——AI 服務設定那組就是這樣寫的，照抄。
:::

::: {.callout .crit}
**🔴 四：依賴注入的簽章漂移是靜默的**

DI 的 `Singleton` / `Factory` 是 lazy——建構參數對不上**不會在啟動時炸**，只在該物件第一次被取用時才炸。改了 `GoogleOAuthClient.__init__` 必須同步改 `di_containers`，而且**驗收要實際打一次端點**（按「連接 Google Drive」），不能只看服務起得來。

另外 `classify_evidence_drive.py:190` 那支**不走 DI、自己直接建構**——簽章改了它就壞，而且要等有人跑那支腳本才知道（T-4.2）。
:::

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

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

這正是 D3 要「自動算 ＋ 複製鈕」而不是讓人手打的原因，也是 D8 選「明確填對外網址」而非推導的原因。另外要記得：驗證憑證按鈕**驗不到**這一項（§5.5 黃框）。
:::

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

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

AI 服務設定的驗收就是在這裡被退回過——以為掛了能力點就安全。驗收清單第 2、3 項就是在驗這件事。
:::

::: {.callout .warn}
**七：`.env.sample` 缺項曾造成難追的誤判**

`config.py:164-170` 記著：`DRIVE_WEBHOOK_PUBLIC_BASE_URL` 曾因 config 沒定義而永遠取到 `None`，症狀是註冊通知頻道那步拋 `'NoneType' object has no attribute 'rstrip'`，**且在設定檔補這個變數也無效**（沒有任何程式碼會去讀它），排查時極易誤判成環境沒設好。config 已修，範本至今沒補，T-4.1 一起收。
:::
