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

AI 多模型集成 + Better Auth:Petrichor 的智能与安全设计

多协议适配器、API Key 加密存储、httpOnly Cookie、LinuxDo OAuth 与 2FA 全链路解析

2026年5月26日 约 11 分钟阅读 开发者
AI 多模型集成 + Better Auth:智能与安全设计 的总结图

一个个人级全栈应用最容易被低估的两个模块是 AI 集成认证。这两块都是”看起来简单、实际上每个边角都是坑”的代表:AI 调用有协议、限流、流式、错误重试、Key 安全;认证有会话、Cookie、OAuth、2FA、CSRF……每一个细节做不好都是事故。

这一篇我们把 Petrichor 这两块的设计和取舍讲清楚。


第一部分:AI 多模型集成

设计原则:用户自带 Key,平台不代付

很多 AI 产品的代付模式注定走向”涨价 → 降智 → 用户流失”的循环。Petrichor 一开始就否定了这条路:

  • 所有 AI 调用都用用户自己的 API Key
  • Petrichor 只负责存好 Key、做好协议适配、把流式响应转回前端
  • 用户想换模型、想用某个国产小厂的新模型,不需要等 Petrichor 升级

这个决定有两个直接后果:

  1. 必须支持多协议——OpenAI / Gemini / DeepSeek / SiliconFlow / 各种 OpenAI 兼容端点
  2. 必须加密存储 API Key——明文存储是不可接受的安全姿态

下面分别讲。

多协议适配:ChatProtocolAdapter

Petrichor 在 src/server/ai/protocol-adapters.ts 里抽象了一个统一的 Adapter 接口:

export interface ChatProtocolAdapter {
  readonly protocol: AiProtocol
  prepareChatBody(body: unknown): unknown
  createFetch(): typeof fetch | undefined
  extractReasoning(message: ChatCompletionResponseMessage | undefined): string | null
}

三个方法分别对应三件事:

方法干什么
prepareChatBody在请求发送前修改 body,注入协议特定字段(如 DeepSeek 的 thinking 字段)
createFetch返回自定义 fetch(如果某协议需要在 HTTP 层做拦截,比如把 reasoning_content 透传)
extractReasoning从响应里抽出”思考链”字段(DeepSeek 的 reasoning_content / OpenAI o-series 的 reasoning

支持的 5 种协议都各自实现一份 Adapter:

export function resolveChatProtocolAdapter(context: ProtocolAdapterContext): ChatProtocolAdapter {
  const protocol = context.protocol
  if (protocol === 'DEEPSEEK') return createDeepSeekAdapter(context)
  if (protocol === 'OPENAI') return createOpenAIAdapter(context)
  // OPENAI_COMPAT / SILICONFLOW 默认走通用兼容
  if (isLegacyDeepSeekCompatibleConfig(context)) {
    return createDeepSeekAdapter(context)
  }
  return createOpenAICompatAdapter(context)
}

上层的业务代码(AI 写作 / LLM Wiki Agent)完全不感知协议差异:它只调用 streamText({ model, system, prompt }),剩下的协议适配、reasoning 抽取、错误处理都被 Adapter 吞掉。

Vercel AI SDK 的角色

具体 HTTP 调用用的是 Vercel AI SDK 6.xai + @ai-sdk/openai)。它的核心价值是:

  • 把”OpenAI Chat Completions”协议抽象成 streamText / generateText 这样的 API
  • 自带流式解析(SSE → token by token)
  • 错误重试、超时控制、abort signal 都封装好

对于非 OpenAI 协议(Gemini、DeepSeek),我们用 createOpenAI({ baseURL, fetch: adapter.createFetch() }) 来”伪装成 OpenAI”——把它们的 OpenAI 兼容端点接进 AI SDK。这样上层就只有一个心智模型:“所有模型都是 OpenAI 兼容”。

实际上 Gemini 不是 OpenAI 协议——但它有官方的 OpenAI 兼容端点(/v1beta/openai/chat/completions),可以通过设置 baseURL 接进来。DeepSeek 的 thinking 模式则需要在 fetch 层做改造(见下)。

DeepSeek 的特殊处理:思考模式 + 工具调用

DeepSeek-R1 / V3 系列有一个 thinking 字段控制是否开启思考链。但有个限制:开启 thinking 时如果带 tools,下一轮请求必须回传 reasoning_content 字段。AI SDK 的 OpenAI 兼容 provider 不会保留这个私有字段,导致带工具的 DeepSeek 请求二次调用时会报错。

我们的处理是:

export function prepareDeepSeekChatBody(body: unknown, options: ChatGenerationOptions) {
  if (!isRecord(body)) return body
  const hasTools = Array.isArray(body.tools) && body.tools.length > 0
  const thinking = hasTools && options.disableDeepSeekThinkingForTools ? 'disabled' : options.deepSeekThinking
  if (!thinking) return body
  return { ...body, thinking: { type: thinking } }
}

当请求带工具时,自动关掉 thinking。这是个折衷——DeepSeek 的工具调用 + 思考模式同时使用,需要更深度的 AI SDK 改造,对 Petrichor 这种轻量场景不值得。

API Key 加密:Spring Text Encryptor 兼容实现

用户配置 AI 模型时填入的 API Key,存进数据库前用 AES-256-CBC + PBKDF2 加密。实现见 src/server/crypto/spring-text-encryptor.ts

const pbkdf2Iterations = 1024
const aesKeyLengthBytes = 32 // AES-256
const aesBlockSizeBytes = 16

export function encryptText(key: string, saltHex: string, plainText: string) {
  const secretKey = deriveAesKey(key, saltHex) // PBKDF2 派生 32 字节密钥
  const iv = randomBytes(aesBlockSizeBytes) // 每次随机 IV
  const cipher = createCipheriv('aes-256-cbc', secretKey, iv)
  const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()])
  return Buffer.concat([iv, encrypted]).toString('hex') // IV + 密文,hex 编码
}

几个设计要点:

  1. PBKDF2 派生密钥(迭代 1024 次)—— 而不是把 PETRICHOR_ENCRYPT_KEY 直接当 AES 密钥用。这样即便密钥不够 32 字节也能正确派生
  2. 随机 IV 每次不同 —— 同一个明文每次加密产生不同密文,无法通过密文比对反推
  3. IV 前置存储 —— 密文格式是 iv + ciphertext,hex 编码后存数据库
  4. 算法兼容 Spring Security 的 TextEncryptor —— 命名上呼应 Java 后端,便于以后跨语言互通

PETRICHOR_ENCRYPT_KEYPETRICHOR_ENCRYPT_SALT 都从环境变量读,只在 Vercel 函数运行时存在。数据库管理员能看到密文、看不到密钥,也就解不出明文。

LLM Wiki 智能问答:编译式 RAG 与 Agent 编排

跟 AI 配置一起串起来的还有 LLM Wiki 智能问答——它完全复用同一套用户自带 Key 与 AES-256-CBC 加密体系,只是消费端从”流式写作”换成了”Agent 多步检索”。

这块的完整实现单独成篇放在第 6 篇,这里只点核心取舍:

  1. 不切 chunk、不引向量库 —— Petrichor 把每篇源文档编译成结构化 Wiki 中间层(# 摘要 / ## 关键要点 / ## 实体 / ## 可回答的问题 / ## 来源),存进普通 Postgres 表
  2. 增量编译 —— 用 sha256(title + content) 作为 sourceHash,源没变就跳过 LLM 编译
  3. Agent 多步阅读 —— 基于 Vercel AI SDK 的 streamText + tool calling,11 个工具,stepCountIs(8) 限步;system prompt 强制”先 Wiki 索引 → 搜 Wiki → 读具体 Wiki → 万不得已才回看源文档”
  4. 沉淀机制 —— Agent 发现值得长期保留的新结论时调用 propose_wiki_patch,写入 petrichor_kb_wiki_patch 表等待用户审批;批准后才升 version 写回 Wiki
  5. 审计 —— 所有 INGEST / PATCH_PROPOSED / PATCH_APPLIED 事件落 petrichor_kb_wiki_event_log

最关键的是审批闭环这个设计:直接让 Agent 写库太危险,但不让它写又没法形成”越用越聪明”的飞轮。让它”提交 PR、等用户合并”是个折中——既允许沉淀,又能拦住误污染。

这也是为什么我们没走主流的向量 RAG 路线:对一个 5 分钟一键部署到 Vercel + Supabase 的项目,编译式 Wiki 的部署门槛(零额外依赖)和可读性(全程 markdown)都更合身


第二部分:Better Auth + Drizzle 桥接

为什么不用 NextAuth.js / Clerk

NextAuth.js 在 App Router 时代设计上还在追赶,配置心智偏复杂;Clerk 是托管服务,依赖第三方且付费上量后不便宜。Better Auth 是一个相对新但发展很快的选择:

  • 自托管,所有数据在你自己的数据库
  • 原生 TypeScript + Drizzle 适配
  • 默认 httpOnly Cookie 会话,不依赖前端存 token
  • OAuth、2FA、邮箱验证、Magic Link 都开箱即用

Petrichor 用的是 Better Auth + @better-auth/drizzle-adapter + 业务用户表桥接。

双表桥接:业务表 + Better Auth 表

一个用户在 Petrichor 里其实是两条记录

better_auth_user (Better Auth 主用户表,UUID 主键)
    ↓ auth_user_id 字段关联
petrichor_user (业务用户表,bigint 主键 + 业务字段)

为什么不直接把所有字段塞 better_auth_user?因为:

  • Better Auth 的 schema 是它定义的,升级时可能改字段,我们不能往里乱加业务字段
  • 业务表 petrichor_user 持有所有跟 Petrichor 业务相关的字段(角色、昵称、签名、绑定的 LinuxDo 账号等),独立演进
  • 两表通过 petrichor_user.auth_user_id 关联到 better_auth_user.id

注册流程(createLocalUserWithBetterAuth)是一个事务

const [user] = await db.transaction(async (tx) => {
    await tx.insert(betterAuthUsers).values({ id: authUserId, name, email, ... })
    await tx.insert(betterAuthAccounts).values({
        accountId: authUserId,
        providerId: "credential",
        userId: authUserId,
        password: passwordHash,
    })
    return await tx.insert(users).values({
        authUserId,
        email,
        passwordHash,
        systemRole,
        userType: "LOCAL",
        ...
    }).returning()
})

三张表(better_auth_userbetter_auth_accountpetrichor_user)一起插入,失败一起回滚。

为什么是 httpOnly Cookie,不是 localStorage token

早期 Petrichor 把 JWT 存在 localStorage,每次请求带在 Authorization header。这个方案的问题:

  • XSS 漏洞放大 — 任何注入的脚本都能直接拿到 token,发到攻击者服务器
  • SSR 拿不到登录态 — 服务端渲染时浏览器 localStorage 不可读,公开博客的”如果是作者本人则显示编辑入口”无法实现
  • 跨域刷新麻烦 — token 过期了要手动刷,前端写一堆中间件

迁移到 Better Auth 后改用 httpOnly Cookie

  • 浏览器自动带在每次同源请求里,JS 读不到(XSS 拿不到)
  • Secure + SameSite=Lax + HttpOnly 三件套,标准防 CSRF + 跨站攻击
  • SSR 拿得到 Cookie,公开页可以判断”是不是作者本人”

唯一的成本是:前端不能再持有 token 用于跨域调用。但 Petrichor 是同源应用,这个成本是 0。

LinuxDo OAuth:第三方登录的轻量接入

LinuxDo(一个国内开发者社区)有自己的 OAuth 2.0 服务。接入只用了一个文件 src/server/auth/linuxdo-handlers.ts,没有依赖第三方 OAuth 库。核心流程:

1. 用户点"用 LinuxDo 登录"
2. /api/auth/linuxdo/login → 重定向到 LinuxDo 授权页 (附 state token)
3. LinuxDo 授权回来 /api/auth/callback?code=xxx&state=yyy
4. 服务端验证 state token、用 code 换 access_token
5. 用 access_token 拉取 LinuxDo 用户信息
6. 如果该 LinuxDo 账号已绑定 Petrichor 用户 → 直接登录
   否则 → 创建新用户(或绑定到已登录的本地账号)

配置只需要 3 个环境变量:

PETRICHOR_LINUXDO_CLIENT_ID=...
PETRICHOR_LINUXDO_CLIENT_SECRET=...
PETRICHOR_LINUXDO_REDIRECT_URI=https://yourdomain.com/api/auth/callback

第三个变量为空时 fallback 到 NEXT_PUBLIC_APP_URL + /api/auth/callback,进一步降低配置门槛。

2FA:基于 TOTP 的双因素认证

Better Auth 内置了 2FA 插件,Petrichor 用 TOTP(Google Authenticator 兼容)。数据存在 better_auth_two_factor 表:

{
    id: text,
    secret: text,           // TOTP secret,base32 编码
    backup_codes: text,     // JSON 数组,一次性恢复码
    verified: boolean,
    user_id: text,          // 关联 better_auth_user.id
}

启用流程:用户在账户页点”启用 2FA” → 后端生成 secret 和 QR 码 → 用户用 Authenticator app 扫码 → 输入一次性验证码确认 → 后端 verify 通过后写库。

下次登录时:邮箱密码正确 → 检查该用户是否启用 2FA → 如果是则要求输入 6 位验证码 → 验证通过才发放 session。

恢复码(backup_codes)的设计是:用户启用 2FA 时一次性显示 10 个一次性恢复码,每个用一次就作废。手机丢了用恢复码能拿回账号。这个细节经常被忽略,但没有恢复码的 2FA 是产品事故的高发点。


总结:两个模块的共同信条

无论是 AI 还是认证,Petrichor 的实现都贯穿同一条原则:用户拥有自己的数据和密钥

  • AI Key 是用户自己的,加密存储,平台不代付
  • 认证 Cookie 在自己的域名下,所有用户数据在自己的 Supabase 数据库
  • LinuxDo / 2FA 都是可选的,留空就完全不接入

一个 self-hostable 的开源项目,本来就该是这样的。任何”必须经过我们的中介服务”的设计,最终都会演化成厂商绑定和数据所有权之争。Petrichor 选了一条更轴的路:把控制权交回用户

这五篇文章下来,从产品定位到架构选型,从编辑器到 AI 与认证,Petrichor 的”底牌”基本都摊开了。如果你对其中某一块特别感兴趣,欢迎到 GitHub 仓库 翻代码——文章里讲的所有东西都对应着可读的实现。