SupabasePatterns SupabasePatterns

Supabase Patterns 是一个开源 Firebase 替代方案,使用 PostgreSQL 作为核心数据库,提供完整的功能集,包括认证、实时订阅、存储、边缘函数以及 AI 应用的向量嵌入。

后端开发 0 次安装 0 次浏览 更新于 3/5/2026

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;
}

快速开始

  1. 创建项目: https://supabase.com/dashboard

  2. 安装 SDK:

    npm install @supabase/supabase-js @supabase/ssr
    
  3. 生成类型:

    npx supabase gen types typescript --project-id <id> > lib/database.types.ts
    
  4. 设置客户端(见上面的示例)

  5. 在所有表上启用 RLS

生产检查清单

  • [ ] 在所有表上启用 RLS
  • [ ] 彻底测试 RLS 策略
  • [ ] 服务角色密钥永远不要暴露给客户端
  • [ ] 配置数据库备份
  • [ ] 配置边缘函数密钥
  • [ ] 设置存储桶策略
  • [ ] 配置速率限制
  • [ ] 设置监控和警报

反模式

  1. 禁用 RLS:即使对于“简单”的应用,始终使用 RLS
  2. 客户端上的服务密钥:永远不要暴露服务角色密钥
  3. 不生成类型:始终生成并使用 TypeScript 类型
  4. 轮询而不是实时:使用订阅来获取实时数据

集成点

  • 认证提供商:Google、GitHub、Apple、SAML 等。
  • AI/ML:pgvector 用于嵌入,OpenAI 集成
  • 存储:与 S3 兼容,内置 CDN
  • 边缘:基于 Deno 的边缘函数

进一步阅读