API 开发
IAM 项目使用 Next.js + Hono + tRPC + Drizzle 技术栈,提供端到端类型安全的 API。当前架构以 apps/server 作为唯一后端权威入口:Hono 挂载 Better Auth、OAuth/OIDC Provider、tRPC 和必要的 REST endpoint;Next.js 负责页面与交互,不直接访问数据库。
前端数据访问优先级:
- Better Auth 原生客户端能力:用户、会话、标准认证流程优先使用
authClient。 - tRPC:IAM 管理后台的业务聚合、统计、搜索和扩展配置。
- Hono REST endpoint:文件上传、第三方回调、内部运维等不适合 tRPC 的场景。
apps/web 不应直接 import @IAM/auth、@IAM/db、@IAM/env/server。需要共享类型时,从 @IAM/contracts 引入纯类型或 schema。
创建 tRPC 路由
Section titled “创建 tRPC 路由”在 packages/api/src/routers/ 目录下创建新的路由文件:
import { z } from 'zod'import { publicProcedure, protectedProcedure, router } from '../index'
export const exampleRouter = router({ // 公共过程 publicEndpoint: publicProcedure.query(() => { return { message: 'This is public' } }),
// 受保护的过程 privateEndpoint: protectedProcedure.query(({ ctx }) => { return { message: 'This is private', userId: ctx.session.user.id } }),
// 带输入验证的过程 createItem: protectedProcedure .input( z.object({ name: z.string().min(1), description: z.string().optional() }) ) .mutation(async ({ input, ctx }) => { // 处理创建逻辑 return { id: '123', ...input } })})publicProcedure: 公共过程,不需要认证protectedProcedure: 受保护的过程,需要认证
query: 用于读取数据(GET 请求)mutation: 用于修改数据(POST/PUT/DELETE 请求)
在 packages/api/src/routers/index.ts 中注册新路由:
import { exampleRouter } from './example'
export const appRouter = router({ example: exampleRouter // ... 其他路由})
export type AppRouter = typeof appRouter前端调用 API
Section titled “前端调用 API”在 React 组件中使用 tRPC:
import { trpc } from "@/utils/trpc";
function MyComponent() { // Query - 用于读取数据 const { data, isLoading, error } = trpc.example.publicEndpoint.useQuery();
// Mutation - 用于修改数据 const createMutation = trpc.example.createItem.useMutation();
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>;
return ( <div> <p>{data?.message}</p> <button onClick={() => createMutation.mutate({ name: "Item", description: "Description" })} > Create </button> </div> );}使用 React Query 选项
Section titled “使用 React Query 选项”const { data } = trpc.example.publicEndpoint.useQuery( undefined, // 输入参数 { enabled: shouldFetch, // 条件查询 refetchOnWindowFocus: false, // 窗口聚焦时不重新获取 staleTime: 5000 // 数据过期时间 })tRPC 自动处理错误,可以在前端捕获:
const mutation = trpc.example.createItem.useMutation({ onError: (error) => { console.error('Error:', error.message) // 显示错误提示 }, onSuccess: (data) => { console.log('Success:', data) // 显示成功提示 // 刷新列表 }})在 tRPC 过程中抛出错误:
import { TRPCError } from '@trpc/server'
protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => { const item = await findItem(input.id)
if (!item) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Item not found' }) }
return item})BAD_REQUEST: 400 - 请求参数错误UNAUTHORIZED: 401 - 未授权FORBIDDEN: 403 - 禁止访问NOT_FOUND: 404 - 资源不存在INTERNAL_SERVER_ERROR: 500 - 服务器错误
使用 Zod 进行输入验证:
import { z } from 'zod'
export const exampleRouter = router({ createUser: publicProcedure .input( z.object({ email: z.string().email(), name: z.string().min(2).max(100), age: z.number().int().min(18).max(120) }) ) .mutation(async ({ input }) => { // input 已经通过验证 return createUser(input) })})数据库操作只应出现在 server-side 包或后端应用中,例如 packages/api、packages/auth、packages/db、apps/server。前端页面不要直接访问 Drizzle。
在 tRPC 过程中使用数据库:
import { db } from '@IAM/db'import { posts } from '@IAM/db/schema'import { eq } from 'drizzle-orm'
export const postsRouter = router({ list: publicProcedure.query(async () => { return await db.select().from(posts) }),
getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => { const [post] = await db.select().from(posts).where(eq(posts.id, input.id)) return post }),
create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string() }) ) .mutation(async ({ input, ctx }) => { const [post] = await db .insert(posts) .values({ id: generateId(), title: input.title, content: input.content, authorId: ctx.session.user.id }) .returning() return post })})数据访问层策略(Drizzle)
Section titled “数据访问层策略(Drizzle)”当前架构强制 Web 与服务端数据层隔离:apps/web 禁止导入 @IAM/db、@IAM/auth 和 @IAM/env/server。即使是 Next.js Server Component,也必须通过 tRPC 或已有同源 API 访问 iam-api。
Hono + tRPC + Drizzle(默认)
Section titled “Hono + tRPC + Drizzle(默认)”场景:客户端交互、搜索、分页、复杂业务逻辑。
路径:浏览器 → tRPC Client → Hono → Drizzle → Database
用户操作时请求发往 Hono,Hono 通过 Drizzle 访问数据库,前后端类型安全贯通,逻辑集中在后端。
// packages/api 或 apps/server 中的 tRPC Routerimport { db } from '@IAM/db'import { users } from '@IAM/db/schema'
export const userRouter = router({ list: publicProcedure.query(async () => { return await db.select().from(users) })})推荐指数:⭐⭐⭐⭐⭐(交互与类型体验最佳)
架构总览(Cheat Sheet)
Section titled “架构总览(Cheat Sheet)”| 场景 | 前端发起方式 | 后端执行位置 | 数据访问方式 |
|---|---|---|---|
| 页面读取 | tRPC query + TanStack Query | @IAM/api |
Drizzle |
| 表单修改 | tRPC mutation | @IAM/api |
Drizzle |
| Better Auth 标准能力 | authClient.* |
@IAM/auth |
Better Auth adapter |
| 文件上传 | fetch('/api/upload') |
Hono REST | R2 UPLOADS |
注意事项:连接池
Section titled “注意事项:连接池”只有服务端 package 使用 Drizzle。当前运行时使用 Neon HTTP;需要多步写入原子性时使用数据库 batch/transaction 能力,避免把查询拆到 Web Worker。
文件上传接口
Section titled “文件上传接口”文件上传使用 REST 接口(multipart),与 tRPC 逻辑一致,便于浏览器通过 FormData 上传。
- 方法:
POST /api/upload - 认证: 需要登录态(Better Auth session),未登录返回 401
- Content-Type:
multipart/form-data - 请求体字段:
pathname(string):存储路径,用于在 R2 中标识文件body(File):要上传的文件
- 成功响应:
200,{ url: string },为文件的公开访问 URL - 错误响应:
400: 缺少pathname或body(或body不是 File)401: 未授权
前端调用示例
Section titled “前端调用示例”const res = await fetch('/api/upload', { method: 'POST', credentials: 'include', body: (() => { const formData = new FormData() formData.set('pathname', 'avatars/user-123.png') formData.set('body', file) // File 来自 input[type="file"] 或拖拽 return formData })()})if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(err.error ?? 'Upload failed')}const { url } = await res.json()// 使用 url 作为图片等资源的地址服务端通过 R2 Binding UPLOADS 保存文件,公开读取地址为 https://iam.cq-i.cn/api/files/{pathname}。本地 Node 开发会回退写入 .local-uploads。
- 类型安全: 始终使用 TypeScript 和 Zod 验证
- 错误处理: 提供清晰的错误消息
- 权限检查: 在受保护的过程中验证权限
- 性能优化: 使用数据库索引和查询优化
- 代码组织: 将相关路由组织在同一文件中
- 数据访问层: Web 端统一通过 tRPC 或已有同源 REST/Auth API,不直连数据库