FR-110 · Drive OAuth 憑證從環境變數搬進 UI · 設計文件 · 2026-09-17(D1–D12 全數定案)
把「本系統在 Google 登記的那組應用程式帳號」從主機設定檔搬到畫面上:畫面填、存資料庫、密鑰加密落庫,外加一顆「驗證憑證」按鈕當場告訴你填對沒有。整套機制照抄 FR-107 T-5.5 的 AI 服務設定,不發明新做法。本頁是設計決策的落點——十二項決策各自寫明選了什麼、為什麼選、排除了什麼。
狀態:設計定案(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 母案 |
落地版(客戶自己機房裝一套)裝完之後,要開通 Google Drive 功能得有人登進主機、編輯 /srv/guidant-ai 的環境設定檔、重啟容器。三個問題:
client_secret 填錯,唯一症狀是使用者按下「連接 Google Drive」之後走到 Google 那邊被擋,錯誤訊息還是 Google 的英文頁面。沒有任何一個地方會說「是你的應用程式帳號填錯了」。Google Drive 整合要能運作需要兩層授權:
沒有第一層,第二層的「連接 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」→ 憑證由解析器讀出(資料庫優先、設定檔後備)
→ 不必重啟服務
整案一句話:把「本系統在 Google 登記的那組應用程式帳號」從主機的設定檔搬到畫面上,讓不會 ssh 的人也設定得了,而且填完當場知道對不對。
四階段,先後順序不可換
前一階段沒完成,後一階段連東西可測都沒有。
| 階段 | 做什麼 | 產出 | 完成怎麼判定(決策者親手檢查法) |
|---|---|---|---|
| ① 存讀與解析 | 資料庫存得下這組設定、密鑰加密、服務端讀得到(資料庫優先、設定檔後備)、依賴注入接線改成每次呼叫都重新解析 | 後端 service + resolver + DI 改線 | 用資料庫工具直接塞一筆設定進去 → 去雲端空間整合頁按「連接 Google Drive」→ 不必重啟服務就走到 Google 授權畫面 |
| ② 驗證與 API | 三支端點:讀設定(密鑰遮罩)、存設定、驗證憑證 | 後端 API + 守門 | Swagger 或 curl 打驗證端點:故意填錯的 client_secret 要回「憑證錯誤」,填對的要回「憑證正確」 |
| ③ 畫面與選單 | 新頁面「Google Drive 應用程式設定」放進「系統設定」群組;五個欄位+複製鈕+驗證鈕 | 前端頁面 + 選單 migration | 用平台管理員登入 → 側邊選單「系統設定」底下看得到新頁 → 填完存檔 → 重新整理,密鑰欄顯示「已設定」而不是明文;用一般租戶管理員登入則完全看不到這一頁 |
| ④ 設定檔退場 | 更新 .env.sample、部署文件、安裝包的 guidant.env 說明,標明這四項改由畫面設定 |
文件 | 打開 .env.sample,Drive 那段有一行白話寫「這幾項改到畫面上設,此處僅開發機後備」 |
依賴:①是全案地基,②③可在①完成後平行;④等③驗收通過才寫(不然文件會描述還沒上線的畫面)。
十二項全部拍板
下表每一列都寫明:定案內容、選它的理由、被排除的方案與排除原因。
| 項目 | 內容 |
|---|---|
| 定案 | 新開 FR-110,只做 Drive 半邊 |
| 理由 | 前身 FR-070 是「AI 金鑰 + Drive 憑證」兩件一起提的白話需求。AI 那半邊已隨 FR-107 T-5.5 落地,剩下的 Drive 半邊性質完整、可獨立驗收,續掛 FR-070 會讓一個已完成一半的需求永遠處於未結案狀態 |
| 被排除 | 續作 FR-070 — 該案的文件與卡片已按 AI 金鑰的脈絡寫成,Drive 半邊塞回去會讓兩件事的驗收條件混在同一張母卡 |
| 項目 | 內容 |
|---|---|
| 定案 | 新開一頁「Google Drive 應用程式設定」,掛「系統設定」群組(pid=47、sort=39),與「AI 服務設定」並列同守門 |
| 理由 | 與租戶層的「雲端空間整合」頁受眾不同、權限不同。並列 AI 服務設定是因為兩者本質相同:都是「填連線資訊與金鑰」 |
| 被排除 | 併進既有的「雲端空間整合」頁加一個分頁 — 那頁是租戶管理員的地盤,平台層欄位混進去會讓租戶管理員看到不該看的設定,且要在同一頁維護兩套守門 |
| 項目 | 內容 |
|---|---|
| 定案 | ① client_id 手填 ② client_secret 手填 ③ 系統對外網址手填(D8 新增)④ 回呼網址自動算、唯讀顯示 + 複製鈕 ⑤ 通知用對外網址自動算、唯讀顯示 + 複製鈕。④⑤ 另有「進階」摺疊區可手動覆寫,預設摺疊 |
| 理由 | 回呼網址必須跟客戶在 Google 後台登記的一字不差,所以要能一鍵複製貼過去;但它其實是算得出來的(對外站台位址 + 固定路徑),讓人手打只會打錯。留覆寫是給反向代理、非標準路徑等特殊部署用 |
| 被排除 | 回呼網址也讓人手填 — Google 逐字比對,差一個結尾斜線就被擋,而錯誤只在使用者按授權那一刻才浮現、且顯示的是 Google 的英文頁 |
| 項目 | 內容 |
|---|---|
| 定案 | client_secret 用既有的 DRIVE_TOKEN_ENCRYPTION_KEY 加密落庫,不新增環境變數。鑰匙是空的就直接拋錯,沒有存明文這條路 |
| 理由 | Drive 這條線本來就強制要有這把鑰匙(FernetCrypto.__init__ 空值直接 ValueError),使用者的授權通行證也是用它加密的。刻意與 AI 金鑰那把不同:AI 那把允許「空鑰匙=不加密」給開發機方便,這裡不給——因為這條線沒有「鑰匙可以不設」的狀態 |
| 被排除 | 新開一把 Drive 應用程式專用金鑰 — 多一把要多一次裝機生成、多一處文件、多一個忘了設的機會,而它保護的東西與既有那把在同一條信任邊界內;沿用既有的「空鑰匙=不加密」寬容路徑 — 那會產生「資料庫裡是明文但沒人知道」的狀態 |
| 項目 | 內容 |
|---|---|
| 定案 | 要做。拿一個假的授權碼去打 Google 的換票端點,依 Google 回的錯誤碼判三態。一支 POST 端點 |
| 理由 | 這是全案最有感的一顆按鈕。目前填錯要等使用者去按授權才發現,而且看到的是 Google 的錯誤頁 |
| 被排除 | 不做驗證、靠文件叮嚀 — 等於把「填錯了怎麼辦」整個留給客戶,而症狀出現的地方(租戶按授權)離原因發生的地方(平台填設定)隔了好幾天與好幾個人 |
| 項目 | 內容 |
|---|---|
| 定案 | 只警告,不自動改各租戶的連線狀態。畫面在存檔前若偵測到 client_id 有變更,跳確認框說明「所有租戶需重新授權」。後端只存不擋 |
| 理由 | 換了應用程式帳號,舊的授權通行證在新帳號底下是無效的。但自動把所有租戶標成「未連接」風險太大——改錯一個字就把全站連線清掉,且這個動作不可逆 |
| 被排除 | 存檔時自動把所有租戶的連線狀態標成失效 — 破壞力與誤觸機率不成比例;完全不提醒 — 症狀是同步排程開始大量失敗而畫面還顯示「已連接」,沒人知道為什麼 |
| 項目 | 內容 |
|---|---|
| 定案 | 路由只掛 @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 風險六) |
| 項目 | 內容 |
|---|---|
| 定案 | 新增一個欄位「系統對外網址」(public_base_url),由本頁一起填,存在同一列設定內。回呼網址與通知用對外網址都由它推導 |
| 理由 | 部署的對外網址是一件明確的事實,不該靠推導。推導錯的兩種方式都不會報錯:症狀是使用者按授權後被 Google 擋、或是檔案變動通知永遠收不到,兩者都極難追。多一個欄位換掉兩種靜默失敗划算 |
| 被排除 | A:用既有的 FRONTEND_BASE_URL — 語意不對,那是前端站台位址,而 Google 要打的是後端 API;同網域時剛好對,分開部署時靜默錯。B:用當次請求的 Host / X-Forwarded-Host 標頭 — 值取決於「管理員是從哪個網址進來設定的」,走反向代理或用內網 IP 進來設定時算出來的網址對外不可達,而且看起來完全正常 |
| 附帶約束 | 這個欄位在本案屬於 GOOGLE_DRIVE_APP_CONFIG 這組設定。日後若有第二個功能也需要「系統對外網址」,應把它升格成獨立的系統設定群組再讓兩邊共用,而不是各自複製一份 |
| 項目 | 內容 |
|---|---|
| 定案 | 進共用的 system_configs 表,新開一個群組 GOOGLE_DRIVE_APP_CONFIG |
| 理由 | AI 金鑰(AI_PROVIDER_CONFIG)、SMTP、議題整合設定都在這張表,遮罩與守門的殼 GuardedSystemConfigService 已經長好,本案只要加一個群組名與對應的遮罩規則。零新表、零新 repository、零新 domain service |
| 被排除 | 新開一張專屬表 — 欄位型別明確、可加約束,但要新 migration、新 repository、新 domain service,且與 AI 金鑰的做法分岔;為四個欄位開一整套 DDD 分層不划算 |
| 項目 | 內容 |
|---|---|
| 定案 | 照抄 AI 金鑰的三條規則,一字不改:讀=密鑰換成 is_set 旗標;寫=沒送密鑰就沿用舊值;清除=必須顯式帶 clear_client_secret: true |
| 理由 | 這三條不是風格選擇,是踩出來的。底層寫入是整格覆寫、不補「缺的從舊值補回」這一步,使用者只改網址按存檔密鑰就被抹掉,而畫面顯示「儲存成功」,要等下次有人按授權失敗才浮現。而只送空字串就清除的設計,會讓「使用者清空輸入框但其實只想改別的欄位」變成一次不可逆的刪除 |
| 被排除 | 讀取時回明文(方便前端顯示) — 密鑰一旦進了瀏覽器就等於在每一台管理員電腦上多存一份;空字串即清除 — 與「沒送=沿用」語意衝突,兩者無法並存 |
| 項目 | 內容 |
|---|---|
| 定案 | 不 seed,純靠環境變數後備 |
| 理由 | 兩者功能上等價(後備那層照樣讀得到),但語意差很多——「資料庫裡有=有人設過」是個乾淨的判準,日後要做「未開通」提示、要判斷該不該跳引導畫面,都靠它。而且 Drive 這組帳號與 AI 金鑰的性質不同:AI 金鑰是原廠掏錢買來送客戶用的,Drive 應用程式帳號通常是客戶自己去 Google 申請的 |
| 被排除 | 裝機時把環境變數的值 seed 進資料庫(AI 金鑰的做法) — 裝完就顯示「已設定」,但沒人填過也顯示「已設定」,分不出「原廠給的」與「客戶自己填的」,於是「未開通提示」這個功能失去判斷依據 |
| 項目 | 內容 |
|---|---|
| 定案 | 保留單例,把「去哪拿憑證」關進 GoogleOAuthClient 內部:建構時注入解析器,每支對外方法一開頭先向解析器拿當次憑證 |
| 理由 | 四個呼叫端一字不用改。而且改法把「憑證從哪來」收斂成一個檔內的一件事,不是散在四個注入點 |
| 被排除 | 改成每次取用都重新建立(Factory) — 兩個主要呼叫端(整合服務、通行證管理)都是「注入後存成成員變數」,改成每次建立它們仍然只在建構時拿一次,等於白改,還得連它們一起動 |
下表每一列都經過實檔查證
行號為 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 |
補上缺的那項 + 四項加註「改由畫面設定」 |
一列即可,存在共用的 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」的程式碼。
GoogleDriveAppConfigResolver放在 app/system_config/service/,形狀照 ai_provider_key_resolver.py(那支是租戶 → ROOT → 設定檔三層,本案砍掉租戶層,因為應用程式帳號本來就全站一份)。
兩層,順序固定:
read_root_config_value("GOOGLE_DRIVE_APP_CONFIG", "CONFIG")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 排序恰好對了。
| 項目 | 做法 |
|---|---|
| 加密器來源 | 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」的無聲失敗。
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 不做的話,至少要在該行加一句說明指向本案。
三支端點,前兩支沿用既有的泛用設定端點,不新開路由。
| 方法 | 路徑 | 做什麼 |
|---|---|---|
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 各補一行。
| 項目 | 座標 | 做什麼 |
|---|---|---|
| 頁面 | 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 的英文錯誤頁。
檔名: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 只回報不自己動。
| 檔案 | 動作 |
|---|---|
.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 那邊已修,範本至今沒補——本階段一起收掉。
%%{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: 通行證加密後存租戶那張表
每張卡都寫成「新 session 不讀其他文件就能開工」的厚度
做什麼、動哪些檔、驗收怎麼判、依賴誰,四項都寫。單張預估一個 session 內可完成。
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,檔頭寫明前端有一份對應常數、兩邊要一起改GET /api/1.0/system/config/GOOGLE_DRIVE_APP_CONFIG/CONFIG 回 client_secret: {"is_set": ...} 而非明文;② 用一般租戶管理員打同一支回 403;③ 讀整組設定列表時,租戶管理員看不到這一列。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_valueclient_id 沒有 public_base_url → 回呼網址是空的而不是設定檔那串(驗證「整組切換」);④ 加密後落庫的值肉眼看不是明文,解出來與原值相同。GoogleOAuthClient 與依賴注入改造(D12)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 → 不重啟再打一次,授權網址跟著變。core/scheduler.py:155-167(drive_sync_worker)、:187-204(webhook_channel_renewer);往下追到解析器的呼叫路徑read_root_config_value()(不是看功能有沒有壞——壞不壞看資料列排序的運氣);② 把環境變數的四項清空、只留資料庫設定,同步排程照常運作不報錯。api/system_config/routes/system_config_route.py:117-160,不新開路由guarded_system_config_service.py — 讀取時把推導後的兩個網址與 is_override 旗標塞進回應(effective_redirect_uri / effective_webhook_base_url)effective_urls()public_base_url 按存檔,密鑰沒有被抹掉;② 帶 clear_client_secret: true 才清得掉;③ 把 {"is_set": true} 原樣送回去也視為「沿用」不當成新值;④ 讀回的 effective_redirect_uri 與實際 callback 路由路徑一字不差。api/cloud_integration/routes/ 新增 route,註冊到 api/cloud_integration/__init__.py:POST /api/1.0/integrations/google-drive/verify-credentialsapp/cloud_integration/service/ 對應 app service 方法,守門呼叫 require_platform_admin()common/code/flow_control_error_code.py — 新增 GRC_400122client_secret → 回 {"result": "invalid_credential"};② 填對的 → 回 {"result": "ok"};③ 三態一律 HTTP 200;④ 沒填 client_id 就按 → 拋 GRC_400122。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: truesrc/config/driveAppConfig.js(新檔)— 三個常數,檔頭指向後端 common/constant/drive_app_config.pysrc/config/locales/i18n/{zh-tw,en}/google-drive-app-config.json 兩支新檔 + 兩支 menu.json 各加標題與說明 + src/config/locales/index.js 四處註冊error-code.json 的 zh-tw 與 en 各補 GRC_400122、GRC_412053client_id 按儲存會跳確認框,文案寫明「所有已連接的客戶都需要重新授權」;⑤ 進階區預設是摺疊的。DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY 兩邊值必須一致(見 §8 風險五)。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 $$ 區塊。psql --single-transaction -v ON_ERROR_STOP=1 跑過無錯;② 平台管理員重新登入,側邊選單「系統設定」底下出現這一頁且圖示不是空白;③ manifest.tsv 有登記;④ 回報「出貨基線待重產」。app/cloud_integration/service/google_drive_integration_service.py 的 build_auth_url 加前置檢查,未設定拋 GRC_412053common/code/flow_control_error_code.py 新增 GRC_412053src/components/integrations/GoogleDriveIntegrationCard.vue 接住這個碼顯示提示、停用按鈕.env.sample 與部署文件.env.sample(Drive 段 :145-153)、docs/claude/ 部署相關文件、安裝包 guidant.env 說明.env.sample 有 DRIVE_WEBHOOK_PUBLIC_BASE_URL 這一行;② 四項憑證各有一行白話註明改由畫面設定、此處僅開發機後備;③ 憑證值本身不入版控(只寫「請查部署文件或詢問管理員」)。scripts/evidence/classify/classify_evidence_drive.py:190-194followup_fr107_remove_legacy_drive_classification_line)。但 T-1.3 會改掉 GoogleOAuthClient 的建構簽章,這支不改就會壞。 若決定不做,至少要在該行加一句說明指向本案,讓下一個跑這支腳本的人知道為什麼壞。| # | 做什麼 | 通過的樣子 |
|---|---|---|
| 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;四項憑證各有一行「改由畫面設定」的白話說明 |
🔴 一:背景排程讀設定會非確定性挑到別人那一列
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 一起收。