Skip to Content
⏱ 本页预计时间
阅读 20 分钟 · 练习 60 分钟

4.3 编写后端服务

前面学了调用别人的 API 和操作数据库,现在学自己写后端服务

🎯

学完这节你会

  • 理解什么时候需要自己写后端
  • 会创建 Next.js API Routes
  • 会处理表单提交、文件上传
  • 会集成第三方服务(邮件、支付等)
  • 掌握 API 设计最佳实践

为什么需要自己写后端?

前面我们学了两种方式:

  1. 调用第三方 API(如天气、AI)
  2. 操作数据库(增删改查)

但有些场景,这两种方式解决不了:

需要自己写后端的场景

  1. 复杂业务逻辑

    • 用户注册时需要发邮件 + 创建数据库记录 + 赠送优惠券
  2. 数据处理

    • 上传图片后需要压缩、加水印、生成缩略图
  3. 安全保护

    • 支付接口不能在前端直接调用,会泄露密钥
  4. 多个服务整合

    • 一个接口调用多个第三方 API,再组合结果返回
  5. 定时任务

    • 每天凌晨清理过期数据

核心原则前端不安全,敏感操作必须在后端处理!


Next.js API Routes 基础

什么是 API Route?

API Route 就是你自己的后端接口,就像餐厅里的厨房。

比喻

  • 第三方 API = 去别人餐厅点菜
  • 自己的 API Route = 你家厨房做菜
  • 数据库 = 冰箱里的食材

客人(前端)在你家点菜,你的厨房(API Route)从冰箱(数据库)拿食材,再用外面买的调料(第三方 API),最后做出一道菜。

创建第一个 API Route

在 Next.js 中,创建 app/api/ 下的文件就自动成为 API 接口:

app/ ├── api/ │ ├── hello/ │ │ └── route.ts # 接口地址: /api/hello │ ├── users/ │ │ └── route.ts # 接口地址: /api/users │ └── upload/ │ └── route.ts # 接口地址: /api/upload

最简单的例子 - 创建 app/api/hello/route.ts

import { NextRequest, NextResponse } from 'next/server' // GET /api/hello export async function GET(request: NextRequest) { return NextResponse.json({ message: 'Hello from API!' }) } // POST /api/hello export async function POST(request: NextRequest) { const body = await request.json() return NextResponse.json({ message: `You sent: ${body.name}` }) }

测试

# GET 请求 curl http://localhost:3000/api/hello # POST 请求 curl -X POST http://localhost:3000/api/hello \ -H "Content-Type: application/json" \ -d '{"name":"Ben"}'

实战 1:表单提交与邮件通知

做一个联系表单,用户提交后:

  1. 保存到数据库
  2. 发送邮件通知你

步骤 1:安装依赖

ppnpm install @supabase/supabase-js nodemailer ppnpm install -D @types/nodemailer

步骤 2:配置环境变量

.env.local 添加:

# Supabase(之前已配置) NEXT_PUBLIC_SUPABASE_URL=你的项目URL NEXT_PUBLIC_SUPABASE_ANON_KEY=你的公钥 # 邮件配置(使用 Gmail 为例) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=[email protected] SMTP_PASS=your-app-password # 不是邮箱密码!需要在 Gmail 设置里生成应用专用密码 EMAIL_FROM=[email protected] EMAIL_TO=[email protected]

Gmail 应用专用密码设置

  1. 访问 https://myaccount.google.com/security 
  2. 开启「两步验证」
  3. 搜索「应用专用密码」
  4. 生成一个新密码(16 位),复制到 SMTP_PASS

步骤 3:创建数据库表

在 Supabase SQL Editor 执行:

create table contacts ( id uuid default gen_random_uuid() primary key, name text not null, email text not null, message text not null, created_at timestamp with time zone default timezone('utc'::text, now()) not null ); -- 允许匿名插入(因为是公开表单) alter table contacts enable row level security; create policy "Allow anonymous insert" on contacts for insert to anon with check (true);

步骤 4:创建 API Route

创建 app/api/contact/route.ts

import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@supabase/supabase-js' import nodemailer from 'nodemailer' // Supabase 客户端 const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) // 邮件发送器 const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: false, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }) export async function POST(request: NextRequest) { try { const { name, email, message } = await request.json() // 1. 验证数据 if (!name || !email || !message) { return NextResponse.json( { error: '所有字段都必填' }, { status: 400 } ) } // 2. 保存到数据库 const { data, error: dbError } = await supabase .from('contacts') .insert([{ name, email, message }]) .select() if (dbError) { console.error('数据库错误:', dbError) return NextResponse.json( { error: '保存失败' }, { status: 500 } ) } // 3. 发送邮件通知 try { await transporter.sendMail({ from: process.env.EMAIL_FROM, to: process.env.EMAIL_TO, subject: `新的联系表单:${name}`, html: ` <h2>新的联系表单提交</h2> <p><strong>姓名:</strong>${name}</p> <p><strong>邮箱:</strong>${email}</p> <p><strong>留言:</strong></p> <p>${message.replace(/\n/g, '<br>')}</p> `, }) } catch (emailError) { console.error('邮件发送失败:', emailError) // 邮件失败不影响整体流程,只记录错误 } return NextResponse.json({ success: true, message: '提交成功,我们会尽快回复!', data: data[0] }) } catch (error) { console.error('请求处理失败:', error) return NextResponse.json( { error: '服务器错误' }, { status: 500 } ) } }

步骤 5:创建前端表单

创建 app/contact/page.tsx

'use client' import { useState } from 'react' export default function ContactPage() { const [formData, setFormData] = useState({ name: '', email: '', message: '' }) const [loading, setLoading] = useState(false) const [result, setResult] = useState<{type: 'success' | 'error', message: string} | null>(null) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setResult(null) try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }) const data = await res.json() if (res.ok) { setResult({ type: 'success', message: data.message }) setFormData({ name: '', email: '', message: '' }) // 清空表单 } else { setResult({ type: 'error', message: data.error || '提交失败' }) } } catch (error) { setResult({ type: 'error', message: '网络错误,请重试' }) } setLoading(false) } return ( <div className="max-w-2xl mx-auto p-8"> <h1 className="text-3xl font-bold mb-6">联系我们</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label className="block mb-2 font-medium">姓名</label> <input type="text" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} className="w-full border rounded p-2" required /> </div> <div> <label className="block mb-2 font-medium">邮箱</label> <input type="email" value={formData.email} onChange={(e) => setFormData({...formData, email: e.target.value})} className="w-full border rounded p-2" required /> </div> <div> <label className="block mb-2 font-medium">留言</label> <textarea value={formData.message} onChange={(e) => setFormData({...formData, message: e.target.value})} className="w-full border rounded p-2 h-32" required /> </div> <button type="submit" disabled={loading} className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50" > {loading ? '提交中...' : '提交'} </button> </form> {result && ( <div className={`mt-4 p-4 rounded ${ result.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }`}> {result.message} </div> )} </div> ) }

步骤 6:测试

  1. 启动开发服务器:pnpm run dev
  2. 访问 http://localhost:3000/contact
  3. 填写表单提交
  4. 检查:
    • Supabase 数据库是否有新记录
    • 你的邮箱是否收到通知邮件

实战 2:图片上传与处理

实现一个图片上传功能,上传后自动:

  1. 压缩图片
  2. 生成缩略图
  3. 保存到云存储

步骤 1:安装依赖

ppnpm install sharp # 图片处理库

步骤 2:创建上传 API

创建 app/api/upload/route.ts

import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@supabase/supabase-js' import sharp from 'sharp' const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) export async function POST(request: NextRequest) { try { const formData = await request.formData() const file = formData.get('file') as File if (!file) { return NextResponse.json( { error: '没有文件' }, { status: 400 } ) } // 1. 读取文件 const bytes = await file.arrayBuffer() const buffer = Buffer.from(bytes) // 2. 压缩图片(最大宽度 1200px,质量 80%) const compressedImage = await sharp(buffer) .resize(1200, null, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer() // 3. 生成缩略图(200x200) const thumbnail = await sharp(buffer) .resize(200, 200, { fit: 'cover' }) .jpeg({ quality: 70 }) .toBuffer() // 4. 生成文件名 const timestamp = Date.now() const originalName = file.name.replace(/\.[^/.]+$/, '') // 去掉扩展名 const mainFileName = `${timestamp}-${originalName}.jpg` const thumbFileName = `${timestamp}-${originalName}-thumb.jpg` // 5. 上传到 Supabase Storage const { data: mainData, error: mainError } = await supabase.storage .from('images') // 需要先在 Supabase 创建这个 bucket .upload(mainFileName, compressedImage, { contentType: 'image/jpeg' }) if (mainError) { console.error('主图上传失败:', mainError) return NextResponse.json( { error: '上传失败' }, { status: 500 } ) } // 6. 上传缩略图 await supabase.storage .from('images') .upload(thumbFileName, thumbnail, { contentType: 'image/jpeg' }) // 7. 获取公开访问 URL const { data: { publicUrl: mainUrl } } = supabase.storage .from('images') .getPublicUrl(mainFileName) const { data: { publicUrl: thumbUrl } } = supabase.storage .from('images') .getPublicUrl(thumbFileName) return NextResponse.json({ success: true, urls: { main: mainUrl, thumbnail: thumbUrl }, info: { originalSize: buffer.length, compressedSize: compressedImage.length, compressionRatio: ((1 - compressedImage.length / buffer.length) * 100).toFixed(2) + '%' } }) } catch (error) { console.error('上传处理失败:', error) return NextResponse.json( { error: '服务器错误' }, { status: 500 } ) } }

步骤 3:创建上传界面

创建 app/upload/page.tsx

'use client' import { useState } from 'react' export default function UploadPage() { const [file, setFile] = useState<File | null>(null) const [preview, setPreview] = useState<string>('') const [loading, setLoading] = useState(false) const [result, setResult] = useState<any>(null) const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const selectedFile = e.target.files?.[0] if (selectedFile) { setFile(selectedFile) setPreview(URL.createObjectURL(selectedFile)) } } const handleUpload = async () => { if (!file) return setLoading(true) const formData = new FormData() formData.append('file', file) try { const res = await fetch('/api/upload', { method: 'POST', body: formData }) const data = await res.json() if (res.ok) { setResult(data) } else { alert(data.error || '上传失败') } } catch (error) { alert('上传失败') } setLoading(false) } return ( <div className="max-w-2xl mx-auto p-8"> <h1 className="text-3xl font-bold mb-6">图片上传</h1> <div className="space-y-4"> <input type="file" accept="image/*" onChange={handleFileChange} className="block" /> {preview && ( <div> <h3 className="font-medium mb-2">预览:</h3> <img src={preview} alt="预览" className="max-w-md border rounded" /> </div> )} <button onClick={handleUpload} disabled={!file || loading} className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50" > {loading ? '上传中...' : '上传'} </button> {result && ( <div className="bg-green-100 p-4 rounded"> <h3 className="font-bold mb-2">上传成功!</h3> <p className="mb-2">压缩率:{result.info.compressionRatio}</p> <div className="space-y-2"> <div> <p className="font-medium">原图:</p> <img src={result.urls.main} alt="原图" className="max-w-md border" /> </div> <div> <p className="font-medium">缩略图:</p> <img src={result.urls.thumbnail} alt="缩略图" className="border" /> </div> </div> </div> )} </div> </div> ) }

步骤 4:配置 Supabase Storage

  1. 访问 Supabase 项目 → Storage
  2. 创建新 bucket:images
  3. 设置为 Public(允许公开访问)
  4. 设置 CORS 允许你的域名

API 设计最佳实践

1. RESTful 风格

使用标准的 HTTP 方法:

// app/api/users/route.ts // 获取所有用户 export async function GET(request: NextRequest) { // ... } // 创建新用户 export async function POST(request: NextRequest) { // ... } // 更新用户 export async function PUT(request: NextRequest) { // ... } // 删除用户 export async function DELETE(request: NextRequest) { // ... }

2. 统一返回格式

成功响应

{ "success": true, "data": { ... }, "message": "操作成功" }

错误响应

{ "success": false, "error": "错误信息", "code": "ERROR_CODE" }

封装一个工具函数 - 创建 lib/api-response.ts

import { NextResponse } from 'next/server' export function apiSuccess(data: any, message?: string) { return NextResponse.json({ success: true, data, message }) } export function apiError(error: string, code?: string, status = 400) { return NextResponse.json( { success: false, error, code }, { status } ) }

使用示例

import { apiSuccess, apiError } from '@/lib/api-response' export async function POST(request: NextRequest) { try { // 验证失败 if (!data.email) { return apiError('邮箱必填', 'EMAIL_REQUIRED') } // 业务逻辑... // 成功 return apiSuccess(result, '创建成功') } catch (error) { return apiError('服务器错误', 'SERVER_ERROR', 500) } }

3. 参数验证

推荐使用 Zod 做验证 - 安装:

pnpm install zod

使用示例

import { z } from 'zod' import { apiError, apiSuccess } from '@/lib/api-response' // 定义验证规则 const ContactSchema = z.object({ name: z.string().min(2, '姓名至少 2 个字符'), email: z.string().email('邮箱格式错误'), message: z.string().min(10, '留言至少 10 个字符') }) export async function POST(request: NextRequest) { try { const body = await request.json() // 验证参数 const result = ContactSchema.safeParse(body) if (!result.success) { return apiError( result.error.errors[0].message, 'VALIDATION_ERROR' ) } const { name, email, message } = result.data // 业务逻辑... return apiSuccess({ id: 123 }, '提交成功') } catch (error) { return apiError('服务器错误', 'SERVER_ERROR', 500) } }

4. 错误处理

分层处理错误

export async function POST(request: NextRequest) { try { // 1. 参数验证错误 if (!data.email) { return apiError('邮箱必填', 'EMAIL_REQUIRED', 400) } // 2. 业务逻辑错误 const user = await findUser(data.email) if (user) { return apiError('邮箱已存在', 'EMAIL_EXISTS', 409) } // 3. 第三方服务错误 try { await sendEmail(data.email) } catch (emailError) { console.error('邮件发送失败:', emailError) // 邮件失败不影响主流程,记录日志即可 } return apiSuccess(newUser) } catch (error) { // 4. 未知错误 console.error('未知错误:', error) return apiError('服务器错误', 'SERVER_ERROR', 500) } }

5. 日志记录

创建日志工具 - lib/logger.ts

export const logger = { info: (message: string, data?: any) => { console.log(`[INFO] ${new Date().toISOString()} - ${message}`, data || '') }, error: (message: string, error?: any) => { console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, error || '') }, warn: (message: string, data?: any) => { console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, data || '') } }

使用

import { logger } from '@/lib/logger' export async function POST(request: NextRequest) { logger.info('收到新的联系表单', { ip: request.ip }) try { // ... logger.info('表单提交成功', { email: data.email }) return apiSuccess(result) } catch (error) { logger.error('表单提交失败', error) return apiError('服务器错误', 'SERVER_ERROR', 500) } }

安全注意事项

API 安全检查清单

  1. 验证所有输入

    • 使用 Zod 等工具验证参数
    • 防止 SQL 注入、XSS 攻击
  2. API Key 放服务端

    • 绝不在客户端暴露敏感密钥
    • 使用环境变量(不带 NEXT_PUBLIC_ 前缀)
  3. 限流保护

    • 防止暴力破解、DDoS 攻击
    • 可以用 upstash/ratelimit
  4. 身份认证

    • 需要登录的接口要验证 Token
    • 使用 NextAuth.js 或 Supabase Auth
  5. CORS 配置

    • 只允许信任的域名访问
  6. 日志记录

    • 记录所有错误和异常操作
    • 不要在日志里记录密码、Token 等敏感信息

限流保护示例

防止用户疯狂调用你的 API:

安装依赖

pnpm install @upstash/ratelimit @upstash/redis

配置 Upstash

  1. 访问 https://upstash.com/  注册
  2. 创建 Redis 数据库
  3. 复制 UPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKEN
  4. 添加到 .env.local

创建限流工具

创建 lib/rate-limit.ts

import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' // 创建限流器:每 60 秒最多 10 次请求 export const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '60 s'), analytics: true, })

在 API 中使用

import { ratelimit } from '@/lib/rate-limit' import { apiError } from '@/lib/api-response' export async function POST(request: NextRequest) { // 限流检查(用 IP 地址作为标识) const ip = request.ip ?? '127.0.0.1' const { success, limit, remaining } = await ratelimit.limit(ip) if (!success) { return apiError( `请求过于频繁,请 ${Math.ceil((limit - Date.now()) / 1000)} 秒后重试`, 'RATE_LIMIT_EXCEEDED', 429 ) } // 正常处理请求... return NextResponse.json({ success: true, rateLimit: { limit: limit, remaining: remaining } }) }

常见问题

1. API Route 和 Server Component 的区别?

特性API RouteServer Component
用途提供 API 接口给前端调用直接在服务端渲染页面
调用方式fetch('/api/xxx')直接写逻辑,不需要 fetch
适用场景前端需要动态调用、表单提交页面初始加载、SEO 优化

何时用 API Route?

  • 表单提交
  • 文件上传
  • 需要前端动态调用
  • 第三方 webhook 回调

何时用 Server Component?

  • 页面初始数据加载
  • SEO 优化
  • 不需要前端交互的数据

2. 如何处理大文件上传?

默认 Next.js 限制请求体大小为 1MB。需要修改配置:

next.config.mjs

export default { api: { bodyParser: { sizeLimit: '10mb', // 设置为 10MB }, }, }

对于超大文件(如视频),建议

  1. 使用分片上传
  2. 或直接从前端上传到云存储(如 Supabase Storage、AWS S3)

3. 如何测试 API?

方法 1:使用 curl

curl -X POST http://localhost:3000/api/contact \ -H "Content-Type: application/json" \ -d '{"name":"Test","email":"[email protected]","message":"Hello"}'

方法 2:使用 Postman

  1. 下载 Postman
  2. 创建新请求
  3. 设置方法(GET/POST)和 URL
  4. 设置 Headers 和 Body
  5. 发送请求查看结果

方法 3:使用 VS Code REST Client 扩展

创建 test.http

### 测试联系表单 POST http://localhost:3000/api/contact Content-Type: application/json { "name": "Test User", "email": "[email protected]", "message": "This is a test message" } ### 测试获取用户 GET http://localhost:3000/api/users

点击「Send Request」即可测试。


快速实践建议

Week 1:基础 API

  1. 做一个简单的 GET API 返回当前时间
  2. 做一个 POST API 接收数据并返回

Week 2:表单处理

  1. 实现联系表单(保存到数据库)
  2. 添加邮件通知功能

Week 3:文件上传

  1. 实现基础图片上传
  2. 添加图片压缩功能

Week 4:集成与优化

  1. 添加参数验证(Zod)
  2. 添加限流保护
  3. 统一错误处理

学习顺序

  1. 先做通一个最简单的 API(Hello World)
  2. 再做表单提交(涉及数据库)
  3. 再做文件上传(稍微复杂)
  4. 最后加上验证、限流、日志等完善功能

不要一开始就追求完美,先跑通,再优化!


下一节5.1 简单网站 - 用完整项目把前后端能力串起来!

学习进度0%
0/60 篇已完成
Last updated on