Google Drive 證據同步 — Design Spec

項目 內容
Spec 版本 v0.4 — Phase 3 端對端測試後續修正 + 性能 + auto-recovery UX
撰寫日期 2026-04-21(v0.1 草稿);2026-04-24 v0.2 修訂;2026-04-24 v0.3 修訂;2026-04-24 v0.4 修訂
撰寫人 Claude / raymond
狀態 Draft — 待 review
影響專案 compliance-manager-be, compliance-manager-fe

v0.4 變更(Phase 3 端對端測試後)

  1. Auto-recovery on Drive (re)connect:OAuth callback 結尾自動掃描「tenant 內未同步 project」並 enqueue INIT_PROJECT_FOLDERS。User 不需手動點任何按鈕,連線完即補建(解決 Phase 3 之前 user 必須在 UI 一個個點 sync 的痛點)。
  2. Project 列表加 drive_synced flag + 同步按鈕:drive_synced 在 route 層 batch 查(避免 grc ↔︎ cloud_integration container 雙向 override 觸發 dependency-injector 無限遞迴);UI 顯示「已連動 / 未與 Google Drive 同步」tag,未同步者多一個 Google icon 按鈕直接觸發 init。
  3. FE 整段 Drive UI 由 tenant 整合狀態 gate:mount 時打 GET /api/1.0/integrations/google-drive;status != 'CONNECTED' 不顯示任何 Drive 相關 tag / button(避免沒接 Drive 的 tenant 看到莫名其妙的 UI)。
  4. Init handler ROOT 健康檢查:每次 init 在 reuse existing ROOT mapping 前 drive.get_folder 驗證;404 / trashed → wipe tenant 全部 mapping + reset root_folder_id → 落到 fresh create。避免 user 手動刪 GuidantAI 後 init job 卡 404 retry 死循環。
  5. Webhook self-event skip — 只對 folder change:INIT / RENAME / ARCHIVE 是我們對 folder 動的,user 只對 file 動。早期版本連 file event 一起 skip 害 user 上傳檔案完全 import 不進來(OAuth 帳號 = user 個人帳號的 dev 場景特別常見)。file events 永不 self-skip
  6. Init handler 同層 parallelThreadPoolExecutor(5) 並發 sibling folder。每 thread 自己 @transaction (session_scope ContextVar per-thread)。實測 145 folders 從 236s → 67s (3.5x);Drive API quota 5 concurrent × 2 calls = 10 req/s 在限制內。
  7. Webhook URL prefix bugfixwebhook_channel_manager.register_for_tenant 組 callback URL 由 /api/webhooks/... 改為 /api/1.0/webhooks/...(Blueprint 有 url_prefix=/api/1.0);同時加「register 前先 stop_channel 既有 channel」避免 Drive 兩 channel 並存。
  8. Admin endpoint 整合POST /webhook/register 同時做 webhook register + project 補建。Response 多回 backfilled_projects
  9. DriveFolderMappingDomainService 補 get_by_uid(漏 wrap,ImportDriveFileHandler crash bug)。
  10. 文案:v0.3 寫的 Drive 同步中/未同步 改為 已連動 Google Drive/未與 Google Drive 同步(避免「同步中」被誤讀為動作進行中);evidence row 的 Drive badge 改為 Google Drive;新增 tooltip_open_in_drive / ref_open_in_drive i18n key。

詳見 changelog:docs/changelog/2026-04-24-google-drive-phase3-followup-and-perf.md

v0.3 重大變更

  1. 移除 v0.1 / v0.2「禁止系統內刪 DRIVE_SYNC evidence」規則。系統刪 DRIVE_SYNC evidence 時改為 把 Drive 上的檔案 move 到 _Archive/ 資料夾,並 DB 軟刪除。Sync pipeline 偵測到 ancestor 為 _Archive/ 就 SKIP,避免「系統刪 → Drive 還在 → webhook 重新 import」的迴圈。
  2. 系統刪整個 task 也改為 把 task folder move 到 _Archive/(取代 v0.2「mapping 標 unlinked + Drive folder 留原處」設計),統一 SYSTEM_UPLOAD / DRIVE_SYNC 兩種 evidence 的刪除 UX。
  3. 每個 AP 下自動建立一個 _Archive/ folder(per-AP,flat 結構,無子目錄),登錄為 drive_folder_mappings.scope_type='ARCHIVE' (scope_uid = AP uid)。
  4. 移除 v0.2 §13.3.1「已刪除 task 的 evidence UI 顯示」段落(依使用者意見:UI 上看不到 task 就不需特別處理)。
  5. GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE error code 標 deprecated,但保留定義(避免 break 既有引用);v0.3+ 不再 raise。

User 還可手動把 _Archive/ 內檔案搬回 active task folder → 視為新 evidence import(不做複雜的 restore 偵測,原軟刪 evidence row 留作歷史紀錄)。

v0.2 重大變更:folder structure 從 5 層 (Project/AP/CG/Control/AO) 改為 6 層,新增 Task 層作為 evidence 檔案實際存放層。原因:1 個 AO 可能對應多個 job_execution(task),evidence FK (job_evidences.job_execution_id) 是綁 task 不是 AO。原設計強迫 evidence 對到「AO 的 active job_execution」在多 task 場景會選錯 task → 稽核資料錯置。新增 Task 層後,每個 task folder 與 job_execution 1:1 對應,sync 路徑明確無歧義。代價:單一 task 的 AO(最常見情境)多一層資料夾點擊,但換來 multi-task 場景的正確性。


1. Goals & Non-goals

Goals

  1. 讓 user 透過 Google Drive 上傳 task 的 evidence 檔案,無須打開系統 UI 即可完成上傳,檔案會自動同步進系統,效果與系統 UI 上傳完全一致(存入 Minio + DB 寫 evidence 紀錄)。Evidence 一律綁特定 task(job_execution),AO 本身不直接持有檔案。
  2. Drive 上的刪除動作會同步回系統(軟刪除)。系統上的刪除同步回 Drive。
  3. 系統 UI 上能清楚區分 evidence 來源(Drive 同步 vs 系統上傳),並提供 Drive 資料夾 / 檔案的快捷連結。
  4. PM 啟動專案時,自動依 Project → AP → Control Group → Control → AO → Task 的 6 層在共用 Drive 帳號下建立完整資料夾結構,user 不需要手動建。AO 層為「容器」資料夾(顯示同 AO 的所有 task),Task 層才是 evidence 檔案實際存放層。

Non-goals (v1)

  • 支援 Google Drive 以外的雲端空間(OneDrive / Dropbox 等)— 介面預留但不實作。
  • Drive 與系統「雙向同步」— 系統內任何變更都不寫回 Drive,只有資料夾改名是 best-effort。
  • 多人協同編輯 Drive 檔案的 conflict resolution — Drive 自己處理。
  • 提供 Drive 端的細粒度 ACL — 統一用 anyoneWithLink + writer
  • Drive Push notification 給其他系統 participant — 不發。
  • Tenant 內多組 Drive 帳號 — 一個 tenant 只允許一組。

2. Glossary

名詞 說明
Tenant Drive Account 每個 tenant 設定一個共用的 Google 帳號(建議用公司 dedicated 帳號,如 compliance@company.com),系統以 OAuth refresh token 代理該帳號操作 Drive。
GuidantAI 根目錄 系統在共用 Drive 帳號下建立的固定根目錄,所有專案資料夾都在這層下。
drive_folder_id Google Drive 為每個資料夾分配的全域唯一 ID,永遠不變(即使 user 改名 / 移動)。我們用它識別資料夾,不靠名稱
drive_change_cursor Google Drive Changes API 的 pageToken,指向該 tenant Drive 上「上次處理過」的位置,下次拉變更從此往後拉。
DRIVE_SYNC evidence source 為 Drive 同步進來的 evidence 紀錄。
SYSTEM_UPLOAD evidence source 為 user 從系統 UI 直接上傳的 evidence 紀錄。
Webhook channel Google Drive changes.watch 註冊的推播通道,最長 7 天到期,需週期性續訂。
**AO 資料夾(容器層) 6 層結構中的第 5 層;本身不**存放 evidence 檔案,只是把同 AO 下所有 task 資料夾包在一起的容器。改名 / 連結對應的是 AO entity 本身。
Task 資料夾(檔案層) 6 層結構中的第 6 層(最底層);evidence 檔案實際上傳到這層。每個 task folder 與系統內某筆 compliance.job_executions 1:1 對應,由 drive_folder_mappings.scope_type='TASK' + scope_uid=job_execution.uid 標示。
Task = job_execution 系統內「task」即是 compliance.job_executions 一筆 record。compliance.job_evidences.job_execution_id FK 直接指向這筆 task。一個 AO 在一次 AP 週期可能有多個 task(例如指派給多人或拆段執行),因此原 v0.1 假設「1 AO + 1 AP = 1 job_execution」不成立,必須以 task 為單位掛載 evidence。
_Archive 資料夾(v0.3) 每個 AP 下自動建立一個 _Archive/(底線開頭使其在 Drive 排序置頂),用來「丟棄」由系統發起刪除的 evidence 檔案 / 整個 task folder。檔案放這裡 = Drive 上仍可被人工救援,但 sync pipeline 不再 import / re-import;DB 對應 evidence 為軟刪除狀態。Flat 結構(無子目錄),重名由 Drive 自動加 (1) (2) 後綴處理。
ARCHIVE scope(v0.3) drive_folder_mappings.scope_type='ARCHIVE'scope_uid 為該 AP 的 uid(一個 AP 一筆)。Sync 處理 file/folder 變更時,walk up parents 找到任一 ancestor 為 ARCHIVE → SKIP;避免歸檔檔案被當成新 evidence 重新 import。

3. High-level Architecture

┌─────────────────────────────────────────────────────────────────┐
│  Frontend (compliance-manager-fe)                               │
│  - 「雲端空間整合設定」頁面(admin)                                │
│  - AO Evidence 區塊(badge / Drive 連結 / 在 Drive 開啟)           │
└─────────┬───────────────────────────────────┬───────────────────┘
          │ REST                              │
          ▼                                   │
┌─────────────────────────────────────────────┴───────────────────┐
│  Backend (compliance-manager-be / Flask)                         │
│  ┌──────────────────┐  ┌─────────────────┐  ┌────────────────┐ │
│  │ integration api  │  │ webhook receiver│  │ project_start  │ │
│  │ (OAuth/connect)  │  │ /webhooks/gdrive│  │   integration  │ │
│  └────────┬─────────┘  └────────┬────────┘  └───────┬────────┘ │
│           │                      │                    │         │
│           ▼                      ▼                    ▼         │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │   GoogleDriveService (drive client wrapper)              │  │
│  │   GoogleDriveTokenManager (token refresh)                │  │
│  │   TokenCryptoService (Fernet | KMS)                      │  │
│  │   DriveSyncJobService (enqueue)                          │  │
│  └──────────────────────────────────────────────────────────┘  │
│           │                      │                              │
│           │                      ▼                              │
│           │           ┌──────────────────┐                      │
│           │           │  drive_sync_jobs │ (DB-backed queue)    │
│           │           └────────┬─────────┘                      │
│           ▼                    ▼                                │
│  ┌─────────────────────────────────────────────┐                │
│  │  APScheduler                                │                │
│  │  - DriveSyncWorker (poll pending jobs)      │                │
│  │  - WebhookChannelRenewer (renew before TTL) │                │
│  └─────────────────────────────────────────────┘                │
└─────────┬─────────────────────────────────────┬─────────────────┘
          │                                     │
          ▼ Drive API                           ▼ Minio
┌──────────────────────┐              ┌──────────────────────────┐
│  Google Drive        │  webhook ──▶ │  jedi-file-upload (Minio)│
│  (Tenant 共用帳號)    │  POST        │  JOB_EVIDENCES/{uid}/... │
└──────────────────────┘              └──────────────────────────┘

核心元件

  • GoogleDriveService:Drive API 的 wrapper(folders.create、files.list、files.export、changes.list、changes.watch、permissions.create)。
  • GoogleDriveTokenManager:取 access token(cache hit → 直接回;過期 → refresh → 更新 cache)。所有 Drive API 呼叫前都過這層。
  • TokenCryptoService:抽象介面,第一版 FernetCrypto,未來可換 AwsKmsCrypto
  • DriveSyncJobService:enqueue / 查詢 sync job 的 app service。
  • DriveSyncWorker(APScheduler 內定時執行):每 N 秒拉一批 pending job 處理(一個 worker 內順序處理同 tenant 的 job 避免 race;不同 tenant 可平行)。
  • WebhookChannelRenewer(APScheduler 內定時執行):每天檢查所有 tenant 的 channel,距到期 < 1 天則 renew。

4. Data Model

4.1 新增 table

compliance.tenant_drive_integrations

每個 tenant 一筆。記錄 OAuth 連線狀態、token、webhook channel、根目錄。

欄位 型別 說明
id SERIAL PK
uid UUID UNIQUE API 對外 ID
tenant_id INT UNIQUE 一個 tenant 一筆
provider VARCHAR(20) 預留,目前固定 'google_drive'
google_account_email VARCHAR(255) 連線帳號 email(顯示用)
refresh_token_encrypted TEXT 加密後的 refresh token
access_token_cache TEXT 加密後的 access token cache
access_token_expires_at TIMESTAMPTZ
root_folder_id VARCHAR(100) GuidantAI 根目錄的 Drive folder ID
drive_change_cursor VARCHAR(255) changes.list 的 pageToken
webhook_channel_id VARCHAR(100) UUID we send to Drive
webhook_resource_id VARCHAR(100) Drive 回傳的 resource ID
webhook_token VARCHAR(100) 隨機 token,驗證 webhook 來源用
webhook_expires_at TIMESTAMPTZ Channel TTL
status VARCHAR(20) CONNECTED / EXPIRED / REVOKED / DISCONNECTED
connected_by_user_id INT 哪個 admin 連的
connected_at TIMESTAMPTZ
last_sync_at TIMESTAMPTZ
last_sync_error TEXT NULL = 無錯誤
created_at, updated_at, created_user, updated_user 標準審計欄位

compliance.drive_folder_mappings

記錄系統實體 ↔︎ Drive folder ID 的對應。

欄位 型別 說明
id SERIAL PK
uid UUID UNIQUE
tenant_id INT RLS 用
scope_type VARCHAR(20) ROOT / PROJECT / AP / CONTROL_GROUP / CONTROL / AO / TASK / ARCHIVE(v0.3)
scope_uid UUID NULLABLE 對應的 entity uid(ROOT 時為 NULL)。TASK 時為 compliance.job_executions.uidARCHIVE 時為該 AP 的 uid(一個 AP 一筆 ARCHIVE mapping)。
parent_drive_folder_id VARCHAR(100) NULLABLE 用於驗證搬移;ROOT 為 NULL
drive_folder_id VARCHAR(100) UNIQUE Drive 給的 ID
display_name_snapshot VARCHAR(255) 上次寫入 Drive 的名稱
is_unlinked BOOL DEFAULT FALSE True 代表 Drive 上已不存在,待重建
created_at, updated_at

UNIQUE INDEX (tenant_id, scope_type, scope_uid) — 確保每個 entity 只有一個 mapping。

CHECK constraint 變更(v0.2):原 migration 2026-04-24-google-drive-folder-mappings.sqlchk_drive_folder_mappings_scope 不含 'TASK',需新增 migration 2026-04-24-add-task-scope-to-drive-folder-mappings.sql 把 constraint ALTER 成 CHECK (scope_type IN ('ROOT','PROJECT','AP','CONTROL_GROUP','CONTROL','AO','TASK'))

CHECK constraint 變更(v0.3):再次 ALTER 加入 'ARCHIVE',最終為 CHECK (scope_type IN ('ROOT','PROJECT','AP','CONTROL_GROUP','CONTROL','AO','TASK','ARCHIVE'))。獨立 migration 2026-04-24-add-archive-scope-to-drive-folder-mappings.sql(接於 task scope migration 之後)。

compliance.drive_sync_jobs

DB-backed job queue。

欄位 型別 說明
id SERIAL PK
uid UUID UNIQUE
tenant_id INT RLS / worker 分組用
job_type VARCHAR(40) 見下方 enum
payload JSONB job 參數
status VARCHAR(20) PENDING / IN_PROGRESS / SUCCESS / FAILED / SKIPPED
priority INT DEFAULT 100 越小越優先
retry_count INT DEFAULT 0
max_retries INT DEFAULT 5
next_run_at TIMESTAMPTZ exponential backoff 用
last_error TEXT
started_at, finished_at
created_at

job_type enum:

  • INIT_PROJECT_FOLDERS — 專案啟動時建整套 6 層資料夾結構(含 task folders)
  • CREATE_FOLDER — 個別建一個資料夾(INIT 內部會 split 出多筆;task 補建 / 上層重建後也用)
  • RENAME_FOLDER — 系統改名同步(含 task rename)
  • PROCESS_DRIVE_CHANGES — webhook 觸發,拉 changes.list
  • IMPORT_DRIVE_FILE — 下載某個 Drive file 進 Minio + 寫 evidence(payload 帶 task_mapping_uid)
  • SOFT_DELETE_EVIDENCE — Drive 刪除偵測到時
  • RECONCILE_TASK_FOLDER — job revert / 資料夾重建後,補抓某 task folder 內檔案並對帳(v0.2 由 RECONCILE_AO_FOLDER 改名)

4.2 既有 table 修改

compliance.job_evidences 新增欄位

欄位 型別 說明
source VARCHAR(20) DEFAULT 'SYSTEM_UPLOAD' SYSTEM_UPLOAD / DRIVE_SYNC
drive_file_id VARCHAR(100) NULLABLE DRIVE_SYNC 時為 Drive file ID
drive_file_modified_at TIMESTAMPTZ NULLABLE Drive 上次修改時間(用於變更偵測)
drive_last_modifying_user_email VARCHAR(255) NULLABLE 預留欄位,目前 UI 不顯示真人(依 Q9.3)
is_deleted BOOL DEFAULT FALSE 軟刪除 flag
deleted_at TIMESTAMPTZ NULLABLE
deleted_reason VARCHAR(40) NULLABLE USER_DELETED / DRIVE_DELETED / DRIVE_MOVED_OUT

UNIQUE INDEX (drive_file_id) WHERE drive_file_id IS NOT NULL — 防止重複 import。

Migration 時所有既有 evidence 預設 source='SYSTEM_UPLOAD'is_deleted=false


5. Folder Structure & Naming

5.1 階層(6 層 — v0.2,加 Archive sibling — v0.3)

{Tenant 共用 Drive 帳號}/
└── GuidantAI/                                              ← scope_type=ROOT
    └── {Project Name}/                                     ← scope_type=PROJECT
        └── 第N輪 - {AP Name}/                              ← scope_type=AP(含輪次前綴)
            ├── _Archive/                                   ← scope_type=ARCHIVE(v0.3,每 AP 一個)
            │   ├── [T-xxxxxxxx] 已刪除任務名稱/             ← 系統刪整個 task → 整個 task folder 搬進來
            │   └── 已刪除的單檔.pdf                         ← 系統刪單一 evidence → 個別檔案搬進來
            ├── [{cg_code}] {cg_name}/                      ← scope_type=CONTROL_GROUP
            │   └── [{ctrl_code}] {ctrl_name}/              ← scope_type=CONTROL
            │       └── {AO title}/                         ← scope_type=AO(容器層,不放檔案)
            │           └── [T-{task_id}] {task_name}/      ← scope_type=TASK(檔案實際放這層)
            └── ...其他 CG / Control / AO / Task ...

為何要加 Task 層compliance.job_evidences.job_execution_id 直接 FK 到 task;同一 AO 在同一 AP 週期可能有多個 task(指派多人 / 拆段執行)。原 v0.1 把檔案直接放 AO 層,遇到多 task 時無法正確判斷檔案應綁哪一個 task → 稽核資料錯置。新 v0.2 在 AO 下多一層 task folder,每個 task folder 與 job_executions 一對一映射,sync 路徑明確。

為何加 _Archive/ (v0.3):原 v0.1/v0.2 為了避免「系統刪 → Drive 還在 → webhook 重新 import」的迴圈,禁止系統刪 DRIVE_SYNC evidence,UX 過於嚴苛(user 必須切到 Drive 操作)。v0.3 改為「系統刪時把檔案 move 到 _Archive/」,sync 偵測 ancestor 為 ARCHIVE 直接 SKIP,迴圈問題消失,UX 也與 SYSTEM_UPLOAD 統一(系統內可刪、Drive 上仍可救援)。

Archive folder 設計

  • 位置:每 AP 一個(不是 per project / per tenant)— 與 task folder 同層級。原因:保留 AP 維度的稽核軌跡,不同 AP 的歸檔不混在一起。
  • 命名_Archive(英文 + 底線開頭)— 底線在 Drive 預設排序中會置頂,user 一眼看到;英文短,避免中文與資料夾命名規則衝突。
  • 結構:flat(v1 不分子目錄)— 簡單;同名檔案靠 Drive 自動加 (1) 後綴;上層 task folder 直接整個 move 進來保留任務脈絡。
  • 建立時機INIT_PROJECT_FOLDERS 內每建一個 AP folder,緊接著建一個 _Archive/ 子資料夾並寫 mapping。

5.2 命名規則

  • GuidantAI — 固定字串,不含日期 / tenant 標記。
  • Project — 純 display name(沒有 OSCAL code)。系統內改名 → best-effort 同步。
  • AP第{round_no}輪 - {AP display name},例如 第1輪 - 2026Q2 內稽。系統內改名 / 輪次調整 → best-effort 同步。
  • Control Group / Control[{code}] {display name}。code 是 OSCAL ID(如 AC-2AC-2.a)幾乎不變;display name 改了 → best-effort 同步。
  • AO — 直接用 {AO title}[{ao_code}] prefix。原因:AO title 本身已含子項標記(如 [a] 確認...[b] 驗證...),再加 OSCAL 碼會冗長。
  • Task[T-{task_id}] {task_name},例如 [T-9f3c2a1b] HR 密碼政策實作
    • task_id = job_execution.uid 的前 8 碼(UUID prefix),與系統其它部分顯示風格一致;不用 DB int id(避免外洩 / 不可變保證較弱)
    • task_name 取自 job_execution 的人類可讀欄位。TODO(待 implementation 階段確認):實際欄位名 — 候選 name / display_name / description / title,Phase 2 實作 Task layer 時用 grep 確認 entity;若該 entity 沒有合適 name 欄位,先 fallback 為 [T-{task_id}](無 trailing name)並回頭跟產品討論補欄位。
  • 名稱過長 / 含 Drive 不允許字元(/\)→ 替換為 _,最長 100 字元。

5.2.1 各層 Mutability Matrix(v0.2 補充)

Scope 改名? 新增/刪除? 觸發點(系統內 API)
ROOT (GuidantAI) (固定,OAuth connect 時建立)
PROJECT 否(project archive 才會「消失」) PUT /grc/project/<uid>
AP 多輪稽核時新增 PUT /grc/project/<pid>/ap/<ap_uid>(rename);新輪 AP 啟動時新增
CONTROL_GROUP 否(OSCAL profile)
CONTROL 否(OSCAL profile)
AO 否(OSCAL profile)
TASK 是(BPMN editor / planning page) PUT /grc/project/<pid>/job/<job_uid> (rename) / POST /grc/project/<pid>/assessment-object/<ao_uid>/jobs (create) / DELETE /grc/project/<pid>/job/<job_uid> (delete)
ARCHIVE (v0.3) AP 啟動時自動建立;user 不可由 UI 刪 INIT_PROJECT_FOLDERS (per AP);user 若手動刪 Drive 上的 _Archive/ → 下次 archive 操作時偵測 mapping is_unlinked=TRUE → 重新建立

設計確認:CONTROL_GROUP / CONTROL / AO 的名稱來源於 OSCAL profile import,對 user 而言為 immutable(無 rename API),因此 Drive 同步不需要也不應該為這三層註冊 rename hook。Spec / plan 明確不為它們補 rename API;如果未來改變這項設計,再回來補。

Task 是唯一在 evidence 檔案實際存放層之上仍可動的 entity(rename / 增 / 刪)— 因為 task 是「執行單位」,user 在 BPMN editor / planning 頁面有充分的編輯需求。

ARCHIVE 是系統管理的「歸檔桶」,名稱固定 _Archive,user 不應改名 / 刪除(即便 user 改了,下次 archive 仍會 SKIP — 因為靠 mapping drive_folder_id 識別 folder,不靠名稱;user 刪掉 folder 後系統會偵測 is_unlinked 並重建)。

5.3 Drive 權限

每個資料夾建立後,立刻呼叫 permissions.create

{ "type": "anyone", "role": "writer", "allowFileDiscovery": false }

(任何拿到連結的人可編輯,但不會被 Drive 搜尋發現)

⚠️ 此設計表示「拿到連結 = 拿到編輯權」,請在 UI 與設定文件提醒 admin 妥善保管連結。未來若需細緻 ACL,再評估改用 domain permission 限制 Workspace 範圍。

5.4 改名同步策略(系統 → Drive)

  • 系統內 entity 改名 → app service 呼叫 DriveSyncJobService.enqueue('RENAME_FOLDER', {scope, uid, new_name})
  • Worker 取 job → 找 mapping → call Drive API 改名。
  • Force overwrite:即使 user 在 Drive 上手動改過,也覆寫。
  • 失敗:retry 5 次 exponential backoff,最終失敗 → status=FAILED + alert admin(不阻擋系統內改名成功)。

5.4.1 各層 rename hook 對照(v0.2 補充)

Scope 系統 API App Service Method Hook 行為
PROJECT PUT /grc/project/<uid> ProjectService.update_project service 層偵測 name 變動 → return (dto, name_changed) → route 呼叫 try_enqueue_rename_folder
AP PUT /grc/project/<pid>/ap/<ap_uid> AssessmentPlanService.update 同上;name 組合改用 f"第{round_no}輪 - {new_name}"
CONTROL_GROUP 無 rename API(OSCAL immutable),不需 hook
CONTROL 無 rename API(OSCAL immutable),不需 hook
AO 無 rename API(OSCAL immutable),不需 hook
TASK PUT /grc/project/<pid>/job/<job_uid> JobService.update_job 比照 PROJECT / AP pattern:service 層偵測 name 變動 → return (dto, name_changed) → route 呼叫 try_enqueue_rename_folder('TASK', task_uid, f"[T-{task_uid[:8]}] {new_name}")

為何 CG / Control / AO 沒有 rename hook:這三層的名稱完全由 OSCAL profile import 決定,user 在系統 UI 上沒有改名入口;Drive 上 user 若改了名,下次系統 sync 也不會 force overwrite(因為沒有觸發 rename event)。如果未來補 rename API(例如允許 user override OSCAL 名稱顯示),再回頭補對應的 hook。

5.5 Drive → 系統「不」同步改名

User 在 Drive 上改資料夾名稱 → 偵測到後忽略(系統名稱才是 source of truth)。下次系統再改名時會被覆寫回去。


6. OAuth Flow

6.1 OAuth Client 設定(一次性)

由 product team 在 Google Cloud Console 完成:

  1. 建立 GCP project(如 guidant-ai-prod / guidant-ai-staging)。
  2. 啟用 Google Drive API。
  3. 建立 OAuth 2.0 Client ID(Web application)。
  4. 設定 Authorized redirect URIs(每環境一條)。
  5. 設定 Authorized JavaScript origins(前端 domain)。
  6. OAuth consent screen:設定 scopes、test users(unverified 階段最多 100 人)。
  7. Restricted scope verification(上 production 前 4-12 週啟動,本 spec 不涵蓋)。

6.2 Connect 流程

[Admin 在「雲端空間整合設定」頁]
        │
        │ 點「連線 Google Drive」
        ▼
GET  /api/integrations/google-drive/auth-url
  → 後端產生 state token (CSRF), 存入 Redis (TTL 10 min)
  → 組 OAuth URL: scope=drive, access_type=offline, prompt=consent, state=xxx
  ← { auth_url }
        │
        ▼
前端 window.open(auth_url, '_blank')
        │
        ▼
[Google OAuth 同意畫面]
  user 用公司共用帳號登入並同意
        │
        ▼
Google redirect → /api/integrations/google-drive/callback?code=xxx&state=yyy
  → 驗證 state(CSRF)
  → 用 code 換 tokens (access_token, refresh_token, expires_in)
  → 用 access_token 查 userinfo 拿 email
  → 加密 refresh_token 寫入 tenant_drive_integrations
  → 建立 GuidantAI 根目錄,寫入 root_folder_id 與 drive_folder_mappings (scope=ROOT)
  → 註冊 webhook channel (changes.watch),寫入 channel 資訊
  → 取 startPageToken 寫入 drive_change_cursor
  → 渲染 HTML:postMessage 通知 opener「連線成功」+ window.close()
        │
        ▼
[整合設定頁] 接到 postMessage → 重新讀狀態 → 顯示「已連線:xxx@company.com」

6.3 Disconnect 流程

[Admin 點「中斷連線」]
        │
        ▼
DELETE /api/integrations/google-drive
  → call Drive channels.stop 關掉 webhook channel
  → call Google revoke API 撤銷 refresh_token(best-effort)
  → tenant_drive_integrations.status = 'DISCONNECTED'
  → refresh_token_encrypted = NULL(clear)
  → 不刪 drive_folder_mappings(資料夾在 Drive 上仍存在)
  → 不刪 DRIVE_SYNC evidence(Minio 快取仍可下載)
  ← { status: 'disconnected' }

6.4 Re-auth 流程

同 Connect,但 callback 額外處理:

  • 若 tenant 已有 integration record → 更新 token,不重建根目錄、不重新註冊 webhook(除非已過期)。

6.5 Token Refresh

GoogleDriveTokenManager.get_valid_access_token(tenant_id)

1. 讀 tenant_drive_integrations.access_token_cache + expires_at
2. 若 expires_at > now() + 60s → decrypt cache 直接回
3. 否則:
   a. 解密 refresh_token
   b. POST https://oauth2.googleapis.com/token (grant_type=refresh_token)
   c. 收新 access_token + expires_in
   d. 加密寫回 access_token_cache + expires_at
   e. 回新 token
4. 若 refresh 失敗 invalid_grant:
   - status = REVOKED
   - 寫 last_sync_error
   - raise DriveTokenRevokedError

6.6 Token Revoke 偵測

任何 Drive API 呼叫遇到 401 invalid_credentials 或 refresh 拿到 invalid_grant

  • 立即標 status=REVOKED
  • enqueue 一個 NOTIFY_ADMIN_REAUTH job(用既有 notification 機制)
  • UI 顯示紅色 banner 提示重連
  • 既有 DRIVE_SYNC evidence 不刪(Minio 快取仍可用)

7. Webhook Setup & Channel Renewal

7.1 註冊 webhook (changes.watch)

OAuth Connect 完成後立即註冊:

channel_id = uuid4()
webhook_token = secrets.token_urlsafe(32)

drive.changes().watch(
    pageToken=startPageToken,
    body={
        "id": channel_id,
        "type": "web_hook",
        "address": f"{PUBLIC_BASE_URL}/api/webhooks/google-drive/{tenant_id}",
        "token": webhook_token,
        "expiration": int((now() + timedelta(days=7)).timestamp() * 1000),
    }
).execute()

寫入 tenant_drive_integrations.webhook_channel_idwebhook_resource_idwebhook_tokenwebhook_expires_at

7.2 Webhook receiver

POST /api/webhooks/google-drive/<tenant_id>
Headers:
  X-Goog-Channel-ID
  X-Goog-Channel-Token        ← 必須等於 DB 存的 webhook_token,否則 401
  X-Goog-Resource-ID
  X-Goog-Resource-State        ← 'sync' (initial) | 'change'
  X-Goog-Channel-Expiration

處理:
  1. 驗證 channel_id + token 對得上 tenant_drive_integrations
  2. 若 resource_state == 'sync' → 忽略(initial handshake)
  3. 否則 enqueue PROCESS_DRIVE_CHANGES job
  4. 立刻回 200(< 1s)

7.3 Channel renewal

APScheduler 每天執行一次 WebhookChannelRenewer

For each tenant where status=CONNECTED and webhook_expires_at < now() + 1 day:
  1. call drive.channels.stop on old channel
  2. register new channel (changes.watch with current cursor)
  3. update DB
  4. on failure → retry; 連續失敗 3 天 → alert admin + status=EXPIRED

7.4 DDNS / Public endpoint 注意事項

  • Dev / demo 環境若用 DDNS:建議用「自有 domain CNAME 指向 DDNS」而非直接用 DDNS provider 子域名(後者通常無法做 DNS TXT 驗證)。
  • 替代:ngrokcloudflared tunnel 起 HTTPS tunnel。
  • Production 環境:需有正式 domain + 公開 HTTPS endpoint。
  • Domain 必須在 GCP Console / Search Console 完成驗證(DNS TXT record 或 HTML 檔案)。

8. Sync Pipeline

8.1 Webhook → 變更處理 main flow

Webhook 觸發
    │
    ▼
enqueue PROCESS_DRIVE_CHANGES { tenant_id }
    │
    ▼
DriveSyncWorker 取 job
    │
    ▼
讀取 tenant_drive_integrations.drive_change_cursor
    │
    ▼
loop:
  resp = drive.changes().list(pageToken=cursor, fields=...)
  for change in resp.changes:
    if change.removed:           → handle_drive_delete(change.fileId)
    elif change.file.trashed:    → handle_drive_delete(change.fileId)
    else:
      file = change.file
      if file is folder:         → handle_folder_change(file)
      else:                      → handle_file_change(file)
  cursor = resp.nextPageToken or resp.newStartPageToken
  if not resp.nextPageToken: break
寫回 cursor

8.2 handle_file_change(file) 處理新檔 / 更新

0. (v0.3) 走 file.parents 往上 walk ancestors —— 若任一 ancestor 為 ARCHIVE mapping → SKIP
   - 實作優化:可預先 cache per-tenant 的 archive folder ID set,
     直接 file.parents ∩ archive_set 判斷(O(1)),不用每次 recursive walk
   - 目的:避免「系統把檔案 move 到 _Archive/ → webhook 觸發 → 重新 import」的迴圈

1. 確認 file.parents 中至少有一個 parent ∈ drive_folder_mappings 且 scope_type='TASK'
   - 若 parent scope_type='AO' / 其他層級(含 ARCHIVE 已在 step 0 排除)→ 忽略並回 admin alert
     (v0.2 起 evidence 必須丟到 task folder,不是 AO 容器層)
   - 若 parent 不在我們管的資料夾下 → 忽略(user 在 GuidantAI 之外的檔案不關我們事)
2. 從 TASK mapping 直接取得 job_execution_id(mapping.scope_uid = job_execution.uid)
   — 不再需要「AO → 找 active job_execution」的反查邏輯
3. 檢查 job_execution.status:
   - == 'COMPLETED' → 跳過(依 Q9.4 B),write log "skipped due to completed job"
   - else → 繼續
4. 檢查檔案大小:
   - file.size > 20MB → reject,寫一筆 "oversized" record,通知 admin (Q9.1 A)
5. 檢查檔案 MIME type:
   - 是 Google native (Docs/Sheets/Slides) → 走 LINK evidence 流程(8.3)
   - 是 binary → 走 FILE evidence 流程(8.4)

v0.2 設計收益:原 v0.1 在 §8.2 step 2 須做 AO uid → 找 active job_execution 反查(牽涉跨表 JOIN 且多 task 時無法決定要哪一個),常會誤綁;改為 TASK mapping 後,task folder 與 job_execution 直接 1:1,mapping.scope_uid 就是答案,邏輯簡化且正確。

v0.3 ARCHIVE SKIP 細節:當系統 archive 一個檔案 / 整個 task folder 時,會主動觸發 Drive API files.update?addParents=&removeParents= 把目標搬到 AP 的 _Archive/ 下。Drive 會發出 webhook 觸發 PROCESS_DRIVE_CHANGES,每筆 change 進到 _process_one_change

  • 對於 file change:跑 step 0 → ancestor 含 ARCHIVE folder ID → return(不 enqueue IMPORT)
  • 對於 folder move(task folder 整個搬入 _Archive/):在 handle_folder_change 也要做同樣 ancestor 檢查,避免重新建 mapping

手動拖回 active task folder 的處理(restore):若 user 在 Drive 上把 archived 檔案手動搬回某個 active task folder → webhook 偵測 → step 0 不命中 ARCHIVE → 走正常 import 路徑 → 寫一筆新的 evidence row(不嘗試「找回原先軟刪的 row 並 un-delete」,避免狀態追蹤複雜化)。原軟刪 evidence row 留作歷史。UI 上可能同時看到「軟刪歷史 + 新 import」兩列,可接受。

1. drive_file_id 已存在 evidence → update reference_url 即可
2. 否則 enqueue IMPORT_DRIVE_FILE,type=LINK
   - reference_url = file.webViewLink
   - description = "[Google Drive] {file.name}"
   - source = DRIVE_SYNC, evidence_type = LINK
   - drive_file_id = file.id

8.4 Binary 檔案 → FILE evidence + Minio 快取

1. drive_file_id 已存在且 modifiedTime 沒變 → 跳過(dedup)
2. drive_file_id 已存在且 modifiedTime 變了 → re-import(更新 Minio 副本)
3. 否則新 import:
   a. drive.files.get_media(file.id) 下載 byte stream
      ⚠ 若 file.size metadata 缺失或為 0:邊串流邊計算 bytes,
         超過 DRIVE_FILE_SIZE_LIMIT_MB → abort、刪除暫存、寫 oversized 紀錄
   b. file_upload_service.upload_stream(stream, filename, save_dir=JOB_EVIDENCES/{job_execution_uid})
      → 取得 Minio file_id
   c. 寫 job_evidences:
      source=DRIVE_SYNC
      evidence_type=FILE
      file_id=Minio file_id
      drive_file_id=file.id
      content_hash=md5
      drive_file_modified_at=file.modifiedTime
      created_user='drive-sync@system' (固定,不顯示真人 — Q9.3 B)
      drive_last_modifying_user_email=file.lastModifyingUser.emailAddress (預留欄位)

8.5 handle_drive_delete(file_id) 刪除同步

1. 找 evidence WHERE drive_file_id = file_id AND is_deleted = FALSE
   - 找不到 → 忽略(可能是無關檔案,或是 Move 動作的副作用)
2. 找到 → 軟刪除:
   is_deleted = TRUE
   deleted_at = now()
   deleted_reason = 'DRIVE_DELETED'
   (Minio 副本不立刻刪,預留 30 天救援期)

8.6 handle_folder_change(folder) 資料夾變化

場景一:user 改了 folder 名稱
  → 我們不同步回系統(依設計,系統是 source of truth),display_name_snapshot 不更新
  → 下次系統內 entity 改名時 force 覆寫回正確名稱
  → 注意:若系統內永遠不改名,Drive 上 user 改的名稱會永久存在(這是刻意行為,
         不額外做 periodic reconcile)

場景二:folder.parents 變了(user 把資料夾搬走)
  → 找 mapping WHERE drive_folder_id = folder.id(適用於所有 scope_type,含 TASK)
  → 若 new parent ≠ mapping.parent_drive_folder_id:
     - 視為「資料夾消失」
     - mapping.is_unlinked = TRUE
     - 若該 mapping scope_type='TASK' → 該 task 下所有 DRIVE_SYNC evidence 軟刪
       (deleted_reason='DRIVE_MOVED_OUT')
     - 若該 mapping 是上層(PROJECT/AP/CG/CONTROL/AO)→ 後代所有 task 的
       DRIVE_SYNC evidence 一併軟刪(避免 dangling)
     - enqueue CREATE_FOLDER job 重建(Q8.3 處理)

場景三:folder 被刪 (change.removed / trashed)
  → 同場景二

8.6.1 系統內 Task CRUD 對 Drive 的影響(v0.2 補充)

系統內 task 的 create / rename / delete 都是「系統 → Drive」單向同步,由 hook 進 JobService 對應方法觸發。

Task 操作 系統 API Drive 端動作 設計理由
Task create POST /grc/project/<pid>/assessment-object/<ao_uid>/jobs enqueue CREATE_FOLDER (scope_type=TASK),新 task folder 出現在 AO 容器下 BPMN editor 新增 Task 節點 / 切分多人 task 後,evidence 上傳前 Drive 必須先有對應 folder
Task rename PUT /grc/project/<pid>/job/<job_uid> enqueue RENAME_FOLDER (scope_type=TASK);force overwrite task name 改了 → Drive 上 folder 顯示名跟著變,user 從 Drive 端找得到
Task delete (v0.3 修訂) DELETE /grc/project/<pid>/job/<job_uid> 把 task folder move 到 AP 的 _Archive/;mapping 標 is_unlinked=TRUE 統一系統刪除 UX;archive 後 Drive 上仍可下載證據;ancestor=ARCHIVE 使 sync 不會 re-import;user 不需切換到 Drive 操作

Task delete 詳細處理(v0.3 設計決策)

1. 系統內 JobService.delete_job 完成 task 軟刪(DB tx commit)
2. Hook 進 JobService.delete_job 在 commit 後呼叫
   DriveSyncOrchestrationService.try_archive_task_folder(tenant_id, task_uid)
3. orchestration:
   a. 找 task mapping (scope_type='TASK', scope_uid=task_uid) → 拿 task folder ID
   b. 從 task → AO → Control → CG → AP 反推所屬 AP,取對應 ARCHIVE mapping
      - 若 ARCHIVE mapping 不存在 / is_unlinked=TRUE → 重建 `_Archive/`
   c. 呼叫 Drive API:files.update?addParents={archive_id}&removeParents={ao_id}
      (搬整個 folder + 內含檔案到 _Archive/)
   d. update task mapping SET is_unlinked=TRUE
4. Best-effort:Drive API 失敗只 log warning + 寫 sync job 失敗紀錄,不阻擋系統內軟刪
5. 系統內 evidence 紀錄保留(軟刪 task 不刪 evidence;歷史視角可查)

為何 v0.3 改成 archive 而非「保持原位」

  • v0.2「保持原位 + mapping unlinked」會讓 user 在 Drive 上看到「明明 task 已刪除但 folder 還在原地」造成混淆
  • archive 後 Drive 上的檔案統一收到 _Archive/,視覺上明確分離 active vs deleted
  • 與單一 evidence delete 的行為一致(兩者都 archive)
  • 避免誤刪:folder 仍在 Drive,user 隨時可救援
  • ancestor=ARCHIVE 自動 SKIP,不會被當成新 evidence 重新 import

8.7 重建資料夾 (CREATE_FOLDER for unlinked)

1. 查 mapping(適用所有 scope_type,含 TASK)
2. 確認 parent mapping 仍存在
3. drive.files.create (folder, parent=parent_drive_folder_id, name=display_name_snapshot)
4. set permission anyoneWithLink+writer
5. 更新 mapping: drive_folder_id=新id, is_unlinked=FALSE
6. 該 scope 是 TASK → 同時 enqueue RECONCILE_TASK_FOLDER 確保資料對齊
   - 若 user 把搬走的舊資料夾再搬回(或往新資料夾上傳檔案),
     RECONCILE 會重新 import;之前因 DRIVE_MOVED_OUT 軟刪的 evidence
     不會自動 un-delete(被視為「新檔案」處理),避免狀態混亂。
7. 該 scope 是 AO(容器層)→ 重建後對其下所有 task mapping 各 enqueue 一次
   RECONCILE_TASK_FOLDER(task folder 的 parent 變了,需要連動重建)。
8. 上層(PROJECT/AP/CG/CONTROL)重建類似:先建自己 → 對所有後代依序重建(CREATE_FOLDER)
   → 對所有最底層 TASK 各跑 RECONCILE_TASK_FOLDER。

8.8 RECONCILE_TASK_FOLDER(v0.2 改名)

v0.1 名為 RECONCILE_AO_FOLDER;v0.2 改成以 task folder 為單位(更精準),enum 與 job_type 同步改名為 RECONCILE_TASK_FOLDER。AO-level 對帳由「對該 AO 下每個 task 各跑一次 RECONCILE_TASK_FOLDER」達成。

Payload: { tenant_id, task_uid (= job_execution.uid) }

觸發時機:
  - job revert (COMPLETED → PROCESSING) 時補抓 COMPLETED 期間的檔案
    (revert hook 拿 job_execution.uid 直接 enqueue,不用走 AO 反查)
  - Task / 上層資料夾重建後(見 §8.7)

流程:
  1. 查 mapping (scope_type='TASK', scope_uid=task_uid) → 拿 drive_folder_id
  2. drive.files.list (q="'{task_folder_id}' in parents and trashed=false")
  3. for each file:
     若 evidence WHERE drive_file_id=file.id AND is_deleted=FALSE 不存在
       → enqueue IMPORT_DRIVE_FILE { drive_file_id, task_mapping_uid }
  4. for each evidence WHERE job_execution_id=task.id
        AND source=DRIVE_SYNC AND is_deleted=FALSE:
     若 drive_file_id 不在當前 Drive 列表中
       → 軟刪 (DRIVE_DELETED)

8.9 Worker 並行策略

  • 同一個 tenant 的 job 序列化處理(用 advisory lock 或 SELECT FOR UPDATE SKIP LOCKED)。
  • 不同 tenant 可並行(pool size 配置)。
  • 失敗:exponential backoff(1m, 5m, 15m, 1h, 6h)。
  • max_retries 用完 → status=FAILED → admin 在整合設定頁可看到失敗清單,可手動重試。

9. Project Start 整合

9.1 觸發時機

OscalProjectStartRoute.post 主流程結束後(21 張 table 寫入完成):

# api/oscal/projects/start_route.py(既有)
result = oscal_project_start_service.start(...)
return success(result)

↓ 改為

result = oscal_project_start_service.start(...)
# 新增:若該 tenant 有 Drive 整合,enqueue 資料夾建立
if tenant_drive_integration_service.is_connected(tenant_id):
    drive_sync_job_service.enqueue('INIT_PROJECT_FOLDERS', {
        'tenant_id': tenant_id,
        'project_uid': result.project_uid,
    })
return success(result)

Transaction 邊界

  • oscal_project_start_service.start(...) 內部的 21 張 table 寫入維持原本的 @transaction
  • enqueue('INIT_PROJECT_FOLDERS', ...) 在 start transaction commit 之後才呼叫,自帶獨立的 @transaction
  • 若 enqueue 失敗(例如 DB 暫時無法寫 drive_sync_jobs)→ project 已啟動成功,但 Drive 資料夾不會自動建。 admin 可在「雲端空間整合設定」頁手動觸發重建(提供一支 POST /integrations/google-drive/projects/<uid>/init-folders)。

9.2 INIT_PROJECT_FOLDERS worker 邏輯(v0.2 — 6 層)

1. 確認 tenant_drive_integration.status == CONNECTED, 否則 SKIP
2. 確認根目錄 mapping 存在,否則建
3. 建立 PROJECT folder(parent = root)
4. for each AP in project:
     建立 AP folder(parent = project,名稱 "第N輪 - {ap.name}")
     for each Control Group:
        建立 CG folder
        for each Control in CG:
           建立 Control folder
           for each AO in Control:
              建立 AO 容器 folder(parent = control, 名稱 "{ao.title}")
              寫 mapping (scope=AO, scope_uid=ao_uid, drive_folder_id=...)
              for each Task (job_execution) of this AO in this AP cycle:
                 建立 Task folder
                   parent = ao folder
                   名稱 "[T-{task_id}] {task_name}"  (task_id = job_execution.uid[:8])
                 寫 mapping (scope=TASK, scope_uid=job_execution.uid, drive_folder_id=...)
5. 每建一個 folder:set anyoneWithLink+writer permission
6. 為避免 Drive API rate limit (1000 req / 100s / user),
   batch 處理 + sleep;單一 project 含 task 預估 < 10 分鐘完成
   (比 v0.1 多 N 個 task folder,N = AO 數 × 平均 task 數)

新增 task 補建:若 AO init 完成後再加 task(指派多人 / split),由 task creation hook 偵測並 enqueue CREATE_FOLDER (scope=TASK),不重跑 INIT。

9.3 失敗處理

  • 中途失敗(如 token 過期)→ 已建的 mapping 不刪 → retry 時 worker 從未建的 scope 繼續。
  • 單個資料夾建立失敗(如名稱衝突)→ 略過該分支,繼續其他,最後一次性 alert admin。

10. Two-source Evidence Model

Evidence 綁定層級(v0.2):所有 evidence(不分 SYSTEM_UPLOAD / DRIVE_SYNC)都直接綁 job_evidences.job_execution_id,亦即「task」。Drive 同步路徑為 Drive task folder → drive_folder_mappings(scope_type='TASK') → job_executions → 寫 evidence,AO 層只是 UI 上的容器概念,不參與 evidence 綁定。

10.1 來源區分

屬性 SYSTEM_UPLOAD DRIVE_SYNC
Source of truth 系統 Google Drive
Minio ✓ 原檔 ✓ 快取副本
DB (job_evidences) ✓(多 drive_file_id 欄位)
Drive ❌ 不寫入 ✓ user 自行管理
系統內可下載 ✓(從 Minio)
系統內可預覽(docx→pdf) ✓(檔案在 Minio,邏輯一致)
系統內可刪除(v0.3) ✓(會把 Drive 檔案 move 到 _Archive/
Drive 上刪除影響 ✓ 軟刪除

v0.3 系統刪除行為統一(取代 v0.1 / v0.2 的「禁刪 DRIVE_SYNC」規則):

系統操作 SYSTEM_UPLOAD evidence DRIVE_SYNC evidence
刪除單一 evidence Minio 檔案保留(30 天救援期) + DB 軟刪 Drive 上 move 檔案到 _Archive/(best-effort)+ DB 軟刪
刪除整個 task Mapping 標 unlinked + task 下所有 evidence 軟刪 Drive 上 move 整個 task folder 到 _Archive/(best-effort)+ mapping 標 unlinked + evidence 軟刪

Archive 動作為 best-effort:Drive API 失敗不阻擋系統內軟刪 commit;失敗時 log warning + 寫 sync job 失敗紀錄,admin 可在「失敗紀錄」介面看到並手動處理。

10.2 UI 顯示

AO Evidence 區塊(AuditControlRef.vue)改造:

┌─ Evidence (5) ──────── [🔗 此 AO 的 Google Drive 資料夾] ─┐
│                                                          │
│  [📁 Drive] policy_v2.pdf       2026-04-20 14:30         │
│             ⬇ 下載  👁 預覽  🔗 在 Drive 開啟              │
│                                                          │
│  [💾 系統]  audit_screenshot.png  bob  2026-04-19 10:15  │
│             ⬇ 下載  👁 預覽  🗑 刪除                       │
│                                                          │
│  [📁 Drive] meeting_notes (Google Doc)                   │
│             🔗 在 Drive 開啟(LINK type)                  │
└──────────────────────────────────────────────────────────┘

Badge 顏色:

  • 📁 Drive: 藍色 outline
  • 💾 系統: 灰色

10.3 預覽功能影響

User 既有的 docx → PDF → iframe 預覽功能 無需修改

  • 兩種來源的 binary 檔案都在 Minio
  • 預覽 service 從 Minio file_id 取檔,不需要 source 區分

10.4 Drive 資料夾 / 檔案連結

  • AO 區塊頂部顯示「此 AO 的 Drive 資料夾」連結 = AO mapping 對應的 Drive folder URL(自組 https://drive.google.com/drive/folders/{ao_drive_folder_id})。User 進到 AO 容器層後可看到下面所有 task folder。
  • 每個 task 區塊(在 evidence 列表分組顯示)顯示「此任務的 Drive 資料夾」連結 = TASK mapping 對應的 Drive folder URL。User 點進去就能直接上傳 evidence 到正確的 task。
  • 個別 DRIVE_SYNC evidence 的「在 Drive 開啟」 = https://drive.google.com/file/d/{drive_file_id}/view

Spec 設計上預期 user 主要走「task folder 連結」路徑直接上傳檔案;AO folder 連結只供瀏覽 / 對照用。


11. Edge Cases & Failure Modes

情境 處理
Tenant 沒設 Drive 整合 Project start 跳過資料夾建立;UI 不顯示「Drive 連結」按鈕
Token revoke 期間有 Drive 變更 webhook 仍會打進來,但 worker call API 失敗 → status=REVOKED → 通知 admin
Drive API rate limit (429) exponential backoff retry
Webhook channel 過期未 renew 偵測到後重新註冊;錯過的變更靠 changes.list(pageToken=cursor) 補回
User 刪除 GuidantAI 根目錄 is_unlinked=TRUE,下次 sync 重建根目錄 + 整套結構(用 RECONCILE_TASK_FOLDER 對每個 task folder 補檔案)
同一 tenant 多人 admin 嘗試 connect DB UNIQUE constraint 阻擋;UI 顯示「已連線:xxx」+ 重新連線需先 disconnect
檔案上傳到 GuidantAI 之外的 Drive 區域 忽略(不在我們管的 mapping 內)
檔案 > 20MB reject + 寫 oversized 紀錄 + alert
Google Doc / Sheet / Slide 走 LINK evidence(reference_url=webViewLink)
檔案被搬到別的 task 資料夾 視為原 task 刪除 + 新 task 新增(兩筆變更)
檔案被丟到 AO 容器層(非 task folder) 忽略 + 寫一筆 admin alert(v0.2 起 evidence 必須在 task folder)
新 task 加到既有 AO(AO 已 init 完) JobService.create_job hook 偵測到 → orchestration try_enqueue_create_task_folder → enqueue CREATE_FOLDER (scope_type=TASK),task folder 出現在 AO 容器下;觸發點:BPMN editor 新增 Task 節點 / planning 頁面新增 task
Task 被改名 JobService.update_job hook 偵測 name 變動 → orchestration try_enqueue_rename_folder('TASK', ...) → worker 改 Drive folder name;force overwrite
Task 被刪 / 軟刪 / 取消(v0.3 修訂) JobService.delete_job hook → orchestration try_archive_task_folder(task_uid) → 把該 task folder move 到對應 AP 的 _Archive/;同時 mapping 標 is_unlinked=TRUE、evidence 軟刪。Drive 上 task folder 仍存在(在 _Archive/ 下),user 可下載證據;新檔案就算被 user 拖進去也 SKIP(ancestor 為 ARCHIVE)
系統內刪 DRIVE_SYNC 單一 evidence(v0.3) JobEvidenceService.delete_job_evidence 不再 raise GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE;改呼叫 orchestration try_archive_drive_file(evidence)(best-effort:Drive 上 move 檔案到 _Archive/)→ DB 軟刪 evidence。Drive 失敗仍允許 DB 軟刪 commit。
User 手動把檔案拖進 _Archive/ webhook 偵測 → _process_one_change 看到 file 從 active task folder 消失(ancestor 變了)→ 走「該檔案在原 task 已不見」邏輯 → 軟刪對應 evidence (deleted_reason=DRIVE_MOVED_OUT)。新位置(ancestor 為 ARCHIVE)SKIP,不重新 import。
User 手動把 archived 檔案拖回 active task folder(restore) webhook 偵測 → ancestor 不再含 ARCHIVE → 走正常 handle_file_change → import 為 evidence row(不嘗試 un-delete 原 row,避免狀態追蹤複雜化)。原軟刪 row 留歷史。
User 手動刪掉 _Archive/ folder webhook → handle_folder_change 偵測 ARCHIVE mapping 對應的 folder 消失 → mapping is_unlinked=TRUE。下次系統需 archive 時,orchestration 偵測 mapping unlinked → 重建 _Archive/(同 §8.7 重建邏輯)後再執行 archive。
同名檔案被 archive 多次 Drive 自動加後綴 file.pdffile (1).pdffile (2).pdf — 可接受,不額外處理。
Job COMPLETED 後 Drive 仍被加檔 worker 偵測到 → SKIP,不 import
Job revert(COMPLETED → PROCESSING) enqueue RECONCILE_TASK_FOLDER(payload 帶 task_uid)補抓 COMPLETED 期間的檔案
重複 webhook(同一變更觸發多次) drive_file_id UNIQUE + modifiedTime 比對防重複 import
Drive 帳號暫時離線 / 503 retry 機制

12. API Endpoints

12.1 整合管理 API

GET    /api/integrations/google-drive
       回傳當前 tenant 的整合狀態(不含 token)

POST   /api/integrations/google-drive/auth-url
       產生 OAuth URL(含 state CSRF token)
       Response: { auth_url }

GET    /api/integrations/google-drive/callback?code=&state=
       OAuth callback(HTML response)

DELETE /api/integrations/google-drive
       中斷連線

POST   /api/integrations/google-drive/sync
       手動觸發 PROCESS_DRIVE_CHANGES(user 點「立即同步」)

GET    /api/integrations/google-drive/sync-jobs?status=&page=
       查 sync job 歷史 / 失敗清單(admin only)

POST   /api/integrations/google-drive/sync-jobs/<uid>/retry
       手動 retry 某筆失敗 job

POST   /api/integrations/google-drive/projects/<project_uid>/init-folders
       手動觸發資料夾重建(用於 project start enqueue 失敗的補救)

12.2 Webhook receiver

POST   /api/webhooks/google-drive/<tenant_id>
       Google 推播進來

12.3 既有 Evidence API 修改

GET    /job-evidences?job_execution_uid=
       Response 新增 source / drive_file_id / drive_url 欄位

DELETE /job-evidence/<uid>
       (v0.3) SYSTEM_UPLOAD / DRIVE_SYNC 都允許刪除
       - SYSTEM_UPLOAD:原邏輯(DB 軟刪 + Minio 保留 30 天)
       - DRIVE_SYNC:先呼叫 orchestration `try_archive_drive_file(evidence)`
                     把 Drive 檔案 move 到 AP 的 `_Archive/` (best-effort) → 再 DB 軟刪
       - 不再 raise GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE(v0.3 已 deprecated 該 error code)

權限要求:

  • 整合管理:tenant admin
  • Webhook:無 JWT(由 X-Goog-Channel-Token 驗證)
  • 手動同步:tenant admin
  • 既有 evidence API:維持原有(manager / participant)

13. Frontend Changes

13.1 新頁面:「雲端空間整合設定」

Route: /settings/cloud-integrations(admin only)

Components:

  • CloudIntegrationsView.vue — 容器頁
  • GoogleDriveIntegrationCard.vue — Drive 區塊
  • SyncJobHistoryDialog.vue — 失敗清單 dialog

狀態 UI:

未連線:
┌─────────────────────────────────────────────────┐
│  Google Drive                                    │
│  📦 透過 Drive 上傳 evidence,自動同步進系統。      │
│                                                  │
│  ⚠ 將授權系統存取連線帳號的整個 Drive,建議使用    │
│     dedicated 共用公司帳號。                       │
│                                                  │
│              [連線 Google Drive]                 │
└─────────────────────────────────────────────────┘

已連線:
┌─────────────────────────────────────────────────┐
│  Google Drive                                    │
│  ✅ 已連線:compliance@company.com                 │
│  🗂 根目錄:GuidantAI/  [🔗 開啟]                 │
│  ⏱ 上次同步:3 分鐘前                              │
│  🔌 Webhook 到期:2026-04-26                      │
│                                                  │
│  [立即同步] [查看失敗紀錄] [中斷連線] [重新授權]    │
└─────────────────────────────────────────────────┘

異常狀態(REVOKED / EXPIRED):
紅色 banner + [重新授權]

13.2 OAuth callback 處理

新增 src/views/integrations/GoogleDriveCallback.vue

  • 接收後端 redirect 後的頁面(其實是後端 render 的 HTML,但若用前端 redirect 模式也可走這頁)
  • window.opener.postMessage('drive-connected') 通知 opener
  • window.close()

或更簡單:後端 callback 直接 render HTML 含 inline script 做 postMessage + close(不經過 Vue route)。

13.3 既有 AO Evidence UI 改造

src/components/grc/AuditControlRef.vue:

  • Evidence 列表加 source badge
  • DRIVE_SYNC evidence 顯示「🗑 刪除」按鈕(v0.3:與 SYSTEM_UPLOAD 一致),點擊呼叫 DELETE API → 後端負責把 Drive 檔案 move 到 _Archive/ + 軟刪 DB;UI toast 提示「已刪除(檔案已移至 Drive Archive)」
  • 同時保留「🔗 在 Drive 開啟」按鈕(供刪除前查看 / 下載)
  • AO 區塊頂部加「📁 此 AO 的 Google Drive 容器」按鈕(前提:tenant 有連 Drive)— 點擊到 AO folder 看所有 task folder
  • 每個 task 區塊頂部另加「📁 此任務的 Drive 資料夾」按鈕,點擊到該 task 的專屬 folder(user 上傳檔案的入口)

src/components/grc/JobExecutionDrawer.vue:

  • 此 component 是「進到單一 task」的視角,evidence 區塊頂部直接顯示 task folder 連結(不需要再顯示 AO 容器層連結)
  • 其他改造同上(badge / DRIVE_SYNC 可刪 / 在 Drive 開啟)

Backend response 規範:

  • GET /job-evidences?job_execution_uid=... response envelope metatask_drive_folder_url(單一 task 視角)
  • AO 視角的 list endpoint(如有 grouping by task)response 各 task 群組內附 task_drive_folder_url,AO 層 response top-level 附 ao_drive_folder_url

v0.3 移除 §13.3.1:原 v0.2 §13.3.1「已刪除 Task 的 evidence 顯示」段落已移除。理由:依 user feedback,「UI 上已經看不到任務了的話,就不需理會」— 已刪除的 task 在系統 UI 上不出現,evidence 列表自然也不會看到,毋需特殊樣式 / 旗標處理。

13.4 新 Service

src/service/CloudIntegrationService.js:

class CloudIntegrationService extends BaseService {
  async getGoogleDriveStatus()
  async getAuthUrl()
  async disconnect()
  async triggerSync()
  async listSyncJobs(params)
  async retrySyncJob(uid)
}

API endpoint constants (in src/config/api/api.js):

INTEGRATION_GDRIVE: '/integrations/google-drive'
INTEGRATION_GDRIVE_AUTH_URL: '/integrations/google-drive/auth-url'
INTEGRATION_GDRIVE_SYNC: '/integrations/google-drive/sync'
INTEGRATION_GDRIVE_SYNC_JOBS: '/integrations/google-drive/sync-jobs'

13.5 路由

// router/index.js
{
  path: '/settings/cloud-integrations',
  name: 'CloudIntegrations',
  component: CloudIntegrationsView,
  meta: { requiresAuth: true, requiresAdmin: true }
}

14. Permissions & Roles

操作 角色
連線 / 中斷 / 重新授權 Google Drive tenant admin
觸發手動同步 tenant admin
查 sync jobs / 重試失敗 job tenant admin
看 AO Drive 資料夾連結 任何 evidence 可看權限的人
看 DRIVE_SYNC evidence 的「在 Drive 開啟」 同上
刪除 SYSTEM_UPLOAD evidence 既有規則(manager 等)
刪除 DRIVE_SYNC evidence (v0.3) 同 SYSTEM_UPLOAD(manager 等);後端會自動把 Drive 檔案 move 到 _Archive/
觸發 INIT_PROJECT_FOLDERS 系統自動(PM 啟動專案)

15. Error Codes

新增於 common/code/grc_error_code.py(接續現有序號):

Code Constant 訊息
GRC_400030 GRC_DRIVE_OAUTH_STATE_INVALID OAuth 狀態驗證失敗
GRC_400031 GRC_DRIVE_FILE_OVERSIZED Drive 檔案超過大小限制
GRC_401010 GRC_DRIVE_TOKEN_REVOKED Drive 連線已失效,請重新授權
GRC_403020 GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE Drive 同步的證據請至 Google Drive 刪除 — v0.3 deprecated(保留定義但不再 raise;既有引用可繼續存在)。v0.3 起 DRIVE_SYNC evidence 改為「系統刪除時把 Drive 檔案 move 到 _Archive/
GRC_404030 GRC_DRIVE_INTEGRATION_NOT_FOUND 此 tenant 尚未連線 Google Drive
GRC_404031 GRC_DRIVE_FOLDER_MAPPING_NOT_FOUND Drive 資料夾對應不存在
GRC_409020 GRC_DRIVE_INTEGRATION_ALREADY_EXISTS Google Drive 已連線,請先中斷
GRC_412020 GRC_DRIVE_WEBHOOK_TOKEN_INVALID Webhook 驗證失敗
GRC_500030 GRC_DRIVE_API_ERROR Google Drive API 錯誤

16. Migration Plan

16.1 SQL migrations

依 CLAUDE.md 規範,每段加日期註解,新 table 給 cm_app 權限:

scripts/sql/2026-MM-DD-google-drive-sync.sql

-- Date: 2026-MM-DD

-- 1. 建立 tenant_drive_integrations (2026-MM-DD)
CREATE TABLE compliance.tenant_drive_integrations (...);
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.tenant_drive_integrations TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.tenant_drive_integrations_id_seq TO cm_app;

-- 2. 建立 drive_folder_mappings (2026-MM-DD)
CREATE TABLE compliance.drive_folder_mappings (...);
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.drive_folder_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.drive_folder_mappings_id_seq TO cm_app;

-- 3. 建立 drive_sync_jobs (2026-MM-DD)
CREATE TABLE compliance.drive_sync_jobs (...);
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.drive_sync_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.drive_sync_jobs_id_seq TO cm_app;
CREATE INDEX idx_drive_sync_jobs_pending ON compliance.drive_sync_jobs (tenant_id, status, next_run_at) WHERE status = 'PENDING';

-- 4. job_evidences 新增欄位 (2026-MM-DD)
ALTER TABLE compliance.job_evidences
  ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'SYSTEM_UPLOAD',
  ADD COLUMN drive_file_id VARCHAR(100),
  ADD COLUMN drive_file_modified_at TIMESTAMPTZ,
  ADD COLUMN drive_last_modifying_user_email VARCHAR(255),
  ADD COLUMN is_deleted BOOL NOT NULL DEFAULT FALSE,
  ADD COLUMN deleted_at TIMESTAMPTZ,
  ADD COLUMN deleted_reason VARCHAR(40);
CREATE UNIQUE INDEX idx_job_evidences_drive_file_id ON compliance.job_evidences (drive_file_id) WHERE drive_file_id IS NOT NULL;

-- 5. RLS policies for new tables (2026-MM-DD)
-- 沿用現有 tenant_id RLS pattern

16.2 環境變數新增

config/<env>.py

GOOGLE_DRIVE_OAUTH_CLIENT_ID
GOOGLE_DRIVE_OAUTH_CLIENT_SECRET
GOOGLE_DRIVE_OAUTH_REDIRECT_URI
DRIVE_TOKEN_ENCRYPTION_KEY      # Fernet key (URL-safe base64)
DRIVE_WEBHOOK_PUBLIC_BASE_URL   # https://xxx.example.com
DRIVE_SYNC_WORKER_POOL_SIZE     # default 4
DRIVE_FILE_SIZE_LIMIT_MB        # default 20

16.3 套件新增

pyproject.toml

  • google-api-python-client
  • google-auth-oauthlib
  • apscheduler
  • cryptography(Fernet,可能已有)

16.4 Rollback

  • 新表 drop 即可。
  • job_evidences 新欄位都有預設值,舊 query 不受影響;rollback 時可保留欄位(資料不刪)。

17. Pre-implementation Checklist(事前準備)

實作前需要 product team / ops 完成:

17.1 Google Cloud 設定

17.2 Domain / DDNS

17.3 環境變數 / Secret

17.4 共用 Drive 帳號

17.5 套件 / 工具


18. Open Questions(spec 階段未解,後續再議)

  1. OAuth verification 時程:full drive scope 上 production 前需跑 Google verification(4-12 週)。這個流程要由誰啟動、預算為何?
  2. KMS 升級時程:第一版用 Fernet + env var,何時升級到 AWS KMS?是否要寫到 production 第一個 release 的 must-have?
  3. 多 tenant 共用 OAuth client 的 ID 額度:unverified 100 user 上限是否足夠覆蓋所有 staging tenant?
  4. Drive 帳號儲存空間配額耗盡:當共用帳號 Drive 滿了該怎麼通知 admin?monitor 機制要做到什麼程度?
  5. 稽核匯出格式drive_last_modifying_user_email 欄位已預留並存入。當客戶稽核要求匯出「實際 Drive 上傳人」時,要在哪裡開放此欄位顯示 / 匯出?是 audit report PDF、CSV 匯出、還是 UI hover tooltip?

19. Out of Scope(v1)

  • OneDrive / Dropbox / Box 等其他雲端整合
  • 系統 → Drive 雙向同步(只做 Drive → 系統)
  • Drive 端細粒度 ACL(只用 anyoneWithLink+writer
  • Drive 檔案版本歷史的同步
  • Drive 上多人協同編輯通知
  • 一個 tenant 多組 Drive 帳號
  • 匯出整個 AO 的 evidence 包到 Drive
  • AI 自動分類 / 標籤 Drive 檔案
  • 細粒度 quota 管理 / 用量分析
  • 整合到 jedi-flow-engine workflow(Drive 上傳是否觸發 workflow 事件 — 未來再評估)