[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 對於市場的破壞性創新

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

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

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

[Gemini][Python] LINE Bot 跟 Google 自動化支付整合試試看 Agent Payments Protocol (AP2) - 企業級架構升級版

Agent Payments Protocol Graphic

前情提要

現在 Agent 的相關框架相當的多,但是其實 Google 在日前也宣布了一個蠻有趣的傳輸協定 Agent Payments Protocol (AP2) ,用來做進銷存管理跟支付的一套 agent framework 。之前我也有分享過一些 Gemini 跟 Google Agent 相關的文章,這次想要來試試看整合 AP2 協議到 LINE Bot 裡面,看看能不能做出一個完整的購物助手。

建議大家可以看一下這個由 NotebookLM 透過我的程式碼還有部落格內容產生的影片,有個快速概念。

這篇文章主要會跟大家分享:

  • 什麼是 AP2 (Agent Payments Protocol)
  • 為什麼要使用 AP2 來做電商整合
  • 如何實作一個具備完整購物流程的 LINE Bot
  • 實際的程式碼架構跟踩坑經驗

實際展示

image-20251031135254894

  • 首先你跟 LINE Bot 說你想要買什麼樣的產品,這時候會啟動 Shopping Agent

LINE 2025-10-31 13.49.26

  • 這時候,Shopping Agent 會去詢問庫存系統,並且回報產品資訊給你。

LINE 2025-10-31 13.49.38

  • 當他看到要付款,就會轉給 Payment Agent 來處理付款相關的資訊。

LINE 2025-10-31 13.49.47

  • 這裡也會看到,Payment Agent 有展示了一個付款 OTP 的 Demo (當然是用測試用 OTP )

範例程式碼

https://github.com/kkdai/linebot-ap2

歡迎給 Star 與分享,如果覺得實用也歡迎參與貢獻添加一些新功能。(透過這個程式碼,可以快速部署到 GCP Cloud Run)

關於 AP2 (Agent Payment Protocol) 的架構圖

image-20251031114041457

AP2 (Agent Payments Protocol) 是 Google 推出的一套創新的支付協議框架,專門設計來整合 AI Agent 與電商支付流程。跟傳統的電商支付不一樣的地方是,AP2 是專門為了 AI Agent 設計的,讓 AI 可以更自然地處理整個購物到支付的完整流程。

完整的架構介紹,我推薦大家看一下官方的介紹影片

🚀 主要整合進 LINE Bot 的相關部分

這次的升級我把整個專案改造成企業級的架構,主要分成兩個階段來實作:

第一階段:現代化專案結構

  • 模組化架構: 採用 src/linebot_ap2/ 的標準 Python 專案結構
  • Pydantic v2: 完整的資料驗證和設定管理
  • 現代化配置: 使用 pyproject.toml 取代傳統的 setup.py

第二階段:企業級安全與穩定性

  • AP2 完全合規: HMAC-SHA256 數位簽章確保交易安全
  • Circuit Breaker: 自動故障恢復機制
  • 增強型服務: 模組化的服務架構,更容易維護和擴展

AP2 的核心組件

1. Cart Mandate (購物車委託) - 企業級升級版 現在的 Cart Mandate 不只是基本的購物車功能,我們加入了完整的 AP2 合規性和安全機制:

# 新版本:企業級 Cart Mandate 創建
async def enhanced_create_cart_mandate(
    cart_items: List[Dict], 
    user_id: str,
    signature_required: bool = True
) -> str:
    """創建具備 AP2 合規性的購物車委託"""
    
    # 計算總金額並創建 mandate
    mandate_data = {
        "mandate_id": mandate_id,
        "type": "cart_mandate",
        "user_id": user_id,
        "items": validated_items,
        "total_amount": total_amount,
        "currency": "USD",
        "created_at": datetime.now(timezone.utc).isoformat(),
        "expires_at": (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat()
    }
    
    # 🔐 重點:AP2 合規的數位簽章
    if signature_required:
        mandate_service = MandateService()
        mandate_data["signature"] = mandate_service.sign_mandate(mandate_data)
        mandate_data["signature_algorithm"] = "HMAC-SHA256"
    
    return json.dumps(mandate_data, ensure_ascii=False, indent=2)

這裡最重要的改進是加入了 HMAC-SHA256 數位簽章,這是 AP2 協議的核心安全要求。

2. 企業級數位簽章服務 這是這次升級最重要的安全功能,符合 AP2 協議的完整要求:

# src/linebot_ap2/services/mandate_service.py
class MandateService:
    """AP2 合規的 Mandate 管理服務"""
    
    def sign_mandate(self, mandate_data: Dict[str, Any]) -> str:
        """使用 HMAC-SHA256 簽署 mandate"""
        # 準備簽章資料
        signable_data = self._prepare_signable_data(mandate_data)
        
        # 🔐 HMAC-SHA256 數位簽章
        signature = hmac.new(
            self.secret_key.encode('utf-8'),
            signable_data.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()
        
        logger.info(f"Mandate signed: {mandate_data.get('mandate_id')}")
        return signature
    
    def verify_mandate_signature(self, mandate_data: Dict[str, Any], signature: str) -> bool:
        """驗證 mandate 簽章"""
        expected_signature = self.sign_mandate(mandate_data)
        is_valid = hmac.compare_digest(signature, expected_signature)
        
        if not is_valid:
            logger.warning(f"Invalid signature for mandate: {mandate_data.get('mandate_id')}")
        
        return is_valid

3. 增強型 OTP 驗證機制 現在的 OTP 系統不只是基本驗證,還加入了企業級的安全機制:

# 新版本:增強型 OTP 驗證
async def enhanced_verify_otp(
    mandate_id: str, 
    otp_code: str, 
    user_id: str,
    max_attempts: int = 3
) -> str:
    """增強型 OTP 驗證,具備防暴力破解機制"""
    
    try:
        # 🛡️ 檢查嘗試次數限制
        if otp_data["attempts"] >= max_attempts:
            await cleanup_expired_data()  # 清理過期資料
            return json.dumps({
                "error": "Too many failed attempts",
                "status": "blocked",
                "retry_after": 300  # 5 分鐘後重試
            })
        
        # ✅ OTP 驗證成功
        if otp_data["otp"] == otp_code:
            # 🔄 Circuit Breaker 保護機制
            with retry_handler.circuit_breaker():
                transaction_id = f"txn_{uuid.uuid4().hex[:12]}"
                
                # 🔐 完整的交易記錄
                payment_service = PaymentService()
                await payment_service.record_transaction(
                    transaction_id, mandate_id, user_id
                )
        
        return json.dumps({
            "mandate_id": mandate_id,
            "transaction_id": transaction_id,
            "status": "payment_successful",
            "timestamp": datetime.now(timezone.utc).isoformat()
        })
        
    except Exception as e:
        logger.error(f"OTP verification failed: {e}")
        # 🔄 自動重試機制
        return await retry_handler.with_retry(
            enhanced_verify_otp, mandate_id, otp_code, user_id
        )

為什麼要使用 AP2 來做電商整合

我之前也有試過很多不同的電商 API 整合方案,但是說實話,大部分都有一些問題。要不就是安全性有疑慮,要不就是整合起來很麻煩。AP2 協議讓我覺得眼睛一亮的地方,主要有以下幾點:

跟 Google ADK 完美整合

如果你之前有玩過 Google ADK (Agent SDK) 的話,就會知道它跟 Gemini 模型的整合非常順暢。AP2 基本上就是為了這個生態系統設計的,所以你可以很輕鬆地:

  • 直接使用 Gemini-2.5-flash 模型來處理用戶的購物需求
  • 自動化的意圖識別,用戶說「我想買 iPhone」就會自動轉到購物 Agent
  • 多語言支援,中英文都沒問題

開發效率真的很高

說實話,如果你要自己從零開始做一套完整的電商支付系統,那真的是會累死。AP2 提供了:

# 基本上概念就是這樣,很簡單(這是簡化示意)
shopping_agent = Agent(
    name="ap2_shopping_assistant",
    model="gemini-2.5-flash",
    tools=[
        search_products,
        get_product_details,
        create_cart_mandate,
        get_shopping_recommendations
    ]
)

注意:上面是概念性的簡化示例。實際專案中我們使用了企業級的 factory pattern 來創建 agents,詳細實作請見下方的「Enhanced Shopping Agent」和「Enhanced Payment Agent」章節。

  • 不需要重新造輪子,協議都幫你定義好了
  • 標準化的 API 介面,跟不同支付服務商整合都是同一套邏輯
  • 內建的安全機制,不用擔心被駭客攻擊

安全性考量很完整

因為涉及到真的要付錢,所以安全性絕對是最重要的。AP2 在這方面做得很不錯:

  • OTP 雙重認證,每筆交易都要驗證碼確認
  • 數位簽章技術,防止交易被篡改
  • 完整的 audit trail,所有交易都有記錄可查

我在測試的時候,還特別試了一下如果 OTP 輸入錯誤會怎樣:

# 最多只能試 3 次,超過就會被鎖定
if otp_data["attempts"] > 3:
    del _otp_store[mandate_id]
    return json.dumps({
        "error": "Too many attempts",
        "status": "blocked"
    })

🏗️ LINE Bot 架構升級實作

這次的升級我完全重新設計了整個系統架構,從原本的三個基本 Agent 升級成企業級的模組化架構。

🛍️ Enhanced Shopping Agent - 企業級購物助手

新的購物 Agent 不只是基本功能,而是具備完整企業級特性:

# src/linebot_ap2/agents/enhanced_shopping_agent.py
def create_enhanced_shopping_agent(model: str = "gemini-2.5-flash") -> Agent:
    """創建增強型購物 Agent,具備企業級功能"""
    
    return Agent(
        name="enhanced_shopping_agent",
        model=model,
        description="""Advanced shopping assistant with comprehensive product search, 
        cart management, and AP2-compliant payment mandate creation.""",
        
        instruction="""You are an intelligent shopping assistant with advanced capabilities:
        
        🛍️ **Core Shopping Functions:**
        - **Product Search**: Use enhanced_search_products with filters (category, price range, brand)
        - **Product Details**: Get comprehensive info with enhanced_get_product_details  
        - **Recommendations**: Provide personalized suggestions with enhanced_get_recommendations
        - **Cart Management**: Add items and manage shopping carts with enhanced_add_to_cart
        
        🔐 **AP2 Payment Integration:**
        - **Secure Mandates**: Create signed payment mandates with enhanced_create_cart_mandate
        - **Transaction Security**: All mandates use HMAC-SHA256 signatures for AP2 compliance
        - **Audit Trail**: Full transaction logging and verification""",
        
        tools=[
            enhanced_search_products,      # 🔍 增強型商品搜尋
            enhanced_get_product_details,  # 📋 詳細商品資訊  
            enhanced_create_cart_mandate,  # 🛒 AP2 合規購物車
            enhanced_get_recommendations,  # 🎯 智能推薦
            enhanced_add_to_cart,         # ➕ 購物車管理
            get_product_categories,       # 📂 商品分類
            get_shopping_cart            # 🛍️ 購物車查詢
        ]
    )

💳 Enhanced Payment Agent - 企業級支付處理

新的支付 Agent 具備完整的企業級安全機制和 AP2 合規性:

# src/linebot_ap2/agents/enhanced_payment_agent.py
def create_enhanced_payment_agent(
    model: str = "gemini-2.5-flash",
    max_otp_attempts: int = 3,
    otp_expiry_minutes: int = 5
) -> Agent:
    """創建增強型支付 Agent,具備企業級安全性"""
    
    return Agent(
        name="enhanced_payment_agent",
        model=model,
        description="""Advanced payment processor with AP2 compliance, enhanced security 
        features, and comprehensive error handling.""",
        
        instruction=f"""You are a secure payment processing agent with advanced capabilities:

        🔐 **Security & Compliance:**
        - **AP2 Protocol**: Full compliance with Agent Payments Protocol standards
        - **OTP Security**: Maximum {max_otp_attempts} attempts, {otp_expiry_minutes}-minute expiry
        - **Encryption**: AES-256 encryption for all payment data
        - **Audit Trail**: Complete transaction logging and monitoring

        💳 **Payment Processing:**
        1. **Payment Methods**: Show available methods with enhanced_get_payment_methods
        2. **Payment Initiation**: Secure processing with enhanced_initiate_payment
        3. **OTP Verification**: Guide users through enhanced_verify_otp process
        4. **Transaction Status**: Real-time updates with enhanced_get_transaction_status
        5. **Refund Processing**: Handle refunds with enhanced_process_refund

        🛡️ **Security Features You Must Explain:**
        - **Mandate Signing**: HMAC-SHA256 signatures ensure transaction integrity
        - **OTP Protection**: Time-limited codes prevent unauthorized access
        - **Fraud Detection**: Real-time monitoring and risk assessment
        - **Data Protection**: PCI DSS Level 1 compliance""",
        
        tools=[
            enhanced_get_payment_methods,    # 🔍 增強型支付方式
            enhanced_initiate_payment,       # 💰 安全支付發起
            enhanced_verify_otp,            # 🔐 強化 OTP 驗證
            enhanced_get_transaction_status, # 📊 交易狀態追蹤
            enhanced_process_refund,        # 💸 退款處理
            get_mandate_details,           # 📋 Mandate 詳情
            cleanup_expired_data          # 🧹 過期資料清理
        ]
    )

🔄 Circuit Breaker 自動故障恢復機制

這是企業級系統必備的穩定性機制:

# src/linebot_ap2/common/retry_handler.py
class CircuitBreaker:
    """Circuit Breaker 實現自動故障恢復"""
    
    def __init__(self, failure_threshold: int = 5, timeout: int = 60):
        self.failure_threshold = failure_threshold
        self.timeout = timeout
        self.failure_count = 0
        self.last_failure_time = None
        self.state = "CLOSED"  # CLOSED, OPEN, HALF_OPEN
    
    def __enter__(self):
        if self.state == "OPEN":
            if time.time() - self.last_failure_time > self.timeout:
                self.state = "HALF_OPEN"
                logger.info("Circuit breaker: HALF_OPEN -> 嘗試恢復")
            else:
                raise CircuitBreakerOpenException("🚫 Circuit breaker is OPEN")
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            # 🟢 成功執行
            if self.state == "HALF_OPEN":
                self.state = "CLOSED"
                self.failure_count = 0
                logger.info("Circuit breaker: HALF_OPEN -> CLOSED (恢復成功)")
        else:
            # 🔴 執行失敗
            self.failure_count += 1
            self.last_failure_time = time.time()
            
            if self.failure_count >= self.failure_threshold:
                self.state = "OPEN"
                logger.warning(f"Circuit breaker: CLOSED -> OPEN (失敗次數: {self.failure_count})")

支付流程大概是這樣:

  1. 用戶確認要付款
  2. 系統產生 OTP 驗證碼
  3. 用戶輸入驗證碼
  4. 驗證成功後完成交易
def initiate_payment(mandate_id: str, payment_method_id: str, user_id: str) -> str:
    # 產生 6 位數 OTP
    otp = f"{random.randint(100000, 999999)}"
    
    # 儲存 OTP (5 分鐘有效期)
    _otp_store[mandate_id] = {
        "otp": otp,
        "user_id": user_id,
        "expires_at": datetime.now() + timedelta(minutes=5),
        "attempts": 0
    }

🤖 智能意圖識別系統

這個是我覺得最有趣的部分,系統會自動判斷用戶想要做什麼:

def determine_intent(message: str) -> str:
    message_lower = message.lower()
    
    # 購物關鍵字
    shopping_keywords = [
        'buy', 'purchase', 'shop', 'product', 
        '買', '購買', '商品', '產品', '購物',
        'iphone', 'macbook', 'airpods'  # 商品名稱
    ]
    
    # 支付關鍵字  
    payment_keywords = [
        'pay', 'payment', 'checkout', 'otp',
        '付款', '支付', '結帳', '驗證'
    ]
    
    # 檢查關鍵字並回傳對應意圖
    for keyword in payment_keywords:
        if keyword in message_lower:
            return 'payment'
            
    for keyword in shopping_keywords:
        if keyword in message_lower:
            return 'shopping'
            
    return 'shopping'  # 預設為購物

📱 LINE Bot 整合的部分

最後是 LINE Bot 的整合,主要在 main.py 裡面:

@app.post("/")
async def handle_callback(request: Request):
    # 處理 LINE webhook
    for event in events:
        if event.message.type == "text":
            msg = event.message.text
            user_id = event.source.user_id
            
            # 判斷意圖並路由到對應 Agent
            intent = determine_intent(msg)
            response = await call_agent_async(msg, user_id, intent)
            
            reply_msg = TextSendMessage(text=response)
            await line_bot_api.reply_message(event.reply_token, reply_msg)

每個用戶都會有獨立的 session,所以可以保持對話的上下文:

async def get_or_create_session(user_id):
    if user_id not in active_sessions:
        session_id = f"session_{user_id}"
        await session_service.create_session(
            app_name=APP_NAME, user_id=user_id, session_id=session_id
        )
        active_sessions[user_id] = session_id
    return active_sessions[user_id]

🔍 如何驗證自己是符合 AP2

這一段大概是我寫(Vibe Coding) 完成後,一直無法完全確定是否是正確的部分。以往一直會以為要符合相關的 Protocol ,一定要使用到相關的套件 SDK ,也就是一定要用到 AP2 的套件才算合規。這邊也是完整對過 Spec 跟討論之後的結果。

AP2 不需要特別的 SDK,只要符合協議規範即可:

  1. ✅ 協議合規 - 你的實作完全符合 AP2 標準

    1. ✅ 數位簽章合規 - HMAC-SHA256 算法(AP2 標準)

      ✅ 生命週期管理 - 創建時間、過期時間、狀態追蹤

      ✅ 安全驗證 - OTP 機制、簽章驗證

      ✅ 審計軌跡 - 完整的交易記錄和狀態追蹤

      ✅ 資料完整性 - 必要欄位驗證、格式標準化

      ✅ 狀態管理 - 符合 AP2 的狀態流轉規則

  2. ✅ 資料格式正確 - Cart/Payment Mandate 格式符合規範

    這邊列出相關程式碼:

    def create_cart_mandate(product_id: str, quantity: int = 1, user_id: str = "") -> str: 
      cart_mandate = {
         "mandate_id": mandate_id,           # ✅ AP2 必要欄位
          "type": "cart_mandate",             # ✅ AP2 協議類型
         "user_id": user_id,                 # ✅ 用戶識別
          "items": [{                         # ✅ 商品清單
              "product_id": product_id,
              "name": product["name"],
              "price": product["price"],
              "quantity": quantity,
              "subtotal": total_amount
          }],
          "total_amount": total_amount,       # ✅ 總金額
          "currency": product["currency"],    # ✅ 貨幣類型
          "created_at": datetime.now().isoformat(),  # ✅ 建立時間
          "status": "pending_payment"         # ✅ 狀態
      }
    

    關於 CartMadate 的資料結構:

      # 創建結構化資料
      mandate = CartMandate(
          mandate_id=mandate_id,              # ✅ 唯一識別碼
          user_id=user_id,                    # ✅ 用戶ID
          items=cart_items,                   # ✅ 商品清單 (CartItem 物件)
          total_amount=total_amount,          # ✅ 總金額
          currency=currency,                  # ✅ 貨幣
          created_at=datetime.now(),          # ✅ 建立時間
          expires_at=datetime.now() + timedelta(...),  # ✅ 過期時間
          status=PaymentStatus.PENDING        # ✅ 狀態
      )
           
        class CartMandate(BaseModel):
          mandate_id: str                         # ✅ 必要:mandate 識別碼
          type: str = "cart_mandate"              # ✅ 必要:AP2 類型標識
          user_id: str                           # ✅ 必要:用戶識別
          items: List[CartItem]                  # ✅ 必要:商品清單
          total_amount: float                    # ✅ 必要:總金額
          currency: str = "USD"                  # ✅ 必要:貨幣類型
          created_at: datetime                   # ✅ 必要:建立時間
          status: PaymentStatus                  # ✅ 必要:處理狀態
          expires_at: Optional[datetime]         # ✅ 可選:過期時間
    
  3. ✅ 安全機制完整 - HMAC-SHA256 簽章、OTP 驗證 附上相關程式碼

      payload = {
          "mandate_id": mandate.mandate_id,   # ✅ 簽章內容
          "user_id": mandate.user_id,
          "total_amount": mandate.total_amount,
          "currency": mandate.currency,
          "items_count": len(mandate.items),
          "timestamp": timestamp,
          "nonce": nonce
      }
         
      # ✅ HMAC-SHA256 簽章 (AP2 標準)
      signature = hmac.new(
          self.secret_key.encode('utf-8'),
          payload_string.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()
    
  4. ✅ 工作流程正確 - 購物→支付→驗證的完整流程

🔧 企業級升級踩坑經驗分享

1. Pydantic v2 升級挑戰

升級到企業級架構時遇到的第一個問題是 Pydantic v2 的導入變更:

# ❌ 舊版本 (Pydantic v1)
from pydantic import BaseSettings

# ✅ 新版本 (Pydantic v2) 
from pydantic_settings import BaseSettings

# 解決方案:更新 pyproject.toml
dependencies = [
    "pydantic>=2.5.0",
    "pydantic-settings>=2.1.0",  # 新增這個套件
]

2. Enhanced Session 管理升級

原本的 session 管理太簡單,企業級版本加入了完整的監控和清理機制:

# src/linebot_ap2/common/session_manager.py
class EnhancedSessionManager:
    """企業級 Session 管理,具備清理和監控功能"""
    
    async def get_or_create_session(self, user_id: str) -> str:
        """取得或創建用戶 session,具備自動清理機制"""
        
        if user_id not in self.active_sessions:
            session_id = f"session_{user_id}_{int(time.time())}"
            
            # 🧹 清理過期 session
            await self.cleanup_expired_sessions()
            
            # 📊 記錄 session 創建
            logger.info(f"Creating new session for user: {user_id}")
            
            await self.session_service.create_session(
                app_name=self.app_name,
                user_id=user_id,
                session_id=session_id
            )
            
            self.active_sessions[user_id] = {
                "session_id": session_id,
                "created_at": datetime.now(timezone.utc),
                "last_activity": datetime.now(timezone.utc)
            }
        
        # 更新最後活動時間
        self.active_sessions[user_id]["last_activity"] = datetime.now(timezone.utc)
        return self.active_sessions[user_id]["session_id"]

3. Circuit Breaker 整合挑戰

實作 Circuit Breaker 時發現 decorator 模式太複雜,簡化為 context manager:

# ❌ 原本想用複雜的 decorator
@retry_handler.with_circuit_breaker()
async def some_function():
    pass

# ✅ 改用簡單的 context manager
async def enhanced_verify_otp(...):
    try:
        with retry_handler.circuit_breaker():
            # 執行 OTP 驗證邏輯
            result = await process_otp_verification(...)
            return result
    except CircuitBreakerOpenException:
        return json.dumps({
            "error": "Service temporarily unavailable",
            "status": "circuit_breaker_open",
            "retry_after": 60
        })

4. AP2 HMAC 簽章的正確實作

一開始對 AP2 的數位簽章規範理解不夠深入,後來研究官方文件才知道:

def _prepare_signable_data(self, mandate_data: Dict[str, Any]) -> str:
    """準備用於簽章的資料,必須按照 AP2 規範排序"""
    
    # 🔐 重要:必須按照特定順序排列欄位
    signable_fields = [
        "mandate_id", "type", "user_id", "total_amount", 
        "currency", "created_at", "expires_at"
    ]
    
    # 過濾並排序欄位
    filtered_data = {
        key: mandate_data[key] 
        for key in signable_fields 
        if key in mandate_data
    }
    
    # 產生可簽章的字串
    return "|".join([f"{k}={v}" for k, v in sorted(filtered_data.items())])

🚀 結語與未來方向

整合完才會知道,其實 VP2 並不是一整套的 SDK 或是框架,而是一個協定與相關規範。讓每一個組織都能夠打造出自己的 Payment Protocol 而不需要依賴其他人。

以下是未來的一些方向

進階 AI 功能

  • 多模態搜尋: 圖片、語音、文字混合搜尋
  • 個人化推薦: 基於用戶行為的 ML 推薦引擎
  • 對話式客服: 整合 Gemini Pro 處理複雜查詢

希望這篇文章能幫助大家了解 AP2 協議的實作方式。如果你對程式碼有任何問題,歡迎到 GitHub 專案 留言討論,也歡迎大家 fork 回去改進!

記得如果覺得有用的話,給個 ⭐ Star 支持一下 :)

[數位憑證皮夾] 入門版 - 如何創立一個新的數位憑證與驗證方式,應用場景為一間公司的 HR 補助申請(附上範例修改程式重點)

image-20251009102618401

(圖片來源: 數位憑證皮夾官方網站)

前提:

數位憑證皮夾是近幾年數發部推動的一個主要政策,希望是透過數位憑證皮夾來取代大大小小的證件、會員卡跟相關的實體晶片卡片。數位憑證皮夾可以是一單一的一個 App ,甚至可以是讓所有的 App 都來當數位憑證皮夾。

這一篇文章稍微解釋數位憑證皮夾的使用方法,還有如何應用數位憑證皮夾,透過一個場景來建立一個數位憑證的發行方與數個認證方。

先來玩一下數位憑證皮夾

首先你需要下載數位憑證皮夾的官方 App (目前 iOS 是 TestFlight 版本)

image-20251009103231602

下載之後,你會發現好像裡面空空的。那是因為你還沒有建立你自己的卡片。

測試記者會的相關流程

打開 App 會看到有以下

image-20251009113551533

建議可以先加入

  • OTP 電子卡(需要輸入電話,收簡訊)
  • 駕照電子卡(可以輸入測試資料沒問題)

加入了相關憑證後,就可以透過出示憑證來測試。 建議可以走「超商取貨」的範例來測試一下。

image-20251009114128661

可以看到,超商領貨需要兩種資料,是可以從兩種數位憑證上面取得。

  • 駕照卡 -> 姓名
  • OTP卡 -> 電話

然後都不需要其他的欄位,這就是選擇性揭露的原則。

數位豆泥卡範例 Web App

網址: https://mashbeanvc.tonyq.org/

Google Chrome 2025-10-09 17.39.55

這個 Web App 作為展示有以下兩個主要功能:

  • 申請一張豆泥卡(只需要暱稱,生日是選填)
  • 幫豆泥點蠟(也就是驗證的意思)

這個範例也充分了應用以下主要 API :

打造一個簡單數位憑證驗證場景吧 (HR數位員工卡系統)

Avatar

接下來要做一個給 HR 的數位員工卡系統,需要有以下的相關功能:

  • 連線小學堂的同仁可以自行申請一張員工卡(輸入: 姓名、英文名字、出生年月日、入職年份、養育小孩數字)
  • 可以申請以下兩種補助:分別是育兒補助跟體育補助兩種。
  • 育兒補助條件:
    • 英文名字(作為帳戶匯款用)
    • 需要入職滿一年
    • 需要有一個小孩以上
  • 體育補助:
    • 需要入職滿一年
    • 英文名字(作為帳戶匯款用)

以上就是一個使用數位憑證皮夾的系統的假設場景,接下來要來說明要如何打造。

透過沙盒系統來設計與發行相關數位憑證

申請沙盒相關流程:

發行數位憑證

(可以參考官方的發行端使用手冊)

到了 發行端沙盒系統 (負責建立發行數位憑證) 透過「建立VC模板」

image-20251010132418021

建立以下的數位憑證

Google Chrome 2025-10-10 13.12.08

其中有一些資料需要記住:

  • 序號
  • 證件類型(credentialType)

這邊可以透過「產生 VC 資料」來輸入一個新的資料。

image-20251010132543934

這樣就會產生一個 QR Code 並且可以讓你匯入到數位錢包之內。

驗證數位憑證

(可以參考官方的驗證端使用手冊)

這邊解釋一下,如何建立一個運動補助的數位憑證驗證的方法,首先來複習一下「運動補助」的條件有哪些:

體育補助條件:

  • 需要入職滿一年
  • 英文名字(作為帳戶匯款用)

接下來,你可以到 「建立 VP 」經過以下流程來創立一個。

image-20251010133429458

  • 輸入 VP 的名字

image-20251010133415287

  • 挑選你需要的群組名稱,

  • 挑選 VC 資料,我這邊就挑選剛剛建立的「連線小學堂」也就是挑選你剛剛建立的數位憑證樣板。

image-20251010133450566

  • 挑選要驗證的數位憑證欄位
  • 因為運動補助只需要知道「入職時間」跟「英文名字」就可以,就不需要挑選其他的資料欄位。

最後就會出現一個 QR Code 看做為驗證使用。

透過 SwaggerUI : 來了解各種 API 的使用方法

接下來透過官方提供的 SwaggerUI 介面,與一些 API 來跟大家講如何找到這些變數。

發行端 SwaggerUI 參數說明

網址: 發行端 SwaggerUI

輸入 API_Key (Access Token)

image-20251010143636547

這個資料在當初註冊沙盒的時候發送兩封信件之一 : 「【數位憑證皮夾】發行端沙盒系統_帳號啟用通知」。

發行端產生 QR Code /api/qrcode/data

image-20251010144626368

這邊會需要輸入一個參數 vcUid ,請去 發行端沙盒系統 (負責建立發行數位憑證) 找到你發行的數位憑證,點下編輯即可看到相關資訊。

image-20251010145116814

這個 /api/qrcode/data 會需要有資料欄位,這邊可能會比較不容易在介面上一個個填寫。可以先用 /api/qrcode/nodata 來測試。

特別說明 - /api/vc-item-data

但是程式碼中會使用到的 APO /api/vc-item-dataSwaggerUI 沒有出現。

相關資訊如下: (以我上面資訊舉例)

  • vcId: 607861
  • vcCiD: 0028680530_line_school
curl -X 'POST' \
  'https://issuer-sandbox.wallet.gov.tw/api/vc-item-data' \
  -H 'accept: */*' \
  -H 'Access-Token: YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "vcId": 607861,
  "vcCid": "0028680530_line_school",
  "fields": [
    {
      資料省略
    },
}'

驗證端 SwaggerUI 參數說明

網址: 驗證端 SwaggerUI

關於 Authorize 的流程跟發行端相同,就跳過。

產生驗證端的 QR Code /api/oidvp/qrcode

這邊會需要有兩個參數

Microsoft PowerPoint 2025-10-10 15.17.10

  • transactionId: 這個需要一個 UUID 的字串,可以用 SwaggerUI 上面原本數值來修改一下即可。

如果資料成功,會出現以下相關資料:

{
  "transactionId": "104158f9-b1dc-4f76-847e-86f6af36d917",
  "qrcodeImage": "data:image/png;base64,...",
  "authUri": "modadigitalwallet://authorize?..."
}

其中:

  • transactionId: 就是你填寫得資料
  • qrcodeImage: base64 的圖片
  • authUri: 就是一個 deeplink 可以開啟 iOS App 數位錢包的 App 並且執行相關的驗證。

最後: 來修改程式與使用相關的參數

image-20251010142916050

接下來程式碼放在這個地方: https://github.com/kkdai/did-usecase-HR ,但是我們先透過發行端跟驗證端的 SwaggerUI 介面來跟大家分享一下,該使用哪些資訊。

各位可以查詢 .env.example 可以看到相關說明 (網址)

# 卡片序號,從發行後台取得
VC_SERNUM=YOUR_VC_SERNUM

# 卡片樣板代號,從發行後台取得
VC_UID=YOUR_VC_UID

# 發行者存取權杖,從發行後台取得
ISSUER_ACCESS_TOKEN=YOUR_ISSUER_ACCESS_TOKEN

# 驗證器參考碼
VERIFIER_SPORT_REF=YOUR_VERIFIER_REF_FOR_SPORT_SUBSIDY
VERIFIER_PARENT_REF=YOUR_VERIFIER_REF_FOR_PARENT_CHECK

# 驗證器存取權杖
VERIFIER_ACCESS_TOKEN=YOUR_VERIFIER_ACCESS_TOKEN

裡面的 VC_SERNUM 就是剛才提到的 vcId 。 其他就可以快速理解才對。

範例使用方式:

可以本地端執行測試一下

建立員工卡

image-20251010152639239

可以快速輸入資料,並且產生一張員工卡。

申請運動補助

image-20251010152718672

點選 運動補助(驗證) 就可以進入申請運動補助,並且透過掃描 QR Code 來傳輸需要的資料。

直接線上體驗?

可以直接打開這個網址來測試。

總結與未來展望

這是一個數位憑證沙盒的展示場景與 demo program ,大部分程式碼還是透過 TonnyQ 當初打造出來的樣板修改的。主要是希望讓大家可以對於數位憑證沙盒能有基礎的理解,這樣的應用場景只會是許多有創意中的起點。很期待可以看到許多有趣的想法與應用場景在未來的應用上。

[好書分享] 折疊者思維 - 做個好軍師,將領導者天馬行空的發想落實,成為不可或缺的得力助手

折疊者思維
做個好軍師,將領導者天馬行空的發想落實,成為不可或缺的得力助手
作者: 設樂悠介  原文作者: Shidara Yusuke  
譯者: 莊雅琇  
出版社:時報出版 

買書推薦網址:

前言:

這是 2025 年第 8 本讀完的書。當初買到這本書,因為在公司內部我經常擔任著協助許多專案去「折疊」的角色。所以想來看看這位作者說的角色究竟是如何扮演。

大綱

幻冬舍社長見城徹欽點新事業主管,與知名編輯箕輪厚介共同打造最高業績團隊的重要推手--設樂悠介,傾囊相授他二十年來成為最佳折疊者的實戰經驗。

看他如何從一名將編輯立為職志的新鮮人,誤打誤撞從小業務員開始,一步步用「折疊者思維」在不到四十歲即成為幻冬舍Comics的董事、編輯本部內容產業局副局長。

「折疊」與「攤開」的由來

在日本有名的日文國語辭典《廣辭苑》中提到【攤開包袱巾】(大風呂敷を広げる),是指「說話或做事不切實際,說大話的意思。」所以本書將其稱為「攤開」者,也就是把工作創意從無到有發想出來的人。

而【折疊包袱巾的人】(風呂敷畳み人)是相對的角色,作者在所主持的節目《折疊包袱巾的人廣播電台》中,稱其為「折疊」者,指讓創意聚焦、穩定執行的人。

彼得•杜拉克說:策略是平價商品,執行力乃是藝術。
學會「摺疊者思維」,累積執行力,方能在最終做到自己的想望!
折疊者:是指具體實踐工作創意,並且穩步實行的人。他可以說是領導者(攤開者)身邊的「軍師」或「得力助手」。

《折疊者思維》重點整理

本書以「折疊者」(負責具體實踐創意、穩步執行的軍師型角色)與「攤開者」(提出天馬行空想法的領導者)為核心概念,探討商務領域中折疊者的重要性、角色、心態與實務技巧。以下根據劃線內容,按章節或主題小節整理重點,聚焦於定義、角色、團隊管理、溝通、時間管理及個人經驗。

什麼是「折疊者」?

  • 折疊者源自「折疊包袱巾的人」(風呂敷畳み人),在商務中指具體實踐攤開者(提出創意者)的想法,並穩步執行的人。折疊者是領導者身邊的「軍師」或「得力助手」,負責將抽象願景轉化為可行行動。
  • 為什麼「折疊者」如此重要?許多團隊缺乏將創意具體實現的實行能力與經驗,甚至連指揮人才也不足。折疊者填補此空白,確保專案順利推進。

對「攤開者」的創意產生共鳴並樂在其中

  • 折疊者應從一開始就對攤開者的創意表示興趣,成為支持者而非評論家。尋找創意中的有趣之處,心想「說不定有潛力」,才能發自內心提出正面意見。
  • 想出創意的人最初排斥否定,因此折疊者首要工作是與攤開者一起樂在其中,從構想階段就響應支持。

先支持攤開者的想法,再適時修正

  • 反駁或建議前,先對創意表示感興趣,成為同盟。這是折疊者的首要之務,避免一開始就否定。
  • 攤開者詢問看法時,首先表達興趣;之後再提出修正,確保創意順利發展。

請努力成為世界上最了解攤開者的人

  • 折疊者是最能與攤開者近距離交流的人,應成為最了解攤開者的人。這有助推動團隊,並是折疊者應有的心態。
  • 成為攤開者堅強的夥伴,與他共享祕密:對風險高的創意表示興趣,與攤開者協力實行;若攤開者徬徨,則聆聽心聲,提供盟友支持。

折疊者要察覺且預想所有的風險

  • 折疊者負責替攤開者設想各種風險,再付諸實行,讓攤開者全心發想創意。
  • 看準機會,隨時把掌控權拿到手:折疊者可在實行過程中視情況控制攤開者,將創意融入專案。痛快之處在於「成功反擊」的那一刻(比喻為反擊,非真實衝突)。

成為宣揚攤開者熱情的傳教士與翻譯

  • 折疊者身為最理解攤開者的人,負責將攤開者的熱忱翻譯給團隊成員。
  • 除了熱忱,還需補充明確指示,讓第一線成員能付諸實行。攤開者的願景常龐大抽象,折疊者將其具體傳達,提升團隊執行力。

成為最能理解團隊成員的人

  • 折疊者也需成為最理解團隊成員的人,掌握各成員的工作情況與狀態,並視情況將意見傳達給攤開者。
  • 平時多聽團隊心聲,定期安排一對一溝通,建立「打開天窗說亮話」的關係(如成員先私下分享想法)。讓成員知曉自己被關心與理解。
  • 下令「Do」的是攤開者,傳達「How」的是折疊者:折疊者指導成員最適切方式,提升團隊效率。

選擇團隊成員最重要的是熱忱

  • 建立團隊關鍵在「人」,招募階段優先召集對專案產生共鳴的夥伴。技能重要,但「共鳴」與「熱忱」才是成敗關鍵,人會竭盡全力實現內心渴望。
  • 本來就不會有100%的完美團隊:專案規模越大,成員越多,困擾越顯(如提升表現)。年輕時可動用「犯規」手段(如強力推動),但需依賴優秀團隊。

成功是團隊成員的功勞,失敗是自己的責任

  • 上司搶功是犯規行為,折疊者不可如此。分辨好上司的關鍵:是否做出適當決定、獲得下屬信賴。
  • 一個好的折疊者,一定會成為好的攤開者:曾任折疊者者具備技能,能盡情揮灑創意。作者經驗:上司長期企劃中任折疊者,電子書業務則任攤開者;與夥伴創立事業時自告奮勇任折疊者,《新經濟》則任徹底攤開者。專案少不了折疊者,缺席時需身兼兩職,極為吃力。

專欄 我就這樣成為「折疊者」4:遇見好上司好戰友,成就了優秀的折疊者

  • 遇見好上司(如常問「你們想做什麼?」)與戰友(如箕輪),成就優秀折疊者。平時交流新事業想法,組成團隊,互相支持。

溝通1 禮貌是工作上的「高CP值武器」

  • 拜託工作後,即使瑣事也道謝,是構築和諧人際的關鍵。緊急時更不可忘,否則下次委託易遭推託(情緒影響行為)。
  • 打招呼是基本且值回票價的舉動,在公司內主動朝氣十足地打招呼,日積月累讓人際更和諧。

溝通3 明白上司真正的想法再執行

  • 收到指示後,不要只言聽計從,需發揮想像力思考「上司的目的為何」,正確解讀深意。

時間管理4 按照緩急程度列代辦清單

  • 先分成四大類,再製待辦清單。若太瑣碎,可按時間順序分「現在立刻」、「稍後再做」、「長期來看」三大類。
  • 可設多頁管理,或在單一清單用記號區隔(省換頁,一目了然,易移動分類)。

心得

先來定義一下,這邊提出的「折疊者」角色,有一點類似「專案/ 產品推動者」。需要完成產品與專案開發與推動中的所有相關的事項,並且能完成「展開者」(也就是專案發起與主要發起者)的工作。

經過不少年的工作經驗,你會發現許多工程師其實都是相當具有想像力的(我自己也是),但是往往能把一些日常瑣事完成的卻相當的少。這樣也就造成了,許多有創意的點子或是產品被扼殺在一開始、許多好的想法在許多公司內的規範下被拒絕掉了。

但是偏偏我又是一個相當鐵頭的人,只要大家告訴我不行的事情,如果我認回很重要,我就會一路往上跑道儘量能申訴的地方為止。也就是除非是公司高層告訴我不能做,或是給我一個理由不可以去執行。不然我不會去聽信一個平常操作端的人因為想要便宜行事的回覆。

「折疊者」這一本書也就是圍繞著這樣角色的一本書籍,也希望每一個想要「將事情做好而非將事情做完」的人,能夠好好的閱讀。