| 項目 | 內容 |
|---|---|
| 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 端對端測試後):
- Auto-recovery on Drive (re)connect:OAuth callback 結尾自動掃描「tenant 內未同步 project」並 enqueue
INIT_PROJECT_FOLDERS。User 不需手動點任何按鈕,連線完即補建(解決 Phase 3 之前 user 必須在 UI 一個個點 sync 的痛點)。- Project 列表加
drive_syncedflag + 同步按鈕:drive_synced 在 route 層 batch 查(避免 grc ↔︎ cloud_integration container 雙向 override 觸發 dependency-injector 無限遞迴);UI 顯示「已連動 / 未與 Google Drive 同步」tag,未同步者多一個 Google icon 按鈕直接觸發 init。- FE 整段 Drive UI 由 tenant 整合狀態 gate:mount 時打
GET /api/1.0/integrations/google-drive;status != 'CONNECTED' 不顯示任何 Drive 相關 tag / button(避免沒接 Drive 的 tenant 看到莫名其妙的 UI)。- Init handler ROOT 健康檢查:每次 init 在 reuse existing ROOT mapping 前
drive.get_folder驗證;404 / trashed → wipe tenant 全部 mapping + resetroot_folder_id→ 落到 fresh create。避免 user 手動刪 GuidantAI 後 init job 卡 404 retry 死循環。- Webhook self-event skip — 只對 folder change:INIT / RENAME / ARCHIVE 是我們對 folder 動的,user 只對 file 動。早期版本連 file event 一起 skip 害 user 上傳檔案完全 import 不進來(OAuth 帳號 = user 個人帳號的 dev 場景特別常見)。file events 永不 self-skip。
- Init handler 同層 parallel:
ThreadPoolExecutor(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 在限制內。- Webhook URL prefix bugfix:
webhook_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 並存。- Admin endpoint 整合:
POST /webhook/register同時做 webhook register + project 補建。Response 多回backfilled_projects。- DriveFolderMappingDomainService 補
get_by_uid(漏 wrap,ImportDriveFileHandler crash bug)。- 文案:v0.3 寫的
Drive 同步中/未同步改為已連動 Google Drive/未與 Google Drive 同步(避免「同步中」被誤讀為動作進行中);evidence row 的Drivebadge 改為Google Drive;新增tooltip_open_in_drive/ref_open_in_drivei18n key。詳見 changelog:
docs/changelog/2026-04-24-google-drive-phase3-followup-and-perf.md
v0.3 重大變更:
- 移除 v0.1 / v0.2「禁止系統內刪 DRIVE_SYNC evidence」規則。系統刪 DRIVE_SYNC evidence 時改為 把 Drive 上的檔案 move 到
_Archive/資料夾,並 DB 軟刪除。Sync pipeline 偵測到 ancestor 為_Archive/就 SKIP,避免「系統刪 → Drive 還在 → webhook 重新 import」的迴圈。- 系統刪整個 task 也改為 把 task folder move 到
_Archive/(取代 v0.2「mapping 標 unlinked + Drive folder 留原處」設計),統一 SYSTEM_UPLOAD / DRIVE_SYNC 兩種 evidence 的刪除 UX。- 每個 AP 下自動建立一個
_Archive/folder(per-AP,flat 結構,無子目錄),登錄為drive_folder_mappings.scope_type='ARCHIVE'(scope_uid= AP uid)。- 移除 v0.2 §13.3.1「已刪除 task 的 evidence UI 顯示」段落(依使用者意見:UI 上看不到 task 就不需特別處理)。
GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCEerror 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_execution1:1 對應,sync 路徑明確無歧義。代價:單一 task 的 AO(最常見情境)多一層資料夾點擊,但換來 multi-task 場景的正確性。
anyoneWithLink + writer。| 名詞 | 說明 |
|---|---|
| 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。 |
┌─────────────────────────────────────────────────────────────────┐
│ 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}/... │
└──────────────────────┘ └──────────────────────────┘
核心元件:
FernetCrypto,未來可換 AwsKmsCrypto。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.uid。ARCHIVE 時為該 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.sql的chk_drive_folder_mappings_scope不含'TASK',需新增 migration2026-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'))。獨立 migration2026-04-24-add-archive-scope-to-drive-folder-mappings.sql(接於 task scope migration 之後)。
compliance.drive_sync_jobsDB-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.listIMPORT_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 改名)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。
{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。
第{round_no}輪 - {AP display name},例如 第1輪 - 2026Q2 內稽。系統內改名 / 輪次調整 → best-effort 同步。[{code}] {display name}。code 是 OSCAL ID(如 AC-2、AC-2.a)幾乎不變;display name 改了 → best-effort 同步。{AO title},不加 [{ao_code}] prefix。原因:AO title 本身已含子項標記(如 [a] 確認...、[b] 驗證...),再加 OSCAL 碼會冗長。[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)並回頭跟產品討論補欄位。/、\)→ 替換為 _,最長 100 字元。| 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 — 因為靠 mappingdrive_folder_id識別 folder,不靠名稱;user 刪掉 folder 後系統會偵測is_unlinked並重建)。
每個資料夾建立後,立刻呼叫 permissions.create:
{ "type": "anyone", "role": "writer", "allowFileDiscovery": false }
(任何拿到連結的人可編輯,但不會被 Drive 搜尋發現)
⚠️ 此設計表示「拿到連結 = 拿到編輯權」,請在 UI 與設定文件提醒 admin 妥善保管連結。未來若需細緻 ACL,再評估改用
domainpermission 限制 Workspace 範圍。
DriveSyncJobService.enqueue('RENAME_FOLDER', {scope, uid, new_name})。| 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。
User 在 Drive 上改資料夾名稱 → 偵測到後忽略(系統名稱才是 source of truth)。下次系統再改名時會被覆寫回去。
由 product team 在 Google Cloud Console 完成:
guidant-ai-prod / guidant-ai-staging)。[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」
[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' }
同 Connect,但 callback 額外處理:
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任何 Drive API 呼叫遇到 401 invalid_credentials 或 refresh 拿到 invalid_grant:
NOTIFY_ADMIN_REAUTH job(用既有 notification 機制)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_id、webhook_resource_id、webhook_token、webhook_expires_at。
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)
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
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
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
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 (預留欄位)
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 天救援期)
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)
→ 同場景二
系統內 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 而非「保持原位」:
_Archive/,視覺上明確分離 active vs deletedCREATE_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。
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)
SELECT FOR UPDATE SKIP LOCKED)。於 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)。
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。
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 綁定。
| 屬性 | 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 可在「失敗紀錄」介面看到並手動處理。
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 顏色:
User 既有的 docx → PDF → iframe 預覽功能 無需修改:
https://drive.google.com/drive/folders/{ao_drive_folder_id})。User 進到 AO 容器層後可看到下面所有 task folder。https://drive.google.com/file/d/{drive_file_id}/viewSpec 設計上預期 user 主要走「task folder 連結」路徑直接上傳檔案;AO folder 連結只供瀏覽 / 對照用。
| 情境 | 處理 |
|---|---|
| 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.pdf、file (1).pdf、file (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 機制 |
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 失敗的補救)
POST /api/webhooks/google-drive/<tenant_id>
Google 推播進來
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)
權限要求:
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 + [重新授權]
新增 src/views/integrations/GoogleDriveCallback.vue:
window.opener.postMessage('drive-connected') 通知 openerwindow.close()或更簡單:後端 callback 直接 render HTML 含 inline script 做 postMessage + close(不經過 Vue route)。
src/components/grc/AuditControlRef.vue:
_Archive/ + 軟刪 DB;UI toast 提示「已刪除(檔案已移至 Drive Archive)」src/components/grc/JobExecutionDrawer.vue:
Backend response 規範:
GET /job-evidences?job_execution_uid=...response envelopemeta加task_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 列表自然也不會看到,毋需特殊樣式 / 旗標處理。
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'
// router/index.js
{
path: '/settings/cloud-integrations',
name: 'CloudIntegrations',
component: CloudIntegrationsView,
meta: { requiresAuth: true, requiresAdmin: true }
}| 操作 | 角色 |
|---|---|
| 連線 / 中斷 / 重新授權 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 啟動專案) |
新增於 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 錯誤 |
依 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 patternconfig/<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
pyproject.toml:
google-api-python-clientgoogle-auth-oauthlibapschedulercryptography(Fernet,可能已有)job_evidences 新欄位都有預設值,舊 query 不受影響;rollback 時可保留欄位(資料不刪)。實作前需要 product team / ops 完成:
drive scope 上 production 前需跑 Google verification(4-12 週)。這個流程要由誰啟動、預算為何?drive_last_modifying_user_email 欄位已預留並存入。當客戶稽核要求匯出「實際 Drive 上傳人」時,要在哪裡開放此欄位顯示 / 匯出?是 audit report PDF、CSV 匯出、還是 UI hover tooltip?anyoneWithLink+writer)