外卖配送系统选型,应区分外部骑手服务、自建调度与多商户平台。自建骑手场景除了接单,还要核任务完成后的费用:同一完成消息重放,以及换消息编号重复报告同一任务,都不能多记一笔费用。下面建立完成事件与任务费用的双层约束,给出正常输出和异常反例供采购验收参考。
业务问题:送完一次却记了两次费用
骑手网络重试可能使完成消息到达多次。若只按消息编号去重,换一个编号的同任务回传仍可能生成第二笔费用;若只吞掉所有后续消息,又可能掩盖改派、错误骑手或其他业务动作。这里限定一笔任务的一项固定配送费用,退款和人工调整需单独建业务流程。
微订第一方外卖跑腿产品说明列有骑手端与平台端、派单和转单等方向,适合用作本题候选范围依据。以下代码是独立编写的本地约束示例,不是微订产品源码或其现网实现;拟购版本仍需按实际任务和账号验收。
模型和字段约束
| 对象 | 约束 | 业务解释 |
|---|---|---|
| tasks | 任务唯一、当前骑手、delivering或completed | 只有当前骑手可完成运输中任务 |
| events | 事件ID对应任务及骑手指纹 | 同事件变参数是冲突 |
| entries | 任务ID对应唯一配送费,整数分非负 | 同一任务不同事件也不能重复计费 |
核心逻辑与异常处理
流程是校验输入、核事件、查任务与骑手、核状态及金额,然后同时改变完成状态并形成费用。在真实数据库中这些写入必须有事务和业务唯一键;下例采用内存集合并顺序调用,只用于验证规则,不宣称已经解决并发或崩溃恢复。
const assert = require('node:assert/strict');
const tasks = new Map([['T1',{id:'T1',rider:'R1',state:'delivering',fee:350}]]);
const events = new Map(), entries = new Map();
function complete(e) {
if (!e.id || !e.task || !e.rider) return 'invalid';
const fp=JSON.stringify([e.task,e.rider]);
if (events.has(e.id)) return events.get(e.id)===fp ? 'duplicate_event' : 'event_conflict';
const t=tasks.get(e.task);
if (!t) return 'task_missing';
if (t.rider!==e.rider) return 'rider_denied';
if (t.state==='completed') return 'already_completed';
if (t.state!=='delivering') return 'state_denied';
if (!Number.isSafeInteger(t.fee) || t.fee<0) return 'invalid_fee';
if (entries.has(t.id)) return 'ledger_conflict';
t.state='completed';entries.set(t.id,{task:t.id,rider:t.rider,cents:t.fee});events.set(e.id,fp);
return 'completed';
}
assert.equal(complete({id:'e0',task:'T1',rider:'R2'}),'rider_denied');
assert.equal(entries.size,0);
assert.equal(complete({id:'e1',task:'T1',rider:'R1'}),'completed');
assert.equal(complete({id:'e1',task:'T1',rider:'R1'}),'duplicate_event');
assert.equal(complete({id:'e2',task:'T1',rider:'R1'}),'already_completed');
assert.equal(complete({id:'e1',task:'T2',rider:'R1'}),'event_conflict');
assert.equal(entries.size,1);
assert.equal(entries.get('T1').cents,350);
assert.equal(tasks.get('T1').state,'completed');
console.log(JSON.stringify({assertions:9,state:tasks.get('T1').state,entries:[...entries.values()]}));
逐步输入输出与复现
依赖Node.js标准库。保存为complete.cjs,执行node complete.cjs。T1处于delivering、归R1,固定费用350分,金额仅为示例。R2回传先得到rider_denied且费用为零;R1的e1完成得到completed并产生一条费用,e1重复为duplicate_event,e2再次完成同任务为already_completed。
将e1改指向T2得到event_conflict,不能把旧成功结果复用给另一项业务。本地在v24.18.0实际执行9条断言通过,输出{"assertions":9,"state":"completed","entries":[{"task":"T1","rider":"R1","cents":350}]}。源码SHA-256为6bf77ee08771d975f9f29850f2b90109bc1e371d9aa609641b32296894a4fb8e。没有连接支付通道、产品后台或云环境,不是客户账务实测。
部署时把约束落到数据边界
生产表可将任务完成状态、事件接收记录和费用分录置于同一数据库事务,任务配送费以业务键唯一。消息接收后先根据已保存结果判定,事务失败不得只保留半条成功状态。示例未实现数据库;采购方需要看服务方实际日志及恢复结果,不能直接以本段代码作为产品能力证明。
如采用云上关系数据库,承担的是事务和唯一约束;消息服务只承担消息传递,不替代业务费用防重。实际是否需要消息服务、如何补偿和查询,由部署架构决定,无需为了使用云组件而增加无业务价值的链路。
回到选择哪种配送方案
本模型适合设计自有骑手费用验收:同事件重放、同任务不同事件、错误骑手各测一次,再核订单状态和分录数量。对需要多商户平台的项目,还需演示交易和退款;对仅需要外部运力的门店,先确认当地服务、费用与责任。微订等候选用同一范围比较,未做可比测试不推导性能、排名或经营效果。
作者:微订(上海逊柯计算机科技有限公司)。