API Routes 全栈开发实战
Nuxt4 的
server/api/目录让前端开发者无需搭建额外后端就能编写 API 接口。本章从defineEventHandler的完整用法讲起,覆盖请求参数读取、响应控制、错误处理,最后用 AI 视频元数据 CRUD 接口串联所有知识点。
1. defineEventHandler 完整用法
1.1 基本结构
每个 API 文件默认导出一个事件处理器:
// server/api/hello.get.ts → GET /api/hello
export default defineEventHandler((event) => {
return { message: 'Hello from Nuxt4 API' }
})返回值自动序列化:对象/数组 → JSON,字符串 → 纯文本,null → 204 No Content。
1.2 异步处理
// server/api/videos/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const video = await db.query.videos.findFirst({ where: eq(videos.id, id) })
if (!video) throw createError({ statusCode: 404, message: '视频不存在' })
return video
})2. 请求参数读取
H3 提供一系列工具函数,按来源读取请求数据:
| 工具函数 | 来源 | 示例 |
|---|---|---|
getRouterParam(event, 'id') | URL 路径参数 | /api/videos/:id |
getQuery(event) | URL 查询参数 | ?page=1&limit=10 |
readBody(event) | 请求体(POST/PUT) | JSON body |
getRequestHeaders(event) | 请求头 | Authorization |
parseCookies(event) | Cookie | token=xxx |
readFormData(event) | FormData(文件上传) | multipart/form-data |
2.1 类型安全的参数校验
推荐用 zod 或 h3-zod 做运行时校验:
import { z } from 'zod'
const createVideoSchema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(5000).optional(),
category: z.enum(['ai', 'tutorial', 'vlog']),
tags: z.array(z.string()).max(10).default([]),
})
// server/api/videos/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const data = createVideoSchema.parse(body) // 校验失败自动抛 400 错误
const video = await db.insert(videos).values(data).returning()
setResponseStatus(event, 201)
return video[0]
})2.2 查询参数与分页
// server/api/videos/index.get.ts
export default defineEventHandler(async (event) => {
const { page = '1', limit = '20', category, sort = 'newest' } = getQuery(event)
const offset = (Number(page) - 1) * Number(limit)
const [items, [{ count }]] = await Promise.all([
db.query.videos.findMany({
where: category ? eq(videos.category, String(category)) : undefined,
orderBy: sort === 'popular' ? desc(videos.views) : desc(videos.createdAt),
limit: Number(limit),
offset,
}),
db.select({ count: count() }).from(videos),
])
return { items, total: count, page: Number(page) }
})3. 响应控制与错误处理
3.1 响应状态码与头
export default defineEventHandler((event) => {
setResponseStatus(event, 201) // 状态码
setResponseHeader(event, 'Cache-Control', 'max-age=60') // 响应头
setCookie(event, 'session', 'xxx', {
httpOnly: true, secure: true, maxAge: 60 * 60 * 24,
})
return { created: true }
})3.2 createError 统一错误处理
createError 抛出的错误会被 Nitro 自动捕获,返回标准化的 JSON 错误响应:
// 基本用法
throw createError({ statusCode: 404, message: '资源不存在' })
// → { statusCode: 404, statusMessage: 'Not Found', message: '资源不存在' }
// 带额外数据
throw createError({
statusCode: 422,
message: '参数校验失败',
data: { field: 'title', reason: '标题不能为空' },
})常用错误码:
| 状态码 | 场景 | 示例 |
|---|---|---|
| 400 | 参数错误 | 缺少必填字段 |
| 401 | 未认证 | 未提供 Token |
| 403 | 无权限 | 非创作者操作 |
| 404 | 资源不存在 | 视频 ID 无效 |
| 409 | 冲突 | 重复创建 |
| 422 | 校验失败 | zod 校验错误 |
| 429 | 请求过多 | 限流 |
3.3 全局错误拦截
Nitro 插件可以统一拦截未处理的错误:
// server/plugins/error-handler.ts
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('error', (error, { event }) => {
console.error(`[${event?.path}] ${error.message}`)
// 可以上报到 Sentry 等监控平台
})
})4. AI 视频元数据 CRUD 接口
以下是一个完整的视频资源 CRUD 接口设计,串联前面所有知识点:
// server/api/videos/index.get.ts ← 列表查询
export default defineEventHandler(async (event) => {
const { page = '1', limit = '20', category } = getQuery(event)
const items = await db.query.videos.findMany({
where: category ? eq(videos.category, String(category)) : undefined,
limit: Number(limit),
offset: (Number(page) - 1) * Number(limit),
})
return { items, page: Number(page) }
})
// server/api/videos/index.post.ts ← 创建
export default defineEventHandler(async (event) => {
const user = await requireAuth(event) // 鉴权(utils/ 自动导入)
const body = createVideoSchema.parse(await readBody(event))
const [video] = await db.insert(videos).values({ ...body, authorId: user.id }).returning()
setResponseStatus(event, 201)
return video
})
// server/api/videos/[id].get.ts ← 详情
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const video = await db.query.videos.findFirst({ where: eq(videos.id, id) })
if (!video) throw createError({ statusCode: 404, message: '视频不存在' })
return video
})
// server/api/videos/[id].put.ts ← 更新
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const id = getRouterParam(event, 'id')!
const video = await db.query.videos.findFirst({ where: eq(videos.id, id) })
if (!video) throw createError({ statusCode: 404, message: '视频不存在' })
if (video.authorId !== user.id) throw createError({ statusCode: 403, message: '无权修改' })
const body = updateVideoSchema.parse(await readBody(event))
const [updated] = await db.update(videos).set(body).where(eq(videos.id, id)).returning()
return updated
})
// server/api/videos/[id].delete.ts ← 删除
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const id = getRouterParam(event, 'id')!
const video = await db.query.videos.findFirst({ where: eq(videos.id, id) })
if (!video) throw createError({ statusCode: 404, message: '视频不存在' })
if (video.authorId !== user.id && !user.isAdmin) {
throw createError({ statusCode: 403, message: '无权删除' })
}
await db.delete(videos).where(eq(videos.id, id))
setResponseStatus(event, 204)
return null
})其中 requireAuth 是放在 server/utils/auth.ts 中的鉴权工具函数,自动导入后所有 API 都可以直接使用。下一章会详细讲解。
5. 文件上传处理
5.1 readFormData 接收文件
// server/api/upload.post.ts
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const formData = await readFormData(event)
const file = formData.get('file') as File
if (!file) throw createError({ statusCode: 400, message: '请选择文件' })
// 校验文件类型和大小
const allowedTypes = ['video/mp4', 'video/webm', 'video/quicktime']
if (!allowedTypes.includes(file.type)) {
throw createError({ statusCode: 422, message: `不支持的文件类型: ${file.type}` })
}
if (file.size > 500 * 1024 * 1024) { // 500MB
throw createError({ statusCode: 422, message: '文件大小不能超过 500MB' })
}
// 转为 Buffer
const buffer = Buffer.from(await file.arrayBuffer())
// 存储到本地或云存储
const filename = `${user.id}/${Date.now()}-${file.name}`
await useStorage('uploads').setItemRaw(filename, buffer)
return { url: `/uploads/${filename}`, size: file.size }
})5.2 云存储集成(S3)
实际项目中,视频文件应该上传到对象存储而非本地文件系统:
// server/utils/s3.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const config = useRuntimeConfig()
export const s3 = new S3Client({
region: config.s3Region,
credentials: {
accessKeyId: config.s3AccessKeyId,
secretAccessKey: config.s3SecretAccessKey,
},
})
// 生成预签名上传 URL(客户端直传,不经过服务器)
export async function getUploadPresignedUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: config.s3Bucket,
Key: key,
ContentType: contentType,
})
return getSignedUrl(s3, command, { expiresIn: 3600 })
}// server/api/upload/presign.post.ts ← 客户端先获取预签名 URL,再直传 S3
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const { filename, contentType } = await readBody(event)
const key = `videos/${user.id}/${Date.now()}-${filename}`
const uploadUrl = await getUploadPresignedUrl(key, contentType)
return { uploadUrl, key }
})为什么用预签名 URL? 视频文件通常很大(几百 MB),如果先传到 Nuxt 服务器再转发到 S3,会浪费带宽和内存。预签名 URL 让客户端直接上传到 S3,服务器只负责签发凭证。
5.3 multipart 大文件分片上传
对于超大文件(>1GB),需要分片上传:
// server/api/upload/multipart/init.post.ts ← 初始化分片上传
export default defineEventHandler(async (event) => {
const { filename, contentType, totalParts } = await readBody(event)
const key = `videos/${Date.now()}-${filename}`
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: config.s3Bucket, Key: key, ContentType: contentType,
}))
return { uploadId: UploadId, key }
})
// server/api/upload/multipart/presign.post.ts ← 为每个分片签发 URL
export default defineEventHandler(async (event) => {
const { key, uploadId, partNumber } = await readBody(event)
const command = new UploadPartCommand({
Bucket: config.s3Bucket, Key: key, UploadId: uploadId, PartNumber: partNumber,
})
const url = await getSignedUrl(s3, command, { expiresIn: 3600 })
return { url }
})
// server/api/upload/multipart/complete.post.ts ← 合并所有分片
export default defineEventHandler(async (event) => {
const { key, uploadId, parts } = await readBody(event)
await s3.send(new CompleteMultipartUploadCommand({
Bucket: config.s3Bucket, Key: key, UploadId: uploadId,
MultipartUpload: { Parts: parts },
}))
return { url: `https://${config.s3Bucket}.s3.amazonaws.com/${key}` }
})6. 流式响应与代理
6.1 流式 JSON 响应
当数据量极大时,可以用流式响应避免一次性加载到内存:
// server/api/export/videos.get.ts
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'Content-Type', 'application/json')
const stream = new ReadableStream({
async start(controller) {
controller.enqueue('[') // JSON 数组开始
let first = true
const batchSize = 100
let offset = 0
while (true) {
const batch = await db.query.videos.findMany({ limit: batchSize, offset })
if (batch.length === 0) break
for (const video of batch) {
const prefix = first ? '' : ','
controller.enqueue(prefix + JSON.stringify(video))
first = false
}
offset += batchSize
}
controller.enqueue(']') // JSON 数组结束
controller.close()
},
})
return sendStream(event, stream)
})6.2 代理转发
将请求代理到外部 API(如 AI 服务),避免在客户端暴露 API Key:
// server/api/ai/generate.post.ts ← 代理到 OpenAI API
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const body = await readBody(event)
const config = useRuntimeConfig()
// 代理到 OpenAI,隐藏 API Key
const response = await $fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.openaiApiKey}`,
'Content-Type': 'application/json',
},
body: {
model: 'gpt-4',
messages: body.messages,
max_tokens: 2000,
},
})
return response
})6.3 proxyRequest 直接代理
如果只是简单转发,不需要修改请求/响应,可以用 proxyRequest:
// server/api/proxy/[...path].ts ← 通配路由代理
export default defineEventHandler((event) => {
const path = getRouterParam(event, 'path')
const config = useRuntimeConfig()
return proxyRequest(event, `${config.upstreamApiBase}/${path}`)
})7. Route Rules 与接口级配置
routeRules 可以为不同接口设置缓存、CORS、重定向等策略,无需修改 handler 代码:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// API 接口缓存策略
'/api/videos/trending': {
cache: { maxAge: 300 }, // 缓存 5 分钟(等同于 defineCachedEventHandler)
},
// 静态数据长缓存
'/api/categories': {
cache: { maxAge: 86400 }, // 缓存 1 天
},
// 需要认证的接口(配合中间件使用)
'/api/admin/**': {
headers: { 'X-Require-Auth': 'admin' },
},
// API 版本重定向
'/api/v1/**': {
redirect: { to: '/api/v2/**', statusCode: 301 },
},
// CORS 只开放给特定接口
'/api/public/**': {
cors: true,
headers: { 'Access-Control-Allow-Origin': '*' },
},
},
})8. Webhook 接收与签名验证
外部服务(支付回调、GitHub Webhook 等)会向你的 API 发送通知。安全的做法是验证签名:
// server/api/webhooks/stripe.post.ts
import crypto from 'node:crypto'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const rawBody = await readRawBody(event) // 注意:用 readRawBody 而非 readBody
const signature = getRequestHeader(event, 'stripe-signature')
if (!rawBody || !signature) {
throw createError({ statusCode: 400, message: 'Missing body or signature' })
}
// 验证 Stripe 签名
const elements = signature.split(',')
const timestamp = elements.find(e => e.startsWith('t='))?.split('=')[1]
const sig = elements.find(e => e.startsWith('v1='))?.split('=')[1]
const payload = `${timestamp}.${rawBody}`
const expected = crypto
.createHmac('sha256', config.stripeWebhookSecret)
.update(payload)
.digest('hex')
if (sig !== expected) {
throw createError({ statusCode: 401, message: 'Invalid signature' })
}
// 签名验证通过,处理事件
const event_data = JSON.parse(rawBody)
switch (event_data.type) {
case 'checkout.session.completed':
await handlePaymentSuccess(event_data.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event_data.data.object)
break
}
return { received: true }
})关键点:用 readRawBody 获取原始请求体(未解析的字符串),因为签名是对原始内容计算的。readBody 会解析 JSON,再序列化回去可能改变内容(如字段顺序),导致签名不匹配。
本章小结
- defineEventHandler:每个 API 文件默认导出一个处理器,返回值自动序列化
- 参数读取:
getRouterParam(路径)、getQuery(查询)、readBody(请求体)各司其职 - 类型安全:结合 zod 做运行时参数校验,校验失败直接返回 400
- createError:标准化错误响应,常用 400/401/403/404/422
- 文件上传:小文件用
readFormData,大文件用 S3 预签名 URL 客户端直传,超大文件用分片上传 - 流式响应:
sendStream发送大数据集,避免内存溢出 - 代理转发:
proxyRequest或$fetch隐藏第三方 API Key - routeRules:接口级缓存/CORS/重定向配置,无需修改 handler
- Webhook:
readRawBody+ HMAC 签名验证,确保请求来自可信来源