摘要:订单有 6 种状态,"发货"只对其中 1 种(待发货)有意义。我写了 6 条用例(2 条基础 + 4 条非法状态),其中 4 条非法状态全部返回成功------待付款、已发货、已取消、已完成订单都能被"发货"。根因是发货接口只校验了店铺权限,完全没校验订单状态。一条缺失的校验,等于给订单系统开了 4 个后门。
前言:很多人测接口就是"能调通、返回 200 就通过"。但一个改变状态的接口,真正的测试点在"它允许哪些输入、拒绝哪些输入"。这篇文章记录我用状态机思维发现这个核心业务逻辑缺陷的完整过程。
一、故事从一个接口开始
Mall4j 后台有个"订单发货"接口:
text
PUT /order/order/delivery
Body: {"orderNumber": "xxx", "dvyId": 1, "dvyFlowId": "SF_TEST_001"}
功能简单到一句话能说完:把订单状态从"待发货"改成"待收货"。
但问题来了------只测"正常发货成功",就够了吗?
二、订单状态有 6 种,发货只允许 1 种
电商订单的状态机大概是这样的:
图1:订单状态机流转图,发货是唯一的合法状态转换。
"发货"这个动作,只对 status=2(待发货)的订单有意义。
6 种状态里,只有 1 种合法(待发货),其余 5 种都是非法场景 。其中"待评价"业务上不可能触发发货------用户还没收到货,怎么可能评价?所以实际测试 4 种非法场景,这 4 种都应该被拒绝。
我写了6条用例:
python
# ============ 基础用例:正向 + 异常 ============
def test_wait_send_delivery_success(self, ...):
"""待发货订单,应该发货成功""" # ✅ PASSED
def test_nonexist_order_delivery_fail(self, ...):
"""不存在的订单号,应该失败""" # ✅ PASSED
# ============ 状态机用例:4 种非法状态 ============
def test_wait_pay_delivery_fail(self, ...):
"""待付款订单,不该发货""" # ❌ XFAIL
def test_success_order_delivery_fail(self, ...):
"""已完成订单,不该发货""" # ❌ XFAIL
def test_fail_order_delivery_fail(self, ...):
"""已取消订单,不该发货""" # ❌ XFAIL
def test_duplicate_delivery_fail(self, ...):
"""已发货订单,不该重复发货""" # ❌ XFAIL
⚠️ 关于改数据的风险提示
上面的 order_with_status fixture 是直接 UPDATE 数据库------仅限本地或测试环境,生产严禁这么干。
原因:直接改 status 会绕过支付、库存、积分、消息通知等一系列联动,改出来的数据本身就不真实。
更规范的做法:
- 优先走正常业务链路造数据:下单 → 支付 → 模拟支付回调 → 走到待发货状态
- 只在个别场景下用 SQL 修正状态:比如要构造"已取消订单"
- 在 fixture 里测完恢复:避免污染后续用例
三、6 条用例,4 条返回成功
跑完之后,结果是这样的:

图2:5 条状态机用例,4 条返回成功(预期应该拒绝)。
| 用例 | 状态 | 应该 | 实际 | 结果 |
|---|---|---|---|---|
| test_wait_send_delivery_success | 待发货(2) | 成功 | 成功 | ✅ PASSED |
| test_nonexist_order_delivery_fail | 不存在订单号 | 失败 | 失败 | ✅ PASSED |
| test_wait_pay_delivery_fail | 待付款(1) | 拒绝 | 成功 | ❌ XFAIL |
| test_duplicate_delivery_fail | 已发货(3) | 拒绝 | 成功 | ❌ XFAIL |
| test_success_order_delivery_fail | 已完成(5) | 拒绝 | 成功 | ❌ XFAIL |
| test_fail_order_delivery_fail | 已取消(6) | 拒绝 | 成功 | ❌ XFAIL |
6 条用例里,2 条 PASSED、4 条 XFAIL------4 种非法状态全部被放行。
四、这是"状态校验缺失",不是"越权"
看后端代码:

图3:发货接口后端源码,仅有 shopId 权限校验,缺失状态判断。
java
// OrderController.java(来源于本地部署的 Mall4j,为说明问题简化过)
@PutMapping("/delivery")
@PreAuthorize("@pms.hasPermission('order:order:delivery')")
public ServerResponseEntity<Void> delivery(@RequestBody DeliveryOrderParam param) {
Long shopId = SecurityUtils.getSysUser().getShopId();
Order order = orderService.getOrderByOrderNumber(param.getOrderNumber());
// 唯一的校验:shopId 权限
if (!Objects.equal(shopId, order.getShopId())) {
throw new YamiShopBindException("您没有权限修改该订单信息");
}
// ❌ 缺失:没有校验 order.getStatus()
Order orderParam = new Order();
orderParam.setOrderId(order.getOrderId());
orderParam.setStatus(OrderStatus.CONSIGNMENT.value()); // 直接改成"待收货"
orderService.delivery(orderParam);
return ServerResponseEntity.success();
}
很多人第一反应是"这是越权漏洞"。
其实不是。越权(IDOR/权限绕过)指的是"A 用户操作了 B 用户的数据 "------这里的 shopId 校验恰恰防的就是这个,它是跨店铺操作的防线。
而这里的问题是另一个维度:状态校验缺失。
| 校验维度 | 防的是什么 | 现状 |
|---|---|---|
| 权限校验 | 跨店铺操作 | ✅ 有(shopId 校验) |
| 状态校验 | 状态机错乱 | ❌ 缺失 |
| 支付校验 | 未付款发货 | ❌ 缺失(依赖 status 间接判断) |
三个维度缺两个,这才是真正的根因。
关于"待付款能发货"的进一步确认
有些人可能会想:会不会 orderService.delivery() 里有别的兜底校验,比如查支付流水?
我确认过了:没有。代码里它既不查支付记录,也不依赖支付回调,纯靠调用方传入的 status。所以只要绕过正常入口、直接调用发货接口,未付款的订单就能被"发货"。
五、一个缺失的校验,4 个业务后果
这 4 条失败用例,看起来是 4 个独立问题,其实是同一个根因------1 个缺陷引发 4 种业务后果:
| 问题 | 业务影响 | 潜在损失 |
|---|---|---|
| 待付款订单能发货 | 用户没付钱就收到货 | 资金损失 |
| 已取消订单能发货 | 失败订单复活 | 状态数据混乱 |
| 已发货订单能重复发货 | 快递单号被覆盖 | 物流纠纷 |
| 已完成订单能发货 | 状态可跨越,业务无意义 | 数据可审计性差 |
严重程度分级:建议按 P0/P1 处理------不是系统崩溃级 P0,但涉及资金风险,应优先修复。
修复很简单------在权限校验之后补一句:
java
if (!Objects.equal(order.getStatus(), OrderStatus.PAID.value())) {
throw new YamiShopBindException("当前订单状态不允许发货");
}
但发现这个过程,需要"状态机思维"。
六、修复建议(比"加一句 if"更完整)
方案 A:状态前置校验 + 数据库 WHERE 条件
java
// 1. 应用层前置校验
if (!Objects.equal(order.getStatus(), OrderStatus.PAID.value())) {
throw new YamiShopBindException("当前订单状态不允许发货");
}
// 2. SQL 更新时带原状态条件,防止并发下重复发货
UPDATE tz_order
SET status = 3, dvy_id = ?, dvy_flow_id = ?, dvy_time = NOW()
WHERE order_number = ?
AND status = 2; -- 只更新仍是"待发货"的记录
// 3. 检查影响行数
int affected = orderMapper.updateDelivery(...);
if (affected == 0) {
throw new YamiShopBindException("订单状态已被其他操作改变,请刷新后重试");
}
核心思想:更新的同时校验原状态,用 affected rows 判断是否发生并发冲突。
方案 B:加状态变更日志表(推荐)
sql
CREATE TABLE tz_order_status_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(50) NOT NULL,
from_status INT NOT NULL,
to_status INT NOT NULL,
operator VARCHAR(50) NOT NULL,
operate_time DATETIME NOT NULL,
remark VARCHAR(255),
INDEX idx_order_number (order_number)
);
每次状态变更记录一条日志,好处:
- 审计:出了问题能查到"谁在什么时候把订单从 1 改成 3"
- 追责:用户投诉"为什么我没付款就发货",直接查日志
- 排查:状态机的所有历史轨迹可追溯
七、为什么用 xfail 标记
发现 bug 之后,用例不能删,也不能改代码让它通过。
我用 @pytest.mark.xfail 标记:
python
@pytest.mark.xfail(
reason="BUG-001: 发货接口缺状态校验",
strict=True, # 关键:修复后必须删除标记
)
def test_delivery_pending_payment_should_fail(api_session):
...
为什么用 strict=True 而不是 strict=False
| 配置 | Bug 存在时 | Bug 修复后 | 隐患 |
|---|---|---|---|
| strict=False | XFAIL | XPASS(CI 依然绿灯) | 容易忘记删标记,代码里堆一堆废弃 xfail |
| strict=True | XFAIL | FAIL(CI 变红) | ✅ 强制你处理 |
也可以在 pytest.ini 里全局配置:
ini
[pytest]
xfail_strict = true
这样所有 xfail 都默认 strict,避免单个漏配。
八、状态机测试方法论(可复用)
这不是我拍脑袋想的,是标准做法:
- 画状态迁移图:列出所有状态节点 + 所有合法转换边
- 生成状态转换树:从初始状态出发,枚举所有可能的路径
- 推导测试路径:保证根节点到每个叶节点的路径至少有一条用例覆盖
- 设计用例 :
- 合法路径:每条边至少 1 条正向用例
- 非法路径:所有"不存在的边"至少 1 条逆向用例
套用到发货接口:
| 起点状态 | 动作 | 终点状态 | 合法? | 用例设计 |
|---|---|---|---|---|
| 待发货(2) | 发货 | 待收货(3) | ✅ | 正向用例 |
| 待付款(1) | 发货 | --- | ❌ | 逆向用例 |
| 已发货(3) | 发货 | --- | ❌ | 逆向用例 |
| 已完成(5) | 发货 | --- | ❌ | 逆向用例 |
| 已取消(6) | 发货 | --- | ❌ | 逆向用例 |
| 待评价(4) | 发货 | --- | ❌(业务不可能) | 跳过 |
这套方法可以推广到任何"改变状态"的接口。
九、这套方法可以推广到哪些业务
| 业务 | 状态 | 危险操作 |
|---|---|---|
| 订单 | 待付款/待发货/待收货/待评价/完成/取消 | 发货、取消、退款 |
| 支付 | 未支付/已支付/已退款 | 退款、二次支付 |
| 优惠券 | 未使用/已使用/已过期 | 核销、二次使用 |
| 会员 | 正常/禁用 | 下单、登录 |
| 库存 | 充足/不足/锁定 | 扣减、释放 |
每一个都值得设计一组"合法状态 + 非法状态"的用例。
十、我从这件事学到的 3 件事
1. 测试覆盖率 ≠ 测试质量
一个接口写 1 条用例,覆盖率 100%。但业务逻辑一条都没覆盖。
2. 状态机思维是测试的核心能力
不是"调用接口看返回值",而是"这个接口允许哪些输入、拒绝哪些输入"。
3. 一个 bug 背后可能是一类问题
4 条失败用例,根因相同。发现根因,等于发现了 4 个业务后果。
十一、如果你也遇到类似问题
状态机测试的 4 步法
- 画状态图:列出所有状态 + 所有合法转换
- 枚举非法转换:所有"不该发生"的状态跳转
- 构造数据:优先走正常链路,必要时用 SQL 修正状态
- 断言:合法转换成功,非法转换必须失败
常用 SQL(改订单状态,仅测试环境)
sql
-- 改成待付款
UPDATE tz_order SET status = 1 WHERE order_number = 'xxx';
-- 改成待发货(唯一合法)
UPDATE tz_order SET status = 2 WHERE order_number = 'xxx';
-- 改成已发货
UPDATE tz_order SET status = 3 WHERE order_number = 'xxx';
状态码说明(Mall4j)
| 状态码 | 含义 |
|---|---|
| 1 | 待付款 |
| 2 | 待发货 |
| 3 | 待收货 |
| 4 | 待评价 |
| 5 | 已完成 |
| 6 | 已取消 |
十二、系列文章
这是 Mall4j 自动化测试系列的第 2 篇:
- 第 1 篇 :一个 Emoji 引发的排查:MySQL utf8mb3 导致接口返回异常MySQL)
- 第 2 篇:订单状态机测试------一个接口发现 4 个 P0 漏洞(本文)
- 第 3 篇:双端电商系统的测试差异分析------同一个功能,管理员端和买家端的测试策略为什么完全不同?
- 第 4 篇:JMeter 三档梯度压测,发现商品列表性能拐点------200 并发时 P99 响应时间从 26ms 暴涨到 677ms,涨了 26 倍,怎么定位的?
- 第 5 篇:从 Mall4j 的 3 个角色,聊聊真实项目需要几种角色
免责声明: 本文结论基于本地部署的某个 Mall4j 版本,不同版本可能已修复。如果你也遇到类似问题,建议先拉最新代码确认,再提 Issue。开源项目维护不易,理性反馈。
本项目为测试工程,不包含 Mall4j 源码。Mall4j 遵循 AGPL-3.0 协议,如需商用请联系原作者获取授权。文中展示的 Mall4j 代码片段仅用于分析本地缺陷,不代表 Mall4j 官方最新版本。
相关链接:
你测过状态机类的接口吗?有没有漏掉某个非法状态?评论区聊聊 👇