Skip to content
后端

订单状态机里插进一个「售后」:uni-app 商城里状态没收口的 5 个地方(附接口复现)

售后可被反复处理、类型随便填、退款中照样发货、已退款计入营收、库存不扣:用脚本打接口逐个复现,附最小补丁。

涛哥2026-10-315 分钟阅读

订单状态机里插进一个「售后」:uni-app 商城里状态没收口的 5 个地方(附接口复现) ​

前几天写过这个 uni-app 商城的购物车和金额问题,这次回头看订单。订单这块一开始我觉得写得挺干净:所有状态流转都走同一个 transit(from, to, msg),用户侧的支付、取消、确认收货都是一行:

js
app.post('/api/orders/:id/pay', auth, transit(['待付款'], '待发货', '支付成功(模拟)', ...))
app.post('/api/orders/:id/cancel', auth, transit(['待付款'], '已取消', '用户取消订单', ...))
app.post('/api/orders/:id/confirm', auth, transit(['待收货'], '已完成', '已确认收货,交易完成'))

transit 里先判 from.includes(o.status),不满足就 400。主干是对的。问题出在后来插进来的「售后」:它有自己的一套状态(待商家处理 / 已同意 / 已拒绝),又会反过来改订单状态(同意后订单变成「已退款」),而这几条线之间没有互相收口。

下面所有结果都是我在本地起服务、用脚本打接口跑出来的(Express 4.22.3 + lowdb 7.0.1,Node 20.19.2)。商家处理售后属于付费版功能,免费版直接返回 402 该功能属于付费版,请配置授权后使用,所以后台那几项是我本地开了 pro 测的。

先说做对的两件事 ​

超时取消是懒执行 + 定时兜底。 sweep() 会把过期的「待付款」改成「已取消」,它既挂在 setInterval(sweep, 10 * 1000) 上,也在 transit 和订单详情接口里先调一次。所以哪怕定时器还没轮到,过期订单也付不了款。我把超时设成 4 秒,等 5 秒再付:

pay after timeout -> 400 当前状态「已取消」不可操作

并发支付天然只成功一次。 同一个订单同时发 3 个支付请求:

['待发货', '400 当前状态「待发货」不可操作', '400 当前状态「待发货」不可操作']

原因是 Node 单线程,transit 里「判断状态 + 改状态」之间没有 await,中间插不进别的请求。注意这是模拟支付;真接支付回调时,重复回调应该返回成功(幂等),而不是 400,否则支付平台会一直重试。

问题 1:售后可以被反复处理 ​

商家处理接口只校验了 action 是不是「同意/拒绝」,没看这条售后是不是已经处理过。我对同一条售后依次调了 同意 → 拒绝 → 同意,三次都成功,订单时间线变成:

[订单已提交, 支付成功(模拟), 申请售后:仅退款, 售后已同意, 售后已拒绝, 售后已同意]

更别扭的是中间那一步:先同意时订单已经变成「已退款」,再点拒绝,订单还是「已退款」,但 afterSaleStatus 变成了「已拒绝」。钱退了,界面上写着拒绝。修法就一行:

js
if (a.status !== '待商家处理') return fail(res, `售后已处理(${a.status}),不能重复操作`)

问题 2:售后类型随便填 ​

用户申请售后时只判断了 type 非空。我传了 type: "我就随便写写",申请成功进入「待商家处理」;商家同意后,因为代码是 if (action === '同意' && a.type !== '换货') o.status = '已退款',这条订单直接变成了已退款。类型应该走白名单:

js
if (!['仅退款', '退货退款', '换货'].includes(type) || !reason) return fail(res, '请选择售后类型和原因')

问题 3:售后还没处理,货已经发出去了 ​

用户在「待发货」时申请仅退款,订单进入售后待处理。这时候后台的发货接口只看 o.status === '待发货',照样能发。我实测:发货成功,订单变「待收货」,填了运单号 SF0001;然后商家再同意退款,订单变成「已退款」,但运单号还挂在上面。

现实里这就是「钱退了,货也寄了」。我的处理是:有待处理售后时,后台发货和用户侧 transit 到「待收货」都拦掉:

js
if (o.afterSaleStatus === '待商家处理') return fail(res, '订单有待处理的售后申请,请先处理售后')

问题 4:已退款的订单还算在营收里 ​

数据概览里判断「已支付」的写法是:

js
const paid = o => !['待付款', '已取消'].includes(o.status)

后来加了「已退款」这个状态,这里没跟着改。我脚本里前面几步一共造了 3 笔订单,跑完全部是「已退款」(每笔 2949),概览却显示营收 8847、已支付订单 3,按实际状态应该是 0。

把「已退款」加进排除列表后,再用补丁版跑同一个脚本:问题 2 那笔因为类型非法、售后申请被拦下,订单停在「待发货」,概览显示营收 2949、已支付 1,正好就是这一笔。

这类 bug 的共同点是:新增一个状态时,没有回头搜所有按状态做判断的地方。 我现在的习惯是加状态前先全局搜一遍状态字面量,比如 rg "'已取消'",凡是排除列表、tab 列表、按钮映射都过一遍。

问题 5:库存只是展示用的 ​

下单接口检查了商品是否在售、数量是否合法,但没看库存,也不扣库存;支付后也不加销量。我把一款耳机的库存改成 0,下单 5 件,成功进入「待付款」,付款后库存还是 0、销量也没变。库存改成 3,下单 2 件,库存仍然是 3。

补上之后的逻辑是:下单时校验并占用库存(同一商品出现在多行要合并算),取消和超时归还,支付时加销量:

js
const restock = o => o.items.forEach(i => { const p = findP(i.productId); if (p) p.stock += i.qty })

sweep() 和用户取消都调用 restock。实测:库存 3 下单 2 件剩 1,取消后回到 3;库存 999 的商品下单 4 件变 995,超时被定时器取消后回到 999。退款归还库存我没加,因为「仅退款」时货可能根本没回来,这得看业务。

前端也有对应的空档 ​

这部分我只读了代码,没在真机上跑:

  • 订单列表的 tab 里没有「已退款」,状态文字只要 afterSaleStatus 有值就拼「售后中 · 」,处理完了也会显示「售后中 · 已退款」;
  • 详情页的状态说明表里没有「已退款」,取出来是 undefined;
  • 按钮映射里「已退款」落到默认分支,显示「再买一单」,这个倒是可以接受。

另外用户侧还有一个 /api/orders/:id/ship 的「模拟发货」接口,演示很方便,正式用之前一定要删掉。

小结 ​

订单状态机最容易出问题的不是主干,而是后加进来的分支:每多一个状态,就要回答三个问题——谁能进入它、在它之上哪些操作要被禁止、所有按状态统计和展示的地方是否认识它。

上面 5 处的改动我整理成了一个补丁(server/index.js 和 server/admin.js,13 行增 5 行删),本地复现脚本跑通,还没有合进仓库。

仓库:https://github.com/MsNan/taohaowu-shop

最后更新: