FR-110 · Drive OAuth 憑證從環境變數搬進 UI · 設計文件 · 2026-09-17(D1–D12 全數定案)

Drive 憑證搬進畫面 — 設計定案

把「本系統在 Google 登記的那組應用程式帳號」從主機設定檔搬到畫面上:畫面填、存資料庫、密鑰加密落庫,外加一顆「驗證憑證」按鈕當場告訴你填對沒有。整套機制照抄 FR-107 T-5.5 的 AI 服務設定,不發明新做法。本頁是設計決策的落點——十二項決策各自寫明選了什麼、為什麼選、排除了什麼。

D1–D12 全數定案 4 子需求 · 11 子任務 跨 2 repo:BE/FE + 選單 migration 先例:FR-107 T-5.5 AI 金鑰 DB 化 陷阱:背景排程無租戶脈絡,必走繞 RLS 讀取 陷阱:平台管理員旗標前後端成對改

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

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

變更紀錄

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

1. 需求背景與端到端流程

為什麼現在不夠用

落地版(客戶自己機房裝一套)裝完之後,要開通 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 秒版)

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

四階段,先後順序不可換

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

階段 做什麼 產出 完成怎麼判定(決策者親手檢查法)
① 存讀與解析 資料庫存得下這組設定、密鑰加密、服務端讀得到(資料庫優先、設定檔後備)、依賴注入接線改成每次呼叫都重新解析 後端 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)

十二項全部拍板

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

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. 現況接入點盤點

下表每一列都經過實檔查證

行號為 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. 詳細設計

5.1 資料模型

一列即可,存在共用的 system_configs 表:

欄位 值
group GOOGLE_DRIVE_APP_CONFIG
key CONFIG
tenant_id ROOT 租戶(SYSTEM_ROOT_TENANT_ID,全站共用一份)
value 見下方 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

🔴 回呼路徑是一個字串常數,不可兩處各寫一份

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

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

🔴 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 讀四項原有變數

🔴 兩層之間是「整組切換」,不是逐欄位各自後備

定案:資料庫那一列只要有 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 旗標 供讀取端點回給畫面顯示與複製用

🔴 解析器讀資料庫一律走繞 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 旗標)

與 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

🔴 依賴注入的簽章漂移是靜默的

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 模組)

讀取回應(密鑰已遮罩):

{
  "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)

🔴 能力點分流表的現況與直覺不同,本案照抄現況

實查 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,請確認主機對外網路

三態一律回 HTTP 200,不用錯誤碼表達

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

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

這顆按鈕驗不到回呼網址

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 端點)而平台憑證未設定

🔴 後端新增 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 等決策者明示放行 環境異動鐵律

🔴 兩個會靜默失效的登記,一個都不能漏

① 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 確認存在,不要憑印象寫。

出貨基線待重產

本檔是主線 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)

.env.sample 缺項本身就是一個踩過的坑的殘留

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

5.9 端到端時序

%%{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: 通行證加密後存租戶那張表
圖 1 — 從平台管理員填設定,到租戶授權時憑證被讀出來的完整路徑

6. 拆分(4 子需求 × 11 子任務)

每張卡都寫成「新 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. 端到端驗收清單(決策者親手可做)

# 做什麼 通過的樣子
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. 風險與陷阱

🔴 一:背景排程讀設定會非確定性挑到別人那一列

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

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

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

🔴 二:換了 client_id,所有租戶的既有授權全部失效

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

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

🔴 三:平台管理員旗標前後端必須成對改

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

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

🔴 四:依賴注入的簽章漂移是靜默的

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

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

五:回呼網址與 Google 後台登記的不一致

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

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

六:能力點不新開,但它擋不住租戶管理員

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

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

七:.env.sample 缺項曾造成難追的誤判

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