Skip to content

L20 · RESTful API 设计与实现 ​

🎯 本节目标:设计规范的 RESTful API,实现 CRUD 控制器 + 错误处理中间件
📦 本节产出:完整的商品 CRUD API + 统一错误处理 + 请求验证
🔗 前置钩子:L19 的 Express + MongoDB 基础
🔗 后续钩子:L21 将在前端用 Axios 调用这些 API

1. 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. 统一响应格式 ​

typescript
// 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 状态码仍然表示请求结果。

typescript
// 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. 商品控制器 ​

typescript
// 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 和管理员检查,完成前不要将这组接口开放到公网。

typescript
// 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
typescript
// 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 app

5. 统一错误处理中间件 ​

typescript
// 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. 请求验证中间件 ​

typescript
// 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:

bash
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列表、详情和写操作都过滤已下架商品
错误中间件顺序错放在路由前面,接不到后面路由传下来的错误错误处理中间件必须在所有路由之后

📐 最佳实践 ​

  1. 统一响应格式:{ success, data, message, pagination } 前后端约定一致
  2. 只读查询可用 .lean():跳过 Mongoose 文档实例化,减少内存与处理开销;结果没有文档的 save()、变更跟踪等能力,收益需要实际测量
  3. 独立查询可并行:列表和总数可用 Promise.all;并发写入时二者不保证来自同一快照,也不应照搬到同一数据库事务内
  4. 状态码语义化:201 创建、204 无内容删除、422 验证失败、409 冲突

Git 提交 ​

bash
git add .
git commit -m "L20: RESTful API + CRUD + 错误处理 + 验证"

🔗 → 下一节 ​

L21 将在前端用 Axios 封装调用这些 API——创建实例、拦截器、API 模块化、useRequest composable。