3.4 订单状态机:待付款到已完成

场景

支付通了不算完,订单的“一生”才刚开始。我们最早把订单想简单了:状态就两个——“未付款”和“已付款”,付完款就完事。第一周运营就乱套:顾客付了款、货发了,系统里还叫“已付款”;顾客申请退款,运营不知道该点哪、状态记录怎么流转;更混乱的是,有个顾客下完单 40 分钟才付款,库存已经被另一笔单子扣走,这种“过期订单”完全没人管。还有个退货的顾客,物流显示已签收三天,系统里订单还停在“已发货”,她以为我们忘了给她退款,打电话来语气就不太好了。

那天晚上我整理了一下问题清单,发现所有混乱都指向同一个根源:订单没有状态机。状态零散、扭转随意、过期无人处理,业务一复杂就散架。

朋友的怨气集中在两件事:一是她在后台看每个订单都是“已付款”,根本分不清哪些是“已发货”、哪些是“已收货”,只能靠记忆和便签;二是顾客来问“我的货到哪了”,她答不上来。有一天她拿着便利贴贴满显示器边框的时候,我知道这个问题不能再拖了——那时候屏幕上同时有十几张单子,谁是谁全凭她脑子记。这不是系统,这是她的人肉状态机。

订单状态机要解决的其实是一组明确的问题:状态有几个、谁能让状态往哪走、非法扭转怎么拦、过期订单怎么自动收场。设计清楚之后,运营点一下按钮系统自己走流程,顾客那边页面也随时反映最新进度。这节我们把订单从“待付款”到“已完成”(含退款、关闭)的完整生命周期讲透。

方案

最终定下来的状态集合和流转规则如下:

状态 含义 下一状态 谁触发
pending 待付款 paid / closed 顾客付款或超时关单
paid 已付款待发货 shipped / refunding 商家发货或退款
shipped 已发货 done / refunding 顾客确认或自动
done 已完成 (终态) ——
refunding 退款中 refunded 退款回调
refunded 已退款 (终态) ——
closed 已关闭 (终态) 超时定时器

设计原则有几条值得强调。第一,状态一律用英文小写单词存在数据库里,展示层再做中文映射,别存中文状态,以后做查询和统计会感谢自己。第二,每次状态扭转都写一条流水(orders_log 集合),谁在什么时间、从哪个状态改到哪个状态、什么原因,全程可追溯——客服与顾客有争议时,流水是唯一有公信力的证据。第三,状态扭转只能向前或走向退款分支,不允许回退——“已发货”不能退回“待发货”,要改用一个新操作(如“拦截发货”)而不是状态回退,这是状态机的纪律。第四,所有扭转收敛到一个入口函数(后面的 transit),业务代码不许绕过它自己写 update,纪律落进代码里才叫纪律。

超时关单是自动化的关键一环:订单创建后 15 分钟未付款,定时器把它关掉,同时回补库存。云函数的定时触发器每分钟扫一次创建时间超过 15 分钟且状态为 pending 的订单。这里和 3.2 节的库存模块就接上了:关单时的回补必须幂等,避免重复回补。定时任务自身的稳定性,可以参考云函数错误与告警那套监控机制。

为什么定 15 分钟而不是更长或更短?我们是按付款转化数据倒推的:一周内下单未付款的订单里,九成以上在 12 分钟内完成支付,15 分钟给了缓冲又不会让库存被吊太久。这个数字每个店不一样,上架后可以根据自己的转化漏斗调整,别抄我们的。

配置/代码

状态扭转的核心逻辑写成统一的函数,所有业务入口都调它,不允许各处自己 update:

// 云函数:统一的状态扭转入口
const TRANS = {
  pending: ['paid', 'closed'],
  paid: ['shipped', 'refunding'],
  shipped: ['done', 'refunding'],
  refunding: ['refunded']
};
async function transit(orderId, from, to, by, note) {
  if (!TRANS[from].includes(to)) return { ok: false, msg: '非法扭转' };
  const r = await db.collection('orders').where({
    _id: orderId, status: from
  }).update({ data: { status: to, updatedAt: Date.now() } });
  if (r.stats.updated) {
    await db.collection('orders_log').add({
      data: { orderId, from, to, by, note, ts: Date.now() }
    });
  }
  return { ok: r.stats.updated === 1 };
}

where 条件里带 status: from 是点睛之笔:并发时只有一个请求能抢到这次扭转,天然防重,和 3.2 节库存条件更新一个思路。

超时关单的定时触发器:

// closeExpired 云函数(定时触发器每分钟一次)
const overdue = Date.now() - 15 * 60 * 1000;
const { data } = await db.collection('orders').where({
  status: 'pending', createdAt: _.lt(overdue)
}).limit(50).get(); // 单次最多 50 条,分批处理
for (const o of data) {
  await transit(o._id, 'pending', 'closed', 'system', '超时未付款自动关闭');
  await releaseStock(o); // 幂等回补库存
}

前端展示侧,订单列表按状态分组(待付款、待发货、待收货、已完成),每个 tab 一个 where 查询。顾客确认收货按钮调 transit,不要直接 update 状态。

订单文档里除了状态,还建议冗余几个展示字段:statusText(中文文案)、timeline(关键节点时间数组,如下单时间、付款时间、发货时间)。订单详情页的时间线直接读这个数组,不用去 orders_log 里现拼,接口设计上“读多写少”的字段冗余一点不寒碜。

踩坑

第一个坑是状态存中文。早期版本的订单状态直接存“已付款”,后来要做统计(按状态分组计数),中文分组查询和程序里比对都不方便,全部改英文又费了一遍功夫。新项目一律英文状态加中文映射表。

第二个坑是并发扭转。运营同时点“发货”两次,或者顾客点“确认收货”时系统又在自动确认,状态可能被写花。统一入口加 where 条件后,重复点击只有第一次生效,后到的请求拿到 updated 为 0 的结果,直接忽略即可。

第三个坑是自动确认收货缺兜底。货发了顾客从不点确认,订单永远“已发货”。后来加了发货后 7 天自动确认(定时器扫,与关单同机制),订单这才有序地走向终态。京东那些大平台也都是自动确认,我们这小系统抄了个实用经验。顺带一提,自动确认的前一天给顾客发一条订阅消息提醒(4.2 节会讲),“您的订单即将自动确认收货”,既透明又减少事后扯皮。

第四个坑是关单时库存回补不幂等。定时器网络重试时同一单被关两次,库存被回补两次。靠 stock_log 里 orderId 的幂等判断解决,与 3.2 节回补逻辑共用一套。

第五个坑是退款与状态机的衔接。退款发起后订单进入 refunding,但退款是异步的(需调用退款接口并等结果),中间态必须可见。运营和顾客都盯着“退款中”这个状态,知道系统在处理、钱打回来有个过程,客服压力小很多。

第六个坑是订单列表 tab 切换缓存。切 tab 时重新查接口,顾客从“已完成”切回“待付款”要重新等加载。做了本地按 tab 缓存后再切回秒开,配合下拉刷新手动更新。

第七个坑是状态文案吓到顾客。订单详情里直接展示“已关闭”,有顾客看到问“是不是出了什么问题”,其实只是超时未付自动关单。后来文案统一改成“交易关闭,可重新下单”,附一个重新购买的入口,消除了误解。状态机是给系统看的,文案是给人看的,两者要分开维护。

小结

状态机上线之后,朋友的便利贴全撕了——后台订单页按状态分列,每一列对应一个待办动作,她说现在看订单像看快递流水一样清楚,发货、退款、追评都能照着状态一排排处理。这套机制后来几乎没再改过结构,只加过状态(比如“预售”),证明底子是对的。有一次我和朋友复盘,她说这套状态机最大的价值不是技术上的,而是“每次顾客来找,我都能立刻说清他的单子在哪一步、下一步该谁动”——信任就是靠这种清楚的交代一点点攒起来的。订单系统是电商项目里真正的“核心资产”,它稳得住,整条交易链路才稳得住。对订单量大了之后的存储和成本问题,5.4 节会算一笔总账。第四篇我们换个视角,看看用户体验:登录、通知、搜索和性能,怎么让顾客买得更顺。

延伸阅读