Supabase Patterns
概览
Supabase 是一个使用 PostgreSQL 作为核心数据库的开源 Firebase 替代品。它提供了完整的功能集,包括认证、实时订阅、存储、边缘函数以及用于 AI 应用的向量嵌入。
为什么这很重要
- PostgreSQL 能力:完整的 SQL、连接、事务和扩展
- 开源:可自托管,无供应商锁定
- 内置实时功能:订阅数据库变化
- 行级安全:细粒度访问控制
- AI 就绪:pgvector 用于嵌入
核心概念
1. 项目设置
// lib/supabase/client.ts
import { createClient } from '@supabase/supabase-js';
import { Database } from './database.types'; // 生成类型
// 浏览器客户端(使用匿名密钥)
export const supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// 服务器客户端(使用服务角色 - 永远不要暴露给客户端)
export const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
// 服务器组件客户端(Next.js App Router)
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createServerSupabaseClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options });
},
remove(name: string, options: CookieOptions) {
cookieStore.set({ name, value: '', ...options });
},
},
}
);
}
2. 认证
// hooks/useAuth.ts
import { useEffect, useState } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase/client';
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 获取初始会话
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
// 监听认证变化
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, []);
const signInWithEmail = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return data;
};
const signInWithOAuth = async (provider: 'google' | 'github' | 'facebook') => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) throw error;
return data;
};
const signUp = async (email: string, password: string, metadata?: object) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: metadata,
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) throw error;
return data;
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
};
return {
user,
session,
loading,
signInWithEmail,
signInWithOAuth,
signUp,
signOut,
};
}
3. 带有类型安全性的数据库查询
// 首先生成类型:npx supabase gen types typescript --project-id <id> > database.types.ts
// services/posts.service.ts
import { supabase } from '@/lib/supabase/client';
import { Database } from '@/lib/supabase/database.types';
type Post = Database['public']['Tables']['posts']['Row'];
type PostInsert = Database['public']['Tables']['posts']['Insert'];
type PostUpdate = Database['public']['Tables']['posts']['Update'];
export const postsService = {
// 获取所有帖子及其作者
async getPosts(options?: { limit?: number; offset?: number }) {
const query = supabase
.from('posts')
.select(`
*,
author:profiles(id, username, avatar_url),
comments(count)
`)
.order('created_at', { ascending: false });
if (options?.limit) query.limit(options.limit);
if (options?.offset) query.range(options.offset, options.offset + (options.limit || 10) - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data, count };
},
// 获取单个帖子
async getPost(id: string) {
const { data, error } = await supabase
.from('posts')
.select(`
*,
author:profiles(*),
comments(
*,
author:profiles(id, username, avatar_url)
)
`)
.eq('id', id)
.single();
if (error) throw error;
return data;
},
// 创建帖子
async createPost(post: PostInsert) {
const { data, error } = await supabase
.from('posts')
.insert(post)
.select()
.single();
if (error) throw error;
return data;
},
// 更新帖子
async updatePost(id: string, updates: PostUpdate) {
const { data, error } = await supabase
.from('posts')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
// 删除帖子
async deletePost(id: string) {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', id);
if (error) throw error;
},
// 搜索帖子
async searchPosts(query: string) {
const { data, error } = await supabase
.from('posts')
.select('*')
.textSearch('title', query, { type: 'websearch' });
if (error) throw error;
return data;
},
};
4. 行级安全 (RLS)
-- migrations/001_create_posts.sql
-- 启用 RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 策略:任何人都可以阅读发布的帖子
CREATE POLICY "Public posts are viewable by everyone"
ON posts FOR SELECT
USING (status = 'published');
-- 策略:用户可以阅读自己的草稿
CREATE POLICY "Users can view own drafts"
ON posts FOR SELECT
USING (auth.uid() = author_id AND status = 'draft');
-- 策略:用户可以插入自己的帖子
CREATE POLICY "Users can create own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = author_id);
-- 策略:用户可以更新自己的帖子
CREATE POLICY "Users can update own posts"
ON posts FOR UPDATE
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
-- 策略:用户可以删除自己的帖子
CREATE POLICY "Users can delete own posts"
ON posts FOR DELETE
USING (auth.uid() = author_id);
-- 策略:管理员可以完全访问
CREATE POLICY "Admins have full access"
ON posts FOR ALL
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);
5. 实时订阅
// hooks/useRealtimePosts.ts
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase/client';
import { RealtimePostgresChangesPayload } from '@supabase/supabase-js';
export function useRealtimePosts(channelId: string) {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
// 初始获取
const fetchPosts = async () => {
const { data } = await supabase
.from('posts')
.select('*')
.eq('channel_id', channelId)
.order('created_at', { ascending: true });
if (data) setPosts(data);
};
fetchPosts();
// 订阅变化
const channel = supabase
.channel(`posts:${channelId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'posts',
filter: `channel_id=eq.${channelId}`,
},
(payload: RealtimePostgresChangesPayload<Post>) => {
if (payload.eventType === 'INSERT') {
setPosts(prev => [...prev, payload.new]);
} else if (payload.eventType === 'UPDATE') {
setPosts(prev =>
prev.map(p => (p.id === payload.new.id ? payload.new : p))
);
} else if (payload.eventType === 'DELETE') {
setPosts(prev => prev.filter(p => p.id !== payload.old.id));
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [channelId]);
return posts;
}
// 存在性(在线用户)
export function usePresence(roomId: string, userId: string) {
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`);
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const users = Object.values(state).flat().map((p: any) => p.user_id);
setOnlineUsers(users);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ user_id: userId, online_at: new Date().toISOString() });
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [roomId, userId]);
return onlineUsers;
}
6. 存储
// services/storage.service.ts
import { supabase } from '@/lib/supabase/client';
export const storageService = {
// 上传文件
async uploadFile(bucket: string, path: string, file: File) {
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, {
cacheControl: '3600',
upsert: false,
});
if (error) throw error;
return data;
},
// 使用自动生成的名称上传
async uploadAvatar(userId: string, file: File) {
const fileExt = file.name.split('.').pop();
const fileName = `${userId}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
await this.uploadFile('avatars', filePath, file);
// 获取公共 URL
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(filePath);
return data.publicUrl;
},
// 下载文件
async downloadFile(bucket: string, path: string) {
const { data, error } = await supabase.storage
.from(bucket)
.download(path);
if (error) throw error;
return data;
},
// 获取签名 URL(用于私有桶)
async getSignedUrl(bucket: string, path: string, expiresIn = 3600) {
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(path, expiresIn);
if (error) throw error;
return data.signedUrl;
},
// 删除文件
async deleteFile(bucket: string, paths: string[]) {
const { error } = await supabase.storage
.from(bucket)
.remove(paths);
if (error) throw error;
},
// 列出文件
async listFiles(bucket: string, folder: string) {
const { data, error } = await supabase.storage
.from(bucket)
.list(folder, {
limit: 100,
sortBy: { column: 'created_at', order: 'desc' },
});
if (error) throw error;
return data;
},
};
7. 边缘函数
// supabase/functions/send-email/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
// 验证 JWT
const authHeader = req.headers.get('Authorization')!;
const { data: { user }, error: authError } = await supabase.auth.getUser(
authHeader.replace('Bearer ', '')
);
if (authError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { to, subject, body } = await req.json();
// 使用 Resend/SendGrid 等发送邮件
const emailResponse = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'noreply@myapp.com',
to,
subject,
html: body,
}),
});
const result = await emailResponse.json();
return new Response(JSON.stringify(result), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
// 从客户端调用
// const { data, error } = await supabase.functions.invoke('send-email', {
// body: { to: 'user@example.com', subject: 'Hello', body: '<h1>Hi</h1>' },
// });
8. 向量搜索 (AI)
-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;
-- 创建带有嵌入列的表
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding VECTOR(1536), -- OpenAI ada-002 维度
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 创建索引以快速相似性搜索
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- 搜索函数
CREATE OR REPLACE FUNCTION match_documents(
query_embedding VECTOR(1536),
match_threshold FLOAT,
match_count INT
)
RETURNS TABLE (
id UUID,
content TEXT,
metadata JSONB,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
documents.id,
documents.content,
documents.metadata,
1 - (documents.embedding <=> query_embedding) AS similarity
FROM documents
WHERE 1 - (documents.embedding <=> query_embedding) > match_threshold
ORDER BY documents.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
// 从客户端进行向量搜索
async function searchDocuments(query: string) {
// 从 OpenAI 获取嵌入
const embeddingResponse = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: query,
});
const embedding = embeddingResponse.data[0].embedding;
// 在 Supabase 中搜索
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: embedding,
match_threshold: 0.7,
match_count: 10,
});
return data;
}
快速开始
-
安装 SDK:
npm install @supabase/supabase-js @supabase/ssr -
生成类型:
npx supabase gen types typescript --project-id <id> > lib/database.types.ts -
设置客户端(见上面的示例)
-
在所有表上启用 RLS
生产检查清单
- [ ] 在所有表上启用 RLS
- [ ] 彻底测试 RLS 策略
- [ ] 服务角色密钥永远不要暴露给客户端
- [ ] 配置数据库备份
- [ ] 配置边缘函数密钥
- [ ] 设置存储桶策略
- [ ] 配置速率限制
- [ ] 设置监控和警报
反模式
- 禁用 RLS:即使对于“简单”的应用,始终使用 RLS
- 客户端上的服务密钥:永远不要暴露服务角色密钥
- 不生成类型:始终生成并使用 TypeScript 类型
- 轮询而不是实时:使用订阅来获取实时数据
集成点
- 认证提供商:Google、GitHub、Apple、SAML 等。
- AI/ML:pgvector 用于嵌入,OpenAI 集成
- 存储:与 S3 兼容,内置 CDN
- 边缘:基于 Deno 的边缘函数