FR-110 · Drive OAuth 憑證從環境變數搬進 UI · 需求討論稿 · 2026-09-17(v1 · 待審)

讓 Drive 憑證用畫面填,不必進主機改設定檔

目前要讓客戶的 Guidant AI 接得上 Google Drive,工程師得登進主機、編輯 .env、再重啟服務——落地版客戶端沒有這條動線。本案新開一頁「Google Drive 應用程式設定」,把 Google 那組應用程式帳號(client_id / client_secret)與兩個對外網址改成畫面上填、存資料庫、密鑰加密落庫,並附一顆「驗證憑證」按鈕當場告訴你填對沒有。做法整套照抄 FR-107 T-5.5 的 AI 服務設定,不發明新機制。

已拍板 7 項(D1–D7) 待決策 D8–D12 跨 2 repo:BE/FE + 選單 migration 前身:FR-070 白話需求(Drive 半邊) 先例:FR-107 T-5.5 AI 金鑰 DB 化 陷阱:背景排程無租戶脈絡,必走繞 RLS 讀取
§1

分工概述(30 秒版)

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

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

四階段,先後順序不可換

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

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

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


§2

需求背景與現況

為什麼現在不夠用

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

.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 至今沒補——本案第④階段要一起收掉。


§3

兩層設定怎麼分工

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

%%{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
圖 1 — 平台層(本案新頁)與租戶層(既有頁)的分工,以及誰讀誰

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


§4

決策定案(D1–D7)

以下七項決策者已拍板,本段為記錄,不是待議項。

# 決策 內容 為什麼
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 → 設定檔),本案砍掉租戶層那一圈,因為應用程式帳號本來就全站一份。

🔴 背景排程讀設定必須走繞過隔離的專用讀取器

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。


§5

待決策(D8–D12)

D8 — 自動算出來的網址,要從哪裡推導?

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

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

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

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

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

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

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

照 guarded_system_config_service.py:174-200 的既有規則(_merge_ai_provider_secrets):

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

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

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

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

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

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

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 內部,外面四個接點一字不動。至於那支離線腳本,見下方接入點表的處置建議。


§6

現況接入點盤點

下表每一列都經過實檔查證(行號為 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 補上缺的那項 + 四項加註「改由畫面設定,此處為開發機後備」

§7

資料模型與 API 草案

資料庫存的東西長什麼樣

假設 D9 選 A(進 system_configs),一列即可:

欄位 值
group GOOGLE_DRIVE_APP_CONFIG
key CONFIG
tenant_id ROOT 租戶(全站共用一份)
value 見下方 JSON
{
  "client_id": "<Google 給的應用程式編號,明文>",
  "client_secret": "<加密後的密文>",
  "redirect_uri_override": "",
  "webhook_base_url_override": ""
}

兩個 _override 欄位空著=用自動算的值,填了=以填的為準(對應 D3 的「進階」摺疊區)。

🔴 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) 同上

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

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

這顆按鈕驗不到回呼網址

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

端到端時序

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

§8

拆分草案

本段是草案,等 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

§9

風險與陷阱

🔴 一:背景排程讀 tenant-scoped 設定會非確定性挑到別人的

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

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

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

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

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

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

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

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

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

四:.env.sample 缺 DRIVE_WEBHOOK_PUBLIC_BASE_URL

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

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

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 版),兩邊一起建,並在各自檔頭互指對方。

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

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

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

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