How to build MCP server

前言

前陣子剛好處於職涯的轉換期,從 Software Engineer 轉變為顧問 Consultant 的角色。在適應新身份和不同工作節奏的過程中,部落格也跟著停更了一段時間。現在好不容易閒下來,是時候把這幾個月「搗鼓」的東西整理出來跟大家分享了~

還記得上一篇 教學:安裝 MCP 至 Claude Desktop,善用 AI 加速工作生產力 提到如何把現成的 MCP server 裝進 Claude 嗎?

今天我們不只要「用」,更要進一步「造」——自己動手建構一個本地的 MCP server,打造專屬於你的 AI 小工具。

為什麼想做這個?

這個靈感其實來自於我很日常的一個 idea。

每天早上醒來或通勤時,習慣做的第一件事大概就是滑手機看新聞。

但我通常只關注科技、娛樂與股市這類特定主題,現在的演算法雖然強大,但內容品質都不一定穩定,或者我需要瀏覽多個來源了解同個主題但不同解讀面向的新聞。

所以我產生了一個想法:能不能透過 API 把新聞抓下來,讓 LLM 幫我統整?

所以目標很明確:利用自定義的 MCP server 串接新聞 API,讓 Claude 能夠根據我的指令,直接調用、過濾並總結我真正想看的內容。

因此我抱持「想偷懶所以變勤勞」的想法,開始了這次的實作

技術架構

整個系統分為三層:

  1. Claude Desktop - 使用者介面,透過自然語言下指令
  2. News MCP Server - 我們要自己建的部分,使用 FastMCP 框架
  3. newsdata.io API - 外部新聞資料來源,支援 200+ 國家、40+ 語言
%%{init: {'theme':'dark', 'themeVariables': { 'darkMode': true, 'mainBkg': '#000000', 'clusterBkg': '#1A1A1A', 'primaryTextColor': '#E0E0E0', 'fontFamily': 'sans-serif' }}}%%
graph TB
    User[使用者] --> Claude[Claude Desktop
] Claude <-->|JSON-RPC| MCP[News MCP Server] MCP <-->|REST API| API[newsdata.io] subgraph "MCP Tools" MCP --> Tool1[get_latest_news] MCP --> Tool2[get_tech_news] MCP --> Tool3[get_taiwan_news] end API --> DB[newsdata.io news] style User fill:#475569,stroke:#94a3b8,stroke-width:2px,color:#E0E0E0 style Claude fill:#254a7c,stroke:#60a5fa,stroke-width:3px,color:#E0E0E0 style MCP fill:#8b4011,stroke:#fcd34d,stroke-width:3px,color:#E0E0E0 style API fill:#5820a4,stroke:#c4b5fd,stroke-width:3px,color:#E0E0E0 style DB fill:#0a5e44,stroke:#34d399,stroke-width:3px,color:#E0E0E0 style Tool1 fill:#546274,stroke:#9ca3af,stroke-width:1px,color:#F0F0F0 style Tool2 fill:#546274,stroke:#9ca3af,stroke-width:1px,color:#F0F0F0 style Tool3 fill:#546274,stroke:#9ca3af,stroke-width:1px,color:#F0F0F0

環境準備

首先安裝Python 套件管理工具 uv

1
2
3
4
5
6
7
8
9
# 安裝 uv
curl -LsSf https://astral.sh/uv/install.sh | sh

# 建立專案
mkdir news-mcp && cd news-mcp
uv init

# 安裝相依套件
uv add "mcp[cli]>=1.9.4" "requests>=2.32.4"

接著到 newsdata.io 註冊免費帳號,取得 API Key(免費版每日 200 次請求)。

核心程式碼

建立 src/mcp_news.py,核心概念就是用 @mcp.tool() decorator 把函數包裝成 MCP 工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from typing import Any, Optional
from mcp.server.fastmcp import FastMCP
import requests
import os

# 初始化 MCP server
mcp = FastMCP("news", dependencies=["requests"])

# API 設定
NEWSDATA_API_KEY = os.getenv("NEWSDATA_API_KEY")
BASE_URL = "https://newsdata.io/api/1"

@mcp.tool()
def get_latest_news(
country: Optional[str] = None,
category: Optional[str] = None,
language: str = "en",
query: Optional[str] = None,
max_results: int = 10
) -> dict[str, Any]:
"""
Get latest news articles

Args:
country: Country code (e.g., 'us', 'tw', 'jp')
category: News category (e.g., 'technology', 'business')
language: Language code (default: 'en')
query: Search keywords
max_results: Maximum results (default: 10)
"""
url = f"{BASE_URL}/news"
params = {
"apikey": NEWSDATA_API_KEY,
"language": language,
}

if country:
params["country"] = country
if category:
params["category"] = category
if query:
params["q"] = query

response = requests.get(url, params=params)
data = response.json()

articles = data.get("results", [])[:max_results]

return {
"total_results": len(articles),
"articles": [
{
"title": article.get("title"),
"description": article.get("description"),
"link": article.get("link"),
"source": article.get("source_id"),
"published_at": article.get("pubDate"),
}
for article in articles
]
}

@mcp.tool()
def get_tech_news(language: str = "en", max_results: int = 10) -> dict[str, Any]:
"""Get latest technology news"""
return get_latest_news(category="technology", language=language, max_results=max_results)

@mcp.tool()
def get_taiwan_news(category: Optional[str] = None, max_results: int = 10) -> dict[str, Any]:
"""Get latest news from Taiwan in Chinese"""
return get_latest_news(country="tw", category=category, language="zh", max_results=max_results)

if __name__ == "__main__":
mcp.run()

LLM 如何知道要用哪個 Function?

這裡的關鍵在於 FastMCP 會自動把 Python 函數轉換成 JSON Schema,讓 LLM 能理解。

當你用 @mcp.tool() decorator 標記一個函數時,FastMCP 會自動:

  1. 讀取函數簽名:從參數的型別標註(type hints)提取每個參數的資料型別
  2. 解析 docstring:從函數說明文件中提取工具的描述和參數說明
  3. 生成 JSON Schema:將這些資訊序列化成標準格式

例如我們的 get_latest_news 函數會被轉換成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "get_latest_news",
"description": "Get latest news articles",
"inputSchema": {
"type": "object",
"properties": {
"country": {
"type": "string",
"description": "Country code (e.g., 'us', 'tw', 'jp')"
},
"category": {
"type": "string",
"description": "News category (e.g., 'technology', 'business')"
},
"language": {
"type": "string",
"description": "Language code (default: 'en')",
"default": "en"
}
}
}
}

當你向 Claude 下達指令時,背後的流程是這樣的:

  1. Claude 接收到你的自然語言指令:「Get me the latest technology news」
  2. 分析可用的工具列表:Claude 看到有 get_latest_newsget_tech_newsget_taiwan_news 等工具
  3. 判斷最適合的工具:根據描述和參數,判斷 get_tech_news 最符合需求
  4. 產生工具呼叫請求:Claude 產生類似這樣的 JSON:
    1
    2
    3
    4
    5
    6
    7
    {
    "tool": "get_tech_news",
    "parameters": {
    "language": "en",
    "max_results": 10
    }
    }
  5. MCP Server 執行函數:接收到請求後執行對應的 Python 函數
  6. 回傳結構化結果:函數執行完回傳 JSON 格式的新聞資料
  7. Claude 整理成自然語言:將結構化資料轉換成易讀的摘要給使用者

docstring 和參數說明越清楚,LLM 就越能準確判斷什麼時候該用哪個工具

這也是為什麼我們定義了 get_tech_news 這樣的函數——它的名稱和描述更明確,讓 LLM 能更快速地匹配使用者的意圖。

測試運行

設定環境變數後,可以直接測試:

1
2
export NEWSDATA_API_KEY="your_api_key_here"
mcp dev src/mcp_news.py

終端機會顯示一個 URL,點擊後會開啟 MCP Inspector,可以在瀏覽器中直接測試各個工具。

整合到 Claude Desktop

編輯 Claude 設定檔(macOS: ~/Library/Application Support/Claude/claude_desktop_config.json):

1
2
3
4
5
6
7
8
9
10
11
{
"mcpServers": {
"news": {
"command": "uv",
"args": ["run", "/path/to/news-mcp/src/mcp_news.py"],
"env": {
"NEWSDATA_API_KEY": "your_api_key_here"
}
}
}
}

重啟 Claude Desktop,在 Settings -> Developer -> Local MCP servers 就可以看到我的 MCP server 顯示 running 的狀態

檢查 Claude 連接到 local MCP server

實際使用

現在可以直接用自然語言跟 Claude 對話:

  • “Get me the latest technology news”
  • “Show me Taiwan technology news in Chinese”
  • “What’s happening with cryptocurrency?”

Claude 會自動判斷要呼叫哪個工具,幫你整理成易讀的摘要。

重要概念

MCP 的運作原理其實很簡單:FastMCP 會把你的函數轉換成 JSON Schema,讓 AI 知道有哪些工具可用、需要什麼參數。

當使用者下指令時,AI 判斷要用哪個工具,MCP server 執行後回傳結果,AI 再把結果轉成人類易懂的回應。整個過程都是自動的,你只需要專注在寫好工具函數本身。

實際運行範例

讓我們來看一個真實的使用案例。我在 Claude 中直接使用這個 News MCP Server,用自然語言向 AI 下指令:

指令:「Summarize me Gemini3 from latest news」

MCP 實際運行結果

可以看到整個流程運作:

  1. AI 理解我的意圖:我想知道 Gemini 3 的最新新聞摘要
  2. 自動選擇正確的工具:AI 判斷應該使用 search_news 函數
  3. 傳入適當參數
    1
    2
    3
    4
    {
    "keywords": "Gemini 3",
    "max_results": 10
    }
  4. 取得即時新聞:MCP Server 向 newsdata.io API 發送請求
  5. AI 整理成摘要:將結構化的 response (從 API 拿到的新聞資料)總結成易讀的內容

回傳的摘要包含

  • Google 最新發布 Gemini 3 的重點功能
  • 在 AI 領導力排行榜上的表現
  • 多模態推理能力的提升
  • “Nano Banana Pro” 圖片生成功能
  • 價格策略調整
  • 市場影響分析

透過 FastMCP 框架,就可以快速建立了一個實用的新聞 MCP server。

這個架構可以延伸到任何有 API 的服務:天氣、股價、待辦事項、內部系統等等。

下次當各位覺得「如果 AI 能幫我做這個就好了」,不妨花個半小時,自己動手打造一個專屬的 MCP 工具吧!!!

教學:安裝 MCP 至 Claude Desktop,善用 AI 加速工作生產力

隨著 OpenAI ChatGPT、Anthropic Claude 等 AI 服務的興起,運用這些工具來加速工作效率已經成為日常。

還記得 ChatGPT 剛問世時,我多半拿它來做翻譯、寫郵件草稿這種基礎應用。

但自從 Anthropic 在去年推出 MCP(Model Context Protocol) 後,AI 助理的能力又往前邁進了一大步——彷彿多了個小幫手,幫你處理繁瑣的日常工作瑣事,讓你能專注在更重要的任務上。

什麼是 MCP?

MCP(Model Context Protocol) 是 Anthropic 在 2024 年 11 月發表的開放標準協議,目的是讓 AI 助理安全地連接並使用各種第三方資料與工具

直接跟大家劇透他的功能 :
只要用戶授權,Claude 就能連結 Gmail、Google Drive、Atlassian、Asana 等常見工作工具,將 AI 從單純的對話夥伴,升級為真正能操作你日常工作平台的幫手。

這也解決了大語言模型長期面臨的資訊孤島問題。過去我們只能透過複製貼上的方式讓 AI 知道外部資訊,現在 Claude 可以直接「看到」並操作這些工具。

這些第三方服務會以 MCP Server 的形式提供功能(例如列出郵件、讀取專案資料等),用戶只要在 Claude 連接對應的 MCP Server,就能直接下指令使用這些功能,一氣呵成完成各種操作。

小提醒:根據 Anthropic 官方說明,部分第三方 MCP 服務需要特定付費方案才能使用(我自己是用 Pro 方案)。

MCP 的連接方式也很靈活,除了直接連接外部服務,也可以自己在本地搭建 MCP Server,客製化專屬功能。

不過今天這篇文章先聚焦如何在 Claude 上快速使用 MCP,至於如何用 Python 搭建本地 MCP Server,會變成下一篇文章跟大家說明(要填坑啦)

實戰示範:連接 Gmail 到 Claude Desktop

Step 1. 開啟 Claude Desktop → 前往 Settings → 點擊 Connectors

Claude 設定頁面

Step 2. 選擇要連接的第三方工具 → 點擊 Connect

接著會跳轉到 Google 的授權頁面,依照提示登入你的 Google 帳號並授權。完成後回到 Claude 的設定頁面,若顯示 Connected 或類似的連接成功狀態,就代表設定完成!

Claude 連接成功

Step 3. 測試 Gmail MCP 功能

重新啟動 Claude Desktop 並開啟新的對話視窗,記得確認 Gmail 連接已啟用(通常會在對話框下方看到相關圖示)。

接著就可以開始操作了

例如:我請 Claude 統計 過去一個月收到多少封來自 The Washington Post 的郵件,Claude 會自動透過 MCP 工具讀取 Gmail,並給出結果(數量讓我驚呆了 😅)。

結語

看吧!設定 MCP 其實很簡單~
而且 MCP 不只可以做資料讀取,還能處理更進階的寫入操作(例如建立 GitHub issue)。

不過還是要提醒大家使用 AI 工具要注意的事項:

  1. 只授權必要的權限給 AI,別把隱私或敏感資料也一併丟進去
  2. 無論 AI 幫你完成什麼操作,都務必檢查並審核結果,確保執行的動作正確哦

參考資料:

  1. Introducing Model Context Protocol
  2. Using the Gmail and Google Calendar Integrations

PostgreSQL Row Level Security 實踐多租戶系統

最近剛好在研究多租戶架構,發現到多租戶架構的實踐方式其實相當多種,今天要來分享如何使用 PostgresSQL Row Level Security 實踐多租戶吧!

什麼是多租戶?

多租戶(Multi-tenancy)是一種軟體架構設計,目的是在同一套系統上服務多個獨立使用者(租戶),既能共用系統資源,又能確保資料隔離。
舉例來說,Slack 本身就是多租戶架構,每個公司(例如 IBM、Grab)都是 Slack 的一個租戶。

多租戶資料儲存方式

以資料庫設計來看,常見的幾種多租戶實踐方式:

獨立模式(Silo)

  • 每個租戶一套獨立資料庫
  • 優點:隔離效果最佳
  • 缺點:成本高、維護麻煩,每新增租戶都要開新資料庫

橋接模式(Bridge)

  • 多個租戶共用資料庫,但用不同 schema 分隔
  • 優點:節省部分資源
  • 缺點:維運與設定相對複雜

共享模式(Pool)

  • 所有租戶共用同一資料庫與命名空間
  • 每個 Table 透過分隔鍵(通常是租戶 ID)來區分
  • 優點:成本低、維護簡單、擴展方便
  • 缺點:隔離性相對低,風險需要額外控管

以下是多租戶模式的比較圖,從左到右維運成本會慢慢降低

這篇文章要介紹的 RLS,就是針對「共享模式」的具體實例

多租戶模式比較圖

PostgreSQL Row Level Security(RLS)是什麼?

RLS 是從 Postgres 9.5 後開始支援的,簡單來說,他可以在 DB 曾作出 「誰能看到/修改哪些列」的限制,在預設情況下的 DB Table,如果沒有特別設定策略,就不會特別限制查詢資料列。

以下透過情境來舉例,這樣可以更明白 RLS 如何做同租戶內隔離:

假設我們在做一個連鎖企業管理系統,總店與多家分店共用一套平台,所有分店資料放同一 Table,並透過 branch_id 區分:

  • 使用者
    • 總部管理人員 (HQ):能看到所有分店資料
    • 分店店長(租戶):只能看到自己的分店資料

沒有 RLS 的情況

分店 A 查自己的營收,需要每次手動加條件:

1
2
3
SELECT * 
FROM sales
WHERE branch_id = 'branch_A';

總店查所有分店資料:

1
2
SELECT * 
FROM sales;

這樣開發會有一些風險:

  1. SQL 漏寫 -> 忘記加上 WHERE 條件,就會讓分店看到其他分店的資料
  2. 維護複雜 -> 要特別寫管理者專用 SQL 才能看到多分店資料

這時候,RLS 的優勢就可以用在這邊了!

RLS 可以把它想像成:

「資料庫自動幫你加上 WHERE tenant_id = …,而且無法繞過」

我們可以透過以下指令啟用 RLS:

1
2
3
4
ALTER TABLE sales ENABLE ROW LEVEL SECURITY;

CREATE POLICY branch_isolation_policy ON sales
USING (branch_id = current_setting('app.current_branch')::UUID);

登入時,設定 Session 參數:

1
SET app.current_branch = 'branch_A';

此後,分店 A 查詢:

1
SELECT * FROM sales; ---- 自動過濾,只會回傳分店 A 的資料

所以可以知道 RLS 的好處在於

  • 資料隔離:各分店數據完全隔離,避免誤查或惡意存取
  • 開發簡化:不再到處手動加 WHERE branch_id = ?,降低遺漏風險
  • 可擴展:新增 1000 家分店也不用修改資料庫 TABLE 架構或程式邏輯

Django 實踐 RLS

接下來說明如何在 Django 框架中實作 RLS 多租戶隔離系統。

原始碼在這ㄦ

這邊的程式碼主要用來展示 RLS 功能,其他像是使用者認證、權限管理等安全細節就不深入探討了,畢竟 Django 本身的功能很豐富,寫起來會太複雜 :p

對 Django 不熟的朋友建議先了解基本的 Django 觀念

檔案架構

1
2
3
4
5
6
7
8
9
10
11
12
13
14
django-rls-multitenant/
├── .env # 環境變數設定
├── config/
│ └── docker-compose.yaml # 容器編排設定
├── scripts/
│ ├── init.sql # 資料庫初始化腳本
│ └── test_rls.sql # 測試資料產生腳本 (AI 產出的,快速又方便)
├── tenants/
│ ├── models.py # Branch 和 Sales 資料模型
│ ├── middleware.py # 分店上下文中介軟體
│ ├── views.py # API 視圖邏輯
│ └── migrations/ # Django 資料庫遷移檔案
└── rls_project/
└── settings.py # Django 專案設定

資料庫角色結構

1
2
3
app_user (Django 應用連線用戶) 
↓ 繼承
app_role (應用角色)
  • Table 擁有者: postgres (管理員) - 防止 RLS 繞過機制
  • 應用連接: app_user - 受 RLS 策略完全限制
  • 強制 RLS: 確保所有用戶都受策略約束

在實作 PoC 的時候 Docker 真的很方便,之後來寫一篇關於 Docker 學習的心路歷程好了(BUT 我還在初學者階段 XDD)

  1. 進入目錄,啟動容器服務

    1
    2
    cd django-rls-multitenant/config
    docker-compose up -d
  2. 初始化 DB

    1
    cat ../scripts/init.sql | docker-compose exec -T postgres psql -U postgres -d rls_db

    init.sql 的作用:

    • 建立 app_roleapp_user (Django 連 DB 用的帳號)
    • 建立 get_current_branch_id() 函數 (RLS 策略會用到)
    • 設定基本權限
  3. 進入 Django 容器

    1
    docker-compose exec web bash
  4. 執行 Django 資料庫 migration

    注意:使用 postgres 身份建立表格

    1
    DB_USER=postgres DB_PASSWORD=postgres python manage.py migrate
  5. 創建 RLS 策略 migration

    1
    2
    3
    4
    5
    # 檢查是否已有 RLS migration
    python manage.py showmigrations tenants

    # 如果沒有 enable_rls migration,需要創建空的 migration 檔案,並貼上 RLS 策略
    python manage.py makemigrations --empty tenants --name enable_rls

    這部分我把 RLS 策略寫在 Django migration 裡,好處是可以跟著專案版本一起管理,這樣開發、測試、正式環境都能用同一套指令部署,而且如果策略有問題還能回滾。

    另外可以確保 Table 先建好,再套用 RLS 策略,避免順序錯亂的問題。

    建立好空的 migration 檔案後,編輯 tenants/migrations/0002_enable_rls.py,貼上以下 RLS 策略:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    from django.db import migrations


    class Migration(migrations.Migration):

    dependencies = [
    ('tenants', '0001_initial'),
    ]

    operations = [
    migrations.RunSQL(
    sql="""
    -- Transfer table ownership to postgres (admin)
    ALTER TABLE tenants_branch OWNER TO postgres;
    ALTER TABLE tenants_sales OWNER TO postgres;

    -- Enable RLS for Branch table
    ALTER TABLE tenants_branch ENABLE ROW LEVEL SECURITY;
    ALTER TABLE tenants_branch FORCE ROW LEVEL SECURITY;

    -- Simple branch access policy
    CREATE POLICY branch_access_policy ON tenants_branch
    FOR ALL
    TO app_role
    USING (id = get_current_branch_id());

    -- Enable RLS for Sales table
    ALTER TABLE tenants_sales ENABLE ROW LEVEL SECURITY;
    ALTER TABLE tenants_sales FORCE ROW LEVEL SECURITY;

    -- Simple sales isolation policy
    CREATE POLICY sales_branch_isolation ON tenants_sales
    FOR ALL
    TO app_role
    USING (branch_id = get_current_branch_id());

    -- Grant permissions to app_role (not ownership)
    REVOKE ALL ON tenants_branch FROM PUBLIC;
    REVOKE ALL ON tenants_sales FROM PUBLIC;

    GRANT SELECT, INSERT, UPDATE, DELETE ON tenants_branch TO app_role;
    GRANT SELECT, INSERT, UPDATE, DELETE ON tenants_sales TO app_role;
    GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_role;
    """,
    reverse_sql="""
    -- Remove RLS policies
    DROP POLICY IF EXISTS branch_access_policy ON tenants_branch;
    DROP POLICY IF EXISTS sales_branch_isolation ON tenants_sales;

    -- Disable RLS
    ALTER TABLE tenants_branch DISABLE ROW LEVEL SECURITY;
    ALTER TABLE tenants_sales DISABLE ROW LEVEL SECURITY;

    -- Restore ownership and permissions
    ALTER TABLE tenants_branch OWNER TO app_user;
    ALTER TABLE tenants_sales OWNER TO app_user;
    GRANT ALL ON tenants_branch TO PUBLIC;
    GRANT ALL ON tenants_sales TO PUBLIC;
    """
    )
    ]

  6. 執行 RLS migration 來應用策略

    1
    DB_USER=postgres DB_PASSWORD=postgres python manage.py migrate
  7. 產生測試資料

    為了時間方便,請 AI 幫我產生測試資料,直接執行起來方便許多~

    1
    cat ../scripts/test_rls.sql | docker-compose exec -T postgres psql -U postgres -d rls_db

    執行後應產生:

    • 3 個分店資料 (西門、東區、板橋)
    • 每個分店 20 筆銷售記錄
    • 總計 60 筆銷售記錄

確認 RLS 功能生效

接下來,測試看看租戶之間能不能達到隔離效果

首先,假設你是系統管理員 postgres ,你可以取得所有租戶的資料:

1
docker-compose exec postgres psql -U postgres -d rls_db -c "SELECT id, name, code FROM tenants_branch ORDER BY code;"

結果:

1
2
3
4
5
                  id                  |   name   | code  
--------------------------------------|----------|-------
01e9ff22-d020-42f7-9045-6c2b74df1ccb | 西門分店 | BR001
b4b465c4-8485-4225-af37-9f5d6432e1ef | 東區分店 | BR002
cb31e356-84f8-4f81-a58c-9c86caf08a8a | 板橋分店 | BR003

接著拿到不同分店的 ID,就可以測試 Django API 了!
我這邊用 curl 來測試:

Django 執行時使用的是 app_user,受到 RLS 策略限制,所以不會看到其他租戶的資料

1
2
3
# 檢查分店上下文
curl -s -H "X-Branch-ID: 01e9ff22-d020-42f7-9045-6c2b74df1ccb" \
http://localhost:8000/api/context-status/ | jq

API 結果

1
2
3
4
5
6
7
8
9
10
11
12
{
"context": {
"current_user": "app_user",
"current_branch_id": "01e9ff22-d020-42f7-9045-6c2b74df1ccb",
"user_type": "Branch User"
},
"visibility": {
"branches": 1,
"sales": 20
},
"request_branch_id": "01e9ff22-d020-42f7-9045-6c2b74df1ccb"
}
1
2
3
# 檢查銷售資料
curl -s -H "X-Branch-ID: 01e9ff22-d020-42f7-9045-6c2b74df1ccb" \
http://localhost:8000/api/sales/ | jq

API 結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"sales": [
{
"id": "fd281cb8-665f-4d42-831e-c080d858a991",
"branch_name": "西門分店",
"date": "2025-07-25",
"amount": "9186.00",
"transaction_count": 16,
"product_category": "飲料"
},
{
"id": "c9fb589c-e2b7-4031-b647-3c21f5a36d04",
"branch_name": "西門分店",
"date": "2025-07-24",
"amount": "8980.00",
"transaction_count": 17,
"product_category": "配菜"
},
... skip ...
],
"count": 20,
"total_amount": "240058.00",
"current_branch_id": "01e9ff22-d020-42f7-9045-6c2b74df1ccb"
}

預期回應

  • visibility.branches: 1 (僅可見自己的分店)
  • visibility.sales: 20 (僅可見自己的銷售記錄)
  • 所有銷售記錄的 branch_name 皆為 “西門分店”

測試不同分店的隔離效果

1
2
3
# 使用東區分店的 ID 測試
curl -s -H "X-Branch-ID: b4b465c4-8485-4225-af37-9f5d6432e1ef" \
http://localhost:8000/api/sales/ | jq

結果會發現

  • 一樣只能看到 1 個分店、20 筆記錄
  • 所有記錄都是 “東區分店” 的資料
  • 總銷售額跟西門分店完全不一樣

試試看如果隨便輸入 id

1
2
3
4
5
6
# 用假的分店 ID 測試
curl -s -H "X-Branch-ID: 11111111-1111-1111-1111-111111111111" \
http://localhost:8000/api/sales/

# 不給分店 ID 測試
curl -s http://localhost:8000/api/sales/

系統會正確拒絕:

  • 假 ID:{"error": "Invalid branch"}
  • 沒給 ID:{"error": "Branch ID required"}

以上就是使用 PostgreSQL RLS 實踐多租戶系統的完整流程 (寫完發現挺累的 XD)

我個人認為,RLS 雖然不是多租戶架構的萬能解法,但在「共享模式」的多租戶設計中,確實一個不錯的使用工具。

對於想要快速構建一個多租戶的平台(SaaS, 多分店管理系統),或許是不錯的選擇!

參考資料

[1] https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/

[2] https://www.postgresql.org/docs/current/ddl-rowsecurity.html

[3] https://docs.djangoproject.com/en/stable/topics/db/multi-db/

My First Post

嗨!歡迎來到我的部落格

從 2023 年研究所畢業到開始工作,這段時間接觸了很多不同領域的東西

因為我本身是雜食性動物,對什麼都很好奇,看到什麼都想學一點,但久了就發現東西學了一堆,卻沒有一個地方好好記錄,總覺得哪裡怪怪 der

所以決定從現在開始,把這些東學一點、西學一點的內容整理起來,分享一些筆記(偶爾可能會出現雜記)

希望能對自己有幫助,也讓剛好路過的人看看有沒有能一起交流的地方

關於我

A secret makes a woman woman 🤫

未來會分享什麼?

在這個部落格中,我計劃分享:

  • 技術筆記:程式設計相關的學習心得和實作經驗
  • 生活感悟:日常生活中的一些思考和體會
  • 讀書心得:好書推薦和閱讀筆記
  • 專案分享:個人專案的開發過程和心得

感謝您花時間閱讀我的第一篇文章,希望未來的內容也能夠帶給您一些價值!