Tarquin note
Tarquin note
本文迁移自 wl.do 原文,已按本站 2024 归档规则保留原文结构。

如果让我用一句话概括 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 调用的处理。
仓库根是一个 pnpm workspace,目前只挂了一个应用:
.
├── apps/
│ └── web/ # Next.js 全栈应用
├── docs/ # SQL 迁移 + 文档
├── package.json # 根 package(workspace 声明)
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
为什么用 monorepo 而不是单仓单包?理由很务实:
根package.json用pnpm --filter把命令转发到子项目:
{
"scripts": {
"dev": "pnpm --filter @petrichor/web dev",
"build": "pnpm --filter @petrichor/web build",
"test": "pnpm --filter @petrichor/web test"
}
}
JSON
这样开发者在仓库根就能跑所有命令,不需要cd进去。
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)
}
}
这种分层有几个好处:
服务端的目录约定:
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 文件结构。这样的好处是,新人接手任意一个模块都知道去哪找东西。
Next.js 标准做法是每个 page 都做 SSR。Petrichor 走了一条混合路线:
为什么这么分?
| 维度 | 工作区 | 公开博客 |
|---|---|---|
| 用户 | 已登录、知道自己在哪 | 来自搜索引擎 / 分享链接 |
| 首屏速度 | 不需要——已经登录的人不会在意第一次加载多 200ms | 必须快 —— SEO、首屏 LCP 都要顾 |
| SEO | 不需要——后台页本来就 noindex | 必须 |
| 复杂度 | 高,状态多,需要 React Query / Zustand | 低,主要是渲染 |
这个分割让我们避免了"为了仪表盘的 SEO 而把整个工作区都跑 SSR"这种过度工程。SPA 部分的页面切换不再走 Next.js 的路由——本质上它们用同一个 React 树,路由切换是纯客户端的。
数据库选型上有几个关键决定:
Drizzle 的好处是:
我们用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
}
注意两个细节:
Supabase 的 Transaction Pooler 用的是 PgBouncer transaction 模式。在这个模式下,多个客户端连接复用同一个底层 Postgres 连接,预编译语句(prepared statements)会跨连接错乱。
如果不关掉 prepare,你会看到一些极其玄学的报错:
PostgresError: prepared statement "s1" already exists
而且这个错只在并发量上来后才出现,本地开发可能永远不复现。所以一开始就要把prepare: false写死。
整个项目的 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
Bash
这个脚本会读 schema,输出一段幂等的初始化 SQL(带if not exists/on conflict do nothing),可以在 Supabase SQL Editor 一次性执行,也可以在已有数据库上重复执行不出错。
增量变更则放在docs/migrations/<日期>-<功能>.sql,每个都是手写、人审过的 SQL。我们故意没有用 Drizzle 的自动 migration——生产数据库的变更必须有人看一眼,自动化在这里反而是风险。
部署部分的设计核心是"让用户一次都不用 cd 到子目录":
环境变量被严格分成四组:
| 组 | 变量 | 是否必填 |
|---|---|---|
| 数据库 | DATABASE_URL | ✅ |
| 认证 / 加密 | SESSION_SECRET 、 PETRICHOR_ENCRYPT_KEY 、 PETRICHOR_ENCRYPT_SALT | ✅ |
| 对象存储 | S3_ENDPOINT / S3_REGION / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | ✅ |
| 应用 URL / 注册策略 / OAuth | NEXT_PUBLIC_APP_URL 、 NEXT_PUBLIC_REGISTER_ENABLED 、 PETRICHOR_LINUXDO_* | ❌(有 fallback) |
所有变量都经过 Zod schema 校验(src/config/server.ts),启动时如果缺关键值或格式错,Vercel 会在构建阶段就报错,不会跑到运行时才炸。
回过头看,这套架构里最值得说的不是"用了什么",而是"没做什么":
对于一个目标"个人 / 小团队"的工具,复杂度上限就是产品上限。每多一层架构,部署难度和维护成本都是指数级的。
下一篇我们聚焦在编辑器层:「PlateJS 重度玩家手记:构建一个媲美 Notion 的编辑器」。