L20 · RESTful API 设计与实现
🎯 本节目标:设计规范的 RESTful API,实现 CRUD 控制器 + 错误处理中间件
📦 本节产出:完整的商品 CRUD API + 统一错误处理 + 请求验证
🔗 前置钩子:L19 的 Express + MongoDB 基础
🔗 后续钩子:L21 将在前端用 Axios 调用这些 API1. RESTful 设计规范
1.1 核心原则
本课程用 JSON 交换数据;REST 并不限定表示格式。下面按 HTTP 资源接口的常见约定实现商品 CRUD。
1.2 API 路由设计
| 方法 | 路径 | 描述 | 状态码 |
|---|---|---|---|
GET | /api/products | 获取商品列表(支持分页/搜索) | 200 |
GET | /api/products/:id | 获取单个商品详情 | 200 / 404 |
POST | /api/products | 创建新商品 | 201 |
PUT | /api/products/:id | 替换全部可编辑字段 | 200 / 404 |
PATCH | /api/products/:id | 部分更新商品 | 200 / 404 |
DELETE | /api/products/:id | 删除商品 | 204 / 404 |
❌ 错误的 URL 设计:
GET /api/getProducts
POST /api/createProduct
POST /api/deleteProduct/123
✅ 正确的 RESTful 设计:
GET /api/products → 列表
GET /api/products/123 → 详情
POST /api/products → 创建
PUT /api/products/123 → 更新
DELETE /api/products/123 → 删除2. 统一响应格式
// server/src/utils/response.ts
import type { Response } from 'express'
export function success<T>(res: Response, data: T, statusCode = 200) {
return res.status(statusCode).json({ success: true, data })
}
export function successWithPagination<T>(
res: Response,
data: T[],
pagination: { page: number; limit: number; total: number },
) {
return res.json({
success: true,
data,
pagination: {
...pagination,
totalPages: Math.ceil(pagination.total / pagination.limit),
},
})
}
export function error(res: Response, message: string, statusCode = 400) {
return res.status(statusCode).json({ success: false, message })
}后续 JSON 接口统一返回 { success: true, data },分页再附 pagination;失败返回 { success: false, message }。204 响应没有 body,不套这个包装。HTTP 状态码仍然表示请求结果。
// server/src/utils/AppError.ts
export class AppError extends Error {
constructor(message: string, public statusCode = 400) {
super(message)
this.name = 'AppError'
}
}本课使用 L19 的 Express 5:async handler 返回的 Promise 拒绝会传给错误中间件,因此以下代码可直接抛错。Express 4 需要额外的 async 包装或 try/catch + next(error);定时器等脱离返回 Promise 的回调错误仍要自行处理。Express 错误处理
3. 商品控制器
// server/src/controllers/productController.ts
import type { Request, Response } from 'express'
import type { FilterQuery, SortOrder } from 'mongoose'
import Product, { type IProduct } from '../models/Product'
import { AppError } from '../utils/AppError'
import { success, successWithPagination } from '../utils/response'
function queryText(value: unknown, fallback = ''): string {
if (value === undefined) return fallback
if (typeof value !== 'string') throw new AppError('查询参数必须是单个字符串')
return value.trim()
}
function positiveInteger(value: unknown, fallback: number): number {
const text = queryText(value, String(fallback))
const n = Number(text)
if (!/^\d+$/.test(text) || !Number.isSafeInteger(n) || n < 1) {
throw new AppError('page 和 limit 必须是正整数')
}
return n
}
function priceBound(value: unknown): number | undefined {
if (value === undefined) return undefined
const text = queryText(value)
const n = Number(text)
if (!text || !Number.isFinite(n) || n < 0) throw new AppError('价格范围必须是非负数字')
return n
}
// GET /api/products
export async function getProducts(req: Request, res: Response) {
const page = positiveInteger(req.query.page, 1)
const limit = Math.min(50, positiveInteger(req.query.limit, 12))
const skip = (page - 1) * limit
if (!Number.isSafeInteger(skip)) throw new AppError('页码过大')
const search = queryText(req.query.search)
const category = queryText(req.query.category)
if (search.length > 100 || category.length > 80) throw new AppError('搜索词或分类过长')
const minPrice = priceBound(req.query.minPrice)
const maxPrice = priceBound(req.query.maxPrice)
if (minPrice !== undefined && maxPrice !== undefined && minPrice > maxPrice) {
throw new AppError('最低价格不能高于最高价格')
}
const sorts: Record<string, Record<string, SortOrder>> = {
'-createdAt': { createdAt: -1, _id: -1 },
createdAt: { createdAt: 1, _id: 1 },
price: { price: 1, _id: 1 },
'-price': { price: -1, _id: -1 },
'-rating': { rating: -1, _id: -1 },
'-reviewCount': { reviewCount: -1, _id: -1 },
}
const sortKey = queryText(req.query.sort, '-createdAt')
if (!Object.prototype.hasOwnProperty.call(sorts, sortKey)) throw new AppError('不支持的排序方式')
const query: FilterQuery<IProduct> = { isActive: true }
if (search) {
// 把用户输入作为普通文本,而不是任意正则表达式
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
query.$or = [
{ name: { $regex: escaped, $options: 'i' } },
{ description: { $regex: escaped, $options: 'i' } },
]
}
if (category) query.category = category
if (minPrice !== undefined || maxPrice !== undefined) {
query.price = {
...(minPrice !== undefined ? { $gte: minPrice } : {}),
...(maxPrice !== undefined ? { $lte: maxPrice } : {}),
}
}
const [products, total] = await Promise.all([
Product.find(query).sort(sorts[sortKey]).skip(skip).limit(limit).lean(),
Product.countDocuments(query),
])
successWithPagination(res, products, { page, limit, total })
}
// GET /api/products/:id,已下架商品对公开接口视为不存在
export async function getProductById(req: Request, res: Response) {
const product = await Product.findOne({ _id: req.params.id, isActive: true }).lean()
if (!product) throw new AppError('商品不存在', 404)
success(res, product)
}
// 请求先经过第 6 节的字段验证;这里只取可编辑字段
const writableFields = ['name', 'description', 'price', 'category', 'images', 'stock'] as const
function pickProduct(body: Record<string, unknown>) {
const data: Record<string, unknown> = {}
for (const field of writableFields) {
if (body[field] !== undefined) data[field] = body[field]
}
return data
}
export async function createProduct(req: Request, res: Response) {
const product = await Product.create(pickProduct(req.body))
success(res, product, 201)
}
// PUT 要求完整可编辑字段;PATCH 共用此函数,但只验证提交的字段
// 不允许覆盖评分、ID 等服务端字段
export async function updateProduct(req: Request, res: Response) {
const product = await Product.findOneAndUpdate(
{ _id: req.params.id, isActive: true },
{ $set: pickProduct(req.body) },
{ new: true, runValidators: true },
)
if (!product) throw new AppError('商品不存在', 404)
success(res, product)
}
// DELETE:软删除;204 不返回 JSON body
export async function deleteProduct(req: Request, res: Response) {
const product = await Product.findOneAndUpdate(
{ _id: req.params.id, isActive: true },
{ $set: { isActive: false } },
)
if (!product) throw new AppError('商品不存在', 404)
res.status(204).end()
}4. 路由配置
先保存第 5、6 节的中间件,再启动服务。这一课的写接口仅用于本机 CRUD 练习,尚未加入登录鉴权;L19 的服务绑定 127.0.0.1。L22 会在这些写路由前加入 JWT 和管理员检查,完成前不要将这组接口开放到公网。
// server/src/routes/productRoutes.ts
import { Router } from 'express'
import {
getProducts, getProductById, createProduct, updateProduct, deleteProduct,
} from '../controllers/productController'
import { validate, productRules } from '../middlewares/validate'
const router = Router()
router.get('/', getProducts)
router.get('/:id', getProductById)
router.post('/', validate(productRules), createProduct)
router.put('/:id', validate(productRules), updateProduct)
router.patch('/:id', validate(productRules, { partial: true }), updateProduct)
router.delete('/:id', deleteProduct)
export default router// server/src/app.ts(替换 L19 的同名文件,index.ts 保留)
import express from 'express'
import cors from 'cors'
import productRoutes from './routes/productRoutes'
import { errorHandler } from './middlewares/errorHandler'
const app = express()
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true,
}))
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true }))
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.use('/api/products', productRoutes)
// 后续各课的新路由都放在 404 与 errorHandler 之前
app.use((_req, res) => {
res.status(404).json({ success: false, message: '接口不存在' })
})
app.use(errorHandler)
export default app5. 统一错误处理中间件
// server/src/middlewares/errorHandler.ts
import type { Request, Response, NextFunction } from 'express'
import mongoose from 'mongoose'
import { AppError } from '../utils/AppError'
export function errorHandler(err: unknown, _req: Request, res: Response, next: NextFunction) {
if (res.headersSent) return next(err)
if (err instanceof AppError) {
res.status(err.statusCode).json({ success: false, message: err.message })
return
}
if (err instanceof mongoose.Error.ValidationError) {
res.status(422).json({
success: false,
message: '数据验证失败',
errors: Object.values(err.errors).map(error => error.message),
})
return
}
if (err instanceof mongoose.Error.CastError) {
res.status(400).json({ success: false, message: '字段格式不正确' })
return
}
if (err instanceof mongoose.mongo.MongoServerError && err.code === 11000) {
res.status(409).json({ success: false, message: '数据已存在,请检查唯一字段' })
return
}
if (err instanceof mongoose.Error.VersionError) {
res.status(409).json({ success: false, message: '数据已被修改,请刷新后重试' })
return
}
if (err instanceof Error && ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'].includes(err.name)) {
res.status(401).json({ success: false, message: '登录凭证无效或已过期' })
return
}
// express.json 的无效 JSON / 请求体过大等 4xx 错误
const status = typeof err === 'object' && err !== null && 'status' in err
? Number(err.status) : 500
if (Number.isInteger(status) && status >= 400 && status < 500) {
res.status(status).json({ success: false, message: status === 413 ? '请求体过大' : '请求格式不正确' })
return
}
console.error(err)
res.status(500).json({ success: false, message: '服务器内部错误' })
}6. 请求验证中间件
// server/src/middlewares/validate.ts
import type { Request, Response, NextFunction } from 'express'
type ValidationRule = {
field: string
required?: boolean
type: 'string' | 'number' | 'boolean' | 'string[]'
min?: number
max?: number
integer?: boolean
minLength?: number
maxLength?: number
}
export const productRules: ValidationRule[] = [
{ field: 'name', required: true, type: 'string', minLength: 1, maxLength: 100 },
{ field: 'description', required: true, type: 'string', minLength: 1, maxLength: 5000 },
{ field: 'price', required: true, type: 'number', min: 0, max: 10000000 },
{ field: 'category', required: true, type: 'string', minLength: 1, maxLength: 80 },
{ field: 'images', required: true, type: 'string[]', maxLength: 12 },
{ field: 'stock', required: true, type: 'number', min: 0, integer: true },
]
export function validate(rules: ValidationRule[], { partial = false } = {}) {
return (req: Request, res: Response, next: NextFunction) => {
const body: unknown = req.body
if (!body || typeof body !== 'object' || Array.isArray(body)) {
res.status(400).json({ success: false, message: '请求体必须是 JSON 对象' })
return
}
const values = body as Record<string, unknown>
const errors: string[] = []
const allowed = new Set(rules.map(rule => rule.field))
if (Object.keys(values).some(key => !allowed.has(key))) errors.push('包含不允许写入的字段')
if (partial && Object.keys(values).length === 0) errors.push('至少提供一个更新字段')
for (const rule of rules) {
const value = values[rule.field]
if (value === undefined) {
if (rule.required && !partial) errors.push(`${rule.field} 为必填项`)
continue
}
if (rule.type === 'string[]') {
if (!Array.isArray(value) || !value.every(item => typeof item === 'string' && item.trim().length > 0)) {
errors.push(`${rule.field} 必须是字符串数组`)
} else if (rule.maxLength !== undefined && value.length > rule.maxLength) {
errors.push(`${rule.field} 最多 ${rule.maxLength} 项`)
}
continue
}
if (typeof value !== rule.type) {
errors.push(`${rule.field} 类型必须为 ${rule.type}`)
continue
}
if (typeof value === 'number') {
if (!Number.isFinite(value) || (rule.integer && !Number.isSafeInteger(value))) {
errors.push(`${rule.field} 必须是有效${rule.integer ? '整数' : '数字'}`)
}
if (rule.min !== undefined && value < rule.min) errors.push(`${rule.field} 不能小于 ${rule.min}`)
if (rule.max !== undefined && value > rule.max) errors.push(`${rule.field} 不能大于 ${rule.max}`)
if (rule.field === 'price' && Math.abs(value * 100 - Math.round(value * 100)) > 0.000001) {
errors.push('price 最多保留两位小数')
}
}
if (typeof value === 'string') {
const text = value.trim()
if (rule.minLength !== undefined && text.length < rule.minLength) errors.push(`${rule.field} 太短`)
if (rule.maxLength !== undefined && text.length > rule.maxLength) errors.push(`${rule.field} 太长`)
values[rule.field] = text
}
}
if (errors.length) {
res.status(400).json({ success: false, message: '参数错误', errors })
return
}
next()
}
}验证中间件限制字段类型和可编辑范围;Mongoose 的 runValidators 再检查更新路径上的 Schema 规则。后者默认关闭,也不能替代完整请求验证。Mongoose 8 更新验证
完成上述文件后运行 npm run build、npm run dev。新数据库没有商品,可先创建一条,再用返回的 _id 测试 PUT/PATCH/DELETE:
curl -X POST http://localhost:3000/api/products \
-H 'Content-Type: application/json' \
-d '{"name":"示例键盘","description":"用于课程接口测试","price":199,"category":"外设","images":[],"stock":10}'
curl 'http://localhost:3000/api/products?page=1&limit=12&sort=price'limit=0、page=abc、价格下限大于上限、空 PATCH、写入 rating 等请求应返回 400;创建价格为 0 的商品允许通过。软删除后再次获取详情应返回 404。此处的正则是普通文本包含搜索,不使用 L19 建立的 MongoDB text 索引;数据量大时应单独设计索引与搜索方案。
7. 本节总结
检查清单
- [ ] 能设计规范的 RESTful URL(资源导向、HTTP 方法语义化)
- [ ] 能实现 CRUD 控制器(getList / getById / create / update / delete)
- [ ] 能实现分页 + 搜索 + 排序 + 价格过滤
- [ ] 理解
.lean()对查询性能的优化 - [ ] 能实现统一错误处理中间件(区分不同错误类型)
- [ ] 能实现请求验证中间件
- [ ] 理解软删除 vs 硬删除的区别
- [ ] 能用
Promise.all并行查询数据和总数
🐞 防坑指南
| 坑 | 说明 | 正确做法 |
|---|---|---|
| URL 中用动词 | /api/getProducts 不符合 REST | 用名词 /api/products + HTTP 方法 |
| 全量 PUT 缺字段 | 本课会拒绝缺少可编辑字段的请求 | 部分更新用 PATCH;PUT 补齐所有可编辑字段 |
| 分页参数直接 parseInt | 非数字、0、负数进入数据库查询 | 先验证正整数,再把 limit 限制到 50 |
| 软删除后仍能查详情 | 只在列表过滤 isActive | 列表、详情和写操作都过滤已下架商品 |
| 错误中间件顺序错 | 放在路由前面,接不到后面路由传下来的错误 | 错误处理中间件必须在所有路由之后 |
📐 最佳实践
- 统一响应格式:
{ success, data, message, pagination }前后端约定一致 - 只读查询可用
.lean():跳过 Mongoose 文档实例化,减少内存与处理开销;结果没有文档的save()、变更跟踪等能力,收益需要实际测量 - 独立查询可并行:列表和总数可用
Promise.all;并发写入时二者不保证来自同一快照,也不应照搬到同一数据库事务内 - 状态码语义化:201 创建、204 无内容删除、422 验证失败、409 冲突
Git 提交
git add .
git commit -m "L20: RESTful API + CRUD + 错误处理 + 验证"🔗 → 下一节
L21 将在前端用 Axios 封装调用这些 API——创建实例、拦截器、API 模块化、useRequest composable。