For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task.
Spec: design.md (§4.1, §6, §13.1, §13.2, §15) Master Index: implementation-plan.md
Goal: 讓 tenant admin 可以在「雲端空間整合設定」頁完成 Google Drive OAuth 連線與中斷,後端安全儲存加密 token,UI 顯示連線狀態。不含任何 Drive 資料夾建立或檔案同步。
Architecture: 新增 cloud_integration 模組(DDD 完整一套:api/app/domain/infra),單張 tenant_drive_integrations 表,Fernet 加密 token,OAuth state 存 Redis(CSRF 防護),標準 OAuth 2.0 server-side flow。前端新增 /settings/cloud-integrations 路由與 settings page。
Tech Stack: google-auth-oauthlib / google-api-python-client (僅 userinfo) / cryptography.fernet / Vue3 + PrimeVue
common/code/
└── grc_error_code.py # 修改:新增 9 個 error codes (見 Task 2)
scripts/sql/
└── 2026-MM-DD-google-drive-oauth-integration.sql # 新增:建表 + GRANT + RLS
api/cloud_integration/
├── __init__.py # create_module() Blueprint
├── routes/
│ └── google_drive_integration_route.py # GET / POST auth-url / GET callback / DELETE / GET status
└── serializers/
└── google_drive_integration.py # marshmallow response schema
app/cloud_integration/
├── service/
│ └── google_drive_integration_service.py # OAuth orchestration (auth_url / handle_callback / disconnect / status)
└── dto/
└── google_drive_integration_dto.py # DTO
domain/cloud_integration/
├── entities/
│ └── tenant_drive_integration_entity.py
├── repository/
│ └── tenant_drive_integration_repository.py # interface
└── service/
├── tenant_drive_integration_domain_service.py # CRUD
└── token_crypto_service.py # interface
infra/cloud_integration/
├── models/
│ └── tenant_drive_integration.py # SQLAlchemy model
├── mapper/
│ └── tenant_drive_integration_mapper.py
├── repository/
│ └── tenant_drive_integration_repo_impl.py
├── crypto/
│ └── fernet_crypto.py # TokenCryptoService 實作
└── google_drive/
└── google_oauth_client.py # OAuth code exchange / userinfo / revoke
di_containers/cloud_integration/
├── __init__.py
└── cloud_integration_containers.py # CloudIntegrationContainer
di_containers/
└── containers.py # 修改:register CloudIntegrationContainer
config/
└── app_modules.py # 修改:append "cloud_integration"
config/
└── config.py # 修改:新增 GOOGLE_DRIVE_OAUTH_* / DRIVE_TOKEN_ENCRYPTION_KEY
pyproject.toml # 修改:add google-* + cryptography
src/config/api/
└── api.js # 修改:append INTEGRATION_GDRIVE_* 常數
src/service/
└── CloudIntegrationService.js # 新增
src/views/integrations/
└── CloudIntegrationsView.vue # 新增主頁
src/components/integrations/
└── GoogleDriveIntegrationCard.vue # 新增 Drive 卡片
src/router/
└── index.js # 修改:append route
src/lang/
├── zh-TW.js # 修改:i18n 字串
└── en.js # 修改:i18n 字串
src/components/layout/
└── AppMenu.vue (or wherever sidebar) # 修改:選單加「雲端空間整合設定」(admin only)
test/cloud_integration/
├── conftest.py # fixtures
├── test_token_crypto_service.py # Fernet 加解密
├── test_tenant_drive_integration_repo.py # repo CRUD
├── test_google_oauth_client.py # OAuth client (mock httpx/google-auth)
├── test_google_drive_integration_service.py # app service (mock client + repo)
└── test_google_drive_integration_route.py # E2E with Flask test client
Files:
pyproject.tomlconfig/app_modules.pyconfig/config.pyapi/cloud_integration/__init__.pygoogle-api-python-client = "^2.130.0"
google-auth-oauthlib = "^1.2.0"
google-auth-httplib2 = "^0.2.0"
cryptography = "^42.0.0" # 已有則跳過GOOGLE_DRIVE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_DRIVE_OAUTH_CLIENT_ID", "")
GOOGLE_DRIVE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_DRIVE_OAUTH_CLIENT_SECRET", "")
GOOGLE_DRIVE_OAUTH_REDIRECT_URI = os.getenv("GOOGLE_DRIVE_OAUTH_REDIRECT_URI", "")
DRIVE_TOKEN_ENCRYPTION_KEY = os.getenv("DRIVE_TOKEN_ENCRYPTION_KEY", "")"""Cloud Integration Module — Flask Blueprint"""
import logging
from flask import Blueprint
from flask_restful import Api
logger = logging.getLogger("api")
def create_module():
bp = Blueprint("cloud_integration", __name__, url_prefix="/api/integrations")
api = Api(bp)
logger.info("Cloud Integration module registered (empty placeholder)")
return bpgit add pyproject.toml poetry.lock config/app_modules.py config/config.py api/cloud_integration/
git commit -m "feat(cloud_integration): scaffold module + add google-api-python-client deps"Files:
common/code/grc_error_code.py # ── Auth / Admin ─────────────────────────────────────────────────────
GRC_NOT_ADMIN = ("需要 tenant admin 權限", "GRC_403021")
# ── Cloud Integration / Google Drive ─────────────────────────────────
GRC_DRIVE_OAUTH_STATE_INVALID = ("OAuth 狀態驗證失敗,請重新嘗試授權", "GRC_400030")
GRC_DRIVE_OAUTH_NO_REFRESH_TOKEN = ("Google 未回傳 refresh token,請至帳號授權頁移除舊授權後重試", "GRC_400032")
GRC_DRIVE_FILE_OVERSIZED = ("Drive 檔案超過大小限制", "GRC_400031") # Phase 3
GRC_DRIVE_TOKEN_REVOKED = ("Drive 連線已失效,請重新授權", "GRC_401010")
GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE = ("Drive 同步的證據請至 Google Drive 刪除", "GRC_403020") # Phase 3
GRC_DRIVE_INTEGRATION_NOT_FOUND = ("此 tenant 尚未連線 Google Drive", "GRC_404030")
GRC_DRIVE_FOLDER_MAPPING_NOT_FOUND = ("Drive 資料夾對應不存在", "GRC_404031") # Phase 2
GRC_DRIVE_INTEGRATION_ALREADY_EXISTS = ("Google Drive 已連線,請先中斷", "GRC_409020")
GRC_DRIVE_WEBHOOK_TOKEN_INVALID = ("Webhook 驗證失敗", "GRC_412020") # Phase 3
GRC_DRIVE_API_ERROR = ("Google Drive API 錯誤,請稍後再試", "GRC_500030")git add common/code/grc_error_code.py
git commit -m "feat(cloud_integration): add Google Drive integration error codes"tenant_drive_integrations 表Files:
scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql-- Date: <YYYY-MM-DD>
-- Purpose: Google Drive OAuth 連線資訊 per tenant
-- 1. 建立 tenant_drive_integrations 表 (<YYYY-MM-DD>)
CREATE TABLE IF NOT EXISTS compliance.tenant_drive_integrations (
id SERIAL PRIMARY KEY,
uid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
tenant_id INTEGER NOT NULL UNIQUE,
provider VARCHAR(20) NOT NULL DEFAULT 'google_drive',
google_account_email VARCHAR(255),
refresh_token_encrypted TEXT,
access_token_cache TEXT,
access_token_expires_at TIMESTAMPTZ,
root_folder_id VARCHAR(100),
drive_change_cursor VARCHAR(255),
webhook_channel_id VARCHAR(100),
webhook_resource_id VARCHAR(100),
webhook_token VARCHAR(100),
webhook_expires_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED',
connected_by_user_id INTEGER,
connected_at TIMESTAMPTZ,
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
created_user VARCHAR(100),
updated_user VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. 建立 status check constraint (<YYYY-MM-DD>)
ALTER TABLE compliance.tenant_drive_integrations
ADD CONSTRAINT chk_tenant_drive_integrations_status
CHECK (status IN ('CONNECTED', 'EXPIRED', 'REVOKED', 'DISCONNECTED'));
-- 3. 索引 (<YYYY-MM-DD>)
CREATE INDEX IF NOT EXISTS idx_tenant_drive_integrations_tenant_id
ON compliance.tenant_drive_integrations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tenant_drive_integrations_status
ON compliance.tenant_drive_integrations(status);
-- 4. 授權給 cm_app (<YYYY-MM-DD>)
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;
-- 5. RLS — tenant 隔離 (<YYYY-MM-DD>)
ALTER TABLE compliance.tenant_drive_integrations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_drive_integrations_tenant_isolation
ON compliance.tenant_drive_integrations
USING (tenant_id = current_setting('app.tenant_id', true)::INTEGER);psql -h $DB_HOST -U postgres -d compliance_manager -f scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql確認無錯誤。
git add scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql
git commit -m "feat(cloud_integration): add tenant_drive_integrations table migration"TenantDriveIntegrationFiles:
infra/cloud_integration/__init__.pyinfra/cloud_integration/models/__init__.pyinfra/cloud_integration/models/tenant_drive_integration.pyimport uuid
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, CheckConstraint
from sqlalchemy.dialects.postgresql import UUID
from jedi_common.session.database.db import Base
class TenantDriveIntegration(Base):
__tablename__ = "tenant_drive_integrations"
__table_args__ = (
CheckConstraint(
"status IN ('CONNECTED', 'EXPIRED', 'REVOKED', 'DISCONNECTED')",
name="chk_tenant_drive_integrations_status",
),
{"schema": "compliance"},
)
id = Column(Integer, primary_key=True)
uid = Column(UUID(as_uuid=True), nullable=False, unique=True, default=uuid.uuid4)
tenant_id = Column(Integer, nullable=False, unique=True)
provider = Column(String(20), nullable=False, default="google_drive")
google_account_email = Column(String(255))
refresh_token_encrypted = Column(Text)
access_token_cache = Column(Text)
access_token_expires_at = Column(DateTime(timezone=True))
root_folder_id = Column(String(100))
drive_change_cursor = Column(String(255))
webhook_channel_id = Column(String(100))
webhook_resource_id = Column(String(100))
webhook_token = Column(String(100))
webhook_expires_at = Column(DateTime(timezone=True))
status = Column(String(20), nullable=False, default="DISCONNECTED")
connected_by_user_id = Column(Integer)
connected_at = Column(DateTime(timezone=True))
last_sync_at = Column(DateTime(timezone=True))
last_sync_error = Column(Text)
created_user = Column(String(100))
updated_user = Column(String(100))
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)注意:用
datetime.now(timezone.utc)(Python 3.12+ 慣用),不用 deprecated 的datetime.utcnow。記得from datetime import datetime, timezone。
from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
def test_tenant_drive_integration_table_name():
assert TenantDriveIntegration.__tablename__ == "tenant_drive_integrations"
assert TenantDriveIntegration.__table_args__[-1] == {"schema": "compliance"}git add infra/cloud_integration/ test/cloud_integration/test_tenant_drive_integration_model.py
git commit -m "feat(cloud_integration): add TenantDriveIntegration ORM model"Files:
domain/cloud_integration/__init__.py + subdirsdomain/cloud_integration/entities/tenant_drive_integration_entity.pydomain/cloud_integration/repository/tenant_drive_integration_repository.pyfrom dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from uuid import UUID
@dataclass
class TenantDriveIntegrationEntity:
tenant_id: int
provider: str = "google_drive"
id: Optional[int] = None
uid: Optional[UUID] = None
google_account_email: Optional[str] = None
refresh_token_encrypted: Optional[str] = None
access_token_cache: Optional[str] = None
access_token_expires_at: Optional[datetime] = None
root_folder_id: Optional[str] = None
drive_change_cursor: Optional[str] = None
webhook_channel_id: Optional[str] = None
webhook_resource_id: Optional[str] = None
webhook_token: Optional[str] = None
webhook_expires_at: Optional[datetime] = None
status: str = "DISCONNECTED"
connected_by_user_id: Optional[int] = None
connected_at: Optional[datetime] = None
last_sync_at: Optional[datetime] = None
last_sync_error: Optional[str] = None
created_user: Optional[str] = None
updated_user: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = Nonefrom abc import ABC, abstractmethod
from typing import Optional
from uuid import UUID
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
class ITenantDriveIntegrationRepo(ABC):
@abstractmethod
def get_by_tenant_id(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]: ...
@abstractmethod
def get_by_uid(self, uid: UUID) -> Optional[TenantDriveIntegrationEntity]: ...
@abstractmethod
def add(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity: ...
@abstractmethod
def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity: ...
@abstractmethod
def delete_by_tenant_id(self, tenant_id: int) -> bool: ...git add domain/cloud_integration/
git commit -m "feat(cloud_integration): add TenantDriveIntegration entity & repo interface"Files:
infra/cloud_integration/mapper/__init__.pyinfra/cloud_integration/mapper/tenant_drive_integration_mapper.pyfrom infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
class TenantDriveIntegrationMapper:
@staticmethod
def to_entity(model: TenantDriveIntegration) -> TenantDriveIntegrationEntity:
return TenantDriveIntegrationEntity(
id=model.id,
uid=model.uid,
tenant_id=model.tenant_id,
provider=model.provider,
google_account_email=model.google_account_email,
refresh_token_encrypted=model.refresh_token_encrypted,
access_token_cache=model.access_token_cache,
access_token_expires_at=model.access_token_expires_at,
root_folder_id=model.root_folder_id,
drive_change_cursor=model.drive_change_cursor,
webhook_channel_id=model.webhook_channel_id,
webhook_resource_id=model.webhook_resource_id,
webhook_token=model.webhook_token,
webhook_expires_at=model.webhook_expires_at,
status=model.status,
connected_by_user_id=model.connected_by_user_id,
connected_at=model.connected_at,
last_sync_at=model.last_sync_at,
last_sync_error=model.last_sync_error,
created_user=model.created_user,
updated_user=model.updated_user,
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def apply_to_model(entity: TenantDriveIntegrationEntity, model: TenantDriveIntegration) -> None:
"""Update model fields from entity (id/uid/created_at protected)."""
model.tenant_id = entity.tenant_id
model.provider = entity.provider
model.google_account_email = entity.google_account_email
model.refresh_token_encrypted = entity.refresh_token_encrypted
model.access_token_cache = entity.access_token_cache
model.access_token_expires_at = entity.access_token_expires_at
model.root_folder_id = entity.root_folder_id
model.drive_change_cursor = entity.drive_change_cursor
model.webhook_channel_id = entity.webhook_channel_id
model.webhook_resource_id = entity.webhook_resource_id
model.webhook_token = entity.webhook_token
model.webhook_expires_at = entity.webhook_expires_at
model.status = entity.status
model.connected_by_user_id = entity.connected_by_user_id
model.connected_at = entity.connected_at
model.last_sync_at = entity.last_sync_at
model.last_sync_error = entity.last_sync_error
model.updated_user = entity.updated_usergit add infra/cloud_integration/mapper/
git commit -m "feat(cloud_integration): add TenantDriveIntegration mapper"Files:
infra/cloud_integration/repository/__init__.pyinfra/cloud_integration/repository/tenant_drive_integration_repo_impl.pytest/cloud_integration/test_tenant_drive_integration_repo.pyimport pytest
from uuid import uuid4
from infra.cloud_integration.repository.tenant_drive_integration_repo_impl import (
TenantDriveIntegrationRepoImpl,
)
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
@pytest.mark.usefixtures("db_session")
def test_add_and_get_by_tenant_id(db_session):
repo = TenantDriveIntegrationRepoImpl(session=db_session)
entity = TenantDriveIntegrationEntity(
tenant_id=99001, # 使用測試用大 ID 避免衝突
status="DISCONNECTED",
created_user="test",
updated_user="test",
)
saved = repo.add(entity)
assert saved.id is not None
assert saved.uid is not None
fetched = repo.get_by_tenant_id(99001)
assert fetched is not None
assert fetched.tenant_id == 99001
@pytest.mark.usefixtures("db_session")
def test_update_status(db_session):
repo = TenantDriveIntegrationRepoImpl(session=db_session)
entity = TenantDriveIntegrationEntity(tenant_id=99002, created_user="t", updated_user="t")
saved = repo.add(entity)
saved.status = "CONNECTED"
saved.google_account_email = "x@y.com"
updated = repo.update(saved)
assert updated.status == "CONNECTED"
assert updated.google_account_email == "x@y.com"
@pytest.mark.usefixtures("db_session")
def test_delete_by_tenant_id(db_session):
repo = TenantDriveIntegrationRepoImpl(session=db_session)
entity = TenantDriveIntegrationEntity(tenant_id=99003, created_user="t", updated_user="t")
repo.add(entity)
assert repo.delete_by_tenant_id(99003) is True
assert repo.get_by_tenant_id(99003) is Nonefrom typing import Optional
from uuid import UUID
from infra.cloud_integration.mapper.tenant_drive_integration_mapper import (
TenantDriveIntegrationMapper,
)
from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
from domain.cloud_integration.repository.tenant_drive_integration_repository import (
ITenantDriveIntegrationRepo,
)
class TenantDriveIntegrationRepoImpl(ITenantDriveIntegrationRepo):
def __init__(self, session):
self.session = session
def get_by_tenant_id(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]:
model = (
self.session.query(TenantDriveIntegration)
.filter(TenantDriveIntegration.tenant_id == tenant_id)
.first()
)
return TenantDriveIntegrationMapper.to_entity(model) if model else None
def get_by_uid(self, uid: UUID) -> Optional[TenantDriveIntegrationEntity]:
model = (
self.session.query(TenantDriveIntegration)
.filter(TenantDriveIntegration.uid == uid)
.first()
)
return TenantDriveIntegrationMapper.to_entity(model) if model else None
def add(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
model = TenantDriveIntegration()
TenantDriveIntegrationMapper.apply_to_model(entity, model)
model.created_user = entity.created_user
self.session.add(model)
self.session.flush()
return TenantDriveIntegrationMapper.to_entity(model)
def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
from jedi_common.handler.exception import NotFound
from common.code.grc_error_code import GrcErrorCode
model = (
self.session.query(TenantDriveIntegration)
.filter(TenantDriveIntegration.id == entity.id)
.first()
)
if not model:
raise NotFound(GrcErrorCode.GRC_DRIVE_INTEGRATION_NOT_FOUND)
TenantDriveIntegrationMapper.apply_to_model(entity, model)
self.session.flush()
return TenantDriveIntegrationMapper.to_entity(model)
def delete_by_tenant_id(self, tenant_id: int) -> bool:
deleted = (
self.session.query(TenantDriveIntegration)
.filter(TenantDriveIntegration.tenant_id == tenant_id)
.delete()
)
self.session.flush()
return deleted > 0git add infra/cloud_integration/repository/ test/cloud_integration/
git commit -m "feat(cloud_integration): add TenantDriveIntegrationRepo impl + tests"Files:
domain/cloud_integration/service/__init__.pydomain/cloud_integration/service/tenant_drive_integration_domain_service.pyfrom typing import Optional
from uuid import UUID
from jedi_common.handler.exception import ConflictError, NotFound
from common.code.grc_error_code import GrcErrorCode
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
from domain.cloud_integration.repository.tenant_drive_integration_repository import (
ITenantDriveIntegrationRepo,
)
class TenantDriveIntegrationDomainService:
def __init__(self, tenant_drive_integration_repo: ITenantDriveIntegrationRepo):
self._repo = tenant_drive_integration_repo
def get_or_none(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]:
return self._repo.get_by_tenant_id(tenant_id)
def get_required(self, tenant_id: int) -> TenantDriveIntegrationEntity:
entity = self._repo.get_by_tenant_id(tenant_id)
if entity is None:
raise NotFound(GrcErrorCode.GRC_DRIVE_INTEGRATION_NOT_FOUND)
return entity
def create(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
existing = self._repo.get_by_tenant_id(entity.tenant_id)
if existing is not None and existing.status == "CONNECTED":
raise ConflictError(GrcErrorCode.GRC_DRIVE_INTEGRATION_ALREADY_EXISTS)
if existing is not None:
# 之前 disconnected,覆寫 update
entity.id = existing.id
entity.uid = existing.uid
return self._repo.update(entity)
return self._repo.add(entity)
def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
return self._repo.update(entity)
def disconnect(self, tenant_id: int) -> bool:
entity = self._repo.get_by_tenant_id(tenant_id)
if entity is None:
return False
entity.status = "DISCONNECTED"
entity.refresh_token_encrypted = None
entity.access_token_cache = None
entity.access_token_expires_at = None
entity.webhook_channel_id = None
entity.webhook_resource_id = None
entity.webhook_token = None
entity.webhook_expires_at = None
self._repo.update(entity)
return Truefrom unittest.mock import MagicMock
import pytest
from jedi_common.handler.exception import NotFound, ConflictError
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
TenantDriveIntegrationDomainService,
)
def test_get_required_raises_not_found_when_missing():
repo = MagicMock()
repo.get_by_tenant_id.return_value = None
svc = TenantDriveIntegrationDomainService(repo)
with pytest.raises(NotFound):
svc.get_required(1)
def test_create_raises_conflict_when_already_connected():
repo = MagicMock()
repo.get_by_tenant_id.return_value = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
svc = TenantDriveIntegrationDomainService(repo)
with pytest.raises(ConflictError):
svc.create(TenantDriveIntegrationEntity(tenant_id=1))
def test_create_overwrites_disconnected_record():
existing = TenantDriveIntegrationEntity(tenant_id=1, status="DISCONNECTED", id=10)
repo = MagicMock()
repo.get_by_tenant_id.return_value = existing
svc = TenantDriveIntegrationDomainService(repo)
new_entity = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
svc.create(new_entity)
repo.update.assert_called_once()
assert new_entity.id == 10 # 覆寫 id
def test_create_can_reconnect_after_revoked():
"""Re-connect 後 status 從 REVOKED → CONNECTED 必須允許覆寫"""
existing = TenantDriveIntegrationEntity(tenant_id=1, status="REVOKED", id=20)
repo = MagicMock()
repo.get_by_tenant_id.return_value = existing
svc = TenantDriveIntegrationDomainService(repo)
new_entity = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
svc.create(new_entity)
repo.update.assert_called_once()
assert new_entity.id == 20注意 domain service
create()邏輯需把「DISCONNECTED / REVOKED / EXPIRED」三者都視為「可覆寫」,只有CONNECTED才拋 ConflictError。請對照if existing is not None and existing.status == "CONNECTED"確保 status 比對是對 CONNECTED,其他都走 update path。
git add domain/cloud_integration/service/ test/cloud_integration/test_tenant_drive_integration_domain_service.py
git commit -m "feat(cloud_integration): add TenantDriveIntegrationDomainService + tests"Files:
domain/cloud_integration/service/token_crypto_service.py (interface)infra/cloud_integration/crypto/__init__.pyinfra/cloud_integration/crypto/fernet_crypto.pytest/cloud_integration/test_fernet_crypto.py# domain/cloud_integration/service/token_crypto_service.py
from abc import ABC, abstractmethod
class TokenCryptoService(ABC):
@abstractmethod
def encrypt(self, plaintext: str) -> str: ...
@abstractmethod
def decrypt(self, ciphertext: str) -> str: ...# test/cloud_integration/test_fernet_crypto.py
from cryptography.fernet import Fernet
from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto
def test_encrypt_decrypt_roundtrip():
key = Fernet.generate_key().decode()
crypto = FernetCrypto(key=key)
plaintext = "my-very-secret-refresh-token"
ciphertext = crypto.encrypt(plaintext)
assert ciphertext != plaintext
assert crypto.decrypt(ciphertext) == plaintext
def test_different_keys_cannot_decrypt():
key1 = Fernet.generate_key().decode()
key2 = Fernet.generate_key().decode()
crypto1 = FernetCrypto(key=key1)
crypto2 = FernetCrypto(key=key2)
ciphertext = crypto1.encrypt("hello")
try:
crypto2.decrypt(ciphertext)
assert False, "expected exception"
except Exception:
pass
def test_decrypt_returns_str_not_bytes():
key = Fernet.generate_key().decode()
crypto = FernetCrypto(key=key)
result = crypto.decrypt(crypto.encrypt("foo"))
assert isinstance(result, str)# infra/cloud_integration/crypto/fernet_crypto.py
from cryptography.fernet import Fernet
from domain.cloud_integration.service.token_crypto_service import TokenCryptoService
class FernetCrypto(TokenCryptoService):
def __init__(self, key: str):
if not key:
raise ValueError("Fernet key is required (set DRIVE_TOKEN_ENCRYPTION_KEY env)")
self._fernet = Fernet(key.encode() if isinstance(key, str) else key)
def encrypt(self, plaintext: str) -> str:
return self._fernet.encrypt(plaintext.encode()).decode()
def decrypt(self, ciphertext: str) -> str:
return self._fernet.decrypt(ciphertext.encode()).decode()git add domain/cloud_integration/service/token_crypto_service.py infra/cloud_integration/crypto/ test/cloud_integration/test_fernet_crypto.py
git commit -m "feat(cloud_integration): add TokenCryptoService interface + FernetCrypto impl"Files:
infra/cloud_integration/google_drive/__init__.pyinfra/cloud_integration/google_drive/google_oauth_client.pytest/cloud_integration/test_google_oauth_client.pyfrom unittest.mock import patch, MagicMock
from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient
def test_build_auth_url_contains_required_params():
client = GoogleOAuthClient(
client_id="my-client-id",
client_secret="my-secret",
redirect_uri="https://app/callback",
)
url = client.build_auth_url(state="csrf123", scopes=["scope1"])
assert "client_id=my-client-id" in url
assert "state=csrf123" in url
assert "access_type=offline" in url
assert "prompt=consent" in url
assert "scope=scope1" in url
@patch("infra.cloud_integration.google_drive.google_oauth_client.requests.post")
def test_exchange_code_returns_tokens(mock_post):
mock_post.return_value.ok = True
mock_post.return_value.json.return_value = {
"access_token": "at-xxx",
"refresh_token": "rt-yyy",
"expires_in": 3600,
"token_type": "Bearer",
}
client = GoogleOAuthClient("cid", "csec", "https://app/callback")
result = client.exchange_code("the-code")
assert result["access_token"] == "at-xxx"
assert result["refresh_token"] == "rt-yyy"
@patch("infra.cloud_integration.google_drive.google_oauth_client.requests.get")
def test_get_userinfo_returns_email(mock_get):
mock_get.return_value.ok = True
mock_get.return_value.json.return_value = {"email": "x@y.com", "verified_email": True}
client = GoogleOAuthClient("cid", "csec", "https://app/callback")
info = client.get_userinfo("access-token")
assert info["email"] == "x@y.com"# infra/cloud_integration/google_drive/google_oauth_client.py
from typing import Iterable
from urllib.parse import urlencode
import requests
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"
class GoogleOAuthClient:
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
def build_auth_url(self, state: str, scopes: Iterable[str]) -> str:
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": " ".join(scopes),
"access_type": "offline",
"prompt": "consent",
"include_granted_scopes": "true",
"state": state,
}
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
def exchange_code(self, code: str) -> dict:
resp = requests.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
},
timeout=10,
)
if not resp.ok:
raise RuntimeError(f"OAuth exchange failed: {resp.status_code} {resp.text}")
return resp.json()
def refresh_access_token(self, refresh_token: str) -> dict:
resp = requests.post(
GOOGLE_TOKEN_URL,
data={
"refresh_token": refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "refresh_token",
},
timeout=10,
)
if not resp.ok:
raise RuntimeError(f"Token refresh failed: {resp.status_code} {resp.text}")
return resp.json()
def get_userinfo(self, access_token: str) -> dict:
resp = requests.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
if not resp.ok:
raise RuntimeError(f"userinfo failed: {resp.status_code} {resp.text}")
return resp.json()
def revoke(self, token: str) -> bool:
resp = requests.post(
GOOGLE_REVOKE_URL,
params={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
return resp.okgit add infra/cloud_integration/google_drive/ test/cloud_integration/test_google_oauth_client.py
git commit -m "feat(cloud_integration): add GoogleOAuthClient (auth-url / exchange / refresh / userinfo / revoke)"Files:
app/cloud_integration/__init__.pyapp/cloud_integration/dto/__init__.pyapp/cloud_integration/dto/google_drive_integration_dto.pyapp/cloud_integration/service/__init__.pyapp/cloud_integration/service/google_drive_integration_service.pytest/cloud_integration/test_google_drive_integration_service.py# app/cloud_integration/dto/google_drive_integration_dto.py
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import UUID
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
@dataclass
class GoogleDriveIntegrationStatusDTO:
uid: Optional[UUID]
status: str # CONNECTED / EXPIRED / REVOKED / DISCONNECTED
google_account_email: Optional[str]
root_folder_id: Optional[str]
connected_by_user_id: Optional[int]
connected_by_user_name: Optional[str]
connected_at: Optional[datetime]
last_sync_at: Optional[datetime]
webhook_expires_at: Optional[datetime]
last_sync_error: Optional[str]
@classmethod
def from_entity(
cls,
entity: Optional[TenantDriveIntegrationEntity],
connected_by_user_name: Optional[str] = None,
) -> "GoogleDriveIntegrationStatusDTO":
if entity is None:
return cls(
uid=None,
status="DISCONNECTED",
google_account_email=None,
root_folder_id=None,
connected_by_user_id=None,
connected_by_user_name=None,
connected_at=None,
last_sync_at=None,
webhook_expires_at=None,
last_sync_error=None,
)
return cls(
uid=entity.uid,
status=entity.status,
google_account_email=entity.google_account_email,
root_folder_id=entity.root_folder_id,
connected_by_user_id=entity.connected_by_user_id,
connected_by_user_name=connected_by_user_name,
connected_at=entity.connected_at,
last_sync_at=entity.last_sync_at,
webhook_expires_at=entity.webhook_expires_at,
last_sync_error=entity.last_sync_error,
)build_auth_url(tenant_id, user_id) -> dict{auth_url, state} — 產 state token 存 Redis (TTL 10min)handle_callback(code, state) -> entity — 驗 state、call OAuth client 換 token、加密存 DBget_status(tenant_id) -> DTO — 查現況disconnect(tenant_id) -> bool — 撤銷 + 清 token# app/cloud_integration/service/google_drive_integration_service.py
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from flask import current_app
from jedi_common.handler.exception import BadRequestError
from jedi_common.session.database.db import transaction
from jedi_auth.domain.service.user_domain_service import UserDomainService
from common.code.grc_error_code import GrcErrorCode
from core.extensions import redis_client
from app.cloud_integration.dto.google_drive_integration_dto import (
GoogleDriveIntegrationStatusDTO,
)
from domain.cloud_integration.entities.tenant_drive_integration_entity import (
TenantDriveIntegrationEntity,
)
from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
TenantDriveIntegrationDomainService,
)
from domain.cloud_integration.service.token_crypto_service import TokenCryptoService
from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient
STATE_REDIS_PREFIX = "drive_oauth_state:"
STATE_TTL_SECONDS = 600 # 10 min
REQUIRED_SCOPES = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/userinfo.email",
]
class GoogleDriveIntegrationService:
def __init__(
self,
tenant_drive_integration_domain_service: TenantDriveIntegrationDomainService,
token_crypto_service: TokenCryptoService,
google_oauth_client: GoogleOAuthClient,
user_domain_service: UserDomainService,
):
self._domain = tenant_drive_integration_domain_service
self._crypto = token_crypto_service
self._oauth = google_oauth_client
self._user_domain = user_domain_service
# ─────────────────────────────────────────────────
def build_auth_url(self, tenant_id: int, user_id: int) -> dict:
state = secrets.token_urlsafe(32)
payload = f"{tenant_id}:{user_id}"
redis_client.setex(STATE_REDIS_PREFIX + state, STATE_TTL_SECONDS, payload)
auth_url = self._oauth.build_auth_url(state=state, scopes=REQUIRED_SCOPES)
return {"auth_url": auth_url, "state": state}
# ─────────────────────────────────────────────────
@transaction
def handle_callback(self, code: str, state: str) -> TenantDriveIntegrationEntity:
payload = redis_client.get(STATE_REDIS_PREFIX + state)
if payload is None:
raise BadRequestError(GrcErrorCode.GRC_DRIVE_OAUTH_STATE_INVALID)
redis_client.delete(STATE_REDIS_PREFIX + state)
tenant_id_str, user_id_str = payload.decode().split(":")
tenant_id = int(tenant_id_str)
user_id = int(user_id_str)
tokens = self._oauth.exchange_code(code)
refresh_token = tokens.get("refresh_token")
access_token = tokens["access_token"]
expires_in = tokens.get("expires_in", 3600)
if not refresh_token:
# 常見原因:user 之前已對該 OAuth client 同意過授權,
# Google 不會再次回傳 refresh_token;要求 user 至 myaccount.google.com/permissions 移除舊授權再試。
raise BadRequestError(GrcErrorCode.GRC_DRIVE_OAUTH_NO_REFRESH_TOKEN)
userinfo = self._oauth.get_userinfo(access_token)
user = self._user_domain.get_by_id(user_id)
login_name = user.login_name if user else "system"
entity = TenantDriveIntegrationEntity(
tenant_id=tenant_id,
provider="google_drive",
google_account_email=userinfo.get("email"),
refresh_token_encrypted=self._crypto.encrypt(refresh_token),
access_token_cache=self._crypto.encrypt(access_token),
access_token_expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in - 60),
status="CONNECTED",
connected_by_user_id=user_id,
connected_at=datetime.now(timezone.utc),
created_user=login_name,
updated_user=login_name,
)
return self._domain.create(entity)
# ─────────────────────────────────────────────────
def get_status(self, tenant_id: int) -> GoogleDriveIntegrationStatusDTO:
entity = self._domain.get_or_none(tenant_id)
connected_by_name = None
if entity and entity.connected_by_user_id:
user = self._user_domain.get_by_id(entity.connected_by_user_id)
if user:
connected_by_name = user.nickname
return GoogleDriveIntegrationStatusDTO.from_entity(entity, connected_by_name)
# ─────────────────────────────────────────────────
def disconnect(self, tenant_id: int) -> bool:
"""Disconnect = (1) 先嘗試 best-effort revoke OAuth token (HTTP, 不在 transaction 內)
(2) 再 commit 把 token 從 DB 清除。
revoke 失敗不影響 (2)。"""
# Step 1: revoke (no DB transaction — HTTP call only)
entity = self._domain.get_or_none(tenant_id)
if entity is None:
return False
if entity.refresh_token_encrypted:
try:
rt = self._crypto.decrypt(entity.refresh_token_encrypted)
self._oauth.revoke(rt)
except Exception:
pass # ignore revoke errors — DB 仍會清
# Step 2: clear DB (own transaction)
return self._clear_db(tenant_id)
@transaction
def _clear_db(self, tenant_id: int) -> bool:
return self._domain.disconnect(tenant_id)重要:OAuth revoke 是 HTTP call 給 Google,可能 timeout (~10s)。如果包在
@transaction內會把 DB session/連線卡住整個 timeout 時間。所以拆成兩段:先做 HTTP 不開 transaction,再單獨開 transaction 清 DB。
build_auth_url 寫 redis、回 urlhandle_callback state 不存在 → BadRequesthandle_callback 成功 → 寫 entityget_status 帶 nicknamedisconnect 撤銷 + 標 status(測試完整 listing 略,依 Task 8 同 pattern;確保用 MagicMock 注入 4 個依賴)
git add app/cloud_integration/ test/cloud_integration/test_google_drive_integration_service.py
git commit -m "feat(cloud_integration): add GoogleDriveIntegrationService (auth-url/callback/status/disconnect)"Files:
api/cloud_integration/serializers/__init__.pyapi/cloud_integration/serializers/google_drive_integration.py# api/cloud_integration/serializers/google_drive_integration.py
from marshmallow import Schema, fields
class GoogleDriveAuthUrlResponse(Schema):
auth_url = fields.String(required=True)
state = fields.String(required=True)
class GoogleDriveStatusResponse(Schema):
uid = fields.UUID(allow_none=True)
status = fields.String(required=True)
google_account_email = fields.String(allow_none=True)
root_folder_id = fields.String(allow_none=True)
connected_by_user_id = fields.Integer(allow_none=True)
connected_by_user_name = fields.String(allow_none=True)
connected_at = fields.DateTime(allow_none=True)
last_sync_at = fields.DateTime(allow_none=True)
webhook_expires_at = fields.DateTime(allow_none=True)
last_sync_error = fields.String(allow_none=True)
class GoogleDriveCallbackQuerySchema(Schema):
code = fields.String(required=True)
state = fields.String(required=True)git add api/cloud_integration/serializers/
git commit -m "feat(cloud_integration): add Drive integration serializers"Files:
api/cloud_integration/routes/__init__.pyapi/cloud_integration/routes/google_drive_integration_route.pyfrom jedi_common.handler.exception import ForbiddenError
from common.code.grc_error_code import GrcErrorCode
def build_auth_url(self, tenant_id: int, user_id: int, *, is_admin: bool) -> dict:
if not is_admin:
raise ForbiddenError(GrcErrorCode.GRC_NOT_ADMIN)
... (原邏輯)
def disconnect(self, tenant_id: int, *, is_admin: bool) -> bool:
if not is_admin:
raise ForbiddenError(GrcErrorCode.GRC_NOT_ADMIN)
... (原邏輯)同時更新 Task 11 的單元測試補:「非 admin 呼叫 build_auth_url / disconnect 拋 ForbiddenError + GRC_NOT_ADMIN」。
# api/cloud_integration/routes/google_drive_integration_route.py
from dataclasses import asdict
from dependency_injector.wiring import inject, Provide
from flask import request, Response, current_app
from flask_apispec import MethodResource, doc, marshal_with
from flask_jwt_extended import jwt_required
from jedi_common.session.auth.auth_context import get_user_context
from api.cloud_integration.serializers.google_drive_integration import (
GoogleDriveAuthUrlResponse,
GoogleDriveStatusResponse,
)
from app.cloud_integration.service.google_drive_integration_service import (
GoogleDriveIntegrationService,
)
from common.util.response_util import return_response
from di_containers.containers import Containers
class GoogleDriveIntegrationRoute(MethodResource):
"""`/google-drive` — 同時負責 GET (status, 任何登入者) 與 DELETE (disconnect, admin only)."""
@doc(description="取得當前 tenant 的 Google Drive 連線狀態", tags=["Cloud Integration"])
@marshal_with(GoogleDriveStatusResponse, apply=False)
@jwt_required()
@inject
def get(
self,
service: GoogleDriveIntegrationService = Provide[
Containers.cloud_integration_container.google_drive_integration_service
],
):
user = get_user_context()
dto = service.get_status(user.tenant_id)
return return_response(True, GoogleDriveStatusResponse().dump(asdict(dto)))
@doc(description="中斷 Google Drive 連線", tags=["Cloud Integration"])
@jwt_required()
@inject
def delete(
self,
service: GoogleDriveIntegrationService = Provide[
Containers.cloud_integration_container.google_drive_integration_service
],
):
user = get_user_context()
ok = service.disconnect(user.tenant_id, is_admin=user.is_admin)
return return_response(True, {"disconnected": ok})
class GoogleDriveAuthUrlRoute(MethodResource):
@doc(description="產生 OAuth 授權 URL", tags=["Cloud Integration"])
@marshal_with(GoogleDriveAuthUrlResponse, apply=False)
@jwt_required()
@inject
def post(
self,
service: GoogleDriveIntegrationService = Provide[
Containers.cloud_integration_container.google_drive_integration_service
],
):
user = get_user_context()
result = service.build_auth_url(user.tenant_id, user.user_id, is_admin=user.is_admin)
return return_response(True, result)
class GoogleDriveCallbackRoute(MethodResource):
@doc(description="OAuth callback (Google redirect 進來)", tags=["Cloud Integration"])
@inject
def get(
self,
service: GoogleDriveIntegrationService = Provide[
Containers.cloud_integration_container.google_drive_integration_service
],
):
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error or not code or not state:
return Response(_callback_html("error", error or "missing_params"), mimetype="text/html")
try:
service.handle_callback(code, state)
except Exception as e:
return Response(_callback_html("error", str(e)), mimetype="text/html")
return Response(_callback_html("success", None), mimetype="text/html")
def _callback_html(status: str, error: str | None) -> str:
"""回 HTML,用 postMessage 通知 opener 後 self.close().
targetOrigin 從 app config 取,避免用 '*' 造成 token leak 風險。"""
err_repr = f'"{error}"' if error else "null"
target_origin = current_app.config.get("FRONTEND_BASE_URL", "*")
return f"""
<!doctype html>
<html>
<body>
<p>Google Drive 連線完成({status}),視窗即將關閉...</p>
<script>
try {{
if (window.opener) {{
window.opener.postMessage(
{{ source: 'guidant-drive-oauth', status: '{status}', error: {err_repr} }},
'{target_origin}'
);
}}
}} catch (e) {{ console.error(e); }}
setTimeout(function() {{ window.close(); }}, 800);
</script>
</body>
</html>
"""並在
config/config.py新增FRONTEND_BASE_URL = os.getenv("FRONTEND_BASE_URL", "*"),production 必設、dev 留*。
def create_module():
from api.cloud_integration.routes.google_drive_integration_route import (
GoogleDriveIntegrationRoute,
GoogleDriveAuthUrlRoute,
GoogleDriveCallbackRoute,
)
# 注意:url_prefix 用 /api(不是 /api/integrations),
# 為了讓 Phase 3 的 webhook receiver (/api/webhooks/google-drive/<tenant_id>)
# 也能掛在同一個 Blueprint 上。
bp = Blueprint("cloud_integration", __name__, url_prefix="/api")
api = Api(bp)
api.add_resource(GoogleDriveIntegrationRoute, "/integrations/google-drive") # GET + DELETE
api.add_resource(GoogleDriveAuthUrlRoute, "/integrations/google-drive/auth-url")
api.add_resource(GoogleDriveCallbackRoute, "/integrations/google-drive/callback")
logger.info("Cloud Integration module registered")
return bp設計注意:
GoogleDriveIntegrationRoute是單一 Resource class 同時實作get()(status) 與delete()(disconnect),避免 Flask-RESTful 不允許多 class 同 path 的限制。- Blueprint
url_prefix='/api'是為了 Phase 3 webhook 共用此 Blueprint(不需多 Blueprint return list —main_app.py只接受單一 Blueprint)。
git add api/cloud_integration/routes/ api/cloud_integration/__init__.py
git commit -m "feat(cloud_integration): add Google Drive integration routes"Files:
di_containers/cloud_integration/__init__.pydi_containers/cloud_integration/cloud_integration_containers.pydi_containers/containers.py# di_containers/cloud_integration/cloud_integration_containers.py
from dependency_injector import containers, providers
from jedi_common.session.database.db import get_session
from infra.cloud_integration.repository.tenant_drive_integration_repo_impl import (
TenantDriveIntegrationRepoImpl,
)
from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto
from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient
from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
TenantDriveIntegrationDomainService,
)
from app.cloud_integration.service.google_drive_integration_service import (
GoogleDriveIntegrationService,
)
class CloudIntegrationContainer(containers.DeclarativeContainer):
config = providers.Configuration()
auth_container = providers.DependenciesContainer()
# repos
tenant_drive_integration_repo = providers.Factory(
TenantDriveIntegrationRepoImpl,
session=providers.Resource(get_session),
)
# domain services
tenant_drive_integration_domain_service = providers.Factory(
TenantDriveIntegrationDomainService,
tenant_drive_integration_repo=tenant_drive_integration_repo,
)
# crypto
# (app.config 已透過 core/app_factory.py 的 container.config.from_dict(app.config) 注入,
# 所以這裡直接用大寫 key — 與 config/config.py 屬性名一致)
token_crypto_service = providers.Singleton(
FernetCrypto,
key=config.DRIVE_TOKEN_ENCRYPTION_KEY,
)
# oauth client
google_oauth_client = providers.Singleton(
GoogleOAuthClient,
client_id=config.GOOGLE_DRIVE_OAUTH_CLIENT_ID,
client_secret=config.GOOGLE_DRIVE_OAUTH_CLIENT_SECRET,
redirect_uri=config.GOOGLE_DRIVE_OAUTH_REDIRECT_URI,
)
# app service
google_drive_integration_service = providers.Factory(
GoogleDriveIntegrationService,
tenant_drive_integration_domain_service=tenant_drive_integration_domain_service,
token_crypto_service=token_crypto_service,
google_oauth_client=google_oauth_client,
user_domain_service=auth_container.user_domain_service,
)from di_containers.cloud_integration.cloud_integration_containers import CloudIntegrationContainer
# ...
cloud_integration_container: CloudIntegrationContainer = providers.Container(
CloudIntegrationContainer,
config=config, # 全 app.config(已自動 from_dict 注入),不限 sub-key
auth_container=auth_container,
)# 在 cloud_integration_containers.py 內參考 config 時:
token_crypto_service = providers.Singleton(
FernetCrypto,
key=config.DRIVE_TOKEN_ENCRYPTION_KEY,
)
google_oauth_client = providers.Singleton(
GoogleOAuthClient,
client_id=config.GOOGLE_DRIVE_OAUTH_CLIENT_ID,
client_secret=config.GOOGLE_DRIVE_OAUTH_CLIENT_SECRET,
redirect_uri=config.GOOGLE_DRIVE_OAUTH_REDIRECT_URI,
)改 Step 1 的 container 定義對應更新(key 名從
config.token_encryption_key改成config.DRIVE_TOKEN_ENCRYPTION_KEY,依此類推)。 用grep -rn "config\." di_containers/看其他既有 container 怎麼存取 app config 為參考(例如di_containers/system_config/、di_containers/notification/)。
git add di_containers/cloud_integration/ di_containers/containers.py main_app.py
git commit -m "feat(cloud_integration): wire DI container + register in main"Files:
test/cloud_integration/test_google_drive_integration_route.pyimport json
from unittest.mock import patch
import pytest
@pytest.mark.usefixtures("db_session")
def test_get_status_disconnected_initially(client, headers):
resp = client.get("/api/integrations/google-drive", headers=headers)
assert resp.status_code == 200
body = resp.get_json()
assert body["data"]["status"] == "DISCONNECTED"
@pytest.mark.usefixtures("db_session")
def test_post_auth_url_returns_url_for_admin(admin_client, admin_headers):
resp = admin_client.post("/api/integrations/google-drive/auth-url", headers=admin_headers)
assert resp.status_code == 200
assert "auth_url" in resp.get_json()["data"]
@pytest.mark.usefixtures("db_session")
def test_post_auth_url_forbidden_for_non_admin(client, headers):
resp = client.post("/api/integrations/google-drive/auth-url", headers=headers)
assert resp.status_code == 403
@pytest.mark.usefixtures("db_session")
@patch("infra.cloud_integration.google_drive.google_oauth_client.requests.post")
@patch("infra.cloud_integration.google_drive.google_oauth_client.requests.get")
def test_callback_creates_integration(mock_get, mock_post, admin_client, admin_headers, redis_client):
# 1. 先取 auth-url 拿到 state
resp = admin_client.post("/api/integrations/google-drive/auth-url", headers=admin_headers)
state = resp.get_json()["data"]["state"]
# 2. mock token exchange + userinfo
mock_post.return_value.ok = True
mock_post.return_value.json.return_value = {
"access_token": "at-x", "refresh_token": "rt-y", "expires_in": 3600,
}
mock_get.return_value.ok = True
mock_get.return_value.json.return_value = {"email": "shared@company.com"}
# 3. 呼叫 callback
resp = admin_client.get(f"/api/integrations/google-drive/callback?code=the-code&state={state}")
assert resp.status_code == 200
assert b"success" in resp.data
# 4. 驗 status 變成 CONNECTED
resp = admin_client.get("/api/integrations/google-drive", headers=admin_headers)
assert resp.get_json()["data"]["status"] == "CONNECTED"
assert resp.get_json()["data"]["google_account_email"] == "shared@company.com"
@pytest.mark.usefixtures("db_session")
def test_disconnect(admin_client, admin_headers):
# 假設已連線(前一個 test 的延伸或 fixture 預先建好)
resp = admin_client.delete("/api/integrations/google-drive", headers=admin_headers)
assert resp.status_code == 200
resp = admin_client.get("/api/integrations/google-drive", headers=admin_headers)
assert resp.get_json()["data"]["status"] == "DISCONNECTED"若沒有
admin_client/admin_headersfixture,先在test/cloud_integration/conftest.py補上(參考tests/conftest.py既有 pattern)。
git add test/cloud_integration/test_google_drive_integration_route.py test/cloud_integration/conftest.py
git commit -m "test(cloud_integration): add E2E tests for Google Drive integration routes"Files:
src/config/api/api.jssrc/service/CloudIntegrationService.js切換工作目錄到前端:cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe
// ── Cloud Integration ─────────────────────────────────────
INTEGRATION_GDRIVE: '/integrations/google-drive',
INTEGRATION_GDRIVE_AUTH_URL: '/integrations/google-drive/auth-url',import { BaseService } from '@/service/BaseService'
import API from '@/config/api/api'
export class CloudIntegrationService extends BaseService {
async getGoogleDriveStatus() {
return this.get(API.INTEGRATION_GDRIVE)
}
async getGoogleDriveAuthUrl() {
return this.post(API.INTEGRATION_GDRIVE_AUTH_URL)
}
async disconnectGoogleDrive() {
return this.delete(API.INTEGRATION_GDRIVE)
}
}git add src/config/api/api.js src/service/CloudIntegrationService.js
git commit -m "feat(cloud-integration): add CloudIntegrationService + API constants"Files:
src/lang/zh-TW.js (or wherever zh strings live)src/lang/en.jscloud_integration: {
page_title: '雲端空間整合設定',
gdrive: {
title: 'Google Drive',
description: '透過 Drive 上傳 evidence,自動同步進系統。',
warning: '⚠ 將授權系統存取連線帳號的整個 Drive,建議使用 dedicated 共用公司帳號。',
connect_btn: '連線 Google Drive',
disconnect_btn: '中斷連線',
reauth_btn: '重新授權',
sync_now_btn: '立即同步',
status_connected: '已連線',
status_disconnected: '未連線',
status_revoked: '連線已失效',
status_expired: '授權已過期',
field_account: '連線帳號',
field_root_folder: '根目錄',
field_connected_at: '連線時間',
field_last_sync: '上次同步',
field_webhook_expires: 'Webhook 到期',
connecting: '連線中…',
connect_success: 'Google Drive 連線成功!',
connect_error: '連線失敗:{error}',
disconnect_confirm: '確定要中斷 Google Drive 連線嗎?已快取的證據檔案仍可下載,但 Drive 上新的變更將不再同步。',
disconnect_success: '已中斷 Google Drive 連線',
}
}git add src/lang/
git commit -m "feat(cloud-integration): add i18n strings for Google Drive integration"Files:
src/components/integrations/GoogleDriveIntegrationCard.vue<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { CloudIntegrationService } from '@/service/CloudIntegrationService'
const { t, d } = useI18n()
const toast = useToast()
const confirm = useConfirm()
const service = new CloudIntegrationService()
const status = ref(null) // GoogleDriveStatusResponse
const loading = ref(false)
const connecting = ref(false)
let messageHandler = null
async function refresh() {
loading.value = true
try {
status.value = await service.getGoogleDriveStatus()
} finally {
loading.value = false
}
}
async function connect() {
connecting.value = true
try {
const { auth_url } = await service.getGoogleDriveAuthUrl()
const popup = window.open(auth_url, 'gdrive-oauth', 'width=500,height=700')
messageHandler = (event) => {
const msg = event.data
if (!msg || msg.source !== 'guidant-drive-oauth') return
if (msg.status === 'success') {
toast.add({ severity: 'success', summary: t('lang.cloud_integration.gdrive.connect_success'), life: 3000 })
refresh()
} else {
toast.add({ severity: 'error', summary: t('lang.cloud_integration.gdrive.connect_error', { error: msg.error || '' }), life: 5000 })
}
connecting.value = false
cleanup()
}
window.addEventListener('message', messageHandler)
} catch (e) {
connecting.value = false
toast.add({ severity: 'error', summary: e?.message || 'Error', life: 4000 })
}
}
function disconnect() {
confirm.require({
message: t('lang.cloud_integration.gdrive.disconnect_confirm'),
header: t('lang.cloud_integration.gdrive.disconnect_btn'),
accept: async () => {
await service.disconnectGoogleDrive()
toast.add({ severity: 'info', summary: t('lang.cloud_integration.gdrive.disconnect_success'), life: 3000 })
refresh()
}
})
}
function cleanup() {
if (messageHandler) {
window.removeEventListener('message', messageHandler)
messageHandler = null
}
}
const isConnected = computed(() => status.value?.status === 'CONNECTED')
const isError = computed(() => ['REVOKED', 'EXPIRED'].includes(status.value?.status))
const driveRootUrl = computed(() => {
if (!status.value?.root_folder_id) return null
return `https://drive.google.com/drive/folders/${status.value.root_folder_id}`
})
onMounted(refresh)
onUnmounted(cleanup)
</script>
<template>
<div class="surface-card border-round-xl shadow-2 p-5 flex flex-column gap-4"
style="border: 1px solid var(--surface-border)">
<div class="flex align-items-center gap-3">
<i class="pi pi-google text-3xl" style="color: #4285f4"></i>
<div>
<div class="text-xl font-semibold">{{ t('lang.cloud_integration.gdrive.title') }}</div>
<div class="text-sm text-color-secondary">{{ t('lang.cloud_integration.gdrive.description') }}</div>
</div>
</div>
<ProgressBar v-if="loading" mode="indeterminate" style="height: 4px" />
<!-- 未連線 -->
<div v-if="status && status.status === 'DISCONNECTED' && !loading"
class="flex flex-column gap-3">
<div class="text-sm text-color-secondary">
{{ t('lang.cloud_integration.gdrive.warning') }}
</div>
<Button :label="t('lang.cloud_integration.gdrive.connect_btn')"
icon="pi pi-link"
:loading="connecting"
@click="connect" />
</div>
<!-- 連線異常 -->
<Message v-else-if="isError" severity="error" :closable="false">
{{ status.status === 'REVOKED' ? t('lang.cloud_integration.gdrive.status_revoked')
: t('lang.cloud_integration.gdrive.status_expired') }}
<Button :label="t('lang.cloud_integration.gdrive.reauth_btn')"
severity="warn" size="small" class="ml-3" @click="connect" />
</Message>
<!-- 已連線 -->
<div v-else-if="isConnected" class="flex flex-column gap-2">
<Tag severity="success" :value="t('lang.cloud_integration.gdrive.status_connected')" class="align-self-start" />
<div class="grid">
<div class="col-12 sm:col-6">
<div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_account') }}</div>
<div class="font-medium">{{ status.google_account_email }}</div>
</div>
<div class="col-12 sm:col-6">
<div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_root_folder') }}</div>
<div>
<a v-if="driveRootUrl" :href="driveRootUrl" target="_blank" rel="noopener"
class="text-primary">
GuidantAI <i class="pi pi-external-link text-xs ml-1"></i>
</a>
<span v-else class="text-color-secondary">—</span>
</div>
</div>
<div class="col-12 sm:col-6">
<div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_connected_at') }}</div>
<div>{{ status.connected_at ? d(status.connected_at, 'long') : '—' }}
<span v-if="status.connected_by_user_name" class="text-color-secondary text-xs">
by {{ status.connected_by_user_name }}
</span>
</div>
</div>
<div class="col-12 sm:col-6">
<div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_last_sync') }}</div>
<div>{{ status.last_sync_at ? d(status.last_sync_at, 'long') : '—' }}</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<Button :label="t('lang.cloud_integration.gdrive.disconnect_btn')"
icon="pi pi-times" severity="danger" outlined
@click="disconnect" />
<Button :label="t('lang.cloud_integration.gdrive.reauth_btn')"
icon="pi pi-refresh" outlined
@click="connect" />
</div>
</div>
</div>
</template>git add src/components/integrations/
git commit -m "feat(cloud-integration): add GoogleDriveIntegrationCard component"Files:
src/views/integrations/CloudIntegrationsView.vuesrc/router/index.jsAppMenu.vue 或 Sidebar.vue)<script setup>
import { useI18n } from 'vue-i18n'
import GoogleDriveIntegrationCard from '@/components/integrations/GoogleDriveIntegrationCard.vue'
const { t } = useI18n()
</script>
<template>
<div class="p-5 flex flex-column gap-4">
<h2 class="m-0 font-semibold text-2xl">{{ t('lang.cloud_integration.page_title') }}</h2>
<div class="grid">
<div class="col-12 lg:col-8 xl:col-6">
<GoogleDriveIntegrationCard />
</div>
</div>
<Toast />
<ConfirmDialog />
</div>
</template>{
path: '/settings/cloud-integrations',
name: 'CloudIntegrations',
component: () => import('@/views/integrations/CloudIntegrationsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}git add src/views/integrations/ src/router/index.js <menu file>
git commit -m "feat(cloud-integration): add CloudIntegrationsView page + route + menu entry"Files: N/A(用 Playwright MCP 操作 dev server)
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
python main_app.pycd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe
npm run dev/settings/cloud-integrationsFiles:
docs/changelog/<YYYY-MM-DD>-google-drive-oauth-integration.md# Google Drive OAuth 整合(Phase 1)
日期:<YYYY-MM-DD>
作者:raymond / Claude
## 需求說明
Tenant admin 可在「雲端空間整合設定」頁完成 Google Drive OAuth 連線、查看狀態、中斷連線。為後續資料夾自動建立與檔案同步打基礎。
## 變更範圍
### 後端
- 新增模組 `cloud_integration`
- 新增 table `compliance.tenant_drive_integrations` (見 `scripts/sql/<...>.sql`)
- 新增 9 個 error codes (GRC_400030-500030)
- 新增依賴:google-api-python-client, google-auth-oauthlib, cryptography
- Token 加密:Fernet(key 由 env `DRIVE_TOKEN_ENCRYPTION_KEY` 提供)
### 前端
- 新增頁面 `/settings/cloud-integrations`(admin only)
- 新增元件 `GoogleDriveIntegrationCard.vue`
- 新增 service `CloudIntegrationService.js`
## API 變更
| Method | Path | 說明 |
|--------|------|------|
| GET | /api/integrations/google-drive | 取得當前 tenant 連線狀態 |
| POST | /api/integrations/google-drive/auth-url | 產生 OAuth URL(admin) |
| GET | /api/integrations/google-drive/callback | OAuth callback(HTML) |
| DELETE | /api/integrations/google-drive | 中斷連線(admin) |
## 環境變數
- `GOOGLE_DRIVE_OAUTH_CLIENT_ID`
- `GOOGLE_DRIVE_OAUTH_CLIENT_SECRET`
- `GOOGLE_DRIVE_OAUTH_REDIRECT_URI`
- `DRIVE_TOKEN_ENCRYPTION_KEY`(用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 產)
## 參考
- Spec: docs/features/FR-016-2604-google-drive-sync/design.md §6
- Implementation Plan: docs/features/FR-016-2604-google-drive-sync/implementation-plan-phase-1-oauth.mdgit add docs/changelog/<YYYY-MM-DD>-google-drive-oauth-integration.md
git commit -m "docs: add changelog for Google Drive OAuth integration phase 1"完成 → 進 Phase 2。