[VS Code][Colab] Google 正式釋出 Colab VS Code Plugin

Connecting to a new Colab server and executing a code cell

前情提要

Google Colab 是一個我很喜歡的服務,你可以在線上透過 JupyterNotebook 的介面,快速使用到 GPU (甚至是 TPU)。有許多需要大量運算資源的東西,都可以很快速的在遠端的機器上面執行。

我自己很常在上面去嘗試一些模型,雖然常常排隊排不到機器。

image-20251114155345861

使用 Colab 可能有的痛點

雖然使用Google Colab 機器非常的方便,但是由於在線上編輯有一些比較麻煩的地方:

  • 無法使用 Copilot 這類型的 Code Assist Tool 來幫我 Auto-Complete 一些程式碼
  • 無法跑 Gemini CLI Code Assist 來幫我寫出一些更多的測試或是幫忙想應用。

Colab for VS Code Plugin

但是現在 Colab VS Code Extension 終於可以在 VS Code Plugin 上面使用了。你可以透過 “Colab” 直接找到官方釋出的 Plugin 。

Code 2025-11-14 15.55.03

安裝過程相當的簡單又快速。

連線到 Colab

Code 2025-11-14 15.23.10

如果要連線,在選擇 Kernel 的時候,就可以選擇 Colab 來遠端連線。

Code 2025-11-14 15.23.16

這裡還可以快速連線,或是找你上次連線過的伺服器。

Code 2025-11-14 15.23.22

這裡就是讓人興奮的地方,可以找找 TPU (不保證排得到隊伍)來用用看。

這樣就可以了。

實際應用:

Code 2025-11-14 15.54.52

這樣比較對味啦!! Vibe Coding 出現之後,我們越來越習慣 Vibe Coding 了。但是如果需要 Step by Step 的去偵錯,或是想要跑一些大型機器才能運行的運算。真的還是需要透過 Colab 來幫忙,但是如果又希望可以有 Gemini CLI 的輔助的話,或許 Colab VS Code Extension 就是你不可或缺的好夥伴。

目前一些需要注意的地方

由於 Colab VS Code Plugin 還在持續開發中,有一些原本在 Colab Web UI 上可以使用的 google.colab 功能目前還無法在 VS Code 中使用。以下是一些主要的限制:

  • auth.authenticate_user(): 認證 URL 會出現在選單中,無法直接點擊。建議改用 Python Cloud Client Library。
  • drive.mount(): 目前無法掛載 Google Drive,可以改用 Drive Python API 來存取檔案。
  • files.download() / files.upload(): 原生的檔案上傳下載功能無法使用,但可以透過 IPyWidget 來達成相同效果。
  • userdata.get(): 目前會回傳錯誤,暫時需要從 Colab Web UI 複製 secret 值到 notebook 中。

雖然有這些限制,但整體來說 Colab VS Code Plugin 還是大幅提升了開發體驗,特別是對於習慣使用 VS Code 和各種 AI Coding Assistant 的開發者來說,絕對是值得一試的好工具!

參考資料

[Python] LINE Bot 名片管家進化:一鍵生成 vCard QR Code,讓名片直接加入手機通訊錄

image-20251114103158657

前情提要

在先前的 LINE Bot 智慧名片管家 專案中,我們已經實作了使用 Gemini Pro Vision API 自動辨識名片的功能。使用者只要拍照上傳名片,AI 就能自動解析姓名、職稱、公司、電話、Email 等資訊,並儲存到 Firebase Realtime Database 中。

但在實際使用時,我發現了一個痛點:

📱 我已經有數位化的名片資料了,但要加入手機通訊錄還是得手動一個一個欄位輸入…

想像這些情境:

  • 📇 參加研討會:收集了 20 張名片,辨識完成後還要手動加入通訊錄
  • 💼 業務拜訪:拿到客戶名片,想快速加入手機聯絡人
  • 🤝 社交場合:認識新朋友,希望立即儲存聯絡方式

於是我想:既然資料已經數位化了,為什麼不能一鍵加入通訊錄呢?

最理想的方式就是:生成 vCard QR Code,讓使用者掃描後直接加入通訊錄

專案程式碼

https://github.com/kkdai/linebot-namecard-python

(透過這個程式碼,可以快速部署到 GCP Cloud Run,享受無伺服器的便利)

📚 關於 vCard 與 QR Code

vCard 格式介紹

vCard(Virtual Contact File)是一種電子名片的標準格式,副檔名為 .vcf。幾乎所有智慧型手機和郵件客戶端都原生支援 vCard,包括:

  • 📱 iPhone:自動識別並提示「加入聯絡人」
  • 🤖 Android:透過聯絡人 App 匯入
  • 💻 電腦:Outlook、Apple Mail、Gmail 等都支援

vCard 3.0 格式範例

BEGIN:VCARD
VERSION:3.0
FN:Kevin Dai
N:Dai;Kevin;;;
ORG:LINE Taiwan
TITLE:Software Engineer
TEL;TYPE=WORK,VOICE:+886-123-456-789
EMAIL;TYPE=WORK:[email protected]
ADR;TYPE=WORK:;;Taipei, Taiwan;;;;
NOTE:Met at DevFest 2025
END:VCARD

QR Code + vCard 的優勢

將 vCard 編碼成 QR Code 有幾個好處:

  1. 一掃即加:相機 App 掃描後自動識別
  2. 跨平台:iPhone/Android 都支援
  3. 無需下載:不用儲存檔案再匯入
  4. 資料完整:包含所有聯絡資訊和備註

✨ 專案功能介紹

核心功能流程

使用者上傳名片圖片
    ↓
Gemini Vision API 辨識
    ↓
儲存到 Firebase Realtime Database
    ↓
顯示名片 Flex Message
    ↓
【新功能】點擊「📥 加入通訊錄」按鈕
    ↓
生成 vCard QR Code
    ↓
上傳到 Firebase Storage
    ↓
回傳 QR Code 圖片給使用者
    ↓
使用者掃描 → 加入通訊錄 ✅

新增功能亮點

  1. 📥 一鍵生成 QR Code
    • 點擊名片上的「加入通訊錄」按鈕
    • 自動生成包含完整資訊的 vCard QR Code
    • 包含姓名、職稱、公司、電話、Email、地址、備註
  2. ☁️ Firebase Storage 整合
    • QR Code 圖片上傳到 Firebase Storage
    • 自動設為公開可讀取
    • 透過 LINE ImageMessage 發送給使用者
  3. 🤖 Gemini Vision 協作
    • 原有的名片辨識功能(Gemini Vision API)
    • 辨識結果 → Firebase Database → QR Code
    • AI 辨識 + 雲端儲存 + 行動應用的完整整合
  4. 📱 使用者友善
    • 自動產生使用說明
    • 支援 iPhone/Android
    • 掃描即可加入通訊錄

💻 核心功能實作

1. vCard 格式生成

首先實作 vCard 格式字串的生成,這是整個功能的基礎。

檔案位置: app/qrcode_utils.py

def generate_vcard_string(namecard_data: Dict[str, str]) -> str:
    """
    Generate vCard 3.0 format string from namecard data.

    Args:
        namecard_data: Dictionary containing namecard fields

    Returns:
        vCard formatted string
    """
    name = namecard_data.get('name', '')
    title = namecard_data.get('title', '')
    company = namecard_data.get('company', '')
    phone = namecard_data.get('phone', '')
    email = namecard_data.get('email', '')
    address = namecard_data.get('address', '')
    memo = namecard_data.get('memo', '')

    # Build vCard 3.0 format
    vcard_lines = [
        'BEGIN:VCARD',
        'VERSION:3.0',
        f'FN:{name}',
        f'N:{name};;;',  # Family Name; Given Name; Additional Names; Honorific Prefixes; Honorific Suffixes
    ]

    if company:
        vcard_lines.append(f'ORG:{company}')

    if title:
        vcard_lines.append(f'TITLE:{title}')

    if phone:
        # Clean phone number format for vCard
        clean_phone = phone.replace('-', '').replace(' ', '')
        vcard_lines.append(f'TEL;TYPE=WORK,VOICE:{clean_phone}')

    if email:
        vcard_lines.append(f'EMAIL;TYPE=WORK:{email}')

    if address:
        # vCard address format: PO Box;Extended Address;Street;City;Region;Postal Code;Country
        vcard_lines.append(f'ADR;TYPE=WORK:;;{address};;;;')

    if memo:
        # Escape special characters in memo
        escaped_memo = memo.replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;')
        vcard_lines.append(f'NOTE:{escaped_memo}')

    vcard_lines.append('END:VCARD')

    return '\n'.join(vcard_lines)

設計要點

  • ✅ 使用 vCard 3.0 格式(相容性最好)
  • 處理空欄位:只在有資料時才加入對應欄位
  • 電話號碼清理:移除 - 和空格,確保格式正確
  • 特殊字元轉義:備註中的換行、逗號、分號需要轉義
  • 完整資訊:包含備註欄位,保留 AI 辨識時的額外資訊

2. QR Code 圖片生成

使用 qrcode 套件將 vCard 字串編碼成 QR Code 圖片。

def generate_vcard_qrcode(namecard_data: Dict[str, str],
                          box_size: int = 10,
                          border: int = 2) -> BytesIO:
    """
    Generate QR Code image containing vCard data.

    Args:
        namecard_data: Dictionary containing namecard fields
        box_size: Size of each box in pixels (default: 10)
        border: Border size in boxes (default: 2)

    Returns:
        BytesIO object containing PNG image data
    """
    # Generate vCard string
    vcard_string = generate_vcard_string(namecard_data)

    # Create QR Code instance
    qr = qrcode.QRCode(
        version=None,  # Auto-determine version based on data size
        error_correction=qrcode.constants.ERROR_CORRECT_L,
        box_size=box_size,
        border=border,
    )

    # Add vCard data
    qr.add_data(vcard_string)
    qr.make(fit=True)

    # Generate image
    img = qr.make_image(fill_color="black", back_color="white")

    # Save to BytesIO
    img_bytes = BytesIO()
    img.save(img_bytes, format='PNG')
    img_bytes.seek(0)  # Reset pointer to beginning

    return img_bytes

關鍵參數說明

參數 說明 選擇理由
version=None 自動決定 QR Code 大小 根據資料量自動調整,確保可掃描
error_correction=L 錯誤修正等級(Low) vCard 資料不會頻繁損壞,選擇最小等級以減少 QR Code 大小
box_size=10 每個方塊 10 像素 在手機螢幕上有良好的掃描性
border=2 邊框 2 個方塊寬 符合 QR Code 標準的最小邊框

為什麼使用 BytesIO?

  • ✅ 不需要寫入實體檔案系統
  • ✅ 直接在記憶體中處理圖片
  • ✅ 方便後續上傳到 Firebase Storage
  • ✅ 減少 I/O 操作,提升效能

3. Firebase Storage 整合

這是整個功能的核心:將 QR Code 圖片上傳到 Firebase Storage 並取得公開 URL。

檔案位置: app/firebase_utils.py

from firebase_admin import storage
from io import BytesIO

def upload_qrcode_to_storage(
        image_bytes: BytesIO, user_id: str, card_id: str) -> str:
    """
    上傳 QR Code 圖片到 Firebase Storage 並回傳公開 URL

    Args:
        image_bytes: QR Code 圖片的 BytesIO 物件
        user_id: 使用者 ID
        card_id: 名片 ID

    Returns:
        圖片的公開 URL,若失敗則回傳 None
    """
    try:
        bucket = storage.bucket()
        blob_name = f"qrcodes/{user_id}/{card_id}.png"
        blob = bucket.blob(blob_name)

        # 上傳圖片
        image_bytes.seek(0)  # 重置指標到開頭
        blob.upload_from_file(image_bytes, content_type='image/png')

        # 設定為公開可讀取
        blob.make_public()

        # 回傳公開 URL
        return blob.public_url
    except Exception as e:
        print(f"Error uploading QR code to storage: {e}")
        return None

設計考量

  1. 檔案路徑結構qrcodes/{user_id}/{card_id}.png
    • 按使用者分類,方便管理
    • 使用 card_id 確保檔名唯一
    • 同一張名片重複生成會覆蓋舊檔案
  2. 公開權限blob.make_public()
    • QR Code 需要被 LINE Bot 透過 URL 存取
    • Firebase Storage Rules 設為 allow read: if true
    • 寫入權限只給 Admin SDK(Cloud Run)
  3. Content-Type 設定content_type='image/png'
    • 確保瀏覽器正確顯示圖片
    • LINE ImageMessage 需要正確的 MIME type

4. Firebase 初始化配置

app/main.py 中正確設定 Firebase Storage Bucket:

import firebase_admin
from firebase_admin import credentials

# Firebase 初始化
firebase_config = {
    "databaseURL": config.FIREBASE_URL,
}
# 如果設定了 Storage Bucket,則加入配置
if config.FIREBASE_STORAGE_BUCKET:
    firebase_config["storageBucket"] = config.FIREBASE_STORAGE_BUCKET

try:
    cred = credentials.ApplicationDefault()
    firebase_admin.initialize_app(cred, firebase_config)
    print("Firebase Admin SDK initialized successfully.")
except Exception as e:
    # 從環境變數解析 JSON
    gac_str = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS_JSON")
    if gac_str:
        cred_json = json.loads(gac_str)
        cred = credentials.Certificate(cred_json)
        firebase_admin.initialize_app(cred, firebase_config)
        print("Firebase Admin SDK initialized successfully from ENV VAR.")

環境變數設定

# 部署到 Cloud Run 時需要設定
FIREBASE_STORAGE_BUCKET=your-project-id.firebasestorage.app

# 或舊格式
FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com

為什麼需要明確設定 Storage Bucket?

  • Firebase Admin SDK 預設只初始化 Database
  • 如果不指定 storageBucket,呼叫 storage.bucket() 會失敗
  • 明確設定可避免執行時錯誤

5. LINE Bot Postback 處理

當使用者點擊「加入通訊錄」按鈕時,處理完整流程。

檔案位置: app/line_handlers.py

from linebot.models import ImageSendMessage, TextSendMessage

async def handle_download_contact(
        event: PostbackEvent, user_id: str, card_id: str, card_name: str):
    """處理下載聯絡人 QR Code 的請求"""
    try:
        # 1. 從 Firebase 取得完整的名片資料
        card_data = firebase_utils.get_card_by_id(user_id, card_id)
        if not card_data:
            await line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='找不到該名片資料。'))
            return

        # 2. 生成 vCard QR Code
        qrcode_image = qrcode_utils.generate_vcard_qrcode(card_data)

        # 3. 上傳到 Firebase Storage 並取得 URL
        image_url = firebase_utils.upload_qrcode_to_storage(
            qrcode_image, user_id, card_id)

        if not image_url:
            await line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text='生成 QR Code 時發生錯誤,請稍後再試。'))
            return

        # 4. 生成使用說明
        instruction_text = qrcode_utils.get_qrcode_usage_instruction(card_name)

        # 5. 回傳 QR Code 圖片和使用說明
        image_message = ImageSendMessage(
            original_content_url=image_url,
            preview_image_url=image_url
        )
        text_message = TextSendMessage(text=instruction_text)

        await line_bot_api.reply_message(
            event.reply_token,
            [image_message, text_message])

    except Exception as e:
        print(f"Error in handle_download_contact: {e}")
        await line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text='處理您的請求時發生錯誤,請稍後再試。'))

流程設計亮點

  1. 完整錯誤處理:每個步驟都有錯誤檢查
  2. 友善提示:失敗時給予明確的錯誤訊息
  3. 一次回傳兩則訊息:圖片 + 說明文字
  4. 非同步處理:使用 async/await 避免阻塞

6. Flex Message 按鈕配置

在名片的 Flex Message 中新增「加入通訊錄」按鈕。

檔案位置: app/flex_messages.py

"footer": {
    "type": "box",
    "layout": "vertical",
    "spacing": "sm",
    "contents": [
        {
            "type": "box",
            "layout": "horizontal",
            "spacing": "sm",
            "contents": [
                {
                    "type": "button",
                    "style": "link",
                    "height": "sm",
                    "action": {
                        "type": "postback",
                        "label": "新增/修改記事",
                        "data": f"action=add_memo&card_id={card_id}",
                        "displayText": f"我想為 {name} 新增記事"
                    },
                    "flex": 1
                },
                {
                    "type": "button",
                    "style": "link",
                    "height": "sm",
                    "action": {
                        "type": "postback",
                        "label": "編輯資料",
                        "data": f"action=edit_card&card_id={card_id}",
                        "displayText": f"我想編輯 {name} 的名片"
                    },
                    "flex": 1
                }
            ]
        },
        {
            "type": "button",
            "style": "primary",
            "height": "sm",
            "action": {
                "type": "postback",
                "label": "📥 加入通訊錄",
                "data": f"action=download_contact&card_id={card_id}",
                "displayText": f"下載 {name} 的聯絡人資訊"
            },
            "margin": "sm"
        }
    ]
}

UI 設計考量

┌────────────────────────────────────┐
│  [新增/修改記事]  [編輯資料]       │  ← 第一排並排(link style)
│  [📥 加入通訊錄]                   │  ← 第二排獨立(primary style)
└────────────────────────────────────┘
  • 第一排並排:常用的編輯功能,使用 link 樣式
  • 第二排獨立:下載功能,使用 primary 樣式突出顯示
  • Emoji 視覺化:📥 圖示讓使用者一眼識別下載功能

7. 使用說明生成

提供清楚的使用指引,讓使用者知道如何使用 QR Code。

def get_qrcode_usage_instruction(name: str) -> str:
    """
    Get user instruction message for using the QR Code.

    Args:
        name: Name of the person on the namecard

    Returns:
        Instruction message string
    """
    return f"""已為「{name}」生成聯絡人 QR Code!

📱 使用方式:
1. 用手機相機 App 掃描上方的 QR Code
2. 系統會自動識別聯絡人資訊
3. 點擊「加入聯絡人」即可匯入

✅ 支援 iPhone 和 Android 所有智慧型手機"""

設計理念

  • 個人化訊息:包含名片主人的姓名
  • 步驟清楚:1-2-3 簡單明瞭
  • 跨平台說明:強調 iPhone/Android 都支援
  • Emoji 視覺化:📱 和 ✅ 讓訊息更友善

🤖 Gemini Vision API 在整體架構中的角色

雖然這次的 QR Code 功能本身沒有用到 Gemini,但整個名片管家系統是以 Gemini Vision API 為核心的完整應用。

Gemini + Firebase Storage 的協作流程

┌─────────────────────────────────────────────┐
│  使用者上傳名片照片                          │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  LINE Bot 接收圖片                          │
│  (app/line_handlers.py)                    │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  Gemini Pro Vision API 辨識                │
│  - 姓名、職稱、公司                         │
│  - 電話、Email、地址                        │
│  (app/gemini_utils.py)                     │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  儲存到 Firebase Realtime Database         │
│  /namecard/{user_id}/{card_id}/            │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  使用者點擊「📥 加入通訊錄」                 │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  生成 vCard QR Code                        │
│  (app/qrcode_utils.py)                     │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  上傳到 Firebase Storage                   │
│  qrcodes/{user_id}/{card_id}.png           │
│  (app/firebase_utils.py)                   │
└──────────────┬──────────────────────────────┘
               ↓
┌─────────────────────────────────────────────┐
│  回傳 QR Code 給使用者                      │
│  使用者掃描 → 加入通訊錄 ✅                  │
└─────────────────────────────────────────────┘

Gemini Vision API 的關鍵作用

app/gemini_utils.py 中,我們使用 Gemini Pro Vision 解析名片圖片:

def generate_json_from_image(img: PIL.Image, prompt: str):
    """
    Use Gemini Pro Vision to extract structured data from image.
    """
    model = genai.GenerativeModel('gemini-1.5-pro')
    response = model.generate_content([prompt, img])
    return response

Prompt 設計 (app/config.py):

IMGAGE_PROMPT = """
這是一張名片,你是一個名片秘書。請將以下資訊整理成 json 給我。
如果看不出來的,幫我填寫 N/A
只好 json 就好:
name, title, address, email, phone, company.
其中 phone 的內容格式為 #886-0123-456-789,1234. 沒有分機就忽略 ,1234
"""

為什麼選擇 Gemini Vision?

  1. 中文辨識能力強:台灣名片常有中文,Gemini 處理效果好
  2. 結構化輸出:直接生成 JSON 格式,方便解析
  3. 容錯能力:無法辨識時自動填 “N/A”
  4. 彈性格式:支援各種名片版型

Firebase 雙服務整合

這個專案同時使用了 Firebase 的兩大服務:

服務 用途 資料類型 存取方式
Realtime Database 儲存名片結構化資料 JSON firebase_admin.db
Storage 儲存 QR Code 圖片 Binary (PNG) firebase_admin.storage

為什麼需要兩個服務?

  • Database:適合結構化資料,支援即時查詢和更新
  • Storage:適合大型二進位檔案,提供 CDN 加速

資料流向

Gemini Vision → Database (結構化資料)
                    ↓
                QR Code 生成
                    ↓
                Storage (圖片檔案)
                    ↓
                LINE Bot (圖片 URL)

🔧 遇到的挑戰與解決方案

1. Firebase Storage Bucket 配置問題

問題:初始化 Firebase Admin SDK 時,沒有設定 Storage Bucket 導致錯誤。

錯誤訊息

ValueError: Invalid None value for Firebase Storage bucket.

原因分析

  • Firebase Admin SDK 預設只初始化 Realtime Database
  • 必須在 initialize_app() 時明確指定 storageBucket
  • 環境變數未正確設定

解決方案

  1. config.py 新增配置
    FIREBASE_STORAGE_BUCKET = os.environ.get("FIREBASE_STORAGE_BUCKET")
    
  2. main.py 初始化時加入: ```python firebase_config = { “databaseURL”: config.FIREBASE_URL, } if config.FIREBASE_STORAGE_BUCKET: firebase_config[“storageBucket”] = config.FIREBASE_STORAGE_BUCKET

firebase_admin.initialize_app(cred, firebase_config)


3. **部署時設定環境變數**:
```bash
--set-env-vars "...,FIREBASE_STORAGE_BUCKET=line-vertex.firebasestorage.app,..."

學到的經驗

  • Firebase 不同服務需要不同的配置參數
  • 環境變數要完整檢查,避免 runtime 錯誤
  • 新格式 .firebasestorage.app 和舊格式 .appspot.com 都支援

2. Storage Rules 的權限設定

問題:如何設定 Firebase Storage Rules,讓 Cloud Run 能寫入,但 QR Code 圖片可以公開讀取?

初始想法

// ❌ 這樣會讓任何人都能寫入
allow read, write: if true;

正確方案

利用 Firebase Admin SDK 會繞過 Rules 的特性:

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read: if true;   // 任何人都可以讀取
      allow write: if false; // 禁止客戶端寫入
    }
  }
}

為什麼這樣可行?

  1. ✅ Cloud Run 使用 Admin SDK,有完整權限(繞過 Rules)
  2. allow read: if true 讓 LINE Bot 能透過 URL 存取圖片
  3. allow write: if false 阻止惡意客戶端上傳檔案
  4. blob.make_public() 設定的公開權限仍然有效

關鍵學習

  • Admin SDK vs 客戶端 SDK 的權限差異
  • Storage Rules 只影響客戶端存取
  • 雲端服務使用 Admin SDK 是最佳實踐

3. QR Code 大小與掃描性優化

問題:生成的 QR Code 太小或太大都不好掃描。

實驗過程

參數組合 結果 問題
box_size=5, border=1 圖片太小 手機掃描困難
box_size=15, border=4 圖片太大 LINE 壓縮後失真
box_size=10, border=2 ✅ 適中 掃描順暢

最終方案

qr = qrcode.QRCode(
    version=None,         # 自動調整大小
    error_correction=qrcode.constants.ERROR_CORRECT_L,  # 低錯誤修正
    box_size=10,          # 每個方塊 10px
    border=2,             # 邊框 2 個方塊
)

為什麼選擇 ERROR_CORRECT_L(低錯誤修正)?

  • vCard 資料相對穩定,不會損壞
  • 低錯誤修正 = QR Code 更簡單 = 掃描更快
  • 如果用高錯誤修正(H),QR Code 會變得很複雜

實測結果

  • ✅ iPhone 相機:秒掃
  • ✅ Android 相機:秒掃
  • ✅ LINE 內建掃描器:正常

4. vCard 特殊字元處理

問題:備註中如果有換行、逗號、分號等特殊字元,會導致 vCard 格式錯誤。

錯誤範例

NOTE:這個人很重要,記得要聯絡;下次見面時間: 2025/11/15

vCard 解析器會把逗號和分號當作分隔符,導致資料錯亂。

解決方案

if memo:
    # Escape special characters in memo
    escaped_memo = memo.replace('\n', '\\n').replace(',', '\\,').replace(';', '\\;')
    vcard_lines.append(f'NOTE:{escaped_memo}')

vCard 轉義規則

字元 轉義後 說明
換行 \n \\n 文字中的換行
逗號 , \\, 避免當作分隔符
分號 ; \\; 避免當作分隔符

學到的經驗

  • vCard 有自己的轉義規則,不能直接照搬 JSON
  • 使用者輸入的備註可能包含任何字元
  • 完整測試各種特殊字元情況

5. BytesIO 指標重置問題

問題:上傳圖片到 Firebase Storage 時,有時會上傳空檔案。

錯誤原因

img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
# ❌ 此時指標在檔案末端

blob.upload_from_file(img_bytes, content_type='image/png')
# ❌ 從末端開始讀取 = 讀到空內容

解決方案

img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)  # ✅ 重置指標到開頭

blob.upload_from_file(img_bytes, content_type='image/png')

為什麼需要 seek(0)?

  1. img.save() 會移動指標到檔案末端
  2. upload_from_file() 從當前位置開始讀取
  3. 如果不重置,會讀取 0 bytes

學到的經驗

  • 使用 BytesIO 要注意指標位置
  • 寫入後要記得 seek(0) 再讀取
  • 這是常見的新手陷阱

6. LINE ImageMessage 的 URL 要求

問題:有時候 QR Code 無法在 LINE 中顯示。

原因分析

LINE Bot 的 ImageSendMessage 對 URL 有嚴格要求:

  1. ✅ 必須是 HTTPS
  2. ✅ 圖片必須是 JPEG 或 PNG
  3. ✅ URL 必須公開可存取
  4. original_content_urlpreview_image_url 可以相同

正確用法

image_message = ImageSendMessage(
    original_content_url=image_url,  # Firebase Storage 的 public URL
    preview_image_url=image_url      # 可以用同一個 URL
)

Firebase Storage 的優勢

  • ✅ 自動提供 HTTPS URL
  • blob.make_public() 確保公開存取
  • ✅ CDN 加速,載入快速
  • blob.public_url 直接取得完整 URL

🎯 總結與未來改進

專案亮點

  1. 🤖 AI 驅動的名片辨識
    • Gemini Pro Vision API 自動解析名片
    • 支援中文名片,辨識準確率高
    • 結構化資料儲存,方便後續處理
  2. 📥 一鍵加入通訊錄
    • vCard QR Code 標準格式
    • iPhone/Android 原生支援
    • 掃描即加,無需手動輸入
  3. ☁️ Firebase 雙服務整合
    • Realtime Database 儲存結構化資料
    • Storage 儲存 QR Code 圖片
    • Admin SDK 確保安全性
  4. 🚀 無伺服器架構
    • 部署到 Google Cloud Run
    • 自動擴展,按需付費
    • 冷啟動優化,回應快速
  5. 🎨 使用者體驗優化
    • LINE Flex Message 精美介面
    • Postback 按鈕互動流暢
    • 清楚的使用說明

架構優勢

┌────────────────────────────────────────┐
│        Google Cloud Platform           │
│  ┌──────────────────────────────────┐  │
│  │      Cloud Run (無伺服器)        │  │
│  │  - FastAPI                       │  │
│  │  - LINE Bot SDK                  │  │
│  │  - Firebase Admin SDK            │  │
│  └──────────────────────────────────┘  │
│                                        │
│  ┌──────────────────────────────────┐  │
│  │   Gemini Pro Vision API         │  │
│  │  - 名片圖片辨識                  │  │
│  │  - 結構化資料提取                │  │
│  └──────────────────────────────────┘  │
│                                        │
│  ┌──────────────────────────────────┐  │
│  │      Firebase Services           │  │
│  │  - Realtime Database (名片資料)  │  │
│  │  - Storage (QR Code 圖片)        │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

實戰經驗分享

1. Firebase 服務的選擇

何時用 Realtime Database?

  • ✅ 結構化資料(JSON)
  • ✅ 需要即時查詢和更新
  • ✅ 資料量不大(名片資訊)
  • ✅ 需要簡單的查詢邏輯

何時用 Firebase Storage?

  • ✅ 二進位檔案(圖片、影片、PDF)
  • ✅ 需要公開存取 URL
  • ✅ 需要 CDN 加速
  • ✅ 檔案大小較大

這個專案的最佳組合

名片文字資料 → Realtime Database
QR Code 圖片 → Storage

2. vCard 標準的實用性

vCard 是個被低估的標準:

  • 跨平台:所有裝置都支援
  • 無需 APP:不用安裝額外軟體
  • 標準化:30 年歷史的成熟標準
  • 可擴展:支援照片、社群媒體等

使用情境遠超名片

  • 電子郵件簽名檔
  • 網站「聯絡我們」頁面
  • 會議報到系統
  • 社群媒體個人檔案

3. QR Code 的設計哲學

好的 QR Code 設計

  • ✅ 大小適中(10-15 px per module)
  • ✅ 最小邊框(2 modules)
  • ✅ 低錯誤修正(如果內容穩定)
  • ✅ 高對比度(黑白最佳)

避免過度設計

  • ❌ 加入 Logo(增加掃描難度)
  • ❌ 使用彩色(容易失真)
  • ❌ 過度藝術化(降低可讀性)

4. Firebase Admin SDK vs 客戶端 SDK

特性 Admin SDK 客戶端 SDK
執行環境 伺服器端 瀏覽器/手機
權限 完整權限(繞過 Rules) 受 Rules 限制
認證 Service Account 使用者認證
適用場景 Cloud Run, Cloud Functions Web App, Mobile App
安全性 高(不暴露憑證) 需要 Rules 保護

這個專案的選擇

  • ✅ 使用 Admin SDK(Cloud Run 環境)
  • ✅ Storage Rules 設為 write: false
  • ✅ Admin SDK 仍可寫入(繞過 Rules)

5. Gemini API 的最佳實踐

Prompt 設計技巧

# ✅ 好的 Prompt
"""
這是一張名片,你是一個名片秘書。請將以下資訊整理成 json 給我。
如果看不出來的,幫我填寫 N/A
只好 json 就好:
name, title, address, email, phone, company.
"""

# ❌ 不好的 Prompt
"Extract name, title, company from this business card"

為什麼第一個更好?

  1. 角色設定:「你是名片秘書」讓 AI 理解任務
  2. 明確格式:要求 JSON,不要其他說明
  3. 容錯處理:無法辨識時填 N/A
  4. 中文指令:處理中文名片時更準確

未來改進方向

1. 功能擴展

短期(1-2 週)

  • QR Code 加入公司 Logo(提升品牌識別)
  • 支援多種 QR Code 樣式選擇
  • QR Code 下載為檔案(不只圖片連結)
  • 批次生成多張名片的 QR Code

中期(1-2 個月)

  • 整合 NFC 虛擬名片(iPhone Wallet)
  • 支援 vCard 4.0 格式(更多欄位)
  • 名片分享統計(誰掃描了 QR Code)
  • 自訂 QR Code 設計(顏色、形狀)

長期(3-6 個月)

  • AI 名片管理助手(自動分類、提醒聯絡)
  • 與 Google Contacts / iCloud 同步
  • 名片交換記錄(何時何地交換)
  • 社群媒體整合(LinkedIn, Facebook)

2. 效能優化

QR Code 快取機制

# 目前:每次都重新生成並上傳
# 改進:檢查名片資料是否變更
if card_data_hash == cached_hash:
    return cached_qrcode_url  # 直接回傳快取的 URL

Storage 成本優化

  • 設定 QR Code 過期時間(7 天後自動刪除)
  • 使用 Cloud Storage Lifecycle Management
  • 壓縮圖片大小(目前約 5KB,可降至 2KB)

Cloud Run 冷啟動優化

  • 使用最小化的 Docker Image
  • Pre-import 常用套件
  • 設定最小實例數(避免冷啟動)

3. 安全性強化

當前挑戰

  • QR Code 圖片是公開的(任何人有 URL 都能存取)
  • 沒有使用者配額限制(惡意使用者可大量生成)
  • 沒有 Rate Limiting(防止 DoS)

改進方案

  1. Signed URL(簽名 URL)
    # 使用時效性 URL,1 小時後失效
    blob.generate_signed_url(expiration=timedelta(hours=1))
    
  2. 使用者配額管理
    # Firebase Database 記錄每個使用者的 QR Code 生成次數
    qrcode_count = db.reference(f"qrcode_quota/{user_id}").get()
    if qrcode_count > 100:  # 每日上限 100 次
     return "您已達到今日生成上限"
    
  3. Rate Limiting: ```python from slowapi import Limiter limiter = Limiter(key_func=get_remote_address)

@app.post(“/webhook”) @limiter.limit(“100/minute”) # 每分鐘最多 100 次請求 async def webhook(request: Request): …


#### 4. 使用體驗提升

**Rich Menu 設計**:

┌────────────┬────────────┬────────────┐ │ 📸 拍攝名片 │ 📇 我的名片 │ ⚙️ 設定 │ ├────────────┼────────────┼────────────┤ │ 📥 匯入名片 │ 🔍 搜尋名片 │ 💡 使用教學 │ └────────────┴────────────┴────────────┘


**名片分享功能**:
- 使用者可以分享自己的名片 QR Code
- 類似 LINE 的「我的 QR Code」
- 對方掃描後自動加入聯絡人

**智能提醒**:
```python
# 使用 Gemini 分析備註,自動設定提醒
if "下週要聯絡" in memo:
    # 設定 7 天後的提醒
    send_reminder(user_id, card_id, days=7)

關鍵學習

透過這個專案,我深入學習了:

  1. Firebase 生態系統
    • Realtime Database vs Firestore vs Storage 的選擇
    • Admin SDK 的權限模型與 Rules 的關係
    • 多服務整合的最佳實踐
  2. vCard 與 QR Code 標準
    • vCard 3.0 的格式規範與轉義規則
    • QR Code 參數優化(大小、錯誤修正)
    • 跨平台相容性測試
  3. Gemini Vision API
    • Prompt Engineering 技巧
    • 結構化資料提取
    • 中文處理的最佳實踐
  4. LINE Bot 開發
    • Flex Message 進階排版
    • Postback 互動設計
    • ImageMessage 的 URL 要求
  5. 雲端原生架構
    • 無伺服器設計模式
    • 環境變數管理
    • Storage 與 Database 的分工

最重要的體悟:

AI + 雲端 + 即時通訊 = 無限可能

這個專案展示了如何將三大技術結合:

  • 🤖 AI:Gemini Vision 自動辨識名片
  • ☁️ 雲端:Firebase 資料儲存 + Cloud Run 部署
  • 💬 即時通訊:LINE Bot 作為使用者介面

關鍵成功因素

  1. ✅ 選對工具(Gemini for OCR, Firebase for Storage)
  2. ✅ 標準化格式(vCard 確保相容性)
  3. ✅ 使用者體驗(一鍵加入通訊錄,無需學習)
  4. ✅ 安全設計(Admin SDK + Storage Rules)

希望這個經驗分享能幫助到正在探索 AI 應用開發的朋友們!

相關資源


如果你覺得這個專案有幫助,歡迎給個 Star ⭐,或是分享給需要的朋友!

LINE Messaging API 新功能介紹: Mark as Read API 讓你的聊天機器人標記訊息已讀

image-20251112102510088

在 2025 年 11 月 5 日,LINE Messaging API 推出了新的功能,讓聊天機器人可以將用戶發送的訊息標記為已讀。這項功能的推出,讓開發者能夠為用戶提供更好的互動體驗,用戶可以清楚知道機器人是否已經「看過」他們的訊息。

前言

image-20251112103925983

以往在回應設定中,如果開啟了聊天,希望可以用真人來回覆客戶的話。這個時候,由於系統是允許「真人聊天」跟「聊天機器人」是共存的。但是如果這個聊天選項打開的話,直到真人打開聊天視窗之前,即便客戶的訊息已經被聊天機器人處理了,他也不會標示成「已讀」。

這一篇文章就要跟大家分享,這一個新功能開放後。該如何應用這個新的 API 。

新 API 功能介紹

已讀標記功能

當用戶發送訊息給 LINE 官方帳號時,機器人現在可以主動將訊息標記為已讀狀態。這讓用戶在聊天介面上可以看到「已讀」的指示,就像一般的 1 對 1 聊天一樣。這項功能特別適合用於:

  • 客服機器人:讓用戶知道他們的問題已經被機器人接收並處理
  • 訂單通知機器人:確認用戶的訂單查詢已被讀取
  • 互動式問答機器人:提供更自然的對話體驗

SDK 版本需求

  • line-bot-sdk-go/v8: v8.18.0 或更新版本
  • Go: 1.24 或更新版本

新增 API 規格

LINE Messaging API 新增了兩個標記已讀的 API:

  1. MarkMessagesAsRead

    使用 userId 來標記特定用戶的所有未讀訊息為已讀。

    • 端點: POST https://api.line.me/v2/bot/message/markAsRead
    • 請求參數:
      {
        "chat": {
          "userId": "U1234567890abcdef1234567890abcdef"
        }
      }
      
  2. MarkMessagesAsReadByToken (本文重點)

    使用訊息專屬的 markAsReadToken 來標記特定訊息為已讀,更精確也更安全。

    • 端點: POST https://api.line.me/v2/bot/message/markAsRead/token
    • 請求參數:
      {
        "markAsReadToken": "abc123def456..."
      }
      

新 API 欄位介紹

markAsReadToken 欄位

LINE Messaging API v8.18.0 在各種訊息內容中新增了 markAsReadToken 欄位:

  • TextMessageContent.markAsReadToken: 文字訊息的已讀標記 token
  • StickerMessageContent.markAsReadToken: 貼圖訊息的已讀標記 token
  • ImageMessageContent.markAsReadToken: 圖片訊息的已讀標記 token
  • VideoMessageContent.markAsReadToken: 影片訊息的已讀標記 token
  • AudioMessageContent.markAsReadToken: 音訊訊息的已讀標記 token
  • FileMessageContent.markAsReadToken: 檔案訊息的已讀標記 token
  • LocationMessageContent.markAsReadToken: 位置訊息的已讀標記 token

每個訊息都會有一個唯一的 markAsReadToken,機器人可以使用這個 token 來標記該訊息為已讀。

如何使用 Golang 來開發相關部分

以下是使用 Golang 實作 Mark as Read 功能的完整範例程式碼: (請注意 github.com/line/line-bot-sdk-go/v8 需要更新到 8.18.0 之後)

範例程式碼在:https://github.com/kkdai/linebot-mark-as-read

實作方式:使用 Quick Reply + Postback

本範例採用使用者友善的互動方式:在每則回覆訊息上加上「Mark as Read」快速回覆按鈕,讓使用者可以主動選擇要將哪些訊息標記為已讀。

步驟 1: 接收訊息並提取 markAsReadToken

case webhook.TextMessageContent:
    // 從訊息內容中取得 markAsReadToken
    markAsReadToken := message.MarkAsReadToken
    log.Printf("Received text message with markAsReadToken: %s\n", markAsReadToken)

    // 建立 Quick Reply,將 token 儲存在 postback data 中
    quickReply := &messaging_api.QuickReply{
        Items: []messaging_api.QuickReplyItem{
            {
                Type: "action",
                Action: &messaging_api.PostbackAction{
                    Label:       "Mark as Read",
                    Data:        fmt.Sprintf("action=markasread&token=%s", markAsReadToken),
                    DisplayText: "Marked as read",
                },
            },
        },
    }

    // 回覆訊息,附帶 Quick Reply 按鈕
    if _, err = bot.ReplyMessage(
        &messaging_api.ReplyMessageRequest{
            ReplyToken: e.ReplyToken,
            Messages: []messaging_api.MessageInterface{
                messaging_api.TextMessage{
                    Text:       message.Text,
                    QuickReply: quickReply,
                },
            },
        },
    ); err != nil {
        log.Print(err)
    } else {
        log.Println("Sent text reply with Quick Reply button.")
    }

步驟 2: 處理 Postback 事件並呼叫 Mark as Read API

case webhook.PostbackEvent:
    // 當使用者點擊 "Mark as Read" 按鈕時觸發
    log.Printf("Postback event: data=%s\n", e.Postback.Data)

    // 解析 postback data 取得 action 和 token
    // 格式: "action=markasread&token=xxxxx"
    values, err := url.ParseQuery(e.Postback.Data)
    if err != nil {
        log.Printf("Failed to parse postback data: %v\n", err)
    } else {
        action := values.Get("action")
        markAsReadToken := values.Get("token")

        if action == "markasread" && markAsReadToken != "" {
            log.Printf("Marking messages as read with token: %s\n", markAsReadToken)

            // 呼叫 Mark as Read By Token API
            _, err := bot.MarkMessagesAsReadByToken(
                &messaging_api.MarkMessagesAsReadByTokenRequest{
                    MarkAsReadToken: markAsReadToken,
                },
            )
            if err != nil {
                log.Printf("Failed to mark messages as read: %v\n", err)
            } else {
                log.Println("Successfully marked messages as read using token")
            }
        }
    }

說明

本實作的流程如下:

  1. 接收訊息:當使用者發送訊息時,從 webhook event 的訊息內容中提取 markAsReadToken
  2. 儲存 token:將 token 編碼在 Quick Reply 按鈕的 postback data 中(格式:action=markasread&token={token}
  3. 回覆訊息:機器人回覆訊息,並附帶 “Mark as Read” 快速回覆按鈕
  4. 使用者互動:使用者看到按鈕並可以選擇點擊
  5. 觸發 Postback:點擊按鈕後觸發 PostbackEvent
  6. 解析 token:使用 url.ParseQuery() 解析 postback data 取得 token
  7. 呼叫 API:使用 bot.MarkMessagesAsReadByToken() 標記訊息為已讀
  8. 顯示已讀:使用者在 LINE 聊天介面看到訊息被標記為已讀

關鍵技術點

  • Quick Reply: 提供使用者友善的互動介面
  • PostbackAction: 可以攜帶資料(data)的按鈕動作
  • url.ParseQuery: 安全地解析查詢字串格式的 postback data
  • MarkMessagesAsReadByToken: 使用 token 精確標記特定訊息

這樣的設計讓使用者可以自主選擇要標記哪些訊息為已讀,提供更好的使用體驗。

未來展望

隨著 Mark as Read API 的推出,開發者可以探索更多創新的應用場景:

  1. 智能客服系統:當客服機器人處理完用戶問題後,自動將訊息標記為已讀,讓用戶清楚知道問題已被處理。搭配自動回覆和人工介入,提供更完整的客服體驗。

  2. 訂單追蹤機器人:用戶查詢訂單狀態時,機器人可以在查詢完成後標記訊息為已讀,給予用戶即時的回饋。這對電商平台的客戶體驗提升特別有幫助。

  3. 互動式教學機器人:在線上教學場景中,當學生提交作業或問題時,機器人可以在檢查或回答後標記已讀,讓學生知道老師(或 AI)已經看過他們的訊息。

  4. 任務管理機器人:企業內部使用的任務管理機器人,可以在接收到任務指派或狀態更新時,標記訊息為已讀,確保團隊成員知道訊息已被系統記錄。

  5. 條件式已讀標記:開發者可以設計更複雜的邏輯,例如:
    • 只在成功處理後才標記已讀
    • 根據訊息類型決定是否標記
    • 延遲一段時間後才標記(模擬真人閱讀)
    • 搭配其他 API(如 Typing indicator)提供更自然的互動
  6. 數據分析與優化:追蹤哪些訊息被標記為已讀,分析用戶行為模式,了解用戶與機器人的互動習慣,進一步優化回應策略和使用者體驗。

技術延伸

開發者還可以考慮:

  • 結合 AI 決策:使用 Gemini 或其他 AI 來決定何時應該標記訊息為已讀
  • 批次處理:在處理大量訊息時,批次標記已讀狀態
  • 狀態管理:在資料庫中記錄已讀狀態,避免重複標記
  • 錯誤處理:當 API 呼叫失敗時,實作重試機制

這些應用場景不僅能提升用戶體驗,也能為企業帶來更多的商業價值。Mark as Read API 雖然是一個簡單的功能,但配合創意的使用方式,可以大幅提升聊天機器人的互動品質,讓機器人的行為更接近真人,提供更好的使用者體驗。

[Python] 用 Python + Gemini File Search 打造智能文件助手 LINE Bot:讓 AI 幫你讀文件

image-20251108080734448

前情提要

在工作和生活中,我們經常需要處理大量的文件:會議記錄、技術文件、合約、研究報告等等。每次要找特定資訊時,都得翻開文件一頁一頁找,既費時又容易遺漏重點。

最近 Google 推出了 Gemini File Search API,讓 AI 可以直接分析上傳的文件並回答問題。我想到,如果能結合 LINE Bot,讓大家透過最常用的通訊軟體就能「問」文件問題,那不是很方便嗎?

想像一下這些場景:

  • 📄 會議記錄:「這次會議的主要決議是什麼?」
  • 📊 技術文件:「這個 API 的參數有哪些?」
  • 🖼️ 圖片內容:「這張圖片裡有什麼?」
  • 📑 研究報告:「這份報告的結論是什麼?」

於是我決定動手打造這個「智能文件助手 LINE Bot」,讓 AI 成為你的私人文件分析師!

專案程式碼

(透過這個程式碼,可以快速部署到 GCP Cloud Run,享受無伺服器的便利)

LINE 2025-11-08 08.07.11

📚 關於 Gemini File Search 基本介紹

Gemini File SearchGoogle DeepMind 於 2025 年 11 月 6 日推出的全新工具,直接內建在 Gemini API 之中。這個工具是一套全託管的 RAG(檢索增強生成,Retrieval-Augmented Generation)系統,目標是讓開發者能更簡單、有效率地將自己的資料與 Gemini 模型結合,產生更精確、相關且可驗證的 AI 回應。


主要特色

  • 簡化開發流程 File Search 免去自行搭建 RAG 管線的麻煩,開發者只需專注於應用程式本身。檔案儲存、分段(chunking)、嵌入(embedding)及檢索等繁瑣細節都自動處理。
  • 強大的向量搜尋 採用最新的 Gemini Embedding 模型,可理解使用者查詢的語意與上下文,找出最相關的資訊,即使關鍵字不同也能命中答案。
  • 自動引用來源 AI 回應會自動附上出處,明確標示答案引用自哪一份文件、哪一段內容,方便核對與驗證。
  • 廣泛格式支援 支援 PDF、DOCX、TXT、JSON 及多種程式語言檔案等主流格式,方便建立多元知識庫。
  • 輕鬆整合 可直接在 generateContent API 中使用,且有完善的 Python SDK,開發者能快速上手。

📚 專案功能介紹

核心功能

  1. 📤 多格式檔案上傳
    • 支援文件檔案:PDF、Word (DOCX)、純文字 (TXT) 等
    • 支援圖片檔案:JPG、PNG 等(利用 Gemini Image Understanding 圖片內容)
    • 自動處理中文檔名,避免編碼問題
    • 即時回饋上傳狀態
  2. 🤖 AI 智能問答
    • 基於 Google Gemini 2.5 Flash 模型
    • 從上傳的文件中搜尋相關內容並回答
    • 支援繁體中文、英文等多語言
    • 理解上下文,提供精準回答
  3. 👥 多對話隔離
    • 1對1聊天:每個人有獨立的文件庫(完全隔離)
    • 群組聊天:群組成員共享文件庫(協作查詢)
    • 自動識別對話類型,無需手動設定
    • File Search Store 自動建立和管理
  4. 📁 檔案管理功能
    • Quick Reply 快速操作:上傳成功後提供快捷按鈕
    • 明確檔案指定:Quick Reply 自動帶入檔案名稱
  5. 🔄 智能錯誤處理
    • 檔案上傳失敗自動重試
    • 沒有文件時引導使用者上傳
    • 詳細的錯誤日誌記錄

💻 核心功能實作

1. File Search Store 的自動管理

這是整個系統的核心,負責管理每個使用者或群組的文件庫。

Store 命名策略

根據對話類型自動生成唯一的 store 名稱:

def get_store_name(event: MessageEvent) -> str:
    """
    Get the file search store name based on the message source.
    Returns user_id for 1-on-1 chat, group_id for group chat.
    """
    if event.source.type == "user":
        return f"user_{event.source.user_id}"
    elif event.source.type == "group":
        return f"group_{event.source.group_id}"
    elif event.source.type == "room":
        return f"room_{event.source.room_id}"
    else:
        return f"unknown_{event.source.user_id}"

Store 存在性檢查與建立

關鍵的設計是:File Search Store 的 name 是由 API 自動生成的(例如 fileSearchStores/abc123),我們只能設定 display_name。因此需要透過 list()display_name 來查找:

async def ensure_file_search_store_exists(store_name: str) -> tuple[bool, str]:
    """
    Ensure file search store exists, create if not.
    Returns (success, actual_store_name).
    """
    try:
        # List all stores and check if one with our display_name exists
        stores = client.file_search_stores.list()
        for store in stores:
            if hasattr(store, 'display_name') and store.display_name == store_name:
                print(f"File search store '{store_name}' already exists: {store.name}")
                return True, store.name

        # Store doesn't exist, create it
        print(f"Creating file search store with display_name '{store_name}'...")
        store = client.file_search_stores.create(
            config={'display_name': store_name}
        )
        print(f"File search store created: {store.name} (display_name: {store_name})")
        return True, store.name

    except Exception as e:
        print(f"Error ensuring file search store exists: {e}")
        return False, ""

Cache 機制優化

為了避免每次都要 list 所有 stores,我們加入了快取機制:

# Cache to store display_name -> actual_name mapping
store_name_cache = {}

# 在上傳時使用 cache
if store_name in store_name_cache:
    actual_store_name = store_name_cache[store_name]
else:
    success, actual_store_name = await ensure_file_search_store_exists(store_name)
    store_name_cache[store_name] = actual_store_name

2. 檔案上傳與狀態管理

LINE 2025-11-10 21.49.26

完整的檔案上傳流程,包含等待 API 處理完成:

async def upload_to_file_search_store(file_path: Path, store_name: str, display_name: Optional[str] = None) -> bool:
    """
    Upload a file to Gemini file search store.
    Returns True if successful, False otherwise.
    """
    try:
        # Check cache first
        if store_name in store_name_cache:
            actual_store_name = store_name_cache[store_name]
        else:
            success, actual_store_name = await ensure_file_search_store_exists(store_name)
            if not success:
                return False
            store_name_cache[store_name] = actual_store_name

        # Upload to file search store
        config_dict = {}
        if display_name:
            config_dict['display_name'] = display_name

        operation = client.file_search_stores.upload_to_file_search_store(
            file_search_store_name=actual_store_name,
            file=str(file_path),
            config=config_dict if config_dict else None
        )

        # Wait for operation to complete (with timeout)
        max_wait = 60  # seconds
        elapsed = 0
        while not operation.done and elapsed < max_wait:
            await asyncio.sleep(2)
            operation = client.operations.get(operation)
            elapsed += 2

        if operation.done:
            print(f"File uploaded successfully")
            return True
        else:
            print(f"Upload operation timeout")
            return False

    except Exception as e:
        print(f"Error uploading to file search store: {e}")
        return False

3. 智能查詢與 File Search 整合

當使用者提問時,系統會先檢查是否有上傳文件,然後使用 File Search 查詢:

async def query_file_search(query: str, store_name: str) -> str:
    """
    Query the file search store using generate_content.
    Returns the AI response text.
    """
    try:
        # Get actual store name from cache or by searching
        actual_store_name = None

        if store_name in store_name_cache:
            actual_store_name = store_name_cache[store_name]
        else:
            # Try to find the store by display_name
            stores = client.file_search_stores.list()
            for store in stores:
                if hasattr(store, 'display_name') and store.display_name == store_name:
                    actual_store_name = store.name
                    store_name_cache[store_name] = actual_store_name
                    break

        if not actual_store_name:
            # Store doesn't exist - guide user to upload files
            return "📁 您還沒有上傳任何檔案。\n\n請先傳送文件檔案(PDF、DOCX、TXT 等)或圖片給我,上傳完成後就可以開始提問了!"

        # Create FileSearch tool with actual store name
        tool = types.Tool(
            file_search=types.FileSearch(
                file_search_store_names=[actual_store_name]
            )
        )

        # Generate content with file search
        response = client.models.generate_content(
            model=MODEL_NAME,
            contents=query,
            config=types.GenerateContentConfig(
                tools=[tool],
                temperature=0.7,
            )
        )

        if response.text:
            return response.text
        else:
            return "抱歉,我無法從文件中找到相關資訊。"

    except Exception as e:
        print(f"Error querying file search: {e}")
        return f"查詢時發生錯誤:{str(e)}"

4. 引用來源(Citations)功能

Gemini File Search API 的一大特色就是會自動提供引用來源,讓使用者可以驗證 AI 回答的準確性。我們實作了完整的引用功能:

提取 Grounding Metadata

當 AI 回答問題時,會在 grounding_metadata 中包含引用資訊:

async def query_file_search(query: str, store_name: str) -> tuple[str, list]:
    """
    Query the file search store using generate_content.
    Returns (AI response text, list of citations).
    """
    # ... (前面的查詢代碼)

    # Extract grounding metadata (citations)
    citations = []
    try:
        if hasattr(response, 'candidates') and response.candidates:
            candidate = response.candidates[0]
            if hasattr(candidate, 'grounding_metadata') and candidate.grounding_metadata:
                grounding_chunks = candidate.grounding_metadata.grounding_chunks
                for chunk in grounding_chunks:
                    if hasattr(chunk, 'web') and chunk.web:
                        # Web source (網頁來源)
                        citations.append({
                            'type': 'web',
                            'title': getattr(chunk.web, 'title', 'Unknown'),
                            'uri': getattr(chunk.web, 'uri', ''),
                        })
                    elif hasattr(chunk, 'retrieved_context') and chunk.retrieved_context:
                        # File search source (文件來源)
                        citations.append({
                            'type': 'file',
                            'title': getattr(chunk.retrieved_context, 'title', 'Unknown'),
                            'text': getattr(chunk.retrieved_context, 'text', '')[:500],
                        })
        print(f"Found {len(citations)} citations")
    except Exception as citation_error:
        print(f"Error extracting citations: {citation_error}")

    return (response.text, citations)

引用快取機制

為了讓使用者可以查看引用詳情,我們實作了引用快取:

# Cache to store citations/grounding metadata for each user/group
# Key: store_name, Value: list of grounding chunks
citations_cache = {}

# 查詢完成後,儲存引用資訊
response_text, citations = await query_file_search(query, store_name)

# Store citations in cache (limit to 3 for Quick Reply)
if citations:
    citations_cache[store_name] = citations[:3]
    print(f"Stored {len(citations_cache[store_name])} citations for {store_name}")

Quick Reply 引用按鈕

在回答中加入 Quick Reply 按鈕,讓使用者一鍵查看引用詳情:

# Create Quick Reply buttons for citations
quick_reply = None
if citations:
    quick_reply_items = []
    for i, citation in enumerate(citations[:3], 1):  # Limit to 3 citations
        quick_reply_items.append(
            QuickReplyButton(action=MessageAction(
                label=f"📖 引用{i}",
                text=f"📖 引用{i}"
            ))
        )
    quick_reply = QuickReply(items=quick_reply_items)

# Reply to user with citations
reply_msg = TextSendMessage(text=response_text, quick_reply=quick_reply)

查看引用詳情

當使用者點擊「📖 引用」按鈕時,顯示完整的引用內容:

# Check if user wants to view a citation
if query.startswith("📖 引用"):
    # Extract citation number
    citation_num = int(query.replace("📖 引用", "").strip())
    if store_name in citations_cache and 0 < citation_num <= len(citations_cache[store_name]):
        citation = citations_cache[store_name][citation_num - 1]

        # Format citation text
        if citation['type'] == 'file':
            citation_text = f"📖 引用 {citation_num}\n\n"
            citation_text += f"📄 文件:{citation['title']}\n\n"
            citation_text += f"📝 內容:\n{citation['text']}"
            if len(citation.get('text', '')) >= 500:
                citation_text += "\n\n... (內容過長,已截斷)"
        elif citation['type'] == 'web':
            citation_text = f"📖 引用 {citation_num}\n\n"
            citation_text += f"🌐 來源:{citation['title']}\n"
            citation_text += f"🔗 連結:{citation['uri']}"

        reply_msg = TextSendMessage(text=citation_text)

設計要點

  • 兩種引用來源:支援文件引用(file)和網頁引用(web)
  • 限制數量:Quick Reply 最多顯示 3 個引用(LINE Bot 限制)
  • 內容截斷:文件內容超過 500 字元會自動截斷,避免訊息過長
  • 快取機制:每個 store 的引用獨立儲存,避免混淆
  • 使用者體驗:一鍵查看引用詳情,無需複製貼上

實際效果

image-20251110211857624

5. Quick Reply 快速操作

當使用者上傳檔案成功後,系統會提供 Quick Reply 按鈕,讓使用者快速執行常見操作:

LINE 2025-11-10 21.57.41

設計要點

  • 明確檔案名稱:Quick Reply 的文字自動帶入 {file_name},避免多檔案時的混淆

  • 一鍵操作:使用者點擊按鈕即可發送完整問題,無需手動輸入

  • 常見需求:提供「生成摘要」、「重點整理」等高頻功能

5. 檔案刪除功能

列出文件

使用者可以輸入「列出檔案」等關鍵字來查看已上傳的文件:

def is_list_files_intent(text: str) -> bool:
    """
    Check if user wants to list files.
    """
    list_keywords = [
        '列出檔案', '列出文件', '顯示檔案', '顯示文件',
        '查看檔案', '查看文件', '檔案列表', '文件列表',
        '有哪些檔案', '有哪些文件', '我的檔案', '我的文件',
        'list files', 'show files', 'my files'
    ]
    text_lower = text.lower().strip()
    return any(keyword in text_lower for keyword in list_keywords)

刪除文件功能

當使用者點擊刪除按鈕時,透過 Postback 事件處理刪除:

async def delete_document(document_name: str) -> bool:
    """
    Delete a document from file search store.
    Note: force=True is required to permanently delete documents from File Search Store.
    """
    try:
        # Try to use SDK method first with force=True
        if hasattr(client.file_search_stores, 'documents'):
            # Force delete is required for File Search Store documents
            client.file_search_stores.documents.delete(
                name=document_name,
                config={'force': True}  # ⚠️ 必須加上 force=True
            )
            return True
    except Exception as sdk_error:
        # Fallback to REST API with force parameter
        import requests
        url = f"https://generativelanguage.googleapis.com/v1beta/{document_name}"
        params = {
            'key': GOOGLE_API_KEY,
            'force': 'true'  # ⚠️ 必須加上 force parameter
        }
        response = requests.delete(url, params=params, timeout=10)
        response.raise_for_status()
        return True

關鍵重點

  • File Search Store 中的文件是 immutable(不可變)
  • 刪除時必須加上 force: True 參數,否則會失敗
  • 雙重後備機制確保相容性(SDK → REST API)

Postback 事件處理

async def handle_postback(event: PostbackEvent):
    """
    Handle postback events (e.g., delete file button clicks).
    """
    try:
        # Parse postback data
        data = event.postback.data
        params = dict(param.split('=') for param in data.split('&'))

        action = params.get('action')
        doc_name = params.get('doc_name')

        if action == 'delete_file' and doc_name:
            success = await delete_document(doc_name)

            if success:
                reply_msg = TextSendMessage(
                    text=f"✅ 檔案已刪除成功!\n\n如需查看剩餘檔案,請輸入「列出檔案」。"
                )
            else:
                reply_msg = TextSendMessage(text="❌ 刪除檔案失敗,請稍後再試。")

            await line_bot_api.reply_message(event.reply_token, reply_msg)
    except Exception as e:
        print(f"Error handling postback: {e}")

🔧 遇到的挑戰與解決方案

1. File Search Store API 的名稱設計

問題:一開始以為可以直接指定 store 的 name,但實際上 create() 不接受 name 參數。

錯誤訊息

FileSearchStores.create() got an unexpected keyword argument 'name'

原因分析

  • File Search Store 的 name 是由 API 自動生成的(格式:fileSearchStores/xxxxx
  • 我們只能設定 display_name 作為識別
  • 需要透過 list() 遍歷來查找對應的 store

解決方案

  1. 使用 display_name 儲存我們定義的名稱(如 user_U123456
  2. 透過 list() 找到對應的 store 並取得實際的 name
  3. 建立 cache 避免重複查詢

2. 中文檔名的編碼問題

問題:當檔案名稱包含中文時,API 呼叫會失敗。

錯誤訊息

'ascii' codec can't encode characters in position 19-21: ordinal not in range(128)

問題分析

# 問題代碼:檔案路徑包含中文
file_path = "uploads/123456_會議記錄.pdf"  # ❌ 編碼錯誤

解決方案

# 解決方案:使用 ASCII 檔名,保留原始名稱供顯示
_, ext = os.path.splitext("會議記錄.pdf")
safe_file_name = f"{message_id}{ext}"  # "123456.pdf" ✅
file_path = UPLOAD_DIR / safe_file_name

# 在 config 中保留原始檔名
config = {'display_name': '會議記錄.pdf'}  # 用於 AI 回答時的引用

好處

  • 檔案系統操作使用 ASCII 路徑(不會出錯)
  • AI 回答時仍然顯示原始中文檔名(使用者友善)

3. Store 不存在時的 404 錯誤

問題:首次上傳檔案時,store 還不存在就嘗試上傳。

錯誤訊息

404 Not Found. {'message': '', 'status': 'Not Found'}

解決方案: 在上傳前先檢查並建立 store:

# 1. 檢查 cache
if store_name in store_name_cache:
    actual_store_name = store_name_cache[store_name]
else:
    # 2. 檢查是否存在,不存在則建立
    success, actual_store_name = await ensure_file_search_store_exists(store_name)
    # 3. 加入 cache
    store_name_cache[store_name] = actual_store_name

# 4. 使用實際的 store name 上傳
operation = client.file_search_stores.upload_to_file_search_store(
    file_search_store_name=actual_store_name,
    file=str(file_path),
    config=config_dict
)

4. 非同步檔案處理

問題:上傳檔案是耗時操作,需要等待處理完成。

解決方案

  1. 使用 aiofiles 進行異步檔案讀寫
  2. 使用 asyncio.sleep() 而非 time.sleep()
  3. 實作輪詢機制等待操作完成
# 等待上傳完成
max_wait = 60  # seconds
elapsed = 0
while not operation.done and elapsed < max_wait:
    await asyncio.sleep(2)  # 異步等待
    operation = client.operations.get(operation)
    elapsed += 2

5. VertexAI 不支援 File Search API

問題:原本想支援 VertexAI,但發現 File Search API 只支援 Gemini API。

官方說明: 根據 Google AI 文件,File Search 功能目前只支援透過 Gemini API 使用。

解決方案

  • 移除所有 VertexAI 相關程式碼和設定
  • 簡化環境變數配置
  • 只需要 GOOGLE_API_KEY 即可

6. 刪除文件需要 force 參數

問題:實作刪除文件功能時,直接呼叫 delete() API 會失敗。

錯誤訊息

刪除失敗,或是沒有回應

原因分析: 根據 Google Gemini File Search API 文件(2025年11月6日發布):

  • File Search Store 中的文件是 immutable(不可變的)
  • 刪除操作必須使用 force: True 參數才能永久刪除
  • 如果不加 force 參數,API 會拒絕刪除請求

解決方案

  1. SDK 方式:在 config 中加上 force: True
    client.file_search_stores.documents.delete(
     name=document_name,
     config={'force': True}  # ⚠️ 必須加上
    )
    
  2. REST API 方式:在 query parameters 中加上 force=true
    params = {
     'key': GOOGLE_API_KEY,
     'force': 'true'  # ⚠️ 必須加上
    }
    response = requests.delete(url, params=params)
    

關鍵學習

  • File Search 的文件一旦建立就是不可變的
  • 如果要「更新」文件,必須先刪除(force delete)再重新上傳
  • 這與一般的 Files API 行為不同(Files API 的檔案 48 小時後自動刪除)

🎯 總結與未來改進

專案亮點

  1. 開箱即用的文件助手:無需安裝 APP,透過 LINE 就能使用
  2. 智能文件分析:結合 Gemini 2.5 Flash 的強大能力
  3. 中文友善:完整支援中文檔名和查詢
  4. 隔離機制:每個對話有獨立的文件庫,安全可靠
  5. 自動化管理:File Search Store 自動建立,使用者無感知
  6. 引用來源追蹤:自動提取並顯示 AI 回答的引用來源,可驗證答案準確性
  7. Quick Reply 便利性:上傳後立即提供快捷操作,查詢後可一鍵查看引用
  8. 多媒體支援:文件查詢 + 圖片分析,一個 Bot 搞定

實戰經驗分享

在開發過程中,我深刻體會到:

1. API 設計的差異

不同雲端服務的 API 設計理念差異很大:

  • Google Gemini:name 由系統生成,開發者設定 display_name
  • 需要適應:透過 list + 遍歷來查找資源

這提醒我們:閱讀官方文件比猜測 API 行為更重要

2. 編碼問題無所不在

即使在 2024 年,編碼問題仍然存在:

  • 檔案系統可能不支援 Unicode
  • API 可能對特殊字元有限制
  • 解決方案:分離「儲存用檔名」和「顯示用檔名」

3. 非同步程式設計的重要性

在處理外部 API 時:

  • 使用 async/await 避免阻塞
  • 使用 asyncio.sleep() 而非 time.sleep()
  • 適當的 timeout 設定避免無限等待

4. Immutable 資料的處理

File Search Store 的設計哲學:

  • 文件一旦上傳就是不可變的(immutable)
  • 刪除需要明確的 force: True 參數
  • 要「更新」文件必須先刪除再上傳
  • 這與其他服務(如 Files API)完全不同

這讓我學到:不同服務有不同的資料模型,不能假設行為一致。

未來改進方向

  1. 效能優化
    • 實作更完整的 cache 機制
    • 批次處理多檔案上傳
    • 壓縮大型檔案
    • 減少 API 呼叫次數
  2. 功能擴展
    • 支援檔案刪除功能(已完成)
    • 支援列出已上傳檔案(已完成)
    • 整合圖片理解功能(已完成)
    • Quick Reply 快速操作(已完成)
    • 引用來源追蹤(已完成,支援查看引用詳情)
    • 支援多檔案批次上傳
    • 檔案分類和標籤管理
    • 檔案內容全文搜尋
    • 引用來源跳轉(如果是文件,顯示頁碼或段落位置)
  3. 使用體驗優化
    • Rich Menu 設計
    • 更友善的錯誤提示
    • 上傳進度顯示(長時間處理時)
    • 查詢歷史記錄
    • 檔案搜尋功能(按檔名或時間)
  4. 安全性強化
    • 檔案大小限制
    • 檔案類型驗證
    • 使用者配額管理
    • 敏感資料過濾
    • Store 定期清理機制

關鍵學習

透過這個專案,我學到了:

  1. Google Gemini File Search 的正確使用方式與 immutable 資料模型
  2. Grounding Metadata 的提取與引用來源追蹤機制
  3. FastAPI 在處理 LINE Bot webhook 的高效性
  4. Python async/await 在 I/O 密集型應用的重要性
  5. 編碼問題的處理策略(分離儲存名稱與顯示名稱)
  6. 雲端原生應用的設計模式
  7. LINE Quick Reply 的情境化應用與使用者體驗提升(檔案摘要 + 引用查看)
  8. AI 對話 vs 傳統 UI:選擇合適的互動方式
  9. API 設計差異:不同服務有不同的資料模型和限制
  10. 雙重後備機制:SDK + REST API 確保穩定性
  11. 依賴版本管理:使用版本範圍避免衝突,追蹤官方文檔更新

最重要的是:

AI 不只是聊天機器人,更是強大的內容分析工具。File Search API 讓我們能輕鬆打造專業級的文件問答系統。

Quick Reply 是 LINE Bot 的靈魂**。在正確的時機提供正確的快捷操作,可以大幅提升使用者體驗和操作效率。無論是上傳後的「檔案摘要」還是查詢後的「查看引用」,都讓使用者能快速完成任務。

引用來源讓 AI 回答更可信。透過 Grounding Metadata 提取引用資訊,使用者可以驗證 AI 的回答來源,這對於專業文件分析特別重要。結合 Quick Reply 的一鍵查看,讓引用功能真正實用。

依賴管理是持續挑戰。新興框架(如 Google ADK)的 API 變化快,需要持續追蹤官方文檔和 GitHub。使用版本範圍而非固定版本,可以提高相容性,但也要注意 breaking changes。

希望這個經驗分享能幫助到正在探索 AI 應用開發的朋友們!

相關資源


如果你覺得這個專案有幫助,歡迎給個 Star ⭐,或是分享給需要的朋友!

[Go] 用 Go + Gemini + GCP 打造智慧垃圾車 LINE Bot:從查詢到提醒的完整解決方案

image-20251102143236468

前情提要

在台灣,垃圾車的時間總是讓人捉摸不定。明明記得昨天是晚上七點來,今天卻遲遲等不到;或是剛好外出倒垃圾,垃圾車就這樣錯過了。相信這是許多人共同的困擾。

隨著智慧城市的發展,越來越多城市開始提供垃圾車即時資訊 API,但這些資料對一般民眾來說並不容易使用。

這時候我看到臉書上一個朋友貼文 ,他敘述他做出了一個垃圾車追蹤的網站。 (網站github)

image-20251102143208176

這時候我在想,難道不能結合 LINE Bot 做出一個可以很快速幫助到其他的工具嗎?因此,我決定打造一個垃圾車 LINE Bot,讓大家可以透過最熟悉的通訊軟體,輕鬆查詢垃圾車資訊,甚至設定提醒通知。更重要的是,這個 Bot 不只是簡單的指令查詢,而是整合了 Google Gemini AI,能夠理解「我晚上七點前在哪裡倒垃圾?」這樣的自然語言,提供真正智慧化的服務體驗。

專案程式碼

https://github.com/kkdai/linebot-garbage-helper

(透過這個程式碼,可以快速部署到 GCP Cloud Run,並使用 Cloud Build 實現自動化 CI/CD)

🗑️ 專案功能介紹

核心功能

  1. 🗑️ 即時查詢垃圾車
    • 輸入地址或分享位置即可查詢附近垃圾車站點
    • 顯示預計抵達時間、路線資訊和 Google Maps 導航連結
  2. ⏰ 智慧提醒系統
    • 可設定垃圾車抵達前 N 分鐘提醒
    • 自動推播通知,再也不會錯過垃圾車
    • 支援多種提醒狀態管理(活躍、已發送、已過期、已取消)
  3. 🤖 自然語言查詢
    • 整合 Google Gemini AI,支援自然語言理解
    • 例如:「我晚上七點前在台北市大安區哪裡倒垃圾?」
    • 自動提取地點、時間範圍等查詢條件
  4. ❤️ 收藏地點功能
    • 儲存常用地點(家、公司等)
    • 快速查詢收藏地點的垃圾車資訊

🏗️ 技術架構說明

系統架構圖

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   LINE Client   │───▶│    Cloud Run     │───▶│   Firestore     │
└─────────────────┘    │  (Go App)        │    │   (Database)    │
                       └──────────────────┘    └─────────────────┘
                              │
                              ▼
                    ┌──────────────────┐
                    │  External APIs   │
                    │  • Google Maps   │
                    │  • Gemini AI     │
                    │  • 垃圾車資料源   │
                    └──────────────────┘
                              │
                              ▼
                    ┌──────────────────┐
                    │ Cloud Scheduler  │
                    │ (提醒排程觸發)    │
                    └──────────────────┘

💻 核心功能實作

1. 垃圾車資訊的處理

這是整個系統的核心部分,負責從開放資料 API 取得垃圾車資訊,並進行智慧化的查詢處理。

資料來源與結構設計

我們使用了開源專案 Yukaii/garbage 提供的垃圾車資料 API,這個 API 提供了台北市的即時垃圾車收集點資訊。

type GarbageData struct {
    Result GarbageResult `json:"result"`
}

type CollectionPoint struct {
    ID            int         `json:"_id"`
    District      string      `json:"行政區"`     // 行政區域
    Neighborhood  string      `json:"里別"`      // 里別資訊
    VehicleNumber string      `json:"車號"`      // 垃圾車車號
    Route         string      `json:"路線"`      // 收集路線
    ArrivalTime   string      `json:"抵達時間"`   // 預計抵達時間
    DepartureTime string      `json:"離開時間"`   // 預計離開時間
    Location      string      `json:"地點"`      // 收集地點
    Longitude     string      `json:"經度"`      // 經度座標
    Latitude      string      `json:"緯度"`      // 緯度座標
}

垃圾車資料適配器 (GarbageAdapter)

我們設計了一個專門的適配器來處理垃圾車資料的取得和查詢:

type GarbageAdapter struct {
    httpClient *http.Client
}

func (ga *GarbageAdapter) FetchGarbageData(ctx context.Context) (*GarbageData, error) {
    url := "https://raw.githubusercontent.com/Yukaii/garbage/data/trash-collection-points.json"
    
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }
    
    resp, err := ga.httpClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
    }
    
    var data GarbageData
    if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
        return nil, err
    }
    
    return &data, nil
}

智慧距離計算與最近站點查詢

系統能夠根據使用者位置,找出最近的垃圾車收集點:

func (ga *GarbageAdapter) FindNearestStops(userLat, userLng float64, data *GarbageData, limit int) ([]*NearestStop, error) {
    var nearestStops []*NearestStop
    now := time.Now()
    
    for _, point := range data.Result.Results {
        // 解析座標
        lat, lng, err := ga.parseCoordinates(point.Latitude, point.Longitude)
        if err != nil {
            continue
        }
        
        // 使用 Haversine 公式計算距離
        distance := geo.CalculateDistance(userLat, userLng, lat, lng)
        
        // 解析時間並處理跨日問題
        eta, err := parseTimeToToday(point.ArrivalTime)
        if err != nil {
            continue
        }
        
        // 如果時間已過,調整到明天
        if eta.Before(now) {
            eta = eta.Add(24 * time.Hour)
        }
        
        nearestStops = append(nearestStops, &NearestStop{
            Stop:            stop,
            Route:           route,
            Distance:        distance,
            ETA:             eta,
            CollectionPoint: &point,
        })
    }
    
    // 按距離排序
    sort.Slice(nearestStops, func(i, j int) bool {
        return nearestStops[i].Distance < nearestStops[j].Distance
    })
    
    return nearestStops[:limit], nil
}

時間窗口查詢功能

這是配合 Gemini AI 的進階功能,當使用者詢問「晚上七點前在哪裡倒垃圾?」時:

func (ga *GarbageAdapter) FindStopsInTimeWindow(userLat, userLng float64, data *GarbageData, timeWindow TimeWindow, maxDistance float64) ([]*NearestStop, error) {
    var validStops []*NearestStop
    
    for _, point := range data.Result.Results {
        lat, lng, err := ga.parseCoordinates(point.Latitude, point.Longitude)
        if err != nil {
            continue
        }
        
        distance := geo.CalculateDistance(userLat, userLng, lat, lng)
        
        // 距離過濾
        if maxDistance > 0 && distance > maxDistance {
            continue
        }
        
        eta, err := parseTimeToToday(point.ArrivalTime)
        if err != nil {
            continue
        }
        
        // 時間窗口過濾
        if !isTimeInWindow(eta, timeWindow) {
            continue
        }
        
        validStops = append(validStops, &NearestStop{
            Stop:            stop,
            Route:           route,
            Distance:        distance,
            ETA:             eta,
            CollectionPoint: &point,
        })
    }
    
    // 按時間排序,顯示最早的收集點
    sort.Slice(validStops, func(i, j int) bool {
        return validStops[i].ETA.Before(validStops[j].ETA)
    })
    
    return validStops, nil
}

彈性的時間解析處理

考慮到資料來源的時間格式可能不一致,我們實作了彈性的時間解析:

func parseTimeToToday(timeStr string) (time.Time, error) {
    now := time.Now()
    
    // 處理 4 位數格式 (1830)
    if len(timeStr) == 4 {
        layout := "1504"
        t, err := time.Parse(layout, timeStr)
        if err != nil {
            return time.Time{}, err
        }
        return time.Date(now.Year(), now.Month(), now.Day(), 
                        t.Hour(), t.Minute(), 0, 0, now.Location()), nil
    }
    
    // 處理標準格式 (18:30)
    layout := "15:04"
    t, err := time.Parse(layout, timeStr)
    if err != nil {
        return time.Time{}, err
    }
    
    return time.Date(now.Year(), now.Month(), now.Day(), 
                    t.Hour(), t.Minute(), 0, 0, now.Location()), nil
}

整合到查詢流程

在實際的查詢處理中,這些功能被整合到統一的介面:

func (h *Handler) searchNearbyGarbageTrucks(ctx context.Context, userID string, lat, lng float64, intent *gemini.IntentResult) {
    log.Printf("Searching nearby garbage trucks for user %s at coordinates: lat=%f, lng=%f", userID, lat, lng)
    
    // 取得最新的垃圾車資料
    garbageData, err := h.garbageAdapter.FetchGarbageData(ctx)
    if err != nil {
        log.Printf("Error fetching garbage data for user %s: %v", userID, err)
        h.replyMessage(ctx, userID, "抱歉,無法取得垃圾車資料。")
        return
    }
    
    var nearestStops []*garbage.NearestStop
    
    // 如果有時間窗口查詢,使用時間過濾
    if intent != nil && (intent.TimeWindow.From != "" || intent.TimeWindow.To != "") {
        fromTime, toTime, err := h.geminiClient.ParseTimeWindow(intent.TimeWindow)
        if err == nil {
            timeWindow := garbage.TimeWindow{From: fromTime, To: toTime}
            nearestStops, err = h.garbageAdapter.FindStopsInTimeWindow(lat, lng, garbageData, timeWindow, 2000)
        }
    }
    
    // 如果沒有時間過濾結果,使用一般的最近距離查詢
    if len(nearestStops) == 0 {
        nearestStops, err = h.garbageAdapter.FindNearestStops(lat, lng, garbageData, 5)
    }
    
    // 發送結果給使用者
    h.sendGarbageTruckResults(ctx, userID, nearestStops)
}

這樣的設計讓系統能夠:

  1. 高效率處理大量資料:每次查詢都會取得最新資料,確保資訊準確性
  2. 智慧化查詢:結合地理位置、時間窗口等多重條件
  3. 彈性擴展:易於加入其他城市的垃圾車資料源
  4. 錯誤處理:對於資料格式異常有適當的容錯機制

2. LINE Webhook 處理

首先來看看如何處理 LINE 的 webhook 事件:

func (h *Handler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
    log.Printf("Webhook received from %s", r.RemoteAddr)
    
    events, err := webhook.ParseRequest(h.channelSecret, r)
    if err != nil {
        log.Printf("Error parsing webhook request: %v", err)
        http.Error(w, "Bad Request", http.StatusBadRequest)
        return
    }
    
    ctx := r.Context()
    for _, event := range events {
        go h.handleEvent(ctx, event)  // 使用 goroutine 處理事件
    }
    
    w.WriteHeader(http.StatusOK)
}

3. Gemini AI 關於 LLM 理解的部分

這是整個系統最有趣的部分,透過 Gemini 來理解使用者的自然語言查詢:

func (gc *GeminiClient) AnalyzeIntent(ctx context.Context, userMessage string) (*IntentResult, error) {
    model := gc.client.GenerativeModel(gc.model)
    
    prompt := fmt.Sprintf(`你是一個查詢意圖分析器,專門分析使用者關於垃圾車的查詢。

使用者輸入可能包含地名與時間。請分析輸入並輸出 JSON 格式的結果。

輸出格式:
{
  "district": "地區名稱(如果有的話)",
  "time_window": {
    "from": "開始時間(HH:MM格式,如果有的話)",
    "to": "結束時間(HH:MM格式,如果有的話)"
  },
  "keywords": ["關鍵字陣列"],
  "query_type": "garbage_truck_eta"
}

範例:
輸入:「我晚上七點前在台北市大安區哪裡倒垃圾?」
輸出:
{
  "district": "台北市大安區",
  "time_window": {
    "from": "",
    "to": "19:00"
  },
  "keywords": ["台北市", "大安區", "倒垃圾", "晚上", "七點"],
  "query_type": "garbage_truck_eta"
}

請分析以下使用者輸入:
「%s」

請只回傳 JSON,不要包含其他說明文字。`, userMessage)

    resp, err := model.GenerateContent(ctx, genai.Text(prompt))
    if err != nil {
        return nil, err
    }
    
    // 解析 JSON 回應
    var result IntentResult
    if err := json.Unmarshal([]byte(responseText), &result); err != nil {
        // 如果 Gemini 無法解析,回退到簡單的關鍵字比對
        return &IntentResult{
            District:  extractDistrict(userMessage),
            Keywords:  []string{userMessage},
            QueryType: "garbage_truck_eta",
        }, nil
    }
    
    return &result, nil
}

4. 智慧提醒排程系統

提醒系統是這個專案的核心功能之一,設計上考慮了可靠性和效能:

func (s *Scheduler) ProcessReminders(ctx context.Context) error {
    now := time.Now()

    // 效能優化:先檢查是否有活躍提醒
    count, err := s.store.CountActiveReminders(ctx)
    if err != nil {
        log.Printf("Warning: failed to count active reminders: %v", err)
    } else if count == 0 {
        log.Printf("No active reminders, skipping processing")
        return nil
    }

    reminders, err := s.store.GetActiveReminders(ctx, now)
    if err != nil {
        return fmt.Errorf("failed to get active reminders: %w", err)
    }

    log.Printf("Found %d active reminders to process", len(reminders))

    for _, reminder := range reminders {
        notificationTime := reminder.ETA.Add(-time.Duration(reminder.AdvanceMinutes) * time.Minute)
        
        // 檢查是否到了發送提醒的時間
        if now.Before(notificationTime) {
            continue  // 還不到發送時間
        }

        if now.After(reminder.ETA) {
            // ETA 已過期,標記為過期
            s.store.UpdateReminderStatus(ctx, reminder.ID, "expired")
            continue
        }

        // 發送提醒通知
        if err := s.sendReminderNotification(ctx, reminder); err != nil {
            log.Printf("Error sending reminder %s: %v", reminder.ID, err)
            continue
        }

        // 更新狀態為已發送
        s.store.UpdateReminderStatus(ctx, reminder.ID, "sent")
    }

    return nil
}

5. Firestore 資料結構設計

我們使用 Firestore 來儲存使用者資料和提醒資訊:

type Reminder struct {
    ID             string    `firestore:"id"`
    UserID         string    `firestore:"userId"`
    StopName       string    `firestore:"stopName"`
    RouteID        string    `firestore:"routeId"`
    ETA            time.Time `firestore:"eta"`
    AdvanceMinutes int       `firestore:"advanceMinutes"`
    Status         string    `firestore:"status"`  // active, sent, expired, cancelled
    CreatedAt      time.Time `firestore:"createdAt"`
    UpdatedAt      time.Time `firestore:"updatedAt"`
}

type User struct {
    ID        string     `firestore:"id"`
    Favorites []Favorite `firestore:"favorites"`
    CreatedAt time.Time  `firestore:"createdAt"`
    UpdatedAt time.Time  `firestore:"updatedAt"`
}

6. 雙重保障的提醒機制

為了確保提醒不會遺漏,系統設計了雙重保障機制:

  1. 本地排程器:應用啟動時自動開始背景排程服務
  2. 外部觸發:透過 Cloud Scheduler 定期調用 /tasks/dispatch-reminders
// 本地排程器
func (s *Scheduler) StartScheduler(ctx context.Context) {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()

    cleanupTicker := time.NewTicker(1 * time.Hour)
    defer cleanupTicker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.ProcessReminders(ctx)
        case <-cleanupTicker.C:
            s.CleanupExpiredReminders(ctx)
        }
    }
}

// 外部觸發端點
r.HandleFunc("/tasks/dispatch-reminders", func(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("Authorization")
    if token != "Bearer "+cfg.InternalTaskToken {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    if err := reminderService.ProcessReminders(r.Context()); err != nil {
        log.Printf("Error processing reminders: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusOK)
})

🔧 遇到的挑戰與解決方案

1. 地址處理的多層 Fallback 策略

問題:使用者輸入具體地址(如「台北市中正區重慶南路一段122號」)時,Gemini 意圖分析可能失敗或只提取部分資訊,導致查詢失敗。

解決方案:設計多層 fallback 機制,確保任何情況下都能找到合適的地址:

func (h *Handler) handleTextMessage(ctx context.Context, userID, text string) {
    // 意圖分析失敗時,仍然繼續處理而非直接返回錯誤
    intent, err := h.geminiClient.AnalyzeIntent(ctx, text)
    if err != nil {
        log.Printf("Error analyzing intent: %v", err)
        intent = nil  // 設為 nil 繼續處理
    }
    
    var addressToGeocode string
    var addressMethod string
    
    // Method 1: 使用 Gemini 解析的 District
    if intent != nil && intent.District != "" {
        addressToGeocode = intent.District
        addressMethod = "intent.District"
    } else {
        // Method 2: 使用 Gemini 地址提取
        extractedLocation, err := h.geminiClient.ExtractLocationFromText(ctx, text)
        if err == nil && strings.TrimSpace(extractedLocation) != "" {
            addressToGeocode = strings.TrimSpace(extractedLocation)
            addressMethod = "gemini.ExtractLocation"
        } else {
            // Method 3: 直接使用原始文字
            addressToGeocode = text
            addressMethod = "original.text"
        }
    }
    
    // 地理編碼處理 + Fallback 策略
    location, err := h.geoClient.GeocodeAddress(ctx, addressToGeocode)
    if err != nil {
        // Fallback 1: 如果不是原始文字,嘗試原始文字
        if addressMethod != "original.text" {
            location, err = h.geoClient.GeocodeAddress(ctx, text)
            if err == nil {
                h.searchNearbyGarbageTrucks(ctx, userID, location.Lat, location.Lng, intent)
                return
            }
        }
        
        // Fallback 2: 嘗試簡化地址(提取縣市區)
        simplifiedAddress := h.extractSimplifiedAddress(text)
        if simplifiedAddress != "" {
            location, err = h.geoClient.GeocodeAddress(ctx, simplifiedAddress)
            if err == nil {
                h.searchNearbyGarbageTrucks(ctx, userID, location.Lat, location.Lng, intent)
                return
            }
        }
        
        // 所有方法都失敗才回傳錯誤
        h.replyMessage(ctx, userID, "抱歉,我找不到位置資訊...")
        return
    }
    
    h.searchNearbyGarbageTrucks(ctx, userID, location.Lat, location.Lng, intent)
}

2. Gemini API 的穩定性處理

問題:Gemini API 偶爾會回傳非 JSON 格式的回應,導致解析失敗。

解決方案:實作錯誤處理和回退機制:

var result IntentResult
if err := json.Unmarshal([]byte(responseText), &result); err != nil {
    // 如果 Gemini 無法解析,回退到簡單的關鍵字比對
    return &IntentResult{
        District:  extractDistrict(userMessage),
        Keywords:  []string{userMessage},
        QueryType: "garbage_truck_eta",
        TimeWindow: TimeWindow{From: "", To: ""},
    }, nil
}

3. Firestore 查詢效能優化

問題:每次都查詢所有活躍提醒會造成不必要的資料讀取。

解決方案:加入 count 查詢作為早期回傳優化:

// 先檢查是否有活躍提醒
count, err := s.store.CountActiveReminders(ctx)
if count == 0 {
    log.Printf("No active reminders, skipping processing")
    return nil
}

4. Cloud Scheduler 區域設定問題

問題:Cloud Scheduler 在某些區域可能不支援,導致自動提醒失效。

解決方案:設計雙重保障機制,即使外部排程器失效,本地排程器仍能正常運作。

5. 時區處理的複雜性

問題:在雲端環境中,伺服器可能運行在 UTC 時區,但垃圾車資料和使用者都在台灣時區,導致提醒時間計算錯誤。

具體症狀

  • 使用者在半夜收到「垃圾車 9 分鐘後抵達」的提醒
  • 提醒時間與實際垃圾車時間不符

問題分析

// 問題代碼:混用不同時區
now := time.Now()                    // 可能是 UTC
eta := parseTimeToToday("19:00")     // 台灣時間 19:00
timeUntil := eta.Sub(now)           // 時區不一致導致計算錯誤

解決方案:建立統一的時區處理機制:

// 1. 建立時區工具函數
package utils

func GetTaiwanTimezone() *time.Location {
    taipeiTZ, err := time.LoadLocation("Asia/Taipei")
    if err != nil {
        // 備用方案:固定時區 UTC+8
        taipeiTZ = time.FixedZone("CST", 8*3600)
    }
    return taipeiTZ
}

func NowInTaiwan() time.Time {
    return time.Now().In(GetTaiwanTimezone())
}

func ToTaiwan(t time.Time) time.Time {
    return t.In(GetTaiwanTimezone())
}

// 2. 修復時間解析
func parseTimeToToday(timeStr string) (time.Time, error) {
    taipeiTZ := utils.GetTaiwanTimezone()
    now := utils.NowInTaiwan()
    
    // 確保解析出的時間在台灣時區
    return time.Date(now.Year(), now.Month(), now.Day(), 
                    hour, minute, 0, 0, taipeiTZ), nil
}

// 3. 修復提醒計算
func (s *Scheduler) processReminder(reminder *store.Reminder) error {
    now := utils.NowInTaiwan()
    etaInTaipei := utils.ToTaiwan(reminder.ETA)
    
    notificationTime := etaInTaipei.Add(-10 * time.Minute)
    timeUntilArrival := etaInTaipei.Sub(now)
    
    // 現在時間計算是正確的
    if now.After(notificationTime) && now.Before(etaInTaipei) {
        // 發送提醒
    }
}

測試驗證

// 建立測試來驗證時區修復
func TestTimezoneHandling() {
    now := utils.NowInTaiwan()
    eta := time.Date(now.Year(), now.Month(), now.Day(), 19, 0, 0, 0, utils.GetTaiwanTimezone())
    
    timeUntil := eta.Sub(now)
    fmt.Printf("距離垃圾車抵達: %.0f 分鐘", timeUntil.Minutes())
}

這個修復確保了無論伺服器運行在哪個時區,所有時間計算都基於台灣時間進行。

6. LINE Bot 訊息推播限制

問題:LINE Bot 有推播訊息的頻率限制。

解決方案

  • 實作提醒狀態管理,避免重複發送
  • 加入適當的錯誤處理和重試機制
  • 使用 goroutine 非同步處理,避免阻塞主要流程

7. Google Maps API 授權配置問題

問題:在實作地理編碼功能時遇到 REQUEST_DENIED - This API project is not authorized to use this API 錯誤。

問題分析

Geocoding API 是 Google Maps Platform 的核心服務,但預設並未啟用。即使有 API Key,如果沒有在 GCP 專案中啟用相應的 API,仍會被拒絕存取。

完整解決方案

1. 啟用必要的 Google Maps API

在 GCP Console 中啟用以下 API(這些都是本專案會用到的):

# 使用 gcloud 命令一次性啟用
gcloud services enable \
  geocoding-backend.googleapis.com \
  maps-backend.googleapis.com \
  places-backend.googleapis.com \
  geolocation.googleapis.com \
  --project=your-project-id

或透過 GCP Console 手動啟用:

  • Geocoding API - 地址轉坐標(必需)
  • Maps JavaScript API - 地圖顯示
  • Places API - 地點搜索
  • Geolocation API - 定位服務

2. 建立並限制 API Key

建立 API Key

  1. 前往 APIs & Services → Credentials
  2. 點擊 “CREATE CREDENTIALS” → “API key”
  3. 複製產生的 API key

設定安全限制(重要!):

API restrictions(推薦設定):
✅ Restrict key
  ✅ Geocoding API
  ✅ Places API
  ✅ Maps JavaScript API
  ✅ Geolocation API

Application restrictions(生產環境必需):
選項 A: HTTP referrers - 適合網頁應用
選項 B: IP addresses - 適合後端服務(本專案建議)
選項 C: None - 僅限開發測試

3. 配置環境變數

Cloud Build 部署: 在觸發器的替代變數中設定:

_GOOGLE_MAPS_API_KEY = 你的API_KEY

本地開發

# .env 文件
GOOGLE_MAPS_API_KEY=你的API_KEY

4. 驗證 API 生效

等待 1-2 分鐘後測試:

# 測試 Geocoding API
curl "https://maps.googleapis.com/maps/api/geocode/json?address=台北101&key=你的API_KEY"

# 成功回應應包含:
# {
#   "results": [...],
#   "status": "OK"
# }

關鍵學習

  1. API 啟用是前提:有 API Key ≠ 有權限使用 API,必須明確啟用每個服務
  2. 安全性優先:生產環境務必設定 API restrictions,避免 API key 被濫用
  3. 費用控管:Google Maps API 有免費額度(每月 $200),但建議設定預算提醒
  4. 區域考量:確保 API key 的限制設定不會阻擋 Cloud Run 的請求

費用說明

  • Geocoding API:每月 $200 免費額度(約 40,000 次請求)
  • 超過後:$5/1000 次請求
  • 建議在代碼中實作快取機制,減少 API 調用次數

這個問題提醒我們:在整合第三方服務時,不僅要有正確的憑證,還要確保所有必要的服務都已正確啟用和配置。

📊 效能監控與可靠性

健康檢查端點

r.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}).Methods("GET")

詳細的日誌記錄

系統在關鍵節點都有詳細的日誌記錄,方便除錯和監控:

log.Printf("Processing reminder %s: ETA=%s, NotificationTime=%s, AdvanceMinutes=%d",
    reminder.ID, reminder.ETA.Format("2006-01-02 15:04:05"),
    notificationTime.Format("2006-01-02 15:04:05"), reminder.AdvanceMinutes)

🎯 總結與未來改進

這個垃圾車 LINE Bot 專案展示了如何結合現代化的技術棧來解決日常生活中的實際問題。透過 Go 語言的高效能、Gemini AI 的自然語言理解、以及 GCP 的雲端服務,我們打造了一個既實用又智慧的解決方案。

專案亮點

  1. 智慧化查詢:透過 Gemini AI 理解自然語言,提供更友善的使用體驗
  2. 可靠的提醒系統:雙重保障機制確保重要通知不會遺漏
  3. 現代化架構:使用微服務架構,易於擴展和維護
  4. 自動化部署:完整的 CI/CD 流程,降低維運成本

未來改進方向

  1. 效能優化
    • 建立 Firestore 複合索引提升查詢效能
    • 實作批量推播減少 API 調用
  2. 可靠性提升
    • 加入分布式鎖避免重複執行
    • 實作指數退避重試機制
  3. 功能擴展
    • 支援更多城市的垃圾車資料
    • 加入使用統計和分析功能
    • 整合更多 AI 能力,如圖片識別
  4. 監控強化
    • 整合 Prometheus/OpenTelemetry
    • 建立完整的效能監控儀表板

透過這個專案,我深刻體會到 Go 語言在雲端原生應用開發上的優勢,以及 AI 技術如何讓傳統應用變得更加智慧化。

實戰經驗分享

在開發過程中,我們遇到了兩個典型的挑戰:

1. AI 模型的不穩定性處理

挑戰:如何處理 AI 模型的不穩定性。透過建立本地測試環境和多層 fallback 策略,我們確保了系統的穩定性:

# 我們建立了完整的測試環境
export GEMINI_API_KEY='your_key_here'
cd test && go run simple_main.go

這個測試程式幫助我們發現 Gemini 在地址解析上的限制,並及時實作了相應的解決方案。

2. 雲端應用的時區陷阱

挑戰:最容易被忽略但影響最大的問題 - 時區處理。

發現過程

  • 使用者反映在半夜收到垃圾車提醒
  • 透過日誌發現時間計算異常
  • 建立時區測試程式驗證問題
# 時區測試驗證
cd test && go run timezone_test_main.go

關鍵學習

  • 雲端部署時,絕對不能假設伺服器時區
  • 建立專門的時區工具函數,統一處理所有時間相關操作
  • 完整的測試覆蓋,包含時區邊界情況

這兩個經驗提醒我們:在 AI 驅動的雲端應用中,永遠要有備用方案,並且要特別注意基礎設施層面的差異。

希望這個經驗分享能夠幫助到正在學習相關技術的開發者們!

相關資源

[好書分享] 矽谷帝國 - 商業巨頭如何掌控經濟與社會

矽谷帝國
商業巨頭如何掌控經濟與社會
Silicon States : The Power and Politics of Big Tech and What It Means for Our Future
作者: 露西.葛芮妮  
原文作者: Lucie Greene  
譯者: 林俊宏  出版社:天下文化 

買書推薦網址:

前言:

這是 2025 年第 9 本讀完的書。當初想要了解一下矽谷內關於創業的精神與幾間巨頭對於社會的觀感。

裡面就有提到對於政治覺醒,社會政治,還有相關的社群媒體變化的內容。

大綱

世界的下一批強權將是創業家,而不是民族國家

從私部門到公部門,從地球到太空
科技企業正在重新思考一切、重新發明一切
但是──
矽谷定義的進步,是我們想要的進步嗎?
矽谷想要的未來,是我們能接受的未來嗎?

隨著民眾對政府及其機構的信心迅速流失,這個空缺正在由矽谷企業逐步填上。一小群企業擁有巨量的現金、人才和野心,正在世界各地掌握領導權與消費者的信心。

儘管科技帶來美好的承諾,對於科技企業勾勒的創新願景,我們卻不能照單全收。相反地,在我們把未來交給一小群精英公司之前,必須先認清科技企業的商業本質,理解它們對政治和社會經濟制度可能造成的影響,從而對抗它們的利益、偏見和固有缺陷。

本書中,著名的未來學者暨國際智庫領導者露西・葛芮妮訪問企業領導者、重要風投資本家、學者、記者與運動人士等,探討矽谷因其全球影響力必然帶來的緊張局勢,洞察這些公司真正著眼的利益所在,還有它們對不知情的公民擁有怎麼樣的巨大力量。

理解谷歌、蘋果、亞馬遜、臉書如何在全球發揮影響力
洞察矽谷巨頭企業如何全面蠶食我們的環境

本書帶你見識:
矽谷的影響力:它如何從晶片中心變成全球的強勢集團?
矽谷的未來版圖:創新跨界的深、遠、廣,超乎你的想像
矽谷的陰暗面:壟斷、操縱、歧視,嚴重的文化缺陷

《矽谷帝國》重點整理

根據提供的28則劃線內容,本書探討矽谷(Silicon Valley)作為科技帝國的崛起、其文化、社會影響、挑戰與爭議。書中強調矽谷不僅是科技創新中心,更是重塑民主、慈善、教育、媒體與全球價值的力量,但也暴露偏見、隱私與不平等問題。以下按主題分類整理重點,每項包含相關劃線摘要,並標註主要標籤與日期。分類基於內容的邏輯關聯,旨在捕捉書的核心論點。

1. 矽谷歷史與文化起源

  • 矽谷的定義與擴張:矽谷一詞源於1971年,指舊金山灣區南部聖塔克拉拉谷的矽晶片製造商,後擴展至舊金山與奧克蘭(2015年Uber遷至奧克蘭)。(日期:2023/11/4)
  • PayPal黑幫的影響:PayPal前員工(如彼得.提爾、伊隆.馬斯克、雷德.霍夫曼)在2002年售出公司後,創立或投資Palantir、SpaceX、特斯拉、LinkedIn、Yelp、YouTube等,定義矽谷文化與名人地位。(日期:2024/05/11;標籤:折衷求生的理想主義)
  • 企業擴張模式:矽谷從賣軟體給傳統產業轉為自建解決方案,如Uber與Lyft取代計程車業。(日期:2024/05/11;標籤:折衷求生的理想主義)

2. 科技與民主:投票、隱私與政府干預

  • 年輕選民的投票障礙:投票系統過時(如紙本登記需印表、郵寄,家有印表機僅4%),非冷漠所致。Vote.org(Y-Combinator支持)用科技、簡訊與行銷推動登記,強調選舉日應放假。(日期:2024/05/18、2025/11/1;標籤:駭進政府、不起眼的安全網)
  • 資料隱私與公眾理解不足:矽谷擴及醫療、金融,民眾欠缺科技隱私知識,後果憂心。FutureGov協助政府數位轉型。(日期:2025/11/1;標籤:不起眼的安全網)
  • 外國干預與社群媒體:俄國用臉書、推特等干預美國大選與脫歐公投,穆勒起訴13名俄國人等。社群媒體成分裂工具。(日期:2024/05/11、2024/06/1;標籤:駭進政府、假新聞及未來)
  • 文化衝突:祖克柏推Free Basics在印度遭批,忽略當地價值(如象神珍惜獨立),象徵西方企業強加控制。(日期:2024/06/1;標籤:矽谷傳教士)

3. 慈善與社會公益:商業化與風險

  • 慈善事業2.0:矽谷以登月級規模進軍慈善,2017年個人捐款達2,810億美元(成長超GDP),不止捐錢,還用資料、科技追求曝光與成效。(日期:2025/10/11;標籤:策略性慈善事業)
  • 公益商業化的危險:將公益變hashtag行銷,扭曲問題、塑矽谷領袖為救世主,僅救「值得」問題。(日期:2025/11/1;標籤:策略性慈善事業)
  • SXSW與潮流:2016年SXSW推社會公益企業,聯合國與谷歌合作,出書如《做好事正時尚》。(日期:2025/10/11;標籤:策略性慈善事業)
  • 臉書的柳樹村:在門羅公園建59英畝社區,包括員工/低收入住宅、自行車道與鐵路更新,仿維多利亞模範村。(日期:2023/11/4)

4. 多元性與偏見:性別、種族與數據問題

  • 女性與少數族群弱勢:臉書女性員工僅35%,科技職位女性19%、大學畢業生27%。零工經濟(如Airbnb)女性更不利。(日期:2025/10/11;標籤:零工經濟裡的女性更形弱勢、增進多元)
  • 矽谷盲點:慈善與創新反映「白人、有錢、25歲男人」視角,種族/性別盲點普遍。(日期:2025/11/1;標籤:策略性慈善事業)
  • 數據偏見在醫療:矽谷試圖去中心化醫療(提供資訊、追蹤健康),但無重大突破。醫療支出加劇不平等,最窮10%所得降47.6%,701萬人陷貧窮。(日期:2025/08/30;標籤:數據的偏見)

5. 教育與技能轉型:矽谷的替代模式

  • UnCollege的間隔年:9個月密集計畫,標榜「掌握教育」,用「同齡群」與「旅程」(如印度志工)教社會影響、同理心、適應。搭教育新創熱潮。(日期:2025/08/30;標籤:如果矽谷成立了一所學校)
  • 未來技能需求:2015年需解決問題、協調、管理;2020年加情緒智商、認知靈活性。但傳統課程未涵蓋。(日期:2025/08/30;標籤:如果矽谷成立了一所學校)

6. 媒體、假新聞與文化影響

  • 假新聞與灰色地帶:矽谷平台成新聞中介/製造者,與未查證內容並列。廣告抽離僅限明顯爭議;AI新聞、業配、俄國宣傳難處理。英國要求祖克柏證據。(日期:2024/06/1、2024/05/18;標籤:假新聞及未來)
  • 媒體觀感惡化:2017年調查,53%對電視、48%對社群、45%對線上新聞變差(69%視為負面)。傳統媒體權力受矽谷侵蝕,川普推特追隨者超SNL收視。(日期:2024/05/18;標籤:假新聞及未來)
  • 矽谷傳教士文化:Instagram驅動旅行(如古巴哈瓦那),但上傳受限。媒體轉向推崇科技新貴(如《浮華世界》峰會),反對聲音(如歐盟罰款、亞馬遜壟斷)被淹沒。(日期:2024/06/1、2024/05/18;標籤:矽谷傳教士、假新聞及未來)

7. 企業活動與市場擴張

  • Airbnb的轉型:從房屋共享跨足旅遊(線上指南、預訂),避法規問題。2016年房東大會門票345美元+60美元獎項,6,000人需住宿。(日期:2025/08/30;標籤:增進多元)
  • 其他活動:Salesforce Dreamforce門票近2,000美元;Refinery29的29Rooms互動展,適合Instagram。(日期:2025/08/30;標籤:增進多元)

心得

如果你也想買,我建議你要找一下有沒有其他更新的書籍。畢竟矽谷是一個高科技密集與人才密集的地方,許多變化都是相當的快速的。而這一本書的原文是在 2019 寫的,也代表著他減少了近幾年幾件重要的事項:

  • COVID 19 的市場變化
  • 區塊鏈的大起大落
  • 川普就任對於矽谷的影響。

但是除了這些之外,你可以觀察到以些相關的矽谷事件。比如說:

  • PayPal 成功後帶來之後對矽谷的影響
  • Uber 對於擴張市場造成各國的反抗浪潮
  • Airbnb 對於市場的破壞性創新

當然,還是有一些比較進階的話題:

  • 性別平等在矽谷中的相關衝突
  • 政治議題帶來矽谷科技圈的影響

這是一本寫得蠻深刻的書籍,雖然時間比較久遠,但是也都是很有趣的一些內容。很推薦給大家。