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)Cookietoken=xxx
readFormData(event)FormData(文件上传)multipart/form-data

2.1 类型安全的参数校验

推荐用 zodh3-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
  • WebhookreadRawBody + HMAC 签名验证,确保请求来自可信来源