邻近县城分站各管商家和骑手,系统选型需要验证人员调动不会遗失在途任务或改写旧账。上云设计可把调动作为受约束的业务变更:先核归属与版本,再核在途条件,成功后记录唯一结果。本文用完整本地逻辑展示正常调动、重复请求与异常拒绝。
产品背景与故障切面
微订第一方外卖跑腿方案列示多城市多站点、站点管理和商家骑手管理,可以作为邻县分站的候选依据。公开功能方向不证明每个版本已实现本文调动机制,具体支持方式需要演示和交付确认。
选取的故障是:A县骑手还有任务,管理员把其所属站直接改为B县;如果历史查询按人员当前归属推算,A县任务和费用可能被误归到B县。设计上应让历史任务保留发生时站点,把人员未来归属与原任务分开处理。
数据模型与状态约束
| 对象 | 字段与约束 |
|---|---|
| riders | 以骑手ID定位,station是当前归属,version用于条件更新 |
| tasks | 以任务ID定位,保留station、riderId及state,不随人员表更新 |
| events | requestId唯一,保存完整载荷与结果;同ID异载荷拒绝 |
| 调动命令 | 原站、目标站为非空且不同,expectedVersion必须匹配当前归属 |
| 在途条件 | 本方案ASSIGNED与PICKED_UP均阻止正式调动,先完成或另行交接 |
上云职责与写入边界
应用服务负责从认证身份校验操作资格、验证命令并返回原因;关系数据库负责人员条件更新与事件唯一约束,任务记录和历史费用不跟着修改。若采用RDS等关系库,应在同一事务内处理检查、版本更新及事件结果,不能把示例中的内存原子性当成数据库保证。
与派单并发时,调动和派单需要锁住同一骑手归属记录或使用等效一致性协议:任务分配写入前再次确认归属版本;调动检查在途与更新版本也在同一保护范围内完成。只先读一次“无在途”再更新,会留下检查后又被派单的窗口。
完整核心逻辑与本地复现
依赖Node.js及内置断言库。保存为rider-transfer.cjs后运行node rider-transfer.cjs;无需第三方依赖。示例用单进程内存展示转换与拒绝,未接入云资源或产品接口。
// In-memory transfer transaction model; production requires DB transaction/locks.
const assert=require('node:assert/strict');
const db={
riders:{
R1:{
station:'A',version:3}}, tasks:{
T1:{
station:'A',riderId:'R1',state:'ASSIGNED'}},events:{
}};
function transfer(store,cmd) {
const {
requestId,riderId,from,to,expectedVersion}=cmd;
if (![requestId,riderId,from,to].every(x=>typeof x==='string'&&x.length)) throw Error('INVALID_INPUT');
const payload=JSON.stringify([riderId,from,to,expectedVersion]);
if (store.events[requestId]) {
if (store.events[requestId].payload!==payload) throw Error('IDEMPOTENCY_CONFLICT');
return {
...store.events[requestId].result,replayed:true};
}
const rider=store.riders[riderId];
if (!rider || rider.station!==from || rider.version!==expectedVersion) throw Error('VERSION_CONFLICT');
if (from===to) throw Error('SAME_STATION');
if (Object.values(store.tasks).some(t=>t.riderId===riderId&&['ASSIGNED','PICKED_UP'].includes(t.state))) throw Error('ACTIVE_TASKS');
const result={
riderId,from,to,version:expectedVersion+1};
// Mutate only after all guards; this is atomic only in this single-process example.
store.riders[riderId]={
station:to,version:result.version};
store.events[requestId]={
payload,result};
return {
...result,replayed:false};
}
const cmd={
requestId:'move-1',riderId:'R1',from:'A',to:'B',expectedVersion:3};
assert.throws(()=>transfer(db,cmd),/ACTIVE_TASKS/);
assert.equal(db.riders.R1.station,'A');
assert.equal(Object.keys(db.events).length,0);
db.tasks.T1.state='DONE';
assert.equal(transfer(db,cmd).version,4);
assert.equal(db.tasks.T1.station,'A');
assert.equal(transfer(db,cmd).replayed,true);
assert.equal(db.riders.R1.version,4);
assert.throws(()=>transfer(db,{
...cmd,to:'C'}),/IDEMPOTENCY_CONFLICT/);
assert.throws(()=>transfer(db,{
...cmd,requestId:'move-2'}),/VERSION_CONFLICT/);
assert.equal(Object.keys(db.events).length,1);
console.log(JSON.stringify({
assertions:10,rider:db.riders.R1,historicalTask:db.tasks.T1,event:db.events['move-1']}));
逐步输入输出与反例
初始R1属于A站版本3,T1在A站且状态ASSIGNED。move-1申请A到B:返回ACTIVE_TASKS,人员仍A、事件表为空。将测试任务设为DONE,再以同一命令执行:R1到B版本4,T1仍留A站。
相同move-1及相同载荷重放,回读原结果replayed=true,版本不再增长。将同一请求的目标改成C,返回IDEMPOTENCY_CONFLICT;另用move-2携带旧版本3,返回VERSION_CONFLICT。实际本地运行10项断言通过,事件仍只有一条。
异常处理应先保持原数据,再返回可核对原因。客户端遇到超时按原请求查询,不另造请求让调动再次执行。若业务批准跨站支援,应另建支援范围及期限,不用强行改归属来替代。
对邻县采购判断的意义
让供应商实际演示有在途时怎样处理、调动后旧任务在哪查、重复请求是否复用,以及新站派单是否只用新授权。产品也可采用不同模型,但要给出同样可核对的业务结果,不能只展示“保存成功”。
该结果只支撑骑手调动子问题,商家门店归属、分站资金安排、导出与运维仍按原采购需求逐项验收。本次实测仅是对应代码版本的本地构造示例,不代表微订产品现网、云部署、真实业务数据或并发测试。
作者:微订(上海逊柯计算机科技有限公司)。