校园跑腿小程序上线,先别急着投放推广,而应拿一笔代取快递把“下单—支付—接单—取件—交接—送达—结算”整条状态链跑通。工程上最容易出问题的地方往往不是页面,而是同一笔订单在校门收餐、楼栋分拣和送达三个环节被重复扫码,造成状态回退、取件通知重发、骑手收益重复记账。下面给出一套可落到 MySQL 的订单表、事件表、条件更新逻辑,以及正常单与三类异常输入的预期输出,用于上线前的本地演示与业务验收。
一、先把一笔代取快递拆成状态机
代取快递的字段比普通商品订单少,但状态更多。下单时写清领取点、件数、取件凭据、送达楼栋和预约时间;接单后绑定骑手;取件时校验凭据与件数;送达时校验楼栋是否与订单一致;最后才进入结算。状态与字段必须一起约束,只做页面流程会在并发扫码时暴露问题。

微订帮拿快递订单页,展示取件信息、联系地址与支付选择。
| 状态 | 业务含义 | 进入条件 | 允许的下一状态 |
|---|---|---|---|
| CREATED | 已创建待支付 | 地址、楼栋、件数已填写 | PAID、CANCELLED |
| PAID | 已支付待接单 | 支付回调验签与金额核对通过 | ACCEPTED、CANCELLED |
| ACCEPTED | 骑手已接单 | 骑手在线且服务校区匹配 | PICKED、CANCELLED |
| PICKED | 已取件待送达 | 取件凭据非空、件数一致 | DELIVERED |
| DELIVERED | 已送达待结算 | 送达楼栋与订单楼栋一致 | COMPLETED |
| COMPLETED | 已生成结算流水 | 收益流水写入成功 | 无 |
改约不单独占一个状态,而是作为事件记录,订单停留在原状态并更新预约时间。这样,骑手看到的仍是同一条任务,历史改约次数可追溯,也方便客服判断该单是否已经被反复推迟。
二、订单表与事件表:用唯一索引兜住重复操作
订单主表保存当前状态,事件表保存“谁在什么状态下做了什么”。两张表通过订单号关联,事件表上的唯一索引负责拦截重复扫码和重复接单。以下 DDL 以 MySQL 8.0 与 InnoDB 为设计目标,示例为结构设计,未在真实业务库执行。
CREATE TABLE errand_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
campus_id BIGINT UNSIGNED NOT NULL,
building_id BIGINT UNSIGNED NOT NULL,
task_type VARCHAR(16) NOT NULL COMMENT 'PICKUP/BUY/ERRAND',
pickup_point VARCHAR(64) NOT NULL,
parcel_count TINYINT UNSIGNED NOT NULL DEFAULT 1,
pickup_code VARCHAR(64) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'CREATED',
rider_id BIGINT UNSIGNED NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_campus_status (campus_id, status),
CONSTRAINT chk_parcel_count CHECK (parcel_count BETWEEN 1 AND 20),
CONSTRAINT chk_status CHECK (status IN ('CREATED','PAID','ACCEPTED','PICKED','DELIVERED','COMPLETED','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE errand_order_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
event_type VARCHAR(24) NOT NULL COMMENT 'PAY/ACCEPT/PICKUP/DELIVER/SETTLE/CANCEL/RESCHEDULE',
idem_key VARCHAR(96) NOT NULL,
from_status VARCHAR(16) NOT NULL,
to_status VARCHAR(16) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_idem (idem_key),
UNIQUE KEY uk_order_event (order_no, event_type),
KEY idx_order (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
uk_idem 由“事件类型+订单号+操作人+操作序号”拼成,用于挡住同一按钮被连续点击;uk_order_event 保证同一订单的取件、送达、结算各只发生一次,改约走 RESCHEDULE 事件,不重复占用取件名额。件数上限与状态枚举放在库表约束里,避免小程序端传空值或负数时把脏数据带进结算。
三、核心实现:INSERT IGNORE 加前置状态条件更新
许多并发问题来自“先查状态、再改状态”的两步写法:两个请求同时读到 PAID,再同时写入 ACCEPTED,结果两名骑手都接到同一单。把判断条件放进 UPDATE 的 WHERE 子句,数据库就会用行锁把竞争串行化。下面用 Go 展示服务层核心逻辑,SQL 随后给出。
func (s *OrderService) Advance(ctx context.Context, c AdvanceCmd) (AdvanceResult, error) {
inserted, err := s.events.InsertIgnore(ctx, c)
if err != nil {
return AdvanceResult{
}, err
}
if inserted == 0 {
status, err := s.orders.Status(ctx, c.OrderNo)
return AdvanceResult{
Duplicated: true, Status: status}, err
}
updated, err := s.orders.CompareAndAdvance(ctx, c)
if err != nil {
return AdvanceResult{
}, err
}
if updated == 0 {
return AdvanceResult{
}, fmt.Errorf("state conflict: %s expect %s", c.OrderNo, c.FromStatus)
}
return AdvanceResult{
Status: c.ToStatus}, nil
}
INSERT IGNORE INTO errand_order_event
(order_no, event_type, idem_key, from_status, to_status, operator_id)
VALUES
(?, ?, ?, ?, ?, ?);
UPDATE errand_order
SET status = ?, rider_id = COALESCE(rider_id, ?), version = version + 1
WHERE order_no = ?
AND status = ?
AND (? IS NULL OR building_id = ?);
第一条语句返回受影响行数 1 表示事件首次落库,返回 0 表示重复提交;第二条语句把“当前状态必须是预期前置状态”和“送达楼栋必须与订单楼栋一致”作为更新条件,返回 0 就抛出状态冲突并让事务整体回滚,事件记录不会单独留下。这样,重复扫码不会推进状态,并发接单只有一方成功,错楼栋送达会被直接拦下。业务上对应的就是:学生只收到一次取件通知,骑手收益只记一条,送达位置与下单地址对得上。

微订校园配送按商家汇总订单,并提供扫码收单入口。
四、正常输入输出与三类异常反例
先把一笔正常单跑出可核对的输出,再用异常输入验证拦截效果。下表为设计示例的预期结果,不代表任何真实环境已执行。
| 步骤 | 输入 | 事件表影响行数 | 订单状态 | 下游动作 |
|---|---|---|---|---|
| 支付回调 | 订单 ORD-20260001,PAY | 1 | CREATED→PAID | 进入待接单池 |
| 骑手接单 | 骑手 9003,ACCEPT | 1 | PAID→ACCEPTED | 推送取件任务 |
| 校门取件 | 凭据 A-17,PICKUP | 1 | ACCEPTED→PICKED | 生成交接批次 |
| 楼栋送达 | 楼栋 12,DELIVER | 1 | PICKED→DELIVERED | 发送取件通知一次 |
| 结算 | 骑手 9003,SETTLE | 1 | DELIVERED→COMPLETED | 写入收益流水一条 |
| 反例 | 输入 | 预期输出 | 业务效果 |
|---|---|---|---|
| 重复扫码送达 | 同一 idem_key 连续提交两次 | 第二次 inserted=0,返回 duplicated,状态仍为 DELIVERED | 通知不重发,收益不重复记账 |
| 并发接单 | 骑手 9003 与 9004 同时 ACCEPT | 一方 updated=1,另一方 updated=0 抛状态冲突并回滚 | 订单只有一个骑手 |
| 错楼栋送达 | 订单楼栋 12,提交楼栋 15 | building_id 条件不满足,updated=0 | 送达地址与下单地址不符时被拦截 |
| 跨状态跳跃 | PAID 状态直接提交 DELIVER | from_status 条件不满足,updated=0 | 未取件不能送达 |
反例设计的目标不是让系统多报错,而是让错误停在现场岗位可处理的范围内。扫码失败的骑手需要看到“该订单已送达”或“楼栋不匹配”的明确提示,调度员据此判断是重发通知、改派还是联系学生确认地址,而不是在后台手工改状态。
五、上线演示清单与业务验收
状态机跑通后,用六类场景做上线前验收。每类场景同时看小程序端提示、事件表记录和骑手收益流水,三者对得上才进入试运营。
| 场景 | 操作 | 核对点 |
|---|---|---|
| 正常单 | 完整走完支付到结算 | 状态推进一次,通知一次,收益一条 |
| 改约 | 已接单后修改预约时间 | 状态不跳变,RESCHEDULE 事件可查 |
| 无法联系 | 送达时电话未接通 | 记录联系次数与暂存位置,订单不误结算 |
| 错楼栋 | 骑手提交非订单楼栋 | 更新被拦截,提示楼栋不符 |
| 退款 | 取件前取消并退款 | 状态转 CANCELLED,收益流水不生成 |
| 提现 | 骑手申请收益提现 | 可提现金额与已完成订单对应 |
结算环节再补一条唯一约束:收益流水表按“订单号+事件类型”建唯一键,与事件表保持同一口径,避免重复扫码或人工补单造成同一笔收益入账两次。上线前用一笔正常单、一笔退款单和一笔重复扫码单走完整流程,把每一步的状态、事件行数和金额记录下来,这份记录就是后续排查与培训的依据。
作者:微订(上海逊柯计算机科技有限公司)。