Files
hr-portal/GOOGLE-INTEGRATION.md
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 20:12:43 +08:00

16 KiB

🔗 Google Workspace 整合指南

📋 概述

整合 Google Calendar 和 Google Meet 到 HR Portal,讓員工可以:

  • 📅 雙向同步 Google Calendar
  • 🎥 透過您的 Google 帳號建立 Google Meet 會議
  • 📧 自動發送會議邀請
  • 🔔 接收 Google 的會議提醒

🎯 整合方案

方案 A: Google Workspace API (推薦)

使用您的 Google One 帳號作為服務帳號

優點:

  • 直接使用您現有的 Google 帳號
  • 完整的 Google Meet 功能
  • 無需額外付費
  • Google Calendar 原生體驗

需要的 API:

  1. Google Calendar API - 行事曆同步
  2. Google Meet API - 建立會議
  3. Google People API - 聯絡人管理 (可選)

🔧 設定步驟

步驟 1: 建立 Google Cloud Project

  1. 訪問 Google Cloud Console

  2. 建立新專案

    專案名稱: HR Portal Integration
    組織: (您的組織或個人)
    
  3. 啟用 API

    • Google Calendar API
    • Google Meet API (部分 Google Workspace Admin API)
    • Google People API

步驟 2: 建立 OAuth 2.0 憑證

  1. API 和服務 → 憑證

  2. 建立憑證 → OAuth 用戶端 ID

    應用程式類型: 網頁應用程式
    名稱: HR Portal
    
    已授權的 JavaScript 來源:
    - https://hr.porscheworld.tw
    - http://localhost:3000 (開發用)
    
    已授權的重新導向 URI:
    - https://hr.porscheworld.tw/api/v1/auth/google/callback
    - http://localhost:3000/auth/google/callback (開發用)
    
  3. 下載 JSON 憑證檔案

    • 檔名: google-credentials.json
    • 儲存到: backend/secrets/
  4. 記錄以下資訊:

    Client ID: xxxxx.apps.googleusercontent.com
    Client Secret: xxxxxxxxxxxxxx
    

步驟 3: 設定 OAuth 同意畫面

  1. OAuth 同意畫面

    使用者類型: 外部 (或內部,如果有 Google Workspace)
    
    應用程式資訊:
    - 應用程式名稱: HR Portal
    - 使用者支援電子郵件: admin@porscheworld.tw
    - 應用程式標誌: (您的 Logo)
    
    授權網域:
    - porscheworld.tw
    
    開發人員聯絡資訊:
    - it@porscheworld.tw
    
  2. 範圍 (Scopes)

    新增以下範圍:
    - https://www.googleapis.com/auth/calendar
    - https://www.googleapis.com/auth/calendar.events
    - https://www.googleapis.com/auth/meetings.space.created
    - https://www.googleapis.com/auth/userinfo.email
    - https://www.googleapis.com/auth/userinfo.profile
    
  3. 測試使用者 (如果應用程式處於測試階段)

    • 新增公司員工的 Gmail 地址

💻 後端實作

1. 安裝相關套件

cd backend
pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client

更新 requirements.txt:

google-auth==2.26.2
google-auth-oauthlib==1.2.0
google-auth-httplib2==0.2.0
google-api-python-client==2.114.0

2. 環境變數設定

編輯 backend/.env:

# Google OAuth
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://hr.porscheworld.tw/api/v1/auth/google/callback

# Google API Scopes
GOOGLE_SCOPES=https://www.googleapis.com/auth/calendar,https://www.googleapis.com/auth/calendar.events

# 服務帳號 (您的 Google One 帳號)
GOOGLE_SERVICE_ACCOUNT_EMAIL=porsche.chen@gmail.com

3. Google 服務類別

創建 backend/app/services/google_service.py:

"""
Google Workspace 整合服務
"""
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from datetime import datetime, timedelta
from typing import List, Dict, Optional
import os

from app.core.config import settings


class GoogleService:
    """Google API 服務"""

    def __init__(self, credentials: Credentials):
        self.credentials = credentials
        self.calendar_service = build('calendar', 'v3', credentials=credentials)

    @staticmethod
    def get_auth_url(state: str) -> str:
        """
        取得 Google OAuth 授權 URL

        Args:
            state: 狀態參數 (用於防止 CSRF)

        Returns:
            授權 URL
        """
        flow = Flow.from_client_config(
            {
                "web": {
                    "client_id": settings.GOOGLE_CLIENT_ID,
                    "client_secret": settings.GOOGLE_CLIENT_SECRET,
                    "redirect_uris": [settings.GOOGLE_REDIRECT_URI],
                    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
                    "token_uri": "https://oauth2.googleapis.com/token",
                }
            },
            scopes=settings.GOOGLE_SCOPES.split(',')
        )

        flow.redirect_uri = settings.GOOGLE_REDIRECT_URI
        authorization_url, _ = flow.authorization_url(
            access_type='offline',
            include_granted_scopes='true',
            state=state,
            prompt='consent'
        )

        return authorization_url

    @staticmethod
    def get_credentials_from_code(code: str) -> Credentials:
        """
        用授權碼換取憑證

        Args:
            code: OAuth 授權碼

        Returns:
            Google Credentials
        """
        flow = Flow.from_client_config(
            {
                "web": {
                    "client_id": settings.GOOGLE_CLIENT_ID,
                    "client_secret": settings.GOOGLE_CLIENT_SECRET,
                    "redirect_uris": [settings.GOOGLE_REDIRECT_URI],
                    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
                    "token_uri": "https://oauth2.googleapis.com/token",
                }
            },
            scopes=settings.GOOGLE_SCOPES.split(',')
        )

        flow.redirect_uri = settings.GOOGLE_REDIRECT_URI
        flow.fetch_token(code=code)

        return flow.credentials

    # === Google Calendar Methods ===

    def list_events(
        self,
        time_min: Optional[datetime] = None,
        time_max: Optional[datetime] = None,
        max_results: int = 100
    ) -> List[Dict]:
        """
        列出 Google Calendar 事件

        Args:
            time_min: 開始時間
            time_max: 結束時間
            max_results: 最大結果數

        Returns:
            事件列表
        """
        if not time_min:
            time_min = datetime.utcnow()
        if not time_max:
            time_max = time_min + timedelta(days=30)

        events_result = self.calendar_service.events().list(
            calendarId='primary',
            timeMin=time_min.isoformat() + 'Z',
            timeMax=time_max.isoformat() + 'Z',
            maxResults=max_results,
            singleEvents=True,
            orderBy='startTime'
        ).execute()

        return events_result.get('items', [])

    def create_event(
        self,
        summary: str,
        start_time: datetime,
        end_time: datetime,
        description: Optional[str] = None,
        location: Optional[str] = None,
        attendees: Optional[List[str]] = None,
        add_meet: bool = True
    ) -> Dict:
        """
        建立 Google Calendar 事件

        Args:
            summary: 事件標題
            start_time: 開始時間
            end_time: 結束時間
            description: 事件描述
            location: 地點
            attendees: 參與者 email 列表
            add_meet: 是否新增 Google Meet 連結

        Returns:
            建立的事件資料
        """
        event = {
            'summary': summary,
            'start': {
                'dateTime': start_time.isoformat(),
                'timeZone': 'Asia/Taipei',
            },
            'end': {
                'dateTime': end_time.isoformat(),
                'timeZone': 'Asia/Taipei',
            },
        }

        if description:
            event['description'] = description

        if location:
            event['location'] = location

        if attendees:
            event['attendees'] = [{'email': email} for email in attendees]

        # 新增 Google Meet
        if add_meet:
            event['conferenceData'] = {
                'createRequest': {
                    'requestId': f"meet-{datetime.utcnow().timestamp()}",
                    'conferenceSolutionKey': {'type': 'hangoutsMeet'}
                }
            }

        # 建立事件
        created_event = self.calendar_service.events().insert(
            calendarId='primary',
            body=event,
            conferenceDataVersion=1 if add_meet else 0,
            sendUpdates='all'  # 發送邀請給所有參與者
        ).execute()

        return created_event

    def update_event(
        self,
        event_id: str,
        summary: Optional[str] = None,
        start_time: Optional[datetime] = None,
        end_time: Optional[datetime] = None,
        description: Optional[str] = None
    ) -> Dict:
        """
        更新 Google Calendar 事件

        Args:
            event_id: Google Event ID
            summary: 新標題
            start_time: 新開始時間
            end_time: 新結束時間
            description: 新描述

        Returns:
            更新後的事件資料
        """
        # 取得現有事件
        event = self.calendar_service.events().get(
            calendarId='primary',
            eventId=event_id
        ).execute()

        # 更新欄位
        if summary:
            event['summary'] = summary
        if start_time:
            event['start']['dateTime'] = start_time.isoformat()
        if end_time:
            event['end']['dateTime'] = end_time.isoformat()
        if description:
            event['description'] = description

        # 更新事件
        updated_event = self.calendar_service.events().update(
            calendarId='primary',
            eventId=event_id,
            body=event,
            sendUpdates='all'
        ).execute()

        return updated_event

    def delete_event(self, event_id: str):
        """
        刪除 Google Calendar 事件

        Args:
            event_id: Google Event ID
        """
        self.calendar_service.events().delete(
            calendarId='primary',
            eventId=event_id,
            sendUpdates='all'
        ).execute()

    def get_meet_link(self, event_id: str) -> Optional[str]:
        """
        取得 Google Meet 連結

        Args:
            event_id: Google Event ID

        Returns:
            Meet 連結或 None
        """
        event = self.calendar_service.events().get(
            calendarId='primary',
            eventId=event_id
        ).execute()

        conference_data = event.get('conferenceData', {})
        entry_points = conference_data.get('entryPoints', [])

        for entry in entry_points:
            if entry.get('entryPointType') == 'video':
                return entry.get('uri')

        return None

🔐 認證流程

1. 員工首次連接 Google 帳號

員工點擊「連接 Google Calendar」
    ↓
重定向到 Google 授權頁面
    ↓
員工登入並授權
    ↓
Google 重定向回 HR Portal (帶授權碼)
    ↓
後端用授權碼換取 Access Token
    ↓
儲存 Token 到資料庫 (加密)
    ↓
開始同步 Google Calendar

2. 建立會議流程

員工在 HR Portal 建立會議
    ↓
選擇「使用 Google Meet」
    ↓
後端呼叫 Google Calendar API
    ↓
自動建立 Calendar 事件 + Google Meet 連結
    ↓
發送邀請給參與者
    ↓
返回 Meet 連結給前端
    ↓
員工可直接點擊加入會議

📦 資料庫擴充

新增欄位到 employees 表:

ALTER TABLE employees ADD COLUMN IF NOT EXISTS
    google_calendar_connected BOOLEAN DEFAULT FALSE,
    google_access_token TEXT,  -- 加密儲存
    google_refresh_token TEXT,  -- 加密儲存
    google_token_expiry TIMESTAMP,
    google_calendar_id VARCHAR(255),
    last_google_sync TIMESTAMP;

🎨 前端整合

React 範例 - 連接 Google Calendar

// 連接 Google Calendar 按鈕
const ConnectGoogleButton = () => {
  const handleConnect = async () => {
    const response = await fetch('/api/v1/auth/google/authorize');
    const { auth_url } = await response.json();

    // 開啟授權視窗
    window.location.href = auth_url;
  };

  return (
    <button onClick={handleConnect}>
      <img src="/google-icon.svg" />
      連接 Google Calendar
    </button>
  );
};

React 範例 - 建立 Google Meet 會議

const CreateMeetingButton = () => {
  const createMeeting = async () => {
    const meeting = {
      title: "團隊會議",
      start_time: "2026-02-08T14:00:00+08:00",
      end_time: "2026-02-08T15:00:00+08:00",
      attendees: ["alice@ease.taipei", "bob@lab.taipei"],
      use_google_meet: true
    };

    const response = await fetch('/api/v1/calendar/events', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(meeting)
    });

    const event = await response.json();

    // 顯示 Google Meet 連結
    alert(`會議已建立!\nGoogle Meet: ${event.hangout_link}`);
  };

  return <button onClick={createMeeting}>建立 Google Meet 會議</button>;
};

🔄 同步機制

雙向同步策略

  1. 從 Google Calendar 同步到 HR Portal (每 15 分鐘)

    • 使用 Google Calendar API 的 sync token
    • 只同步變更的事件
    • 更新本地資料庫
  2. 從 HR Portal 同步到 Google Calendar (即時)

    • 員工在 HR Portal 建立事件
    • 立即推送到 Google Calendar
    • 取得 Google Event ID 並儲存
  3. 衝突處理

    • Google 為主要來源
    • 顯示衝突警告
    • 讓員工選擇保留哪個版本

推薦的 UI/UX 套件

1. FullCalendar (推薦)

網址: https://fullcalendar.io/

特色:

  • 功能完整的行事曆元件
  • 支援日/週/月/議程視圖
  • 拖放事件
  • 與 Google Calendar 整合良好
  • React 版本: @fullcalendar/react

安裝:

npm install @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction @fullcalendar/google-calendar

使用範例:

import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import googleCalendarPlugin from '@fullcalendar/google-calendar';

function Calendar() {
  return (
    <FullCalendar
      plugins={[
        dayGridPlugin,
        timeGridPlugin,
        interactionPlugin,
        googleCalendarPlugin
      ]}
      initialView="dayGridMonth"
      googleCalendarApiKey="YOUR_API_KEY"
      events={{
        googleCalendarId: 'primary'
      }}
      headerToolbar={{
        left: 'prev,next today',
        center: 'title',
        right: 'dayGridMonth,timeGridWeek,timeGridDay'
      }}
      editable={true}
      selectable={true}
      select={handleDateSelect}
      eventClick={handleEventClick}
    />
  );
}

2. React Big Calendar

網址: https://jquense.github.io/react-big-calendar/

特色:

  • 輕量級
  • 類似 Google Calendar 介面
  • 自訂性高

3. Ant Design Calendar

如果您使用 Ant Design:

import { Calendar, Badge } from 'antd';

<Calendar dateCellRender={dateCellRender} />

📊 功能對比

功能 Jitsi Meet (自架) Google Meet (整合)
費用 免費 包含在 Google One
時間限制 24小時
參與人數 視伺服器資源 100人 (Google One)
錄影 支援 支援
螢幕分享 支援 支援
背景模糊 支援 支援
整合難度
維護成本 需自行維護 Google 維護

建議: 使用 Google Meet 整合,更簡單且功能完整!


下一步我會建立完整的 Google 整合程式碼! 🚀