Google Drive 整合 Phase 3 — Review Fix Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 修補 Phase 3 (Google Drive bidirectional sync) code review 發現的 Critical(資安 + 資料一致性)與 Important(DDD 規範 + 韌性)問題,分兩個 PR 漸進落地。

Architecture: 不改變 Phase 3 整體架構(webhook → enqueue → APScheduler tick → handler dispatch),僅在既有檔案內修補:

  • Phase A:6 項 Critical bug fix(FE OAuth popup 安全 / BE cursor 推進 / BE webhook recovery / BE DDD 違規)
  • Phase B:10 項 Important(domain 不依賴 infra、webhook receiver 永遠回 200、deterministic error dead-letter、archive 韌性、過度防禦清理)

Tech Stack: Python 3.11 + Flask + dependency-injector + SQLAlchemy + APScheduler / Vue 3 Composition API + PrimeVue + Pinia

Reference:

  • Code review 結果:見對話歷史(41 issues 總覽)
  • 變更後需在 docs/changelog/ 各別建立 2026-04-25-drive-review-fix-phase-a.md2026-04-25-drive-review-fix-phase-b.md

§1

Phase A — Critical Hotfix(1 天,獨立 PR)

目標:補上資安洞 + 資料流失風險。不引入新架構,最小修補。

完成定義:6 項 Critical 全部修完 + 手動端到端測通 + commit + changelog。

Task A1: FE OAuth postMessageevent.originevent.source 驗證

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/integrations/GoogleDriveIntegrationCard.vue:30-65

讀檔確認以下三個現況:

  1. messageHandler 是否為 module-level 變數(會跟 popup 變數一起被覆蓋)
  2. connect()window.open 後是否有保留 popup reference
  3. 目前驗證條件只有 msg.source !== 'guidant-drive-oauth'

修改 messageHandler 為:

const ALLOWED_ORIGIN = window.location.origin
let popup = null
let messageHandler = null

function connect() {
    cleanup()  // A3 也會用到,先抽出
    connecting.value = true
    popup = window.open(`${import.meta.env.VITE_API_BASE_URL}/api/1.0/integrations/google-drive/oauth/start`, ...)
    if (!popup) {
        toast.add({severity: 'warn', summary: t('lang.cloud_integration.popup_blocked')})
        connecting.value = false
        return
    }
    messageHandler = (event) => {
        if (event.origin !== ALLOWED_ORIGIN) return
        if (event.source !== popup) return
        const msg = event.data
        if (!msg || msg.source !== 'guidant-drive-oauth') return
        // ... 既有邏輯
    }
    window.addEventListener('message', messageHandler)
}

修改:

  • src/config/locales/i18n/zh-tw/cloud-integration.json"popup_blocked": "請允許瀏覽器彈出視窗以完成 Google Drive 連線"
  • src/config/locales/i18n/en/cloud-integration.json"popup_blocked": "Please allow popups to complete Google Drive connection"

在 dev 環境:

  1. 觸發 OAuth 連線 → 確認原本流程仍正常
  2. 開瀏覽器 console,模擬攻擊:window.postMessage({source:'guidant-drive-oauth', status:'success'}, '*') → 確認 toast 不會出現(被 origin 擋掉)
  3. 在 Brave/Firefox 開「擋彈出視窗」→ 點連線 → 確認顯示 popup_blocked toast 且 connecting 重置
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/components/integrations/GoogleDriveIntegrationCard.vue \
        src/config/locales/i18n/zh-tw/cloud-integration.json \
        src/config/locales/i18n/en/cloud-integration.json
git commit -m "fix(cloud-integration): OAuth popup 加 origin/source 驗證避免訊息偽造"

Task A2: FE popup 關閉偵測 + null check + timeout fallback

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/integrations/GoogleDriveIntegrationCard.vue:30-65

延續 A1 後的 connect()

let popupCheckTimer = null
let popupTimeoutTimer = null

function connect() {
    cleanup()
    connecting.value = true
    popup = window.open(...)
    if (!popup) {
        toast.add({severity: 'warn', summary: t('lang.cloud_integration.popup_blocked')})
        connecting.value = false
        return
    }
    messageHandler = (event) => { /* ...A1 */ }
    window.addEventListener('message', messageHandler)

    // 偵測 user 手動關掉 popup
    popupCheckTimer = setInterval(() => {
        if (popup.closed && connecting.value) {
            cleanup()
            connecting.value = false
            toast.add({severity: 'info', summary: t('lang.cloud_integration.connect_cancelled')})
        }
    }, 500)

    // 5 分鐘 timeout fallback
    popupTimeoutTimer = setTimeout(() => {
        if (connecting.value) {
            cleanup()
            connecting.value = false
            toast.add({severity: 'warn', summary: t('lang.cloud_integration.connect_timeout')})
        }
    }, 5 * 60 * 1000)
}
function cleanup() {
    if (messageHandler) {
        window.removeEventListener('message', messageHandler)
        messageHandler = null
    }
    if (popupCheckTimer) {
        clearInterval(popupCheckTimer)
        popupCheckTimer = null
    }
    if (popupTimeoutTimer) {
        clearTimeout(popupTimeoutTimer)
        popupTimeoutTimer = null
    }
    if (popup && !popup.closed) {
        try { popup.close() } catch (e) { /* ignore cross-origin */ }
    }
    popup = null
}

zh-tw/cloud-integration.json:

"connect_cancelled": "已取消 Google Drive 連線",
"connect_timeout": "連線逾時,請重新嘗試"

en/cloud-integration.json:

"connect_cancelled": "Google Drive connection cancelled",
"connect_timeout": "Connection timed out, please retry"
  1. 點連線 → 立刻關掉 popup → 1 秒內看到 connect_cancelled toast,按鈕回到 idle
  2. 點連線 → popup 開著放 5 分鐘不動 → 看到 connect_timeout toast
  3. 點連線 → 正常完成 OAuth → success toast 出現後,timer 都被清掉(在 messageHandler 內呼叫 cleanup)
git add src/components/integrations/GoogleDriveIntegrationCard.vue \
        src/config/locales/i18n/zh-tw/cloud-integration.json \
        src/config/locales/i18n/en/cloud-integration.json
git commit -m "fix(cloud-integration): OAuth popup 關閉/逾時偵測避免按鈕卡 loading"

Task A3: FE connect() 入口先 cleanup

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/integrations/GoogleDriveIntegrationCard.vue

A1 的 step 2 範本已包含 cleanup(),這個 task 只是驗收:

connect() 檢查:

  • 第一行就是 cleanup()(在 connecting.value = true 之前)
  • 沒有任何 path 會跳過 cleanup 直接 addEventListener

如果 A1+A2 寫對了,這個 task 直接打勾。

連續快速點「連接 Google Drive」按鈕 5 次(不關 popup),確認:

  • console 沒有重複 messageHandler 觸發訊息
  • 取消所有 popup 後再連線,仍正常 work

Task A4: BE process_drive_changes_handler cursor 每頁推進

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/handlers/process_drive_changes_handler.py:73-91

handle() 主迴圈,確認:

  • 目前是否在所有 page 處理完才呼叫 _update_cursor
  • _process_one_change 是否為 @transaction
def handle(self, payload: dict) -> None:
    tenant_id = payload["tenant_id"]
    self_email = self._get_self_email(tenant_id)
    cursor = self._get_cursor(tenant_id)

    while True:
        resp = self._drive.list_changes(tenant_id, cursor, page_size=100)
        for change in resp.get("changes", []):
            try:
                self._process_one_change(tenant_id, change, self_email)
            except Exception as e:
                logger.warning(
                    "process_one_change failed (skipped): tenant=%s change=%s err=%s",
                    tenant_id, change.get("fileId"), e,
                )
                # 個別 change 失敗不能阻塞 cursor 推進,否則會卡死
        next_page = resp.get("nextPageToken")
        new_cursor = next_page if next_page else resp.get("newStartPageToken", cursor)
        if new_cursor != cursor:
            self._update_cursor(tenant_id, new_cursor)  # commit per page
            cursor = new_cursor
        if not next_page:
            break

如果沒有,加上:

@transaction
def _update_cursor(self, tenant_id: int, cursor: str) -> None:
    integration = self._tenant_integration.get_by_tenant_id(tenant_id)
    if integration:
        integration.drive_change_cursor = cursor
        self._tenant_integration.update(integration)
  1. tenant 102 / project ba2eac81 已有 145 folders
  2. 在 Drive 上手動上傳 3 個檔案到不同 task folder
  3. 觸發 POST /api/1.0/webhooks/google-drive/<tenant_id> 模擬 Drive notification
  4. 在 backend log 確認看到 cursor 每頁推進訊息
  5. 模擬中途 crash:在 _process_one_change 內 raise Exception 一次,看 cursor 是否推進到該頁、其他 change 仍處理
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
git add app/cloud_integration/service/handlers/process_drive_changes_handler.py
git commit -m "fix(cloud_integration): drive changes cursor 每頁 commit 避免 crash 重跑"

Task A5: BE webhook register 失敗 recovery

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/domain/cloud_integration/service/webhook_channel_manager.py:44-99
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/core/scheduler.py(如 renew_if_needed 在這裡呼叫)

目前流程是 stop_old → init cursor → watch → update DB。改成:

def register_for_tenant(self, tenant_id: int) -> None:
    integration = self._integration_repo.get_by_tenant_id(tenant_id)
    if not integration or integration.status != "CONNECTED":
        raise PreconditionFailedError(GrcErrorCode.GRC_DRIVE_NOT_CONNECTED)

    # Step A: stop 舊 channel(best-effort)+ 立刻清 DB 欄位(重點)
    if integration.webhook_channel_id and integration.webhook_resource_id:
        try:
            self._drive.stop_channel(tenant_id, integration.webhook_channel_id, integration.webhook_resource_id)
        except Exception as e:
            logger.warning("stop_channel failed (continuing): %s", e)
        integration.webhook_channel_id = None
        integration.webhook_resource_id = None
        integration.webhook_token = None
        integration.webhook_expires_at = None
        self._integration_repo.update(integration)  # commit 清空狀態

    # Step B: init cursor 若缺
    if not integration.drive_change_cursor:
        integration.drive_change_cursor = self._drive.get_start_page_token(tenant_id)

    # Step C: watch — 失敗時 DB 已是 clean state,下次 renew_if_needed 會重試
    channel_id = str(uuid.uuid4())
    token = secrets.token_urlsafe(16)
    callback_url = f"{config.DRIVE_WEBHOOK_PUBLIC_BASE_URL}/api/1.0/webhooks/google-drive/{tenant_id}"
    resp = self._drive.watch_changes(tenant_id, channel_id, token, callback_url)

    # Step D: 寫回新 channel 資訊
    integration.webhook_channel_id = channel_id
    integration.webhook_resource_id = resp["resourceId"]
    integration.webhook_token = token
    integration.webhook_expires_at = datetime.fromtimestamp(int(resp["expiration"]) / 1000, tz=timezone.utc)
    self._integration_repo.update(integration)

注意:Step A 結束後就 commit DB(在 register_for_tenant@transaction 行不通,需要顯式分兩次)。最簡單做法是 Step A 用獨立 with session_scope(): 包,Step C-D 也獨立。

def renew_if_needed(self, integration) -> bool:
    # 既有條件:webhook_expires_at < now() + 1 day → renew
    needs_renew = (
        integration.webhook_expires_at is None  # 新增:channel 沒建起來
        or integration.webhook_expires_at < datetime.now(timezone.utc) + timedelta(days=1)
    )
    if not needs_renew:
        return False
    if integration.status != "CONNECTED":
        return False
    self.register_for_tenant(integration.tenant_id)
    return True

core/scheduler.py,確認 webhook_channel_renewer 仍在 6h 排程。

  1. 用 super_admin 直接 SQL 把 tenant 102 的 webhook 欄位清空:
    SET app.is_super_admin='t';
    UPDATE compliance.tenant_drive_integrations
    SET webhook_channel_id=NULL, webhook_resource_id=NULL, webhook_expires_at=NULL
    WHERE tenant_id=102;
  2. 手動 trigger renewer:在 flask shellWebhookChannelManager.renew_all()
  3. 確認 channel 重建成功(DB 欄位重新填上)
git add domain/cloud_integration/service/webhook_channel_manager.py core/scheduler.py
git commit -m "fix(cloud_integration): webhook register 失敗保證 DB clean state + renewer 自動 recovery"

Task A6: BE _populate_drive_synced 搬到 app service 層

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/grc/service/project_service.py
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/api/grc/routes/project_route.py:23-56
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/di_containers/grc/grc_containers.py(DI wiring)
# app/grc/service/project_service.py
class ProjectService:
    def __init__(
        self,
        ...,  # 既有依賴
        drive_folder_mapping_domain_service=None,  # 新加,Optional 因為 cloud_integration_container 是 sibling
    ):
        ...
        self._drive_folder_mapping_domain_service = drive_folder_mapping_domain_service

    @transaction
    def enrich_drive_synced(self, dtos: list, tenant_id: int) -> None:
        if not dtos or self._drive_folder_mapping_domain_service is None:
            return
        uids = [str(d.uid) for d in dtos]
        try:
            mappings = self._drive_folder_mapping_domain_service.list_active_by_scope_uids(
                tenant_id, "PROJECT", uids
            )
            synced = {str(m.scope_uid) for m in mappings}
        except Exception as e:
            logger.warning("enrich_drive_synced failed: %s", e)
            synced = set()
        for d in dtos:
            d.drive_synced = d.uid in synced

di_containers/grc/grc_containers.py

class GrcContainer(containers.DeclarativeContainer):
    config = providers.Configuration()
    cloud_integration_container = providers.DependenciesContainer()  # 新加

    project_service = providers.Singleton(
        ProjectService,
        ...,
        drive_folder_mapping_domain_service=cloud_integration_container.drive_folder_mapping_domain_service,
    )
# di_containers/containers.py
class Containers(containers.DeclarativeContainer):
    cloud_integration_container = providers.Container(CloudIntegrationContainer)
    grc_container = providers.Container(
        GrcContainer,
        cloud_integration_container=cloud_integration_container,  # 單向 OK,不能反向(記憶檔已記錄 deepcopy 陷阱)
    )

注意:依記憶 feedback_di_no_bidirectional_override.md絕對不要讓 cloud_integration_container 反向 inject grc_container。

# api/grc/routes/project_route.py
class ProjectListRoute(Resource):
    @inject
    def get(self, project_service: ProjectService = Provide[Containers.grc_container.project_service]):
        ...
        dtos = project_service.list(...)
        project_service.enrich_drive_synced(dtos, get_user_context().tenant_id)
        return {"data": [d.to_dict() for d in dtos], ...}

刪除 _populate_drive_synced module-level helper 與 from jedi_common.session.database.db import session_scope import。

  1. Backend restart(lsof -i :8000 + kill -9 + 重啟)
  2. 確認 GET /api/1.0/grc/projects/list 回傳的 drive_synced 欄位仍正確
  3. 同樣 endpoint 但 tenant 沒連 Drive 的 case,確認 drive_synced=False(exception swallow path)
  4. GET /api/1.0/grc/audits/my/list 也要測(也用同樣的 service)
git add app/grc/service/project_service.py api/grc/routes/project_route.py \
        di_containers/grc/grc_containers.py di_containers/containers.py
git commit -m "refactor(grc): drive_synced enrich 從 route 搬到 app service (DDD)"

Task A7: 寫 Phase A changelog 並合併 commit

Files:

  • Create: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/docs/changelog/2026-04-25-drive-review-fix-phase-a.md

包含:

  • 需求說明:Phase 3 上線前的 code review 發現 6 項 Critical bug,本 PR 修補
  • 變更範圍:列出所有變更檔案(FE + BE)
  • API 變更:無(只改實作)
  • 安全性 fix:FE OAuth postMessage origin 驗證 (CVE 等級的 message injection 風險)
  • 韌性 fix:cursor 每頁推進、webhook register 失敗 recovery
  • 規範 fix:route 層不再開 session
git add docs/changelog/2026-04-25-drive-review-fix-phase-a.md
git commit -m "docs: 新增 Drive 整合 Phase A review fix changelog"

§2

Phase B — DDD 規範 + 韌性(1.5 天,獨立 PR)

目標:補 Important 中影響規範與 production 韌性的項目。

完成定義:10 項 Important 全部修完 + 端到端回測 + commit + changelog。

Task B1: 抽 IDriveApiClient interface + WebhookChannelManager 搬 app 層

Files:

  • Create: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/domain/cloud_integration/service/drive_api_client.py(interface)
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/infra/cloud_integration/google_drive/google_drive_api_client.py(implements interface)
  • Move: domain/cloud_integration/service/webhook_channel_manager.pyapp/cloud_integration/service/webhook_channel_manager.py
  • Modify: app/cloud_integration/service/handlers/process_drive_changes_handler.pyimport_drive_file_handler.pyreconcile_task_folder_handler.pyinit_project_folders_handler.pycreate_folder_handler.pyrename_folder_handler.py(type hints)
  • Modify: di_containers/cloud_integration/cloud_integration_containers.py
# domain/cloud_integration/service/drive_api_client.py
from abc import ABC, abstractmethod

class IDriveApiClient(ABC):
    @abstractmethod
    def get_start_page_token(self, tenant_id: int) -> str: ...

    @abstractmethod
    def list_changes(self, tenant_id: int, cursor: str, page_size: int = 100) -> dict: ...

    @abstractmethod
    def watch_changes(self, tenant_id: int, channel_id: str, token: str, callback_url: str) -> dict: ...

    @abstractmethod
    def stop_channel(self, tenant_id: int, channel_id: str, resource_id: str) -> None: ...

    @abstractmethod
    def get_file_metadata(self, tenant_id: int, file_id: str, fields: str | None = None) -> dict | None: ...

    @abstractmethod
    def download_file(self, tenant_id: int, file_id: str, max_bytes: int) -> bytes: ...

    @abstractmethod
    def create_folder(self, tenant_id: int, name: str, parent_id: str) -> dict: ...

    @abstractmethod
    def rename(self, tenant_id: int, file_id: str, new_name: str) -> None: ...

    @abstractmethod
    def move_to_archive(self, tenant_id: int, file_id: str, archive_parent_id: str) -> None: ...
# infra/cloud_integration/google_drive/google_drive_api_client.py
from domain.cloud_integration.service.drive_api_client import IDriveApiClient

class GoogleDriveApiClient(IDriveApiClient):
    ...  # 既有實作
git mv domain/cloud_integration/service/webhook_channel_manager.py \
       app/cloud_integration/service/webhook_channel_manager.py

修改檔內 imports(infra 的 GoogleDriveApiClient 改 import IDriveApiClient ABC,type hint 用它)。

di_containers/cloud_integration/cloud_integration_containers.pywebhook_channel_manager provider 的 import 路徑改 app.cloud_integration.service.webhook_channel_manager

所有 4+2 個 handler 的 __init__ 參數型別註記改為 drive_api_client: IDriveApiClient。實作仍由 DI 注入 GoogleDriveApiClient

pytest test/cloud_integration/ -v

預期全綠(接口不變)。

git add -A
git commit -m "refactor(cloud_integration): 抽 IDriveApiClient interface + WebhookChannelManager 改放 app 層"

Task B2: webhook receiver 永遠回 200 + log

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/google_drive_webhook_service.py:49-56
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/api/cloud_integration/routes/google_drive_webhook_route.py:50-56
def handle_notification(self, tenant_id: int, headers: dict) -> None:
    channel_id = headers.get("X-Goog-Channel-ID")
    token = headers.get("X-Goog-Channel-Token")

    if not channel_id or not token:
        logger.warning("webhook missing channel_id or token: tenant=%s", tenant_id)
        return  # silent 200

    integration = self._tenant_integration.get_by_tenant_id(tenant_id)
    if not integration:
        logger.warning("webhook for unknown tenant: %s", tenant_id)
        return

    if (integration.webhook_channel_id != channel_id
        or integration.webhook_token != token):
        logger.warning(
            "webhook channel/token mismatch: tenant=%s expected_channel=%s got=%s",
            tenant_id, integration.webhook_channel_id, channel_id,
        )
        return

    # enqueue PROCESS_DRIVE_CHANGES job
    self._drive_sync_jobs.enqueue(
        tenant_id=tenant_id,
        job_type="PROCESS_DRIVE_CHANGES",
        payload={"tenant_id": tenant_id},
    )
class GoogleDriveWebhookRoute(Resource):
    @inject
    def post(self, tenant_id: int,
             webhook_service: GoogleDriveWebhookService = Provide[...]):
        try:
            webhook_service.handle_notification(tenant_id, dict(request.headers))
        except Exception as e:
            logger.exception("webhook handle_notification crashed: %s", e)
        return {"received": True}, 200
# 缺 token:應該回 200
curl -X POST http://localhost:8000/api/1.0/webhooks/google-drive/102 \
     -H "X-Goog-Channel-ID: fake" \
     -i

# 錯 channel_id:應該回 200
curl -X POST http://localhost:8000/api/1.0/webhooks/google-drive/102 \
     -H "X-Goog-Channel-ID: wrong" \
     -H "X-Goog-Channel-Token: wrong" \
     -i

兩者都要 HTTP/1.1 200,log 有 warning 訊息。

git add app/cloud_integration/service/google_drive_webhook_service.py \
        api/cloud_integration/routes/google_drive_webhook_route.py
git commit -m "fix(cloud_integration): webhook receiver 永遠回 200 避免 Drive 關閉 channel"

Task B3: terminal_errors 白名單避免 deterministic 失敗 retry

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/domain/cloud_integration/service/drive_sync_job_domain_service.py
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/handlers/import_drive_file_handler.py:154-163
TERMINAL_ERROR_CODES = {"FILE_OVERSIZED", "FILE_NOT_FOUND_ON_DRIVE", "PERMISSION_DENIED"}

def fail_or_retry(self, job_id: int, error_msg: str, error_code: str | None = None) -> None:
    if error_code in TERMINAL_ERROR_CODES:
        self._repo.mark_failed(job_id, error_msg, retry=False)  # 不 retry
        return
    # 既有 retry 邏輯
    ...
try:
    if file_size > MAX_DOWNLOAD_BYTES:
        self._jobs.fail_or_retry(
            job.id,
            f"File oversized: {file_size} > {MAX_DOWNLOAD_BYTES}",
            error_code="FILE_OVERSIZED",
        )
        return
    ...
except DriveFileNotFoundError as e:
    self._jobs.fail_or_retry(job.id, str(e), error_code="FILE_NOT_FOUND_ON_DRIVE")
    return

寫一個 ad-hoc test 或在 dev 環境塞一個超大假 file_id 的 job,確認 retry_count 不會超過 0。

git add domain/cloud_integration/service/drive_sync_job_domain_service.py \
        app/cloud_integration/service/handlers/import_drive_file_handler.py
git commit -m "fix(cloud_integration): deterministic 失敗(檔案過大/不存在)標 FAILED 不 retry"

Task B4: revert_job best-effort 補 log

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/flow_engine/service/workflow_execution_service.py:763-776
try:
    if drive_orchestration is not None and tenant_id is not None:
        drive_orchestration.try_enqueue_reconcile_task_folder(tenant_id, job_uid)
except Exception as e:
    logger.warning(
        "revert_job: drive reconcile enqueue failed: tenant=%s job=%s err=%s",
        tenant_id, job_uid, e,
    )
git add app/flow_engine/service/workflow_execution_service.py
git commit -m "fix(workflow): revert_job drive reconcile 失敗補 log 避免吞錯"

Task B5: archive 失敗 enqueue ARCHIVE_DRIVE_FILE job + 自開 transaction

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/drive_sync_orchestration_service.py:380-396, 398, 522
  • Create: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/handlers/archive_drive_file_handler.py
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/di_containers/cloud_integration/cloud_integration_containers.py
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/core/scheduler.py(worker tick dispatch)
# app/cloud_integration/service/handlers/archive_drive_file_handler.py
class ArchiveDriveFileHandler:
    def __init__(self, drive_api_client, tenant_integration_domain_service, ...):
        ...

    def handle(self, payload: dict) -> None:
        tenant_id = payload["tenant_id"]
        drive_file_id = payload["drive_file_id"]
        # 找 archive folder(同 orchestration._ensure_archive_folder 邏輯)
        # 呼叫 drive_api.move_to_archive
        # 失敗 → fail_or_retry(已有重試 schedule)
def try_archive_drive_file(self, tenant_id: int, drive_file_id: str | None) -> bool:
    if not drive_file_id:
        return False
    try:
        with session_scope():  # 獨立 transaction
            self._drive_sync_jobs.enqueue(
                tenant_id=tenant_id,
                job_type="ARCHIVE_DRIVE_FILE",
                payload={"tenant_id": tenant_id, "drive_file_id": drive_file_id},
            )
        return True
    except Exception as e:
        logger.warning("try_archive_drive_file enqueue failed: %s", e)
        return False

這樣使用者刪除的 transaction 完全與 archive 解耦。

HANDLER_DISPATCH = {
    "INIT_PROJECT_FOLDERS": init_project_folders_handler,
    "CREATE_FOLDER": create_folder_handler,
    "RENAME_FOLDER": rename_folder_handler,
    "PROCESS_DRIVE_CHANGES": process_drive_changes_handler,
    "IMPORT_DRIVE_FILE": import_drive_file_handler,
    "SOFT_DELETE_EVIDENCE": soft_delete_evidence_handler,
    "RECONCILE_TASK_FOLDER": reconcile_task_folder_handler,
    "ARCHIVE_DRIVE_FILE": archive_drive_file_handler,  # 新加
}

cloud_integration_containers.pyarchive_drive_file_handler = providers.Factory(...)

scripts/sql/2026-04-24-google-drive-folder-mappings.sql 看 drive_sync_jobs.job_type CHECK constraint。如有限制,新增:

-- scripts/sql/2026-04-25-add-archive-drive-file-job-type.sql
-- Date: 2026-04-25
ALTER TABLE compliance.drive_sync_jobs
DROP CONSTRAINT IF EXISTS chk_drive_sync_jobs_job_type;

ALTER TABLE compliance.drive_sync_jobs
ADD CONSTRAINT chk_drive_sync_jobs_job_type CHECK (
    job_type IN (
        'INIT_PROJECT_FOLDERS', 'CREATE_FOLDER', 'RENAME_FOLDER',
        'PROCESS_DRIVE_CHANGES', 'IMPORT_DRIVE_FILE',
        'SOFT_DELETE_EVIDENCE', 'RECONCILE_TASK_FOLDER',
        'ARCHIVE_DRIVE_FILE'  -- 2026-04-25
    )
);

執行:

PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cm_app -d guidant_ai_stg \
    -f scripts/sql/2026-04-25-add-archive-drive-file-job-type.sql
  1. 在 tenant 102 / project ba2eac81 上傳一個 evidence
  2. 呼叫 DELETE evidence API
  3. 確認:
    • DB job_evidences.is_deleted=true
    • DB drive_sync_jobs 有一筆 ARCHIVE_DRIVE_FILE PENDING job
    • APScheduler tick 後該 job COMPLETED
    • Drive 上該檔案被搬到 ARCHIVE folder
git add -A
git commit -m "feat(cloud_integration): archive 改非同步 job 增加韌性 + 自動 retry"

Task B6: 拿掉過度防禦的 getattr default / None check

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/flow_engine/service/job_evidence_service.py:160-167
# Before
source = getattr(evidence, "source", "SYSTEM_UPLOAD")
if (drive_orchestration is not None
    and tenant_id is not None
    and source == "DRIVE_SYNC"):

# After
if evidence.source == "DRIVE_SYNC":
    drive_orchestration.try_archive_drive_file(tenant_id, evidence.drive_file_id)

drive_orchestration 改用 keyword-only 必填(在 __init__ 移除 None default)。

delete_job_evidence(self, uid, tenant_id) — 拿掉 =None,所有 caller 必傳。

pytest test/flow_engine/ -v

如果有 caller 漏傳 tenant_id,pytest 會立刻抓到。

git add app/flow_engine/service/job_evidence_service.py api/flow_engine/routes/job_evidence_route.py
git commit -m "refactor(flow_engine): 移除 delete_job_evidence 過度防禦的 None check"

Task B7: ROOT scope mapping unique constraint 風險修復

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/handlers/process_drive_changes_handler.py:202-231

_handle_folder_change 與 mapping 的 scope_uid 反查邏輯。確認:

  • ROOT scope 的 scope_uid 是 NULL 嗎?
  • folder change handler 會不會把 ROOT folder 也標 is_unlinked=True

如果可能走到,加:

def _handle_folder_change(self, mapping, change):
    if mapping.scope_type == "ROOT":
        # ROOT folder 不應該被 mark unlinked + enqueue create
        # ROOT 健康檢查由 InitProjectFoldersHandler 處理
        logger.warning("ROOT folder change detected, skipping: tenant=%s", mapping.tenant_id)
        return
    # 既有邏輯
git add app/cloud_integration/service/handlers/process_drive_changes_handler.py
git commit -m "fix(cloud_integration): folder change handler 跳過 ROOT scope 避免 unique constraint 衝突"

Task B8: retry path 清 started_at

Files:

  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/infra/cloud_integration/repository/drive_sync_job_repo_impl.py:51-76, 93-111
def mark_failed(self, job_id: int, error_msg: str, retry: bool = True) -> None:
    job = self.session.get(DriveSyncJobModel, job_id)
    if not job:
        return
    if retry and job.retry_count < MAX_RETRIES:
        job.status = "PENDING"
        job.retry_count += 1
        job.last_error = error_msg
        job.started_at = None  # 新加:retry 重置
        job.next_run_at = self._compute_next_run(job.retry_count)
    else:
        job.status = "FAILED"
        job.last_error = error_msg
        job.completed_at = datetime.now(timezone.utc)
def test_mark_failed_retry_resets_started_at(repo, claimed_job):
    # claim 過後 started_at 會有值
    repo.mark_failed(claimed_job.id, "transient error", retry=True)
    refreshed = repo.get_by_id(claimed_job.id)
    assert refreshed.status == "PENDING"
    assert refreshed.started_at is None
git add infra/cloud_integration/repository/drive_sync_job_repo_impl.py \
        test/cloud_integration/test_drive_sync_job_repo.py
git commit -m "fix(cloud_integration): retry path 重置 started_at 避免 admin UI 顯示誤導"

Task B9: OAuth client raise 改用 OAuthExchangeError + ErrorCode

Files:

  • Create: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/common/code/cloud_integration_error_code.py
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/infra/cloud_integration/google_drive/google_oauth_client.py:58, 83, 93
  • Modify: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/app/cloud_integration/service/google_drive_integration_service.py(catch 改抛 BadRequestError)
# common/code/cloud_integration_error_code.py
from jedi_common.enums.base_code import BaseCode

class CloudIntegrationErrorCode(BaseCode):
    DRIVE_OAUTH_EXCHANGE_FAILED      = ("Google OAuth 授權交換失敗",  "CI_400001")
    DRIVE_TOKEN_REFRESH_FAILED       = ("Google Drive Token 更新失敗", "CI_400002")
    DRIVE_NOT_CONNECTED              = ("尚未連接 Google Drive",       "CI_412001")
    DRIVE_API_QUOTA_EXCEEDED         = ("Google Drive API 配額已滿",   "CI_429001")
# infra/cloud_integration/google_drive/exceptions.py
class OAuthExchangeError(Exception):
    pass

class TokenRefreshError(Exception):
    pass
raise OAuthExchangeError(f"Token endpoint returned {resp.status_code}: {resp.text}")
# app/cloud_integration/service/google_drive_integration_service.py
from common.code.cloud_integration_error_code import CloudIntegrationErrorCode
from infra.cloud_integration.google_drive.exceptions import OAuthExchangeError
from jedi_common.handler.exception import BadRequestError

try:
    tokens = self._oauth_client.exchange_code(code)
except OAuthExchangeError as e:
    logger.warning("OAuth exchange failed: %s", e)
    raise BadRequestError(CloudIntegrationErrorCode.DRIVE_OAUTH_EXCHANGE_FAILED)
  1. 拿一個過期/無效的 OAuth code 觸發 callback
  2. 確認回傳的 callback HTML 帶 error,且 error code 是 CI_400001 而不是 generic 500
git add common/code/cloud_integration_error_code.py \
        infra/cloud_integration/google_drive/exceptions.py \
        infra/cloud_integration/google_drive/google_oauth_client.py \
        app/cloud_integration/service/google_drive_integration_service.py
git commit -m "refactor(cloud_integration): OAuth/Token 錯誤改 ErrorCode pattern 取代 RuntimeError"

Task B10: 寫 Phase B changelog

Files:

  • Create: /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/docs/changelog/2026-04-25-drive-review-fix-phase-b.md

包含:

  • 需求說明:Phase A 後續,補 Important 規範與韌性 issues
  • 變更範圍:列出所有 BE 變更檔案
  • API 變更:新增 ARCHIVE_DRIVE_FILE job type;新增 ErrorCode(CI_400001/400002/412001/429001)
  • DDD 規範改進:抽 IDriveApiClient interface、WebhookChannelManager 移 app 層
  • 韌性改進:webhook 200、deterministic dead-letter、archive 非同步、ROOT folder 跳過
git add docs/changelog/2026-04-25-drive-review-fix-phase-b.md
git commit -m "docs: 新增 Drive 整合 Phase B review fix changelog"

§3

端到端回測 checklist(Phase A + B 完成後執行)

測試座標: tenant 102 / raymond.jedicotech@gmail.com / project ba2eac81 / AP 1972c6be

    • 正常連線 → success toast → drive 卡片顯示 connected
    • 故意關掉 popup → cancelled toast,按鈕回 idle
    • 故意擋掉 popup → blocked toast
    • Console 模擬 postMessage 攻擊 → 無反應
    • 仍能在 ~67 秒內建完
    • 同時連兩次連線(race) → 只有一條 init 路徑跑(B5/B7 可能影響)
    • 上傳 file → IMPORT_DRIVE_FILE job → evidence 出現
    • 上傳超大檔(>50MB)→ FAILED 不 retry
    • 中途強制重啟 backend → cursor 推進 OK,重啟後不重跑已處理的 change
    • DELETE evidence → ARCHIVE_DRIVE_FILE PENDING → tick 後 COMPLETED → Drive 上檔案進 ARCHIVE folder
    • Drive 端先撤 OAuth → DELETE evidence → archive job FAILED 但使用者刪除仍成功(B5 解耦)
    • SQL 清空 webhook 欄位 → 6h 內 renewer 重建 channel
    • Drive 端透過 channels.stop 強制關閉 → 同上
    • 非 admin user 直接打 /settings/cloud-integrations URL → 被 redirect(router guard)
    • 跨 tenant 拿不到別人的 drive_synced 資訊
    • 連 Drive 的 tenant:badge 顯示「已連動 Google Drive」
    • 沒連 Drive 的 tenant:根本看不到 badge / 同步按鈕

§4

Remember

  • DRY、YAGNI、TDD、frequent commits
  • 每個 task 完成後立刻 commit,不批次
  • DDD 層級:route 不開 session、app service 不 import ORM、domain 不 import infra
  • ErrorCode 命名:<模組前綴>_<HTTP狀態碼>序號
  • changelog 不可漏(即使是 subagent 平行做也要一個主題一個 changelog)
  • 不自動 commit 全部完成 — 每個 Phase 結束等 user 確認再合 PR