Supabase Realtime Authorization 授權與私有聊天室實戰 | Supabase 完整教學
前面三篇我們把 Realtime 三大能力練齊了:Postgres Changes 管「有沒有寫進資料庫」、Broadcast 管「當下的高頻互動」、Presence 管「誰在線上」。但這三者要放進正式產品,還缺最關鍵的一環——授權(Authorization)。這一篇要解決:如何用
config: { private: true }建立私有 channel、用realtime.messages表的 RLS policy 授權 Broadcast 與 Presence、用supabase.realtime.setAuth()帶入 JWT,做到「只有房間成員進得來、收得到、看得到」。最後我們把三大功能整合成一個安全、可上線的即時聊天室,為 SB-3 核心服務畫下句點。
前言
一句話定義本篇主題:Realtime Authorization 是 Supabase 為 Broadcast 與 Presence 提供的授權機制——透過在系統表 realtime.messages 上撰寫 RLS policy,決定「哪些使用者能加入哪個私有 channel、能不能收發 Broadcast、能不能追蹤與讀取 Presence」;私有 channel 用 config: { private: true } 宣告,並以 realtime.topic() 與 realtime.messages.extension 兩個輔助函式在 policy 裡做判斷。
先接住前面的脈絡。到目前為止,我們在《Postgres Changes》《Broadcast 廣播》《Presence 線上狀態》三篇裡做出來的範例,多半是**「公開」channel**——任何人只要知道 channel 名稱就能加入、收發訊息、看到誰在線上。在教學階段這很方便,但在真實產品裡是災難:任何人都能偷聽你的私有聊天室、看到不該看到的 Presence 名單、對別人的房間亂送 Broadcast。
這裡有一個最容易踩的致命誤解,務必先講清楚:
Broadcast 與 Presence「不會」自動套用你業務資料表(例如
messages表)的 RLS。
很多人以為「我 messages 表有設 RLS 了,那 Broadcast/Presence 也安全了吧?」——錯。Broadcast 和 Presence 的訊息根本不經過你的 messages 表,它們的授權是由一張特別的系統表 realtime.messages 上的 RLS policy 控管,這就是 Realtime Authorization。你不在 realtime.messages 上寫 policy,私有 channel 就沒有真正的授權邏輯。
三種功能的授權來源,用一張表釐清(這是本篇的骨架):
| 功能 | 授權來自哪裡 | 關鍵動作 |
|---|---|---|
| Postgres Changes | 你訂閱的那張資料表的 RLS(如 messages 表) | 表上建 RLS policy,訂閱者只收到通過 RLS 的事件 |
| Broadcast | realtime.messages 表的 RLS(extension = 'broadcast') | private channel + realtime.messages 的 SELECT/INSERT policy |
| Presence | realtime.messages 表的 RLS(extension = 'presence') | private channel + realtime.messages 的 SELECT/INSERT policy |
用一個生活類比:把整個聊天室想成一棟有門禁的辦公大樓。
config: { private: true }是門口那句「這裡需要刷卡」——它只是宣告要檢查,本身不決定誰能進。realtime.messages的 RLS policy 是門禁系統的判斷規則——「這張卡(JWT)代表的人,是不是這間會議室(topic)的成員?」規則你不寫,門就形同虛設(或乾脆誰都進不去)。supabase.realtime.setAuth()是把識別卡交給門禁系統——確保刷卡機讀得到你是誰。
本篇你會學到:
- 私有 channel:
config: { private: true }的意義與限制,以及為何它一定要搭配 RLS realtime.messages的 RLS policy:用realtime.topic()與realtime.messages.extension授權 Broadcast/PresencesetAuth與 token 生命週期:什麼時候要手動呼叫、policy 快取與 token 過期的坑- 即時聊天室實戰:整合 Postgres Changes(存訊息)+ Broadcast(typing)+ Presence(線上)做一個安全可上線的房間
核心概念
要把 Realtime Authorization 用對,先建立三張心智圖:一張是「私有 channel 的授權流程」,一張是「realtime.messages policy 的兩個關鍵輔助函式」,一張是「授權何時評估、token 何時失效」。
一、私有 channel 的授權流程
當客戶端嘗試加入一個私有 channel 時,Supabase Realtime 會走一段授權檢查。流程大致如下:
[客戶端帶著 JWT 嘗試加入 private channel]
│ config: { private: true },topic 例如 room:room-123
▼
1. 客戶端建立 WebSocket 連線,發送 phx_join(帶 private: true)
│
▼
2. Realtime 讀取這次連線的 JWT(access token)
→ 決定 auth.uid()、JWT claims 等身分資訊
│
▼
3. Realtime 對 realtime.messages 表執行 RLS 檢查:
→ 用 realtime.topic() 取得對方想加入的 topic('room:room-123')
→ 用 realtime.messages.extension 區分 broadcast / presence
→ policy 判斷:這個 auth.uid() 是不是 room-123 的成員?
│
├── 通過 → 成功加入 channel(SUBSCRIBED)
└── 不通過 → CHANNEL_ERROR,加不進來
│
▼
4. 加入後,policy 結果被「快取」;
只有收到新的 access_token(token 刷新)時才重新評估
三個要點:
config: { private: true }是開關,不是規則。 它告訴 Realtime「這個 channel 要走授權檢查」,但具體誰能進、能不能收發,全看realtime.messages的 RLS policy。沒有 policy,私有 channel 通常會直接拒絕(CHANNEL_ERROR)。- 授權在「加入 channel」時檢查一次。 不是每則訊息都查,而是在
phx_join那一刻對realtime.messages做 RLS 判斷,決定你能不能進、能不能收(SELECT)、能不能送(INSERT)。 realtime.messages是系統表,預設已啟用 RLS。realtimeschema 本身是鎖定的,但 Supabase 允許你在realtime.messages上建立自己的 policy——這正是你撰寫授權邏輯的地方。
二、policy 裡的兩個關鍵輔助函式
在 realtime.messages 上寫 policy 時,你需要兩樣東西來判斷「這個使用者能不能對這個 topic 做這件事」:
| 輔助函式 / 欄位 | 回傳 | 用途 |
|---|---|---|
realtime.topic() | 客戶端嘗試加入的 channel topic 名稱(字串,例如 'room:room-123') | 判斷「這是哪個房間」,通常拿去比對成員表 |
realtime.messages.extension | 訊息類型:'broadcast' 或 'presence' | 判斷「這是 Broadcast 還是 Presence 的授權」,可分別給不同權限 |
有了這兩個,典型的授權邏輯就是:「用 realtime.topic() 解析出房間 ID,去 room_members 表確認 auth.uid() 是不是這個房間的成員,並用 extension 決定放行 broadcast 還是 presence」。
再搭配 SELECT/INSERT 兩種 policy 的分工(這點很關鍵):
| policy 類型 | 對應能力 |
|---|---|
FOR SELECT | 收:能不能加入 channel、接收 Broadcast、讀取 Presence 名單 |
FOR INSERT | 送:能不能發送 Broadcast、能不能 track() 發布自己的 Presence |
也就是說,如果你只給 SELECT policy,使用者能「進來聽」但不能「發言」;要能發 Broadcast 或 track Presence,就得再給 INSERT policy。這讓你能做出「唯讀觀眾 vs 可發言成員」這種細緻的權限分級。
三、授權何時評估、token 何時失效
最後一張心智圖是時間軸,這是很多人踩坑的地方:
- 加入時評估一次,之後快取。 Realtime 在
phx_join時對realtime.messages做一次 RLS 檢查,通過後把結果快取住。連線期間不會每則訊息都重查。 - 只有 token 刷新才重新評估。 當客戶端送出新的
access_token(JWT 刷新)時,Realtime 才重新跑一次授權。這代表「你在資料庫把某人踢出房間」不會即時反映到他當前的連線,要等他 token 刷新或重連。 - token 會過期。 JWT 有有效期,過期後若沒刷新,連線會斷。正式產品一定要實作 token refresh,並確保刷新後的新 token 有進到 Realtime(登入流程下 supabase-js 多半自動處理;自訂情境用
supabase.realtime.setAuth(newToken))。
一句話記住:授權是「加入時查、token 刷新時重查」,所以要顧好 token 的生命週期。
實作範例
下面用可直接執行的 SQL 與 TypeScript,從「建立私有 channel 的授權」一路做到「整合三大功能的聊天室骨架」。假設 supabase 客戶端已初始化、使用者已透過 Supabase Auth 登入。以下 API 名稱與行為細節請以官方文件為準。
一、準備:成員表與訊息表
先建立房間成員表(決定誰能進哪個房間)與訊息表(Postgres Changes 用來存訊息)。
-- 房間成員表:授權判斷的依據
CREATE TABLE public.room_members (
room_id TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES auth.users(id),
role TEXT NOT NULL DEFAULT 'member', -- 'member' 可發言、'viewer' 唯讀
PRIMARY KEY (room_id, user_id)
);
-- 訊息表:聊天訊息本體(走 Postgres Changes 持久化)
CREATE TABLE public.messages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES auth.users(id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 授權查詢會頻繁用到,建索引避免成為瓶頸
CREATE INDEX idx_room_members_user ON public.room_members(user_id, room_id);
CREATE INDEX idx_messages_room ON public.messages(room_id, created_at);
二、messages 表的 RLS(Postgres Changes 的授權)
聊天訊息走 Postgres Changes,它的授權不是用 realtime.messages,而是直接套用 messages 表自己的 RLS。所以我們給 messages 表建 policy:只有房間成員能讀、只能以自己的身分寫入自己所屬房間的訊息。
-- 啟用 RLS
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
-- 讀取:只有該房間的成員能 SELECT 訊息(Postgres Changes 才推得到他)
CREATE POLICY "房間成員可讀取訊息"
ON public.messages
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = messages.room_id
AND m.user_id = (SELECT auth.uid())
)
);
-- 寫入:只能用自己的 user_id、寫進自己所屬的房間
CREATE POLICY "房間成員可發送訊息"
ON public.messages
FOR INSERT
TO authenticated
WITH CHECK (
user_id = (SELECT auth.uid())
AND EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = messages.room_id
AND m.user_id = (SELECT auth.uid())
AND m.role = 'member' -- viewer 不能發言
)
);
-- 讓 messages 表加入 Realtime publication(Postgres Changes 才會監聽)
ALTER PUBLICATION supabase_realtime ADD TABLE public.messages;
重點:Postgres Changes 會針對每個訂閱者逐一跑上面的 SELECT policy,只把通過 RLS 的 INSERT 事件推給他。所以就算某人硬訂閱了 messages,只要他不是房間成員,就一則都收不到。
三、realtime.messages 的 RLS(Broadcast + Presence 的授權)
這是本篇的核心。Broadcast 與 Presence 不看 messages 表,看的是 realtime.messages。 我們在這裡撰寫 policy,用 realtime.topic() 解析房間、用 extension 區分類型。約定 channel topic 命名為 room:<room_id>(例如 room:room-123)。
-- 收(SELECT):能加入 channel、接收 Broadcast、讀取 Presence
-- 條件:使用者是「realtime.topic() 對應房間」的成員
CREATE POLICY "房間成員可接收 broadcast 與 presence"
ON "realtime"."messages"
FOR SELECT
TO authenticated
USING (
realtime.messages.extension IN ('broadcast', 'presence')
AND EXISTS (
SELECT 1 FROM public.room_members m
WHERE
-- topic 形如 'room:room-123',去掉前綴取出 room_id
m.room_id = replace((SELECT realtime.topic()), 'room:', '')
AND m.user_id = (SELECT auth.uid())
)
);
-- 送(INSERT):能發送 Broadcast、能 track 自己的 Presence
-- 這裡要求 role = 'member'(viewer 只能收、不能送)
CREATE POLICY "房間成員可發送 broadcast 與 presence"
ON "realtime"."messages"
FOR INSERT
TO authenticated
WITH CHECK (
realtime.messages.extension IN ('broadcast', 'presence')
AND EXISTS (
SELECT 1 FROM public.room_members m
WHERE
m.room_id = replace((SELECT realtime.topic()), 'room:', '')
AND m.user_id = (SELECT auth.uid())
AND m.role = 'member'
)
);
三個要點:
realtime.topic()回傳的是完整 topic 字串(例如'room:room-123')。上面用replace(..., 'room:', '')取出room_id再去比對成員表——你也可以用split_part等字串函式,重點是把 topic 解析成你的房間 ID。extension讓你能精細控制:這裡是 broadcast 和 presence 一起放行;若你想「只讓某些人看 Presence 但不能發 Broadcast」,就把兩者拆成不同 policy。- SELECT 決定「能不能進來收」、INSERT 決定「能不能送」。所以
viewer角色(只給 SELECT 通過、INSERT 不通過)就變成「能進來看、能看到誰在線上,但不能發言、不能廣播 typing」。
四、客戶端:建立私有 channel 與 setAuth
客戶端這邊,關鍵是 config: { private: true },以及確保 Realtime 拿到有效 JWT。登入流程下 supabase-js 通常會自動帶 token;若是自訂 JWT 或伺服器端情境,用 supabase.realtime.setAuth()。
import type { RealtimeChannel } from '@supabase/supabase-js'
async function joinPrivateRoom(roomId: string): Promise<RealtimeChannel> {
// (通常不必手動呼叫)若使用自訂 JWT 或要確保 token 就緒,可先 setAuth。
// 不帶參數:使用目前登入使用者的 token;帶字串:使用自訂 token。
// await supabase.realtime.setAuth()
const channel = supabase.channel(`room:${roomId}`, {
config: { private: true }, // 宣告私有 → 觸發 realtime.messages 的 RLS 授權
})
channel
.on('broadcast', { event: 'typing' }, (payload) => {
console.log('有人正在打字:', payload)
})
.subscribe((status, err) => {
if (status === 'SUBSCRIBED') {
console.log('已成功加入私有房間(通過授權)')
} else if (status === 'CHANNEL_ERROR') {
// 最常見原因:realtime.messages 沒有對應 policy、或此人不是房間成員、或 JWT 無效
console.error('加入失敗(授權未通過或 policy 缺失):', err)
}
})
return channel
}
關於 setAuth() 什麼時候要手動呼叫:一般使用者登入後 supabase-js 會自動把 token 帶進 Realtime,你不一定要呼叫。需要手動呼叫的情境包括——伺服器端(Node.js/Edge Functions)用 service role 或自訂 JWT、使用第三方簽發的 token、或某些「從資料庫觸發 Broadcast」的範例會在訂閱前 await supabase.realtime.setAuth() 確保 token 就緒。細節以官方為準。
五、整合實戰:安全的即時聊天室骨架
現在把三大功能整合進同一個私有 channel,做出一個聊天室的核心邏輯:Postgres Changes 收新訊息、Broadcast 送 typing、Presence 顯示線上。訊息的發送則走一般 insert(寫進 messages 表,由 Postgres Changes 廣播回來)。
import type { RealtimeChannel } from '@supabase/supabase-js'
interface Message {
id: number
room_id: string
user_id: string
content: string
created_at: string
}
interface PresenceUser {
userId: string
username: string
online_at: string
}
let chatChannel: RealtimeChannel | null = null
async function initChatRoom(
roomId: string,
me: PresenceUser,
): Promise<void> {
// 切換房間前先清掉舊 channel,避免連線洩漏
if (chatChannel) await supabase.removeChannel(chatChannel)
chatChannel = supabase.channel(`room:${roomId}`, {
// 私有:Broadcast / Presence 會走 realtime.messages 的 RLS 授權
config: {
private: true,
presence: { key: me.userId }, // 多分頁 / 多裝置去重
},
})
chatChannel
// (1) Postgres Changes:新訊息(授權靠 messages 表的 RLS)
.on<Message>(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`, // 伺服器端過濾,只收本房間
},
(payload) => appendMessage(payload.new),
)
// (2) Broadcast:typing 打字指示(授權靠 realtime.messages 的 RLS)
.on('broadcast', { event: 'typing' }, ({ payload }) => {
showTyping(payload.userId)
})
// (3) Presence:線上名單(授權靠 realtime.messages 的 RLS)
.on('presence', { event: 'sync' }, () => {
const state = chatChannel!.presenceState<PresenceUser>()
const users = Object.values(state).map(([first]) => first)
renderOnlineUsers(users)
})
.subscribe(async (status, err) => {
if (status === 'SUBSCRIBED') {
// 授權通過、成功加入後才 track 自己的 Presence
await chatChannel!.track(me)
} else if (status === 'CHANNEL_ERROR') {
console.error('無法加入房間(授權失敗):', err)
}
})
}
// 發送訊息:寫進 messages 表 → 由 Postgres Changes 廣播回所有成員
async function sendMessage(roomId: string, userId: string, content: string) {
const { error } = await supabase
.from('messages')
.insert({ room_id: roomId, user_id: userId, content })
if (error) console.error('發送失敗(可能被 RLS 擋下):', error)
}
// typing:高頻、稍縱即逝,用 Broadcast(不寫 DB)
function sendTyping(userId: string) {
chatChannel?.send({
type: 'broadcast',
event: 'typing',
payload: { userId },
})
}
// 離開房間 / 元件卸載:務必清理,避免連線洩漏與幽靈上線
async function leaveChatRoom() {
if (chatChannel) {
await supabase.removeChannel(chatChannel)
chatChannel = null
}
}
把這個骨架與前三篇對照,你會發現三大功能各司其職、且全部鎖在同一個私有 channel 的授權之下:
- 訊息本體(要保存、要能翻歷史)→
messages表 INSERT + Postgres Changes 訂閱,授權靠messages表 RLS(第 027 篇《Postgres Changes》) - typing 打字指示(高頻、不保存)→ Broadcast,授權靠
realtime.messagesRLS(第 028 篇《Broadcast 廣播》) - 誰在線上(自動同步、斷線自動清)→ Presence,授權靠
realtime.messagesRLS(第 029 篇《Presence 線上狀態》)
只要 room_members 裡沒有這個 user,他既加不進 channel(Broadcast/Presence 被 realtime.messages 擋)、也收不到訊息(Postgres Changes 被 messages 表 RLS 擋)——這才是一個安全、可上線的聊天室。
常見錯誤與最佳實踐
坑一:以為 Broadcast/Presence 會自動套用業務資料表的 RLS。
最致命的誤解。你在 messages 表設了再嚴的 RLS,Broadcast 和 Presence 也完全不受它保護——它們的訊息根本不經過 messages 表。正確做法:Broadcast/Presence 的授權一律在 realtime.messages 表上寫 RLS policy,用 realtime.topic() 判斷房間、用 extension 區分 broadcast/presence。沒寫這層 policy,config: { private: true } 只會讓人「進不來」,而不是「安全地進來」。
坑二:設了 private: true 卻忘了建 realtime.messages 的 policy,結果誰都加不進來。
config: { private: true } 只是開關,真正的規則在 policy。沒有對應的 SELECT policy,Realtime 會判定「沒人有權加入」,客戶端一律拿到 CHANNEL_ERROR。正確做法:宣告私有 channel 的同時,就要在 realtime.messages 建好 SELECT(能收)與 INSERT(能送)policy;用 SQL 或 Dashboard 都可以,缺一就會出現「明明程式碼沒錯卻加不進來」的情況。
坑三:混淆「誰能收」與「誰能送」,該給 INSERT 的忘了給。
只建了 SELECT policy,使用者能加入 channel、能收到別人的 Broadcast、能看到 Presence 名單,但一 send() broadcast 或 track() presence 就失敗——因為那需要 INSERT policy。正確做法:想清楚每個角色的權限:唯讀觀眾只給 SELECT,可發言成員 SELECT + INSERT 都要。用 role 這類欄位在 policy 裡分流,就能做出「viewer 能看不能說」。
坑四:忽略 token 過期與 policy 快取,導致授權狀態變舊或連線斷掉。
Realtime 在加入時查一次授權並快取,之後只有 token 刷新才重查;而 JWT 會過期。結果常見兩種狀況:一是「把某人踢出房間了,他當前連線卻還在」——因為 policy 被快取,要等他 token 刷新;二是「JWT 過期沒刷新,連線莫名斷掉」。正確做法:正式產品要實作 token refresh,確保刷新後的新 token 有進到 Realtime(登入流程多半自動,自訂情境用 supabase.realtime.setAuth(newToken));保持 JWT 有效期較短並可靠刷新。需要「立即撤權」的場景,要主動讓對方重連或刷新 token。
坑五:授權查詢的欄位沒建索引,高負載下變成瓶頸。
realtime.messages 的 policy 每次有人加入 channel 都會執行;messages 表的 RLS 則對每個訂閱者逐一評估。若 room_members 的 user_id/room_id 沒有索引,人一多授權查詢就會拖慢 join rate、增加延遲。正確做法:在 policy 會用到的欄位(如 room_members(user_id, room_id)、messages(room_id))建索引,這在《RLS》篇也強調過,Realtime 場景下更關鍵。
坑六:topic 命名太隨便,policy 難寫又容易洩漏。
如果 channel 叫 chat 或 room 這種通用名,你在 policy 裡就很難用 realtime.topic() 對應到「哪個房間」,也容易讓不相關的人誤入。正確做法:用有結構的多層命名,例如 room:${roomId} 或 tenant:${tenantId}:room:${roomId},讓 policy 能穩定地從 topic 解析出資源 ID,也降低誤訂閱到別人資料的風險。
最佳實踐總結:
- Broadcast/Presence 授權寫在
realtime.messages:用realtime.topic()判房間、extension分類型 - Postgres Changes 授權寫在業務資料表:如
messages表自己的 RLS,兩套授權各管一邊 private: true與 policy 成對出現:宣告私有的同時就把 SELECT/INSERT policy 建好- SELECT = 能收、INSERT = 能送:用角色分流做出「觀眾 vs 成員」
- 顧好 token 生命週期:實作 refresh,理解 policy 快取「加入時查、token 刷新才重查」
- 授權欄位建索引:
room_members、messages上的 policy 欄位務必有索引 - topic 用結構化命名:
room:${roomId}讓 policy 好寫、資料不外洩 - 不確定的 API 以官方為準:
setAuth()的行為、realtime.topic()/extension用法、policy 快取細節都可能調整,正式決策前查官方文件
小結
這是 Supabase 系列教學的第 030 篇,也是 SB-3 核心服務(Realtime) 的收尾。上一篇《Presence 線上狀態》我們用 channel.track(state) 與 presenceState() 做出「會自動同步、斷線自動清理」的線上名單。這一篇補上讓三大能力能安全上線的最後一塊——Realtime Authorization:用 config: { private: true } 宣告私有 channel、在 realtime.messages 表寫 RLS policy 授權 Broadcast 與 Presence、用 setAuth 帶入 JWT,並把三大功能整合成一個安全的即時聊天室。
核心觀念濃縮成一句話:Broadcast 與 Presence 不會自動套用業務資料表的 RLS,它們的授權來自 realtime.messages 表上的 policy;用 realtime.topic() 解析房間、realtime.messages.extension 區分 broadcast/presence、SELECT policy 管「能收」INSERT policy 管「能送」;私有 channel 用 config: { private: true } 宣告並搭配這些 policy;授權在加入時評估並快取,只有 token 刷新才重查,所以要顧好 JWT 生命週期。Postgres Changes 則走它訂閱的那張資料表自己的 RLS。
- 私有 channel:
config: { private: true }是開關,規則在realtime.messages的 RLS - 兩個輔助:
realtime.topic()判房間、realtime.messages.extension分 broadcast/presence - 收送分權:
FOR SELECT能收(加入/收 Broadcast/讀 Presence)、FOR INSERT能送(發 Broadcast/track) - 兩套授權:Broadcast/Presence 看
realtime.messages;Postgres Changes 看業務資料表 - 聊天室整合:Postgres Changes 存訊息 + Broadcast 送 typing + Presence 顯示線上,全鎖在同一私有 channel
到這裡,Realtime 三大能力(Postgres Changes、Broadcast、Presence)加上授權,你已經能做出安全、可上線的即時協作應用,SB-3 核心服務也正式收尾。接下來我們要離開「資料與即時」的世界,進入 運算與 AI——當你需要在伺服器端跑自訂邏輯(處理 webhook、呼叫第三方 API、做背景運算、串接 AI 模型),就輪到 Edge Functions 登場了。下一篇《Edge Functions 入門》,我們從第一個 Deno 函式開始,帶你把後端邏輯部署到 Supabase 的全球邊緣網路。核心服務齊備,運算篇章即將開始,下一篇見。