导读:商城上线自提和同城配送后,最典型的故障是"订单状态乱了":用户自提码还没核销,订单却显示已完成;配送超时没人管,骑手到了系统还停在待配送;同一个自提码被两个店员核销成功。根子在于把"自提"和"配送"两条履约线塞进了一个订单状态字段。本文给一套双履约状态机的设计:主状态与履约状态分层、超时自动流转、核销幂等,附可直接抄的建表与代码。
只用一个 status 字段管订单,自提和配送场景会互相打架。自提订单"已支付→待自提→已完成"三步走;配送订单则是"已支付→待接单→配送中→已完成",中间还要支持超时未接、配送异常、用户取消。如果把两种路径写进同一枚举,状态机要么过度膨胀,要么无法表达"支付已完成、但配送还没开始"这种中间态。
两者分离,交易和交付才能独立演进。支付完成不意味着履约开始,退款也不一定要求履约必须结束。
用一张订单表存主状态,一张履约表存履约明细,主外键关联。这样同一个订单既能挂自提核销记录,也能挂配送轨迹。
-- 订单主表:只表达交易进度
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL, -- WAIT_PAY/PAID/CANCELED/FINISHED/REFUNDING
pay_amount DECIMAL(10,2) NOT NULL,
version INT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL
);
-- 履约表:一次履约一行,履约类型区分自提/配送
CREATE TABLE order_fulfillment (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
type VARCHAR(8) NOT NULL, -- PICKUP/DELIVERY
status VARCHAR(16) NOT NULL, -- PREPARING/PENDING_PICKUP/OUT_FOR_DELIVERY/DELIVERED/TIMEOUT
store_id BIGINT, -- 自提门店
pickup_code VARCHAR(16), -- 自提码
pickup_at DATETIME, -- 实际核销时间
timeout_at DATETIME, -- 超时判定时间点
version INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
);状态流转必须带乐观锁版本号,防止两个终端同时推进状态:
// 自提核销:带 version 条件更新,影响行数为 0 说明状态已被别人推进
int rows = jdbc.update(
"UPDATE order_fulfillment SET status='DELIVERED', pickup_at=NOW(), version=version+1 " +
"WHERE id=? AND status='PENDING_PICKUP' AND version=?",
fulfillmentId, currentVersion);
if (rows == 0) {
throw new IllegalStateException("核销失败:该自提码状态已变更");
}超时是状态机里最容易被漏掉的分支。自提订单到点没人来取,需要从"待自提"流转到"超时未取"并触发通知;配送订单商家超时未接单,要么自动取消、要么转人工。实现上不要靠定时任务扫全表,用延时消息按单触发。
// 支付成功后,为履约单注册一个超时任务:自提单 24h、配送单 30min
TimeoutTask task = TimeoutTask.builder()
.fulfillmentId(f.getId())
.triggerAt(now.plus(f.getType().equals("PICKUP") ? 24 : 1, HOURS))
.build();
delayQueue.publish(task);// 超时回调:仍是条件更新,避免超时通知与正常核销竞态
int rows = jdbc.update(
"UPDATE order_fulfillment SET status='TIMEOUT', version=version+1 " +
"WHERE id=? AND status IN ('PENDING_PICKUP','OUT_FOR_DELIVERY') AND version=?",
fId, version);
if (rows > 0) {
notifyService.timeout(fId); // 给用户发提醒,自提订单可重新预约
}自提码核销是典型的"重复请求"场景:店员连点、扫码枪重发、多端同时核销。幂等的关键不是"查一下状态再更新",而是让更新本身带状态约束,配合唯一约束兜底。
-- 核销流水表:一次核销一行,order_id + type 唯一,重复核销自然插入失败
CREATE TABLE fulfillment_pickup_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
fulfillment_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_fulfillment (fulfillment_id)
);// 先写流水(唯一键兜底),成功才推进状态;流水失败说明已核销过
try {
jdbc.update("INSERT INTO fulfillment_pickup_log(fulfillment_id,operator_id) VALUES (?,?)",
fId, operatorId);
} catch (DuplicateKeyException e) {
throw new IllegalStateException("该自提码已完成核销,不能重复使用");
}
jdbc.update("UPDATE order_fulfillment SET status='DELIVERED', pickup_at=NOW(), " +
"version=version+1 WHERE id=? AND status='PENDING_PICKUP' AND version=?",
fId, version);要点:流水表唯一键是最后一道闸,状态条件更新是第一道闸,两层都挡不住才需要分布式锁(单门店场景通常不需要)。配送履约同理,配送轨迹按 fulfillment_id + 节点 唯一,避免回调重复落库。
订单状态机先画清楚"主状态 + 履约状态"两张图再建表;自提单和配送单各自独立流转,共用订单主表的支付/取消/完成语义。状态变更统一走一个方法并带乐观锁,超时走延时消息。这类能力在商业 SaaS 产品(如乔拓云的网上商城)中通常作为内置模块提供。上线时先接自提单、再放开配送,逐步验证状态流转。
A:拆成两层:订单主表只管交易进度(待支付/已支付/已完成),履约表按自提/配送分别记录交付进度,两张表通过订单号关联,状态各自流转,互不干扰。
A:支付成功后为履约单注册延时超时任务,到点执行条件更新把状态置为"超时未取"并通知用户;订单主状态保持已支付,由运营在后台决定改期或取消,不自动变成已完成。
A:核销流水表对履约单建唯一索引,先写流水再推进状态;重复请求会因唯一键插入失败被拦截,配合状态的条件更新,两层防护保证核销只能成功一次。
订单状态设计的目标不是状态多,而是每种履约方式都有清晰的流转边界。主状态与履约状态分离、超时与核销走条件更新,自提和配送共存也不会乱。方案按自身业务取舍,以上仅作技术分享。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。