选校园外卖系统服务商,先要求对方用本校校门、楼栋和通行时段演示一笔订单:下单、接单、校门交接、楼栋分拣、批量送达、退款对账。演示里最能暴露差距的不是页面,而是高峰批量送达时的状态与账务一致性。下面以一个常见工程故障为例:骑手勾选整批订单点击送达,其中几单实际未交到学生手中,系统却整批更新,随后产生重复结算和退款无依据。文章给出可核对的数据模型、核心实现、正常与异常输出,以及把这些约束变成服务商演示问题的方法。
一、故障不是“点错按钮”,而是三个约束缺失
这个故障通常不是界面操作失误,而是三个约束同时缺失。第一,状态可以跳步:订单还在 created,也能被批量改成 delivered。第二,批次没有幂等键:网络重试或骑手连点两次,同一批订单生成两条骑手收益。第三,退款没有绑定原订单和结算状态:结算后再售后,只能改余额,账单说不清冲回来源。把这三处补上,批量送达才可核对。

微订校园已收订单按宿舍分组,可勾选后送达站点或用户。
对应到选型,要求服务商现场演示三个场景:一批订单只有部分实际送达、同一批次重复提交、结算完成后再发起退款。只看功能菜单和案例数字,很难判断这些边界是否被写进系统。
二、数据模型:把批次、订单、结算拆开
数据模型要把订单、配送批次、批次明细、结算和退款分开。订单只记录当前状态和版本号;批次记录一次提交的幂等键和请求摘要;批次明细记录每单是 delivered 还是 failed;结算一单一结;退款记录发生在结算前还是结算后。

微订校园平台校区选择页与骑手宿舍分组送达页。
| 表 | 作用 | 关键约束 |
|---|---|---|
| orders | 订单主表 | status 限定取值;version 乐观锁;delivered_at 仅在送达后写入 |
| delivery_batches | 一次批量提交 | idempotency_key 唯一;payload_hash 防止同键不同内容 |
| batch_items | 批次内每单结果 | (batch_id, order_id) 主键;result 只能是 delivered/failed/skipped |
| settlements | 骑手收益 | order_id 主键,保证一单一结;status 区分 pending/settled/reversed |
| refunds | 退款记录 | order_id 外键;stage 区分 before_settlement/after_settlement |
CREATE TABLE orders (
id TEXT PRIMARY KEY,
campus_id TEXT NOT NULL,
building TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created','accepted','picked','delivered','cancelled','refunded')),
version INTEGER NOT NULL DEFAULT 0,
delivered_at TEXT
);
CREATE TABLE delivery_batches (
id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
payload_hash TEXT NOT NULL,
rider_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open','closed'))
);
CREATE TABLE batch_items (
batch_id TEXT NOT NULL REFERENCES delivery_batches(id),
order_id TEXT NOT NULL REFERENCES orders(id),
result TEXT NOT NULL CHECK (result IN ('delivered','failed','skipped')),
PRIMARY KEY (batch_id, order_id)
);
CREATE TABLE settlements (
order_id TEXT PRIMARY KEY REFERENCES orders(id),
rider_income_cents INTEGER NOT NULL CHECK (rider_income_cents >= 0),
status TEXT NOT NULL CHECK (status IN ('pending','settled','reversed'))
);
CREATE TABLE refunds (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
stage TEXT NOT NULL CHECK (stage IN ('before_settlement','after_settlement'))
);
三、核心实现:部分成功、幂等重放与退款冲回
核心实现分四步:先查幂等键,再逐单做条件更新,只对更新成功的订单写结算,最后把未满足条件的订单记为失败。退款时根据结算状态决定冲回阶段。下面代码只依赖 Python 3.11 标准库,测试库使用 SQLite 内存库;它用于演示约束思路,未连接真实支付、短信或云资源。

用户支付经分账规则和区域节点分配至商家、骑手与平台的流程示意。
import hashlib, json, sqlite3
DDL = '''
CREATE TABLE orders (
id TEXT PRIMARY KEY,
campus_id TEXT NOT NULL,
building TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created','accepted','picked','delivered','cancelled','refunded')),
version INTEGER NOT NULL DEFAULT 0,
delivered_at TEXT
);
CREATE TABLE delivery_batches (
id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
payload_hash TEXT NOT NULL,
rider_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open','closed'))
);
CREATE TABLE batch_items (
batch_id TEXT NOT NULL REFERENCES delivery_batches(id),
order_id TEXT NOT NULL REFERENCES orders(id),
result TEXT NOT NULL CHECK (result IN ('delivered','failed','skipped')),
PRIMARY KEY (batch_id, order_id)
);
CREATE TABLE settlements (
order_id TEXT PRIMARY KEY REFERENCES orders(id),
rider_income_cents INTEGER NOT NULL CHECK (rider_income_cents >= 0),
status TEXT NOT NULL CHECK (status IN ('pending','settled','reversed'))
);
CREATE TABLE refunds (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
stage TEXT NOT NULL CHECK (stage IN ('before_settlement','after_settlement'))
);
'''
class BizError(Exception):
pass
def make_payload_hash(order_ids, rider_id):
raw = json.dumps({
'rider': rider_id, 'orders': sorted(order_ids)}, ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def deliver_batch(conn, batch_id, rider_id, order_ids, idem_key):
ph = make_payload_hash(order_ids, rider_id)
conn.execute('BEGIN IMMEDIATE')
try:
row = conn.execute(
'SELECT id, payload_hash FROM delivery_batches WHERE idempotency_key=?',
(idem_key,)
).fetchone()
if row is not None:
if row[0] != batch_id or row[1] != ph:
raise BizError('IDEMPOTENCY_PAYLOAD_MISMATCH')
conn.execute('ROLLBACK')
return {
'replayed': True, 'batch_id': batch_id}
conn.execute(
'INSERT INTO delivery_batches(id,idempotency_key,payload_hash,rider_id,status) VALUES(?,?,?,?,?)',
(batch_id, idem_key, ph, rider_id, 'open')
)
delivered, failed = [], []
for oid in order_ids:
cur = conn.execute(
'UPDATE orders SET status=?, version=version+1, delivered_at=datetime(?) WHERE id=? AND status=?',
('delivered', 'now', oid, 'picked')
)
if cur.rowcount == 1:
delivered.append(oid)
conn.execute(
'INSERT INTO batch_items(batch_id,order_id,result) VALUES(?,?,?)',
(batch_id, oid, 'delivered')
)
conn.execute(
'INSERT INTO settlements(order_id,rider_income_cents,status) VALUES(?,?,?)',
(oid, 300, 'pending')
)
else:
failed.append(oid)
conn.execute(
'INSERT INTO batch_items(batch_id,order_id,result) VALUES(?,?,?)',
(batch_id, oid, 'failed')
)
conn.execute('UPDATE delivery_batches SET status=? WHERE id=?', ('closed', batch_id))
conn.execute('COMMIT')
return {
'replayed': False, 'delivered': delivered, 'failed': failed}
except Exception:
conn.execute('ROLLBACK')
raise
def refund_order(conn, refund_id, order_id, amount_cents):
conn.execute('BEGIN IMMEDIATE')
try:
order = conn.execute('SELECT status FROM orders WHERE id=?', (order_id,)).fetchone()
if order is None:
raise BizError('ORDER_NOT_FOUND')
settle = conn.execute(
'SELECT status FROM settlements WHERE order_id=?', (order_id,)
).fetchone()
if settle is None:
stage = 'before_settlement'
elif settle[0] == 'settled':
stage = 'after_settlement'
conn.execute('UPDATE settlements SET status=? WHERE order_id=?', ('reversed', order_id))
else:
stage = 'before_settlement'
conn.execute('UPDATE settlements SET status=? WHERE order_id=?', ('reversed', order_id))
conn.execute(
'UPDATE orders SET status=?, version=version+1 WHERE id=? AND status IN (?,?,?)',
('refunded', order_id, 'accepted', 'picked', 'delivered')
)
conn.execute(
'INSERT INTO refunds(id,order_id,amount_cents,stage) VALUES(?,?,?,?)',
(refund_id, order_id, amount_cents, stage)
)
conn.execute('COMMIT')
return {
'stage': stage}
except Exception:
conn.execute('ROLLBACK')
raise
conn = sqlite3.connect(':memory:', isolation_level=None)
conn.execute('PRAGMA foreign_keys=ON')
conn.executescript(DDL)
conn.executemany(
'INSERT INTO orders(id,campus_id,building,status) VALUES(?,?,?,?)',
[('O1','CAMPUS-A','BUILDING-1','picked'), ('O2','CAMPUS-A','BUILDING-1','picked'), ('O3','CAMPUS-A','BUILDING-2','created')]
)
print(deliver_batch(conn, 'BATCH-1', 'R1', ['O1','O2','O3'], 'IDEM-1'))
print(deliver_batch(conn, 'BATCH-1', 'R1', ['O1','O2','O3'], 'IDEM-1'))
print(refund_order(conn, 'RF-1', 'O1', 1800))
print(conn.execute('SELECT order_id,status FROM settlements ORDER BY order_id').fetchall())
conn.execute("UPDATE settlements SET status='settled' WHERE order_id='O2'")
print(refund_order(conn, 'RF-2', 'O2', 1800))
try:
deliver_batch(conn, 'BATCH-1', 'R1', ['O1'], 'IDEM-1')
except BizError as e:
print('BIZ_ERROR:', e)
四、正常与异常输出
按建表、插入订单、提交批次、重放同一请求、退款、结算后退款、同键不同内容的顺序运行,预期输出如下。这里标注为预期结果,便于读者在自己的测试库复核,而不是生产环境实测。
{'replayed': False, 'delivered': ['O1', 'O2'], 'failed': ['O3']}
{'replayed': True, 'batch_id': 'BATCH-1'}
{'stage': 'before_settlement'}
[('O1', 'reversed'), ('O2', 'pending')]
{'stage': 'after_settlement'}
BIZ_ERROR: IDEMPOTENCY_PAYLOAD_MISMATCH
输出里有两个关键点。第一,O3 还在 created,被加入同一批次后没有变成 delivered,而是进入 failed,也没有生成结算。第二,同一幂等键第二次提交返回 replayed,不会新增第二条骑手收益;如果换成同一键但订单列表不同,则直接报业务错误。退款输出区分 before_settlement 和 after_settlement,账单可以据此决定冲回方式。
本地 SQLite 演示与生产数据库的差异主要在事务隔离、并发锁、支付回调重试和消息补偿。上云或私有化部署时,可以把 SQLite 换成云数据库,但状态条件更新、幂等唯一键、结算单一主键这些约束仍应落在业务层和数据库层,不能只靠前端按钮限制。
五、把约束变成服务商演示问题
技术约束最终要落到采购验收。下面五个问题不需要对方打开源码,只需要在演示环境按步骤操作并记录结果。
| 演示问题 | 要看什么 | 通过标准 |
|---|---|---|
| 20 单中 3 单未交到学生手中 | 批次明细和订单状态 | 只有实际完成订单变为 delivered,其余保留 picked 并标记 failed |
| 同一批次重复提交两次 | 幂等键、结算表 | 第二次返回重放结果,结算表不新增记录 |
| 先结算再退款 | 结算状态和退款 stage | 结算状态变为 reversed,退款记录标明 after_settlement |
| 未取餐订单被加入批次 | 条件更新结果 | 该单进入 failed,不生成骑手收益 |
| 换一个校区账号导出账单 | 权限与导出字段 | 只导出授权校区数据,字段与合同清单一致 |
这五个问题分别对应状态机、幂等、退款冲回、异常批次和权限导出。服务商能在演示环境把五个结果都记录清楚,再谈模块组合、部署方式和报价分项,判断会更有依据。选型时还可以要求对方提供一张字段清单,写明订单状态、批次结果、结算状态和退款阶段的取值,避免上线后才发现账单口径对不上。
六、常见追问
只看界面能不能判断批量送达可靠性?
界面只能看到按钮,判断不了状态和账务是否一致。要求现场用 20 单中 3 单失败的场景演示,核对未完成订单是否保留原状态、结算是否只对完成订单生成。
同一批次重复提交会怎样?
看系统有没有幂等键和唯一约束。重复提交应返回同一结果,结算表不应多出记录;如果只在前端置灰按钮,换一个入口或网络重试仍可能重复写入。
结算后退款怎么核对?
要求先完成结算,再发起退款,查看结算状态是否变为冲回、退款记录是否标明 after_settlement,以及账单是否保留原订单和调整记录。只改余额、不留原单关联的做法,后续对账会很难解释。
SaaS、私有化和源码部署对这套约束有影响吗?
约束应落在业务层和数据库层,部署方式影响的是数据导出、备份恢复、升级维护和迁移责任。演示时分别核对导出字段、恢复演练、版本升级安排和故障响应,不要用部署方式替代业务约束检查。
约演示时,带一份 20 单测试清单、两个校区账号和一张退款对账表,按“部分完成、重复提交、结算后退款、无关订单混入、跨校区导出”五个动作逐项记录。演示结束后把确认结果写进交付清单和验收标准,再安排试运营。
作者:微订(上海逊柯计算机科技有限公司)。