Petrichor logoPetrichor
返回深度文章
03 技术向

架构总览:用 Vercel + Supabase 撑起一个全栈应用

Petrichor 的技术选型、monorepo 结构、服务端分层与 Postgres 连接池实战

2026年5月26日 约 9 分钟阅读 开发者
架构总览:用 Vercel + Supabase 撑起一个全栈应用 的总结图

如果让我用一句话概括 Petrichor 的架构选型原则:能让平台扛的事,绝不让自己扛

这一篇我们打开盖子,看看为了支撑前一篇讲的五大功能,底下的技术栈是怎么搭起来的,以及每个选型背后的考虑。


整体结构

Petrichor 不是一个传统的”前后端分离 + 自部署”项目。它的运行形态更接近”一个跑在 Vercel 边缘上的 Next.js 全栈应用,背后挂一个 Supabase Postgres”。

┌─────────────────────────────────────────────────────────┐
│  浏览器 (SPA + SSR Public Pages)                        │
└────────────┬────────────────────────────────────────────┘
             │ HTTPS

┌─────────────────────────────────────────────────────────┐
│  Vercel Edge / Serverless Functions                      │
│  ├─ app/[...path]/page.tsx     ← 客户端 SPA 壳          │
│  ├─ app/api/**/route.ts        ← API 薄路由层           │
│  ├─ app/rss.xml/atom.xml/...   ← 公开 RSS / SEO         │
│  └─ src/server/**              ← handler + 业务逻辑     │
└──────┬────────────────────┬─────────────────────┬───────┘
       │ Postgres            │ S3 兼容              │ 用户自带 AI Key
       │ (Transaction        │ (上传 / 头像 /        │ (OpenAI/Gemini/
       │  Pooler 6543)        │  附件 / 封面)         │  DeepSeek/...)
       ▼                     ▼                     ▼
┌──────────────┐    ┌──────────────────┐   ┌─────────────────┐
│ Supabase     │    │ Bitiful / R2 /   │   │ AI 厂商 OpenAI  │
│ PostgreSQL   │    │ AWS S3 / MinIO   │   │ 兼容 HTTP API   │
└──────────────┘    └──────────────────┘   └─────────────────┘

整套系统没有任何长驻进程——Vercel 函数都是请求触发的、无状态的。这是一个重要的隐性约束,后面会看到它如何影响我们对数据库连接、缓存、AI 调用的处理。


Monorepo:pnpm workspace

仓库根是一个 pnpm workspace,目前只挂了一个应用:

.
├── apps/
│   └── web/                # Next.js 全栈应用
├── docs/                   # SQL 迁移 + 文档
├── package.json            # 根 package(workspace 声明)
├── pnpm-workspace.yaml
└── pnpm-lock.yaml

为什么用 monorepo 而不是单仓单包?理由很务实:

  • 早期项目预留了未来可能拆出 apps/desktop(Tauri 客户端)或 packages/shared 共享类型库的扩展空间
  • pnpm workspace 的隔离能力让”安装一次依赖、所有子项目共享 node_modules”成为可能,CI 构建时间更短
  • Vercel 对 monorepo 友好——只需要把 Root Directory 设为 apps/web,部署按钮 URL 里加 root-directory=apps%2Fweb 就行

package.jsonpnpm --filter 把命令转发到子项目:

{
  "scripts": {
    "dev": "pnpm --filter @petrichor/web dev",
    "build": "pnpm --filter @petrichor/web build",
    "test": "pnpm --filter @petrichor/web test"
  }
}

这样开发者在仓库根就能跑所有命令,不需要 cd 进去。


Next.js App Router:薄路由 + 厚 handler 的分层

Petrichor 用 Next.js 16 的 App Router,但 API 路由文件保持极薄,所有业务逻辑都在 src/server/** 里。

一个典型的 route.ts 长这样:

// app/api/kb/list/route.ts
export { listKnowledgeBases as POST } from '@/server/kb/handlers'

仅此一行。

src/server/kb/handlers.ts 里才是真正的处理逻辑:

export async function listKnowledgeBases(request: NextRequest) {
  try {
    const user = await requireCurrentUser(request)
    const input = listKnowledgeBasesSchema.parse(await readJson(request))
    const result = await listKnowledgeBasesByUser({
      userId: user.id,
      ...input
    })
    return ok(tableData(result.items, result.total))
  } catch (error) {
    return toErrorResponse(error, request.nextUrl.pathname)
  }
}

这种分层有几个好处:

  1. route.ts 永远不需要做单元测试 —— 它没有逻辑可测
  2. 业务逻辑可以脱离 Next.js 环境单测 —— Vitest 直接对 src/server/kb/logic.ts 调用,不需要 mock NextRequest
  3. HTTP 框架可替换 —— 如果某天想从 Next.js Route Handler 迁移到 Hono 或自建 Fastify,只需要替换路由壳
  4. 统一的错误响应 —— toErrorResponse 把任意异常变成 { code, msg, path, timestamp },不泄露内部细节

服务端的目录约定:

src/server/
├── kb/                   # 知识库模块
│   ├── handlers.ts       # HTTP handler(薄)
│   ├── logic.ts          # 业务逻辑(厚,可测试)
│   ├── logic.test.ts
│   └── mappers.ts        # 数据库 → API 响应的映射
├── auth/                 # 认证:邮箱密码、Better Auth 桥接、LinuxDo OAuth
├── ai/                   # AI 模型配置 / 写作 / LLM Wiki Agent
├── upload/               # S3 预签名上传
├── notification/
├── db/
│   ├── schema.ts         # Drizzle 表结构(单一信源)
│   ├── client.ts         # 连接池
│   └── full-migration.ts # 生成完整初始化 SQL
└── http/
    ├── response.ts       # ok / tableData / toErrorResponse
    └── pagination.ts     # 通用分页参数解析

每个业务模块都遵守”handlers / logic / mappers / tests”的 4 文件结构。这样的好处是,新人接手任意一个模块都知道去哪找东西


客户端:SPA 入口 + SSR 公开页混合

Next.js 标准做法是每个 page 都做 SSR。Petrichor 走了一条混合路线:

  • 登录后的工作区(仪表盘 / 知识库编辑 / AI 配置 / 后台管理……)是一个客户端 SPA,通过 app/[...path]/page.tsx 这个 catch-all 路由动态加载 apps/web/src/client-app.tsx,内部用 react-router-dom 管理路由
  • 公开博客页(文章详情 / 博客首页 / RSS / sitemap)才走真正的 SSR

为什么这么分?

维度工作区公开博客
用户已登录、知道自己在哪来自搜索引擎 / 分享链接
首屏速度不需要——已经登录的人不会在意第一次加载多 200ms必须快 —— SEO、首屏 LCP 都要顾
SEO不需要——后台页本来就 noindex必须
复杂度高,状态多,需要 React Query / Zustand低,主要是渲染

这个分割让我们避免了”为了仪表盘的 SEO 而把整个工作区都跑 SSR”这种过度工程。SPA 部分的页面切换不再走 Next.js 的路由——本质上它们用同一个 React 树,路由切换是纯客户端的。


数据层:Drizzle + Postgres.js + Supabase Transaction Pooler

数据库选型上有几个关键决定:

1. Drizzle ORM 而非 Prisma

Drizzle 的好处是:

  • 零运行时代码生成,schema 就是 TS 文件,类型推断完全静态
  • SQL-like API,写起来更接近 SQL,调试时一眼能看出生成的查询
  • 比 Prisma 启动快得多 —— 这在 Serverless 场景下意义重大

2. Postgres.js 而非 node-postgres

我们用 drizzle-orm/postgres-js 的驱动,背后是 postgres 这个轻量库。配置只有 13 行:

// apps/web/src/server/db/client.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'

let client: postgres.Sql | null = null
let db: ReturnType<typeof drizzle<typeof schema>> | null = null

export function getSqlClient() {
  client ??= postgres(getServerConfig().databaseUrl, {
    max: 5,
    prepare: false
  })
  return client
}

注意两个细节:

  • max: 5 —— Vercel Serverless 函数每个实例最多 5 个连接,避免连接风暴
  • prepare: false —— 这是 Supabase Transaction Pooler(端口 6543)的硬性要求

3. 为什么必须 prepare: false

Supabase 的 Transaction Pooler 用的是 PgBouncer transaction 模式。在这个模式下,多个客户端连接复用同一个底层 Postgres 连接,预编译语句(prepared statements)会跨连接错乱

如果不关掉 prepare,你会看到一些极其玄学的报错:

PostgresError: prepared statement "s1" already exists

而且这个错只在并发量上来后才出现,本地开发可能永远不复现。所以一开始就要把 prepare: false 写死。

4. Schema 是单一信源

整个项目的 schema 定义都在 apps/web/src/server/db/schema.ts 一个文件里:

export const users = pgTable(
  'petrichor_user',
  {
    id: bigint('id', { mode: 'bigint' }).primaryKey().generatedAlwaysAsIdentity(),
    authUserId: text('auth_user_id'),
    email: text('email').notNull(),
    passwordHash: text('password_hash').notNull(),
    systemRole: text('system_role').notNull().default('USER')
    // ...
  },
  table => [uniqueIndex('ux_petrichor_user_email').on(table.email)]
)

但我们不直接用 Drizzle 的 migration——而是写了一个 scripts/print-initial-migration.ts

pnpm --silent --filter @petrichor/web db:sql > petrichor-init.sql

这个脚本会读 schema,输出一段幂等的初始化 SQL(带 if not exists / on conflict do nothing),可以在 Supabase SQL Editor 一次性执行,也可以在已有数据库上重复执行不出错。

增量变更则放在 docs/migrations/<日期>-<功能>.sql,每个都是手写、人审过的 SQL。我们故意没有用 Drizzle 的自动 migration——生产数据库的变更必须有人看一眼,自动化在这里反而是风险。


部署:Vercel 一键化

部署部分的设计核心是”让用户一次都不用 cd 到子目录”:

  • 根目录有一个 pnpm-workspace.yaml,Vercel 自动识别为 monorepo
  • Vercel 部署时 Root Directory 设置为 apps/web,构建从该目录开始
  • 部署按钮 URL 里把这个配置写死成 &root-directory=apps%2Fweb,用户点一下就自动设好

环境变量被严格分成四组:

变量是否必填
数据库DATABASE_URL
认证 / 加密SESSION_SECRETPETRICHOR_ENCRYPT_KEYPETRICHOR_ENCRYPT_SALT
对象存储S3_ENDPOINT / S3_REGION / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
应用 URL / 注册策略 / OAuthNEXT_PUBLIC_APP_URLNEXT_PUBLIC_REGISTER_ENABLEDPETRICHOR_LINUXDO_*❌(有 fallback)

所有变量都经过 Zod schema 校验src/config/server.ts),启动时如果缺关键值或格式错,Vercel 会在构建阶段就报错,不会跑到运行时才炸


总结:架构里的克制

回过头看,这套架构里最值得说的不是”用了什么”,而是”没做什么”:

  • 没有自建任何后端服务 —— Postgres 用 Supabase 托管,函数跑在 Vercel,对象存储用任何 S3 兼容服务
  • 没有引入消息队列、缓存层、搜索引擎 —— Postgres 的 tsvector 解决全文搜索,petrichor_public_content_cache 表解决公开页缓存
  • 没有引入 ORM 的 migration 工具 —— 手写 SQL 更可控,迁移历史更可读
  • 没有为编辑器搭实时协作框架 —— 个人 / 小团队的写作场景里,单人编辑足够,多人评论 / Suggestion 已经覆盖了协作的 80%

对于一个目标”个人 / 小团队”的工具,复杂度上限就是产品上限。每多一层架构,部署难度和维护成本都是指数级的。

下一篇我们聚焦在编辑器层:「PlateJS 重度玩家手记:构建一个媲美 Notion 的编辑器」