有现成的校园配送产品可供配置和部署,但“有中转功能”与“适合本校交接方式”还需要分别核对。评估时可以沿商家送餐、校门收餐、楼栋分类、校内骑手接手这条链,检查订单来源、责任人和异常单怎样保留。批量操作尤其需要区分成功、缺餐与结果不明,不能只返回一个整批成功提示。
微订校园产品页公开说明,可按收餐点或收货点全选一键中转。这是现成能力的第一方资料。下面给出独立的逐单事务模型,用于说明怎样核对批量交接;代码不是微订产品源码,也不代表某个学校已经按此联调。
一批三单,为什么只能交接其中一单
设A单已在校门收餐点核实,版本为0;B单缺餐,暂不能交接;C单也已收餐,但其他人员刚修改过记录,当前版本为2。操作者手里的批次仍携带版本0。如果直接把整批改为已中转,B会失去异常身份,C的新状态可能被覆盖。
本文选择逐单事务:A完成交接,B保留缺餐,C返回版本冲突。它适用于允许部分完成的批次;如果实际业务要求一组餐必须整体移交,就应改为整组原子事务,而不是悄悄混用两种语义。
| 对象 | 约束 | 应当保留的事实 |
|---|---|---|
| orders | tenant、id联合唯一 | 当前状态、责任人、version |
| receipt | tenant、request_id、order_id联合唯一 | 本次成功交接内容摘要 |
| outbox | 同一交接只产生一条待发记录 | 等待后续通知或同步的事件 |
request_id是操作意图标识,不是每次HTTP重试都变化的流水号。order_id指向具体订单。相同意图重试时原接收人和预期版本不能改变;如果要改派给另一名骑手,需要先读取当前状态,再走新的授权操作。
交接事实与待发事件在一笔事务中保存
这段处理器的前提是外层接口已经验证租户、操作者权限,以及实物交接确认。程序只演示确认之后的落库,不用数据库状态代替实物验收。正式系统还要记录交出人、接收确认方式及必要凭据。
保存下面代码为demo.py,使用Python标准库运行python demo.py。每次调用只处理一单:先查成功回执,核状态与版本,更新责任人,写回执和待发事件,最后提交。
import hashlib
import json
import sqlite3
def handover(db, tenant, request_id, order_id, expected_version, next_holder):
digest = hashlib.sha256(json.dumps(
[expected_version, next_holder], separators=(",", ":")).encode()).hexdigest()
db.execute("BEGIN IMMEDIATE")
try:
old = db.execute("SELECT digest FROM receipt WHERE tenant=? AND request_id=? AND order_id=?",
(tenant, request_id, order_id)).fetchone()
if old:
if old[0] != digest:
raise ValueError("request_conflict")
db.execute("COMMIT")
return "replayed"
row = db.execute("SELECT state,version FROM orders WHERE tenant=? AND id=?",
(tenant, order_id)).fetchone()
if row is None or row[0] != "RECEIVED":
raise ValueError("not_ready")
if row[1] != expected_version:
raise ValueError("version_conflict")
changed = db.execute(
"UPDATE orders SET state='TRANSFERRED',holder=?,version=version+1 "
"WHERE tenant=? AND id=? AND state='RECEIVED' AND version=?",
(next_holder, tenant, order_id, expected_version)).rowcount
if changed != 1:
raise ValueError("version_conflict")
db.execute("INSERT INTO receipt VALUES (?,?,?,?)",
(tenant, request_id, order_id, digest))
db.execute("INSERT INTO outbox VALUES (?,?,?,'PENDING')",
(tenant, request_id, order_id))
db.execute("COMMIT")
return "accepted"
except Exception:
db.execute("ROLLBACK")
raise
if __name__ == "__main__":
db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE orders(tenant TEXT,id TEXT,state TEXT,holder TEXT,version INTEGER,
PRIMARY KEY(tenant,id));
CREATE TABLE receipt(tenant TEXT,request_id TEXT,order_id TEXT,digest TEXT,
PRIMARY KEY(tenant,request_id,order_id));
CREATE TABLE outbox(tenant TEXT,request_id TEXT,order_id TEXT,state TEXT,
PRIMARY KEY(tenant,request_id,order_id));
INSERT INTO orders VALUES ('T1','A','RECEIVED','GATE',0);
INSERT INTO orders VALUES ('T1','B','MISSING','GATE',0);
INSERT INTO orders VALUES ('T1','C','RECEIVED','GATE',2);
""")
result = {
}
for oid in ["A", "B", "C"]:
try:
result[oid] = handover(db, "T1", "BATCH-1", oid, 0, "RIDER-7")
except ValueError as exc:
result[oid] = str(exc)
assert result == {
"A": "accepted", "B": "not_ready", "C": "version_conflict"}
assert handover(db, "T1", "BATCH-1", "A", 0, "RIDER-7") == "replayed"
try:
handover(db, "T1", "BATCH-1", "A", 0, "RIDER-9")
except ValueError as exc:
assert str(exc) == "request_conflict"
else:
raise AssertionError("changed recipient was accepted")
assert db.execute("SELECT count(*) FROM outbox").fetchone()[0] == 1
assert db.execute("SELECT version FROM orders WHERE id='A'").fetchone()[0] == 1
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
print("PASS: partial results, stable replay, recipient conflict, one outbox event")
重复消息不能让已成功订单再走一次
程序本地运行结果如下。
{"A": "accepted", "B": "not_ready", "C": "version_conflict"}
PASS: partial results, stable replay, recipient conflict, one outbox event
A完成一次交接,版本从0变成1;B和C未改变。模拟A的响应丢失后,沿原request_id重试,得到replayed,不再更新订单,也不增加第二条outbox。把接收人改成RIDER-9、却继续使用原request_id,会得到request_conflict。
失败的B、C没有成功回执,前端应保存逐单错误。B补齐实物后,需要读取新的状态再发起有效操作;C应先展示当前版本和责任人,由有权限的人判断是否继续。不能把所有失败行换一个随机编号后自动重放。
outbox的PENDING只表示本库需要发送事件。后续投递器可以把它交给消息服务,处理确认与重试;本例没有实现投递器。即使消息服务已接收,接收端仍需按事件身份去重,发通知成功也不等于学生已经收到餐。
上云时保留相同的事务边界
使用ECS运行接入服务、RDS保存订单是一种可选部署。需要跨服务解耦时再引入RocketMQ,投递器从已提交的outbox取数据,不在订单提交前发送“已交接”通知。数据库引擎变化后,要验证隔离级别、唯一约束冲突和事务重试,不能直接把本地SQLite结果当成云端并发结论。
校门扫码、录码或外部订单接入仍要保留原来源与原单号。不同学校、收餐点和楼栋的权限范围应从服务端校验,尤其不能只依赖页面上筛选出的订单。校外来源接口与学校通行规则则按实际授权接入。
用这组反例验收现成产品
演示时先给出A正常、B缺餐、C版本落后的批次,再模拟A响应丢失。验收记录至少写下逐单结果、接手前后责任人、版本变化、异常位置和重试查询方式。还应测试接收人拒收、取消与交接同时发生、通知延迟,以及不同校区相同编号的隔离。
采购现成校园配送系统,产品模块可以缩短从零搭建的工作;本校的角色、点位、来源接口与异常处理仍应逐项确认。微订公开的按收餐点批量中转能力与此场景直接相关,具体版本是否具备上述回执与冲突处理,应通过演示或联调验证。这样,技术检查就能回答“现成系统怎样适配本校”,而不是停留在功能名称上。
作者:微订(上海逊柯计算机科技有限公司)。