Skip to content

API 开发

IAM 项目使用 Next.js + Hono + tRPC + Drizzle 技术栈,提供端到端类型安全的 API。当前架构以 apps/server 作为唯一后端权威入口:Hono 挂载 Better Auth、OAuth/OIDC Provider、tRPC 和必要的 REST endpoint;Next.js 负责页面与交互,不直接访问数据库。

前端数据访问优先级:

  1. Better Auth 原生客户端能力:用户、会话、标准认证流程优先使用 authClient。
  2. tRPC:IAM 管理后台的业务聚合、统计、搜索和扩展配置。
  3. Hono REST endpoint:文件上传、第三方回调、内部运维等不适合 tRPC 的场景。

apps/web 不应直接 import @IAM/auth、@IAM/db、@IAM/env/server。需要共享类型时,从 @IAM/contracts 引入纯类型或 schema。

在 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

在 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>
);
}
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 { user } from '@IAM/db/schema/auth'
import { eq } from 'drizzle-orm'
export const userRouter = router({
list: publicProcedure.query(async () => {
return await db.select().from(user)
}),
getById: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const [found] = await db.select().from(user).where(eq(user.id, input.id))
return found
}),
rename: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1)
})
)
.mutation(async ({ input, ctx }) => {
requirePlatformAdmin(ctx.session.user.role)
const [updated] = await db
.update(user)
.set({ name: input.name })
.where(eq(user.id, input.id))
.returning()
return updated
})
})

Schema 按 auth / wecom / provisioning 三个模块拆分,导入时必须带模块子路径(@IAM/db/schema/auth);裸的 @IAM/db/schema 无法解析。表与模块的对应关系见数据库管理。

当前架构强制 Web 与服务端数据层隔离:apps/web 禁止导入 @IAM/db、@IAM/auth 和 @IAM/env/server。即使是 Next.js Server Component,也必须通过 tRPC 或已有同源 API 访问 iam-api。

场景:客户端交互、搜索、分页、复杂业务逻辑。

路径:浏览器 → tRPC Client → Hono → Drizzle → Database

用户操作时请求发往 Hono,Hono 通过 Drizzle 访问数据库,前后端类型安全贯通,逻辑集中在后端。

// packages/api 或 apps/server 中的 tRPC Router
import { db } from '@IAM/db'
import { user } from '@IAM/db/schema/auth'
export const userRouter = router({
list: publicProcedure.query(async () => {
return await db.select().from(user)
})
})

推荐指数:⭐⭐⭐⭐⭐(交互与类型体验最佳)

场景 前端发起方式 后端执行位置 数据访问方式
页面读取 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

只有服务端 package 使用 Drizzle。当前运行时使用 Neon HTTP;需要多步写入原子性时使用数据库 batch/transaction 能力,避免把查询拆到 Web Worker。

除 tRPC 之外,apps/server 还注册了这些 REST 端点,新增路由前先确认是否已有同类能力:

方法与路径 认证 用途
GET /.well-known/oauth-authorization-server/api/auth 公开 RFC 8414 授权服务器元数据(只在后端 origin)
GET /api/auth/.well-known/openid-configuration 公开 OIDC 发现文档
ALL /api/auth/* 按端点决定 Better Auth handler(含 OAuth/OIDC 端点)
POST /api/upload 登录态 上传文件到 R2 UPLOADS
GET /api/files/* 公开 按路径读取已上传文件
GET / POST /api/wecom/callback 企业微信回调校验 通讯录事件回调
POST /api/internal/provisioning/snapshot HMAC 签名头 应用开通快照,见账户与访问权限同步协议
POST /api/internal/wecom/full-sync Bearer token 企微全量同步,供外部 Cron 兜底
POST /api/internal/maintenance/cleanup Bearer token 清理过期的限流与防重放记录
GET /api/health 公开 健康检查与环境变量自检

/api/internal/* 中的运维端点使用 Authorization: Bearer <WECOM_FULL_SYNC_CRON_TOKEN> 认证;开通快照使用 x-iam-* 签名头。/api/files/* 没有鉴权,任何知道路径的人都能读取,因此不要把敏感文件放到该前缀下。

文件上传使用独立的 REST 接口(multipart),因为浏览器通过 FormData 上传更直接;@IAM/api 中没有对应的 tRPC 上传过程。

  • 方法: 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: 未授权
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。

  1. 类型安全: 始终使用 TypeScript 和 Zod 验证
  2. 错误处理: 提供清晰的错误消息
  3. 权限检查: 在受保护的过程中验证权限
  4. 性能优化: 使用数据库索引和查询优化
  5. 代码组织: 将相关路由组织在同一文件中
  6. 数据访问层: Web 端统一通过 tRPC 或已有同源 REST/Auth API,不直连数据库