上一节,我们把客户端流量收进了 API 网关。网关会认证、限流、路由,还会在订单服务连续超时时打开熔断器。到这里,一套订单 API 已经能从网关进入订单服务,再依次调用库存服务和支付服务;如果只看正常路径,它甚至显得很完整:请求发出,库存预留,支付成功,订单返回 201 Created。
但“能跑”只是说我们找到了一条成功路径,“敢上线”问的则是成功路径之外发生了什么。先把最可能让订单系统翻车的情况摆在桌面上:
这些问题都来自拆分之后的新边界。单体里的一次回归,往往发生在同一份代码和同一个进程中;微服务里,同一次回归可能来自两个团队、两个版本、两个部署窗口,甚至来自一次“双方单独看都没错”的接口变更。
安全也发生了同样的变化。网关验证令牌,并不自动等于订单服务已经做好对象级授权;外部请求经过 TLS,也不等于内部服务之间可以无条件互相信任。服务拆分让各模块能够独立演进,却把验证范围从“这个函数对不对”扩展成了“这条跨服务链路在正常、异常和恶意条件下还能不能守住业务事实”。

假设订单团队刚完成一次“无害”的重构:把支付响应里的 id 改成了 paymentId,因为后者更清楚。支付服务自己的单元测试全部通过,订单服务自己的单元测试也全部通过,因为测试里使用的支付存根已经跟着改了。两个服务分别部署后,订单服务却仍在读取旧字段,真实下单链路开始生成没有支付编号的订单;问题并不是简单地“少写了一个测试”,而是测试没有覆盖变化发生的边界。
再看另一个场景。测试账号携带有效令牌,请求 GET /api/orders/ord-0001,返回 200,团队于是宣布授权测试通过。可攻击者并不需要伪造令牌,他只要登录自己的账号,再把 ord-0001 改成 ord-0002;如果服务只检查“令牌有效”,没有检查“当前主体是否可以读取这笔订单”,认证完全正确,数据仍然越权泄露。
所以,上线闸门不能只写成“覆盖率超过 80%”或者“安全扫描没有高危项”。这些数字可以辅助判断,却没有说明哪些业务事实必须守住。对于这套订单系统,我们更关心请求越过边界之后发生了什么,因此先把闸门写成下面这些可观察结果:
这张表才是测试计划的起点,工具只是用来制造条件、发出请求和收集证据。只要闸门写得含糊,再强的工具也只会更快地产生一份含糊的报告;反过来,业务结果写清楚后,团队才知道该在哪个层级制造故障、检查哪一份状态。
“所有接口都返回了预期状态码”并不等于业务正确。支付失败后返回 422 是一项断言,库存是否恢复到调用前的数量是另一项断言。微服务测试要同时检查 HTTP 表面和跨服务状态。
微服务测试最容易走向两个极端。一个极端是全部使用模拟:测试很快,但模拟会把调用方相信的世界重复一遍,字段改名、序列化差异、真实超时和进程配置都可能被遮住。另一个极端是每次都启动整套系统:真实程度提高了,失败却更难定位,执行更慢,数据和环境稍有波动就可能让测试不稳定。
更实用的做法是分成单元测试、组件测试、集成测试和端到端测试。越靠下越快、越多、越容易定位,越靠上越接近真实部署,但只保留最关键的旅程。这样安排不是为了追求一个漂亮的金字塔,而是把同一风险放到反馈成本最低、又足以发现它的层级。

单元测试不启动 HTTP 服务,也不连接真实下游,它适合验证纯计算和状态规则。这一层故意避开网络与进程,只把输入、状态转换和预期结果放在一起,例如:
COMPLETED 订单可以取消,REJECTED 订单不可以;如果把创建订单的校验提取成纯函数,就能使用 Node.js 自带的测试运行器快速覆盖边界。下面这项测试只关心数量规则,不需要为一次错误输入启动四个服务:
const test = require('node:test');
const assert = require('node:assert/strict');
test('数量为 0 时拒绝创建订单', () => {
assert.throws(
() => validateCreateOrder({ productId: 'keyboard-001', quantity: 0, amount: 398 }),
(error) => error.code === 'VALIDATION_ERROR'
);
这里的 validateCreateOrder 是适合从路由中提取的纯业务函数,并不是说当前项目已经有这个函数。当前实现把校验直接写在 createOrder 中,因此现有 12 项脚本会从 HTTP 层间接覆盖它;后续如果规则变多,先提取再做单元测试,会比为每个边界都启动四个进程更快。
组件测试把单个服务当成黑盒,使用真实路由、JSON 解析、错误映射和 HTTP 响应,但把数据库、消息系统或下游服务换成可控替身。例如测试订单服务时,可以让库存替身返回 201,支付替身先超时再返回幂等重放,这样既能验证订单服务真实的 HTTP 行为,又能精确控制下游故障。它尤其适合发现下面这些单服务边界问题:
Content-Type 不符合约定;替身不是越像真实服务越好,它只应该实现契约要求的行为,并主动拒绝契约外请求。否则,替身会逐渐变成一个没人能确认真假的第二套实现,组件测试虽然持续通过,却只是证明实现与自己的想象一致。
集成测试让两个或多个真实组件协作,验证序列化、网络调用、配置和状态变化。本课程项目的 scripts/test.js 就在这一层:它会启动库存、支付、订单和网关四个真实 Node.js 进程,然后通过真实 HTTP 请求验证完整链路。它没有隔离函数,也没有真实前端、身份提供方、数据库和部署基础设施,因此更准确地说,这是一组多服务黑盒集成测试,而不是单元测试或完整端到端测试。
端到端测试应该从系统外部进入,使用与生产近似的身份、网络、配置和数据存储。因为它慢且失败原因多,所以只覆盖真正需要整条环境才能证明的高价值流程:
端到端测试失败时,原因可能来自代码、配置、证书、部署、依赖或测试数据,因此它适合证明“关键旅程可用”,不适合承担所有边界组合。把四层放在一起看,它们并不是重复劳动:单元测试检查规则,组件测试检查单个服务的 HTTP 外壳,集成测试检查服务是否真的协作,端到端测试则确认用户最终能否完成任务。
覆盖率只能说明哪些代码被执行过,不能说明哪些风险被验证过。对订单系统而言,“支付拒绝后库存恢复”“相同幂等键不重复扣款”这样的业务不变量,比一个孤立的覆盖率百分比更接近上线证据。
scripts/test.js 没有引入第三方测试框架。它使用 Node.js 的严格断言模块和一个很小的测试包装函数:
async function test(name, fn) {
try {
await fn();
tests.push({ name, passed: true });
process.stdout.write(`✓ ${name}\n`);
} catch (error) {
tests.push({ name, passed: false, error });
process.stdout.write(`✗ ${name}\n
测试开始时,startCluster 使用 43300 到 43303 四个端口启动服务。每项测试通过网关发请求,最后无论成功还是失败,都在 finally 中停止子进程;这个清理动作不是附属细节,如果失败测试把服务留在后台,下一次执行可能撞端口、继承旧内存数据,最后产生“单独运行通过、整套运行失败”的污染。下面是这套流程的完整执行结果:
> restful-api-microservices-demo@1.0.0 test
> node scripts/test.js
✓ 聚合健康检查会确认三个下游服务
✓ 未携带令牌时返回统一 401 问题详情
✓ 资源型 URL 可创建订单并返回 Location、ETag 和链接
✓ 重复的幂等键返回同一个订单
✓ If-None-Match 命中时返回 304
✓ 支付已入账但回执超时时,相同幂等键的重试不会重复扣款
✓ 支付被拒后会释放已预留库存
✓ If-Match 防止旧版本覆盖,取消后执行退款和库存补偿
✓ 长时间任务先返回 202,再通过操作资源查询
✓ 两次超时后网关打开熔断器
✓ 限流超额时返回 429 和 Retry-After
✓ 同一 trace ID 会穿过网关、订单、库存和支付服务
12/12 项测试通过。这 12 项并不是随意凑出来的接口检查,而是沿着资源语义、重复调用、跨服务失败和运行保护四条风险线组织的业务证据。我们逐组拆开看,才能知道每个绿色对勾究竟排除了哪一种回归。
创建订单必须返回 201、Location、ETag 和资源链接;条件读取命中 ETag 时返回 304;长任务先返回 202,再通过操作资源查询。这些断言检查的不只是响应体,而是客户端赖以继续工作的 HTTP 语义,能防止服务升级后仍“能返回 JSON”,却悄悄破坏缓存、跳转或异步轮询。
重复幂等键要返回同一订单,支付回执丢失后的第二次尝试要重放原支付,旧 If-Match 必须收到 412。这组测试针对的不是某一行代码,而是分布式调用中最容易说谎的两件事:调用方不知道第一次请求是否生效,以及两个调用方可能同时基于旧状态写入;只要其中一条断言消失,重复扣款或旧版本覆盖就可能重新出现。
支付被拒时,测试会先读取库存,再触发拒付,最后重新读取库存。这个前后对账把 HTTP 错误和库存状态绑在同一个场景里,代码如下:
const before = await api('/api/inventory/mouse-001');
const failed = await api('/api/orders', {
method: 'POST',
headers: {
'idempotency-key': 'test-decline',
'x-demo-payment-mode': 'decline',
},
body: { productId: 'mouse-001', quantity: 1, amount: 99 },
});
const after =
这段测试先对账再下结论,因为只断言 PAYMENT_DECLINED 会漏掉最危险的回归:订单虽然失败了,库存却永远少了一件。只有 after.body.available 与调用前相等,才能证明拒付分支不仅返回了正确错误,还完成了跨服务补偿。
连续两次上游超时后,第三次请求收到 503 CIRCUIT_OPEN;第 13 个同客户端请求收到 429 与 Retry-After;同一个 trace ID 能在四个服务的结构化日志中出现。限流、熔断和追踪在这里都被写成可以失败、可以断言的行为,而不是“配置里好像开了”的功能。不过,这套测试仍然只证明下列范围内的事实:
因此,12/12 的正确读法是“这 12 条已声明行为通过了”,不是“系统已经证明没有风险”。把未覆盖范围写在结果旁边,后续团队才能决定哪些风险应补到单元、组件、集成或故障演练中,而不是让一个总数制造错误安全感。
集成测试需要把多个服务一起启动,随着服务数量增加,如果每个消费者都等待所有提供者就绪,反馈会越来越慢。契约测试把问题收窄到一个更常见的变化边界:消费者真正依赖的请求和响应,当前提供者还认不认;它不运行完整业务流程,却能更早指出是哪一项接口期待发生了错位。

项目中的 openapi.yaml 已经把创建订单请求写成机器可读结构,字段类型、必填项和额外属性规则都能被工具读取。先看其中的 CreateOrder,再比较运行时实现,差异就会很清楚:
CreateOrder:
type: object
additionalProperties: false
required: [productId, quantity, amount]
properties:
productId: { type: string }
quantity: { type: integer, minimum: 1 }
amount: { type: number, exclusiveMinimum:
这份描述能回答字段类型、必填项和额外属性是否允许,却不会自动证明运行中的服务真的遵守它。例如契约写了 additionalProperties: false,当前实现会读取白名单字段,因此额外字段不会被写进订单,但它并不会因为出现额外字段而主动拒绝请求。这就是“描述”和“运行时行为”之间需要自动验证的差异。
规范文件也可能落后于代码:只更新 YAML 不会改变服务,只更新服务也不会自动修正规范。真正的门禁要把规范和运行中的提供者放在一起验证,发现差异后明确决定是修实现、改契约,还是先保留兼容行为。
以订单服务调用支付服务为例,可以把闭环拆成四步。每一步都由不同角色产出可执行证据,最后让“可以一起部署”不再只靠口头确认:
订单服务用消费者测试声明自己会发送 POST /payments,请求中包含订单编号、金额、币种和幂等键,并且依赖成功响应中的支付 id。
测试框架根据这些交互产出机器可读契约。契约只记录消费者真正使用的字段和状态,不复制支付服务全部实现。
支付服务在自己的流水线中读取契约,使用真实提供者实现重放请求,检查状态码、响应字段和类型。
部署门禁确认当前消费者版本和提供者版本能够共存,再允许发布。字段改名会在进入共享环境前失败。
契约测试不会代替集成测试。它很擅长发现字段、类型、头部和状态码漂移,却不会证明支付数据库真的落账,也不会证明一次完整订单失败后库存得到补偿。我们需要把它放回完整测试策略中,让不同证据各自负责最合适的风险:
如果消费者只读取 id 和 status,契约不该要求响应对象的每个字段都一字不差,否则提供者新增一个可选链接也会让测试失败,团队很快就会把契约门禁关掉。反过来,如果订单服务会依据 422 PAYMENT_DECLINED 执行库存补偿,契约就必须写出这个状态和稳定业务码,只写“返回一个 4xx”无法保护补偿分支。契约测试的价值不在于把接口永远冻结,而在于把“谁依赖什么”变成可以在发布前执行的事实。
普通函数调用通常给人两种直觉:成功返回,或者抛出异常。网络调用却会多出第三种结果:远端已经成功,本地没有收到回执;“支付已入账但回执超时”就是这个场景。支付服务先把支付写入内存,再故意延迟 350 毫秒,订单服务等待 180 毫秒后超时并使用同一个支付幂等键重试,第二次请求最终返回第一次已经创建的资源,而没有再创建一笔支付。
HTTP 201
orderId: ord-0002
status: COMPLETED
paymentAttempts: 2
secondAttemptReplayedOriginalPayment: true
下面三条真实断言把 HTTP 结果、尝试次数和幂等重放放在一起检查。少掉任何一条,都可能把“重试成功”和“没有重复扣款”错误地当成同一件事:
assert.equal(response.status, 201);
assert.equal(response.body.diagnostics.paymentAttempts, 2);
assert.equal(response.body.diagnostics.paymentReplayed, true);“尝试了两次”不是成功标准,真正的成功标准是第二次尝试重放原支付,没有重复扣款。调用方还要复用同一个业务幂等键;如果每次重试都生成新键,提供者会把它们当成两次不同操作,幂等存储即使工作正常也救不了调用方。
支付服务明确返回 422 PAYMENT_DECLINED 时,订单服务知道支付没有成功,可以释放此前创建的库存预留。与回执丢失不同,这里不存在“可能已经扣款”的未知状态,因此测试要同时检查拒付结果和补偿后的库存,场景输出如下:
HTTP 422
problemCode: PAYMENT_DECLINED
stockBefore: 3
stockAfter: 3补偿不是数据库回滚,库存预留曾经真实发生过,释放库存是后续发起的另一个业务操作。因此补偿接口本身也必须幂等:项目中的 DELETE /reservations/{id} 再次执行时会返回已经释放的预留,而不会再把库存加一遍。这个断言守住的是“修复动作重复执行也不能制造新错误”。
网关等待订单服务 240 毫秒,测试通过 x-demo-order-delay-ms: 350 注入延迟,预期先收到 504 UPSTREAM_TIMEOUT。连续两次失败后,熔断器打开 800 毫秒,下一次请求不再访问订单服务,而是立即返回下面的降级响应:
{
"title": "订单服务暂时降级",
"status": 503,
"code": "CIRCUIT_OPEN",
"detail": "网关检测到连续失败,暂停访问订单服务。"
}这里要验证两件事:慢请求会在截止时间内结束,连续失败会让后续请求快速失败。熔断并没有让订单服务恢复,它只是把“等待一个大概率失败的下游”换成“暂时拒绝部分能力”,防止更多请求堆积;后续还要验证半开探测与恢复,才能证明熔断器不会一直卡在打开状态。
当前订单编排把状态保存在内存里,如果进程在库存预留成功后、支付请求发出前突然退出,内存订单和后续补偿都可能消失。仅注入 HTTP 超时发现不了这个问题,因为网络仍在、进程也仍在;更接近生产的故障演练必须把崩溃点插到每个副作用之间,并覆盖下面这些恢复动作:
故障注入不是随机“搞坏环境”,每次注入都要有明确故障点、业务不变量、可观察证据和恢复条件。只有当团队能说明故障发生在哪里、系统允许暂时留下什么状态、谁负责把它收敛,演练结果才会变成可复用的设计证据。
功能测试问“结果对不对”,性能测试则追问“在多少负载下,结果还能按时且稳定地给出来”。只报平均响应时间很容易误导:假设 100 个请求里有 95 个在 40 毫秒内完成,另 5 个因为支付连接池排队用了 2 秒,平均值看起来可能还能接受,但尾部用户已经明显感到系统卡住。因此,一次容量测试至少要同时观察下面这些指标:
基线负载使用日常流量,确认版本之间没有明显性能回退;阶梯负载逐步增加请求速率,直到 P95、错误率或资源占用越过目标,找出容量拐点。突发负载在短时间内突然放大流量,检查令牌桶、队列、自动扩容和熔断是否按预期工作;浸泡负载则在稳定压力下运行较长时间,寻找内存泄漏、连接泄漏、缓存膨胀和周期性抖动。四种负载回答的问题不同,不能用一次短时压测替代其余三种。
更有用的容量边界应该带上服务目标和依赖条件,而不是只报一台服务器的峰值请求数。下面这段定义把支付延迟、入口负载、订单尾延迟、错误率和 CPU 余量放在同一个测试条件里:
在支付服务 P95 不超过 120 ms、错误率低于 0.5% 时,
订单入口保持目标负载,订单 P95 不超过约定值,
整体 5xx 低于约定值,CPU 保留故障余量。这里故意不填一个看似精确的 RPS 数字,因为当前项目没有性能测试输出;没有测量就宣布“可承载 5000 RPS”,只是把猜测包装成指标。实际执行时,可以用下面的阶梯方式逐步找出第一个违反目标的阶段,并验证降载后能否恢复:
先固定数据、机器规格、服务副本数、连接池和下游延迟,建立可重复的基线。
以小步长增加到达速率,每个阶段保持足够时间,让队列和缓存进入稳定状态。
同时记录入口和每个下游的延迟、错误、资源与饱和度,不能只看网关总时间。
找到第一个违反服务目标的阶段,再降低负载,确认系统能否自动恢复,而不是一直卡在过载状态。
限流测试不能只看第 13 个请求收到 429,还要确认被拒请求没有继续打到订单服务,Retry-After 合理,其他客户端仍有可用配额。熔断测试也不能只看 503,它还要证明打开期间下游请求量下降、半开探测数量受控,恢复后不会瞬间把积压流量全部灌回去。超时则要形成一条总预算:客户端总截止时间如果是 800 毫秒,网关、订单、库存和支付不能各自都等待 800 毫秒,越往下游剩余预算越少,重试也必须消耗同一份预算。
性能测试若直接在生产数据和真实支付副作用上执行,可能制造订单、扣款或成本。测试环境必须隔离,业务副作用必须可识别、可清理,任何破坏性演练都要有明确范围。
安全部分最常见的误解,是把“令牌验证通过”写成整个授权故事的结尾。实际上,一次读取订单的请求至少要连续回答三个问题,而且后一个问题不能由前一个问题代替:
如果接口支持局部更新,还要再问第四个问题:这个身份能修改对象里的哪些字段。把四道边界分开之后,你会发现“拿到有效令牌”只走完了第一步,离“能读取或修改这笔订单”还有对象与字段两层决策。

教学项目使用两个固定令牌来演示最小认证流程。网关从 Authorization 头中取出 Bearer 值,把两个已知字符串映射为管理员或只读主体,其余值统一返回 401:
function authenticate(req) {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (token === 'demo-admin-token') {
return { subject: 'course-admin', role: 'admin' };
}
if (token === 'demo-read-token') {
return
认证得到主体后,网关继续执行端点级授权:只读角色只能发送 GET 或 HEAD,任何写方法都会收到 403。这个判断只依赖方法与角色,尚未读取具体订单:
function authorize(req, principal) {
if (!['GET', 'HEAD'].includes(req.method) && principal.role !== 'admin') {
throw new HttpError(403, 'FORBIDDEN', '无权执行', '当前令牌只有读权限。');
}
}这两段代码足以演示 401 与 403 的区别,但还不能代表完整生产认证。先把两个状态的责任分清,再看固定令牌缺少了哪些真实安全属性:
401 表示当前请求没有可接受的身份凭据;403 表示身份已经确定,但不允许执行这个动作。固定字符串令牌没有签名、过期、签发者、受众、撤销或密钥轮换,因此不能用于生产。它在本章中的作用只是让测试稳定地区分“没有身份”和“有身份但动作不允许”,不应被复制到真实部署中。
订单对象没有 ownerId 或租户字段,订单服务也没有收到可信主体,因此它无法判断“这笔订单属于谁”。这意味着现有角色检查不是对象级授权:只要读令牌能够访问订单端点,就能按已知 ID 读取任何内存订单。要补上这道边界,生产实现至少需要完成下面几件事:
对象级授权必须发生在读取到资源之后、返回资源之前,因为策略同时依赖主体和订单属性。下面的伪代码只展示最小判断位置,真实策略仍要由业务规则决定:
const order = await orderRepository.findById(orderId);
if (!order) throw notFound();
const canRead = principal.role === 'admin'
|| order.ownerId === principal.subject;
if (!canRead) throw forbidden();
return order;真实系统还要处理租户、客服代查、共享订单、数据区域和审计等规则,重点不是套用这一小段判断,而是每次都把“主体、动作、对象”一起交给授权策略。随机 UUID 只能降低批量枚举的便利,不能代替授权;只要对象 ID 因日志、链接或浏览器历史泄露,没有权限检查仍然会暴露数据。
“质量赋值”问题常发生在服务把整个请求体直接合并进内部对象时。客户端表单没有展示某个字段,并不代表攻击者不能手工把它放进 JSON;下面这种整体合并会把请求体的控制权直接交给外部调用者:
Object.assign(order, body);攻击者可能多传 status: 'COMPLETED'、ownerId: 'attacker' 或 role: 'admin',即使这些字段不在页面表单里,它们仍然可以被手工加入 JSON。因此,服务不能把“前端没提供输入框”误当成字段授权。
当前创建订单实现逐项读取 productId、quantity、amount 和 currency,额外字段不会直接写入订单,这比整体合并安全。更清楚的做法是让运行时 Schema 拒绝未知字段,再按当前主体和资源状态应用字段白名单;读响应也使用单独的输出模型,避免把内部风控标记、成本价或密钥材料一起序列化。
Bearer 令牌的特点很直接:谁拿到它,谁通常就能在有效期和权限范围内使用它。因此,保护令牌不能停在“给 JWT 加签名”,生产资源服务还要确认令牌由谁签发、准备交给谁、何时有效以及允许做什么,至少验证下面这些边界:
iss 必须是信任的签发者;aud 必须包含当前资源服务;exp 不能已经过期,nbf 不能仍在未来;scope 或权限声明必须覆盖当前动作;JWT 的签名主要保护完整性,并不默认加密载荷。把密码、身份证号或内部秘密放进载荷,再做 Base64URL 编码,并不会让它们变得不可见;设计令牌声明时仍应坚持最小披露,并把令牌当作会出现在客户端内存、代理和诊断链路中的敏感凭据。
假设一个令牌只为订单 API 签发,但支付服务也接受同一令牌,那么订单服务日志一旦泄漏令牌,攻击者就可能拿它直接调用支付接口。资源服务检查 aud,能把令牌限制在预期接收方;再配合最小 scope,即使令牌泄漏,影响范围也不会自动扩大到所有服务和所有动作。因此,网关不应该把外部令牌无差别转发给每个下游,可以根据架构选择下面一种受控方式:
无论选择原令牌验证、令牌交换还是工作负载身份,都不能把“请求来自内网”当成用户授权依据。服务身份说明是谁在调用,用户上下文说明代表谁调用,对象级策略还要继续判断这次动作是否允许,三者不能互相省略。
TLS 建立的通道主要提供机密性、完整性和服务端身份验证,能阻止网络中的旁观者直接读取或篡改令牌和响应。但 TLS 终止在哪里,保护就在哪里结束;如果公网连接只在负载均衡器终止,而负载均衡器到网关、网关到订单服务仍是明文,那么内部网络上的攻击者或错误配置仍可能读取数据。
因此要画清每一段链路:客户端到入口、入口到网关、网关到服务、服务到数据库、服务到第三方,并对每段分别决定是否使用 TLS、是否校验证书、证书如何轮换,以及谁有权发起连接。当前项目只在本机回环地址上使用 HTTP,用于教学运行;它没有配置证书,也没有服务间身份,不能被描述成已经实现了生产传输安全。
输入校验的目的不只是给用户显示“字段不能为空”,还要阻止不受信数据改变程序原本打算执行的含义。我们可以沿着请求进入系统的顺序来安排防线:先控制它能消耗多少资源,再检查结构与业务规则,最后阻止它进入解释器或越过出站网络边界。

项目中的 JSON 读取函数默认最多接收 64 KiB,而且会在累计字节数越界时立即中止。这个顺序确保超大请求还没有完整进入内存就被拒绝,核心代码如下:
async function readJson(req, maxBytes = 64 * 1024) {
const chunks = [];
let bytes = 0;
for await (const chunk of req) {
bytes += chunk.length;
if (bytes > maxBytes) {
throw new HttpError(
413
顺序很重要,如果先把无限请求体完整读进内存,再检查字段,攻击者已经消耗了资源。生产环境还要分别限制压缩前后大小、数组长度、嵌套深度、字符串长度、文件大小和批处理条目数,并确保入口代理与应用层采用一致或更严格的上限。
Schema 校验回答“形状对不对”,例如字段是否存在、类型是否正确、是否允许额外属性;业务校验回答“这件事能不能做”,例如库存是否足够、金额与商品是否匹配、订单当前状态是否允许取消。quantity: 2 在结构上是正整数,但如果库存只有 1,它仍然不能创建预留。对排序字段等有限集合则优先使用允许列表,例如只接受 createdAt 或 amount,不要把用户提供的任意字段名拼进数据库语句。
SQL、Shell、LDAP 和模板注入的共同点,是不受信输入被解释器当成了指令的一部分。防护重点不是猜攻击者会输入哪些字符,而是让数据没有机会改变指令结构,可以按下面的顺序处理:
仅把单引号替换掉并不可靠,因为不同解释器、编码和上下文都有不同语义,黑名单很容易漏掉变体。参数化接口把“指令”和“数据”分开,允许列表限制确实有限的选择,最小权限再负责压低漏网输入造成的后果,这三层要共同存在。
订单系统以后可能增加回调地址、发票下载 URL、头像抓取或第三方 Webhook 测试,只要服务根据用户提供的 URL 发起请求,就要考虑 SSRF。攻击者希望把服务器变成自己进入内网的代理,因此会尝试把目标指向这些位置:
防护不能只检查字符串是否以 https:// 开头,而要使用可靠 URL 解析器,限制协议、目标域名和端口,解析后拒绝环回、私网、链路本地与保留地址,控制 DNS 解析变化,默认不跟随重定向,并从网络层限制出站目标。如果业务目标本来就是有限集合,最好让客户端提交短标识,再由服务端映射到预先配置的地址,不要接收任意 URL。
第三方服务或内部服务返回的数据同样可能畸形、超长或被攻陷,因此下游调用也要设置连接与响应超时、响应体上限、允许的 Content-Type,并校验 Schema。不要盲目跟随携带敏感请求体的重定向,也不要把下游返回字符串直接拼进 SQL、日志模板或 Shell 命令。拆分服务后,信任边界不再只有公网入口;每一次网络读取都要问,如果对方返回超出契约的数据,本服务会怎样限制、拒绝并留下证据。
安全控制如果无法被观察,就很难知道它是在工作,还是只存在于设计文档里。错误响应、限流结果和追踪日志要提供一组彼此能对上的证据:客户端知道请求为何失败,服务端知道失败发生在哪里,监控则知道它是偶发错误还是持续攻击。
项目使用统一问题详情,已知业务错误保留稳定状态和业务码,未知异常则映射为通用 500。这样既不让实现细节穿过边界,又能给客户端一个可处理的响应,核心映射如下:
const status = error instanceof HttpError ? error.status : 500;
const code = error instanceof HttpError ? error.code : 'INTERNAL_ERROR';
const title = error instanceof HttpError ? error.title : '服务内部错误';
const detail = error instanceof HttpError
? error.message
: '服务无法完成请求。'这段映射避免把堆栈、绝对路径、数据库语句或密钥直接返回给客户端,客户端仍然会得到 code 和 traceId,可以稳定分支,并把追踪编号交给排障人员。服务内部日志则保留服务名、trace ID、方法、路径、状态和耗时,但敏感头、完整令牌、密码、银行卡号和大段请求体不能因为“方便排障”而无条件记录。测试时可以主动触发未知异常,并围绕同一 trace ID 断言下面这些内外部结果:
traceId 存在且能关联内部日志;当前网关按 x-client-id 或远端地址计数,每秒最多 12 个请求,第 13 个请求返回 429 并带 Retry-After。这清楚展示了协议行为,但生产环境不能直接信任客户端随意填写的 x-client-id,否则攻击者每次换一个值就能绕过计数。更可靠的维度来自认证主体、客户端凭据、租户、资源和动作,还要按业务风险组合成不同限制:
速率限制也不是唯一资源上限,一次请求可能触发大量数据库查询、短信、邮件、AI 推理或第三方计费。即使入口 RPS 很低,单个昂贵请求仍可能耗尽预算,因此还要限制单次请求成本、批量大小、并发任务数和每日额度,并为不同主体保留公平份额。
同一个 trace ID 穿过网关、订单、库存和支付服务后,会在每个服务留下结构化记录。把这些记录按同一编号拼起来,就能还原下面这条跨服务调用链:
inventory-service POST /reservations 201
payment-service POST /payments 201
order-service POST /orders 201
api-gateway POST /api/orders 201这条链既能定位性能瓶颈,也能回答安全问题:某个主体在何时访问了哪个对象,请求经过哪些服务,又在哪一步被拒绝。但 trace ID 由客户端提供时必须校验格式,当前项目只接受 1 到 80 个字母、数字、点、下划线或连字符,其他值会被新的随机 UUID 替代,避免把换行和任意日志内容注入追踪字段。
生产审计还要补上经过验证的主体、租户、授权决策和策略版本,并限制谁能读取审计数据。日志本身也可能包含敏感业务信息,不能因为它用于安全就失去访问控制;同样,审计留存时间和检索权限也应围绕排障与合规需要明确设定。
如果测试只在发布前一天手工执行,它会很快变成时间不够时最先被跳过的步骤。更稳妥的做法是让证据随版本一起流动,按执行成本从快到慢进入发布流水线,让便宜、定位清楚的失败尽早发生:
每次提交先运行格式检查、静态检查和单元测试。纯规则错误应在几秒到几分钟内返回给开发者。
启动单服务组件测试,覆盖 JSON 边界、统一错误、认证中间件和可控下游故障。
验证 OpenAPI 与消费者契约,阻止请求、响应、状态码和权限声明的破坏性变更。
运行多服务集成测试,包括本章展示的幂等、补偿、条件请求、熔断、限流和追踪测试。
一个只输出“订单场景失败”的端到端测试,会让排障人员重新手工走完整链路,流水线虽然阻止了发布,却没有缩短修复时间。测试报告要能指出哪个业务不变量被破坏、故障注入在哪里发生,并至少保留下面这些上下文:
失败信息越接近业务事实,团队越不容易把偶发失败简单标记成“再跑一次”。当报告能把状态差异、服务版本和 trace ID 放在一起时,开发者可以直接进入出错分支,而不必先猜是环境、依赖还是断言本身出了问题。
微服务测试容易因为固定等待时间、共享端口、共享数据、依赖启动顺序和不受控网络而偶发失败。这套项目的 startCluster 会轮询网关健康状态,而不是写死“等待两秒”;两组脚本使用独立端口,每次启动都得到新的内存数据,finally 还会停止所有子进程。这些动作共同减少了测试之间的状态污染。
生产级测试还要使用唯一资源名、可等待的状态条件、独立租户或数据库,并记录随机种子和依赖版本。不要用无限重跑掩盖不稳定,重跑可以帮助收集证据,却不能把第一次失败抹掉;只要失败条件仍不清楚,这项测试就还没有恢复成可信的发布证据。
在允许版本进入生产前,团队需要把前面的测试结果重新汇总成一组可以明确回答的问题。每个“是”都应能指向具体测试、指标或部署门禁,而不能只依赖设计文档里的预期:
如果其中任何一项只能回答“理论上应该可以”,那它还不是上线证据。把这一项留在清单里并不丢人,真正危险的是用一次正常请求或一个总覆盖率数字替它打勾,然后把未验证的边界带进生产。
本章把系统从“成功路径能运行”推进到了“关键失败有证据”。四层测试各自负责不同范围,契约测试在服务独立发布时提前抓住错位,12 项真实集成测试则证明了资源语义、幂等重放、补偿、熔断、限流和追踪的当前行为。它们共同说明一件事:上线判断必须落到可执行的业务不变量上。
安全部分补上了一个常被网关遮住的事实:认证只回答你是谁,授权还要继续回答你能做什么、能操作哪一个对象、能修改哪些字段。令牌、TLS、输入校验、错误脱敏和资源限制都是边界控制,没有任何单独一层能够包办全部安全;把这些边界分别测试,才知道恶意请求究竟会在哪一步停下。
不过,测试通过仍然不是分布式事务。当前订单流程依次预留库存和创建支付,如果订单进程恰好在两个步骤之间退出,或者补偿请求持续失败,系统会留下一个需要后续处理的中间状态。下一节要继续面对这个问题:当一次业务动作跨越多个服务、无法使用单个数据库事务时,订单、库存和支付怎样组合、记录进度、重试与补偿,并让暂时不一致最终收敛。
这一步会把我们的关注点从“怎样证明当前实现没有轻易出错”,带到“系统真的出错以后,怎样把业务事实带回一致”。前者靠测试发现边界,后者还需要持久化状态、恢复机制和跨服务协作,两者一起才构成可以长期运行的订单流程。
在隔离环境执行对象越权、字段越权、恶意输入、SSRF 出站限制和错误脱敏等负向安全测试。
对候选版本运行基线性能回归;定期执行阶梯、突发、浸泡和进程崩溃演练,而不是每次提交都跑最长测试。
部署少量实例并观察真实指标。只有错误率、尾延迟、授权拒绝和下游饱和度都在门限内,才继续放量。