上一节让请求找到了归宿,这一节回答归宿里该装什么:把订单业务建模成 REST 资源,设计资源与方法矩阵,再把矩阵落成可运行的 Koa 代码。REST 常被误解为"URL 好看一点",本节要立住的是它的实质——用 HTTP 本身的语义(方法、状态码、头)承载接口的全部约定,让客户端不需要读文档就能猜对一半。
REST 的第一步是把业务名词翻译成资源。订单系统里的名词有:订单(orders)、订单里的条目(orders 的子资源 items)。动词——创建、查询列表、查详情、改地址、取消——全部映射到统一的方法集上,不允许自造动词路径(/deleteOrder 这类设计在这一步就被拦下)。
资源与方法矩阵是这一步的交付物,对着它写代码、对着它写文档、对着它做测试:
| 操作 | 方法与路径 | 成功状态码 | 失败状态码 |
|---|---|---|---|
| 查询订单列表 | GET /orders | 200 | 401 |
| 分页与筛选 | GET /orders?status=paid&page=2 | 200 | 401 |
| 查询订单详情 | GET /orders/:id | 200 | 401 / 404 |
| 创建订单 | POST /orders | 201 + Location 头 | 400 / 401 / 422 |
| 全量替换(罕用) | PUT /orders/:id | 200 | 400 / 404 |
| 部分更新地址 | PATCH /orders/:id/address | 200 | 400 / 404 |
| 取消订单 | POST /orders/:id/cancellation | 200 | 409 / 404 |
| 删除订单 | DELETE /orders/:id | 204 | 403 / 404 |
矩阵里有三处值得展开的判断。其一,取消订单用了 POST 加名词路径,而不是 PATCH 状态字段——"取消"是有独立业务规则的动作(只有待支付可取消、要触发退款流程),一个动词化的子资源比让客户端 PUT status 字段更安全也更可审计。REST 不是教条,状态迁移类动作用动作子资源表达是主流实践。其二,删除成功回 204 无内容,客户端拿到的就是"删掉了",没有废话。其三,409 Conflict 留给状态冲突(已发货的订单不可取消),与 400(参数本身不合法)、422(参数合法但语义不通过)形成梯度。

把矩阵落成代码。为了聚焦路由与语义,存储用内存 Map,真实项目换成数据库即可,接口层不变:
const Router = require('koa-router'); const { BadRequest, NotFound, Forbidden, Conflict } = require('../errors'); const { validate } = require('../middleware/validate'); const router = new Router({ prefix: '/api/v1/orders' }); const store = new Map(); // id -> order let seq = 1000; // 列表:支持 status 筛选与 page 分页 router.get('/', auth(), async (ctx) => { const { status, page = '1', size = '20' } = ctx.query; let list = [...store.values()].filter((o) => o.userId === ctx.state.user.id); if (status) list = list.filter((o) => o.status === status); const p = Math.max(1, parseInt(page, 10) || 1); const s = Math.min(100, Math.max(1, parseInt(size, 10) || 20)); ctx.body = { total: list.length, page: p, items: list.slice((p - 1) * s, p * s), }; }); // 详情:不存在即 404,语义由异常类携带 router.get('/:id', auth(), async (ctx) => { const order = store.get(ctx.params.id); if (!order) throw new NotFound(`订单 ${ctx.params.id} 不存在`); if (order.userId !== ctx.state.user.id) throw new Forbidden(); ctx.body = order; }); // 创建:201 + Location 头 router.post('/', auth(), validate(createSchema), async (ctx) => { const id = `ORD-${++seq}`; const order = { id, userId: ctx.state.user.id, items: ctx.validated.items, address: ctx.validated.address, status: 'pending', createdAt: new Date().toISOString(), }; store.set(id, order); ctx.status = 201; ctx.set('Location', `/api/v1/orders/${id}`); ctx.body = order; }); // 取消:动作子资源,状态冲突回 409 router.post('/:id/cancellation', auth(), async (ctx) => { const order = store.get(ctx.params.id); if (!order) throw new NotFound(); if (order.status !== 'pending') { throw new Conflict(`订单当前状态为 ${order.status},不可取消`); } order.status = 'cancelled'; order.cancelledAt = new Date().toISOString(); ctx.body = order; }); // 删除:204 无内容 router.delete('/:id', auth(), requireRole('admin'), async (ctx) => { if (!store.delete(ctx.params.id)) throw new NotFound(); ctx.status = 204; });
对照矩阵逐行核对:状态码全部来自异常类或显式赋值;创建回 201 并带 Location 让客户端能拿到新资源地址;取消是状态迁移,用动作子资源加 409 语义;删除回 204。处理器里没有一处手写 ctx.status = 4xx 的散落判断——错误语义全部委托给 2.4 节的异常体系,这正是分层的回报。
接口写完,curl 会话就是验收单:
# 创建:201,响应头带 Location curl -i -X POST http://localhost:3000/api/v1/orders \ -H 'authorization: Bearer <token>' -H 'content-type: application/json' \ -d '{"items":[{"sku":"A1","qty":2}],"address":"上海市某路 1 号"}' # HTTP/1.1 201 Created # Location: /api/v1/orders/1001 # 查详情:200 curl -i http://localhost:3000/api/v1/orders/ORD-1001 -H 'authorization: Bearer <token>' # 用错误方法访问:405 + Allow 头 curl -i -X PUT http://localhost:3000/api/v1/orders/ORD-1001/cancellation \ -H 'authorization: Bearer <token>' # HTTP/1.1 405 Method Not Allowed # Allow: POST # 删除:204,无响应体 curl -i -X DELETE http://localhost:3000/api/v1/orders/ORD-1001 \ -H 'authorization: Bearer <token>' # HTTP/1.1 204 No Content
⚠️ 常见坑:把业务规则硬塞进 PATCH。客户端
PATCH /orders/:id直接改 status 字段,等于把状态机交给前端随意拨动——已发货也能改成待支付。状态迁移必须走显式动作(cancellation、payment),PATCH 只留给真正的属性编辑(如收货地址)。状态机的入口收得越窄,系统的可审计性越强。
矩阵里的 ?status=paid&page=2 与请求体怎么从"不可信输入"变成"可信参数"——下一节把参数解析与校验做成一层前置事实。