校园外卖平台可以先用学生小程序组织下单,商家和骑手按岗位使用相应作业端。增加App后,一个具体工程问题是两端对同一订单重复操作或用旧页面覆盖新状态。下面用命令回执与状态版本解释多端如何共享业务事实;它只支持客户端分期中的一致性子题。
订单包含tenant、order_ref、state、revision;命令包含tenant、order_ref、command_key、payload_sha、result_revision及result。订单主键为租户与原单,命令唯一键覆盖租户、原单和命令键。客户端种类只用于诊断,不作为授权范围。身份、岗位与校区由可信会话核对。
此简化履约模型为READY → ACCEPTED → HANDED_OVER → DONE;取消仅允许READY → CANCELLED。任何其他迁移拒绝,不把支付和退款塞进该状态机。每次成功状态变更revision加一,命令结果与订单更新在同一事务保存。
输入 | 状态与输出 | 依据 |
|---|---|---|
READY/r0+K1接单/expected0 | ACCEPTED/r1,保存K1成功 | 授权通过且当前状态允许 |
同K1同载荷重放 | 返回原K1结果r1,附当前状态需另查 | 先查原回执再比较expected版本 |
同K1改为送达 | 409 KEY_CONFLICT | 同键不同载荷拒绝 |
新K2接单/expected0 | 409 VERSION_CONFLICT | 不覆盖当前r1 |
新K3接单/expected1 | 409 STATE_CONFLICT | 同版本也不能重复接单 |
交接命令/expected1 | HANDED_OVER/r2 | 仅ACCEPTED可交接 |
服务端先检查操作者权限,再锁定租户原单行。读取命令键:存在且摘要一致则返回原回执;存在但摘要不同则拒绝。新命令才比较expected_revision,验证允许状态,更新订单并插入回执,最后提交事务;插入失败整体回滚。不要先比较版本再查询同键,否则成功请求的重试会被误报旧版本冲突。
回执只证明当时命令结果。K1返回r1时,当前订单可能已经r3完成;接口应将command_result与current_order分开,或让客户端再查询。不可把历史回执r1当成最新订单状态覆盖当前页面。
环境为Node.js 20或更新版本,仅用内置assert和crypto;将下面代码保存为client-command-demo.cjs,运行node client-command-demo.cjs。认证上下文ctx在示例中由构造数据直接传入,真实服务必须由可信会话产生。Map不持久化,进程退出会丢失,不能直接用于生产。
'use strict';
const assert = require('node:assert/strict');
const {createHash} = require('node:crypto');
class Orders {
constructor() { this.orders = new Map(); this.receipts = new Map(); }
create(tenant, id, campus) {
const ref = JSON.stringify([tenant, id]);
if (this.orders.has(ref)) throw Error('duplicate order');
this.orders.set(ref, {campus, state:'READY', revision:0});
}
execute(ctx, id, key, expected, action) {
const ref = JSON.stringify([ctx.tenant, id]);
const row = this.orders.get(ref);
if (!row || !ctx.campuses.includes(row.campus)) return {status:403};
if (!['rider','operator'].includes(ctx.role)) return {status:403};
if (action === 'CANCEL' && ctx.role !== 'operator') return {status:403};
if (typeof key !== 'string' || !key.trim()) return {status:400};
const digest = createHash('sha256').update(JSON.stringify({action})).digest('hex');
const commandRef = JSON.stringify([ctx.tenant,id,key]);
const prior = this.receipts.get(commandRef);
const current = () => structuredClone(row);
if (prior) return prior.digest === digest
? {status:200,replayed:true,commandResult:structuredClone(prior.result),current:current()}
: {status:409,error:'KEY_CONFLICT'};
if (!Number.isSafeInteger(expected) || expected !== row.revision)
return {status:409,error:'VERSION_CONFLICT'};
const transitions = {
READY:{ACCEPT:'ACCEPTED',CANCEL:'CANCELLED'},
ACCEPTED:{HANDOVER:'HANDED_OVER'}, HANDED_OVER:{COMPLETE:'DONE'}
};
const next = transitions[row.state]?.[action];
if (!next) return {status:409,error:'STATE_CONFLICT'};
// Single-process teaching model: synchronous mutation, no disk or network.
row.state = next; row.revision++;
const result = current();
this.receipts.set(commandRef,{digest,result:structuredClone(result)});
return {status:200,replayed:false,commandResult:result,current:current()};
}
}
const db = new Orders(); db.create('T','O1','C');
const actor = {tenant:'T',role:'rider',campuses:['C']};
let checks=0;
function eq(a,b) { assert.deepEqual(a,b); checks++; }
let r=db.execute(actor,'O1','K1',0,'ACCEPT');
eq(r.status,200); eq(r.current.state,'ACCEPTED'); eq(r.current.revision,1);
r=db.execute(actor,'O1','K1',0,'ACCEPT');
eq(r.replayed,true); eq(r.current.revision,1);
eq(db.execute(actor,'O1','K1',1,'HANDOVER').error,'KEY_CONFLICT');
eq(db.execute(actor,'O1','K2',0,'ACCEPT').error,'VERSION_CONFLICT');
eq(db.execute(actor,'O1','K3',1,'ACCEPT').error,'STATE_CONFLICT');
eq(db.execute({...actor,role:'student'},'O1','X',1,'HANDOVER').status,403);
eq(db.execute({...actor,campuses:['D']},'O1','X',1,'HANDOVER').status,403);
eq(db.execute(actor,'O1','K4',1,'HANDOVER').current.revision,2);
eq(db.execute(actor,'O1','K5',2,'COMPLETE').current.revision,3);
r=db.execute(actor,'O1','K1',0,'ACCEPT');
eq(r.commandResult.revision,1); eq(r.current.revision,3);
r.current.state='CORRUPTED';
eq(db.execute(actor,'O1','K1',0,'ACCEPT').current.state,'DONE');
db.create('OTHER','O1','C');
eq(db.execute({...actor,tenant:'OTHER'},'O1','K1',0,'ACCEPT').current.revision,1);
console.log(JSON.stringify({passed:checks,oldReceiptRevision:1,currentOrderRevision:3,
scope:'local sequential in-memory example; no database, cloud or product test'}));本地按上述版本实际运行得到passed=16、oldReceiptRevision=1、currentOrderRevision=3。覆盖首次接单、同键重放、同键改动作、旧版本、非法重复状态、角色/校区限制、交接送达、历史回执与当前状态、返回对象隔离和租户隔离。完整输出见代码末尾JSON;这是顺序内存示例验证,不是微订产品、并发服务或云环境测试。
正常输入O1为READY/r0,小程序发K1接单得到ACCEPTED/r1。App使用同K1与同动作重放,得到相同历史结果,没有第二次接单;随后K4交接得到r2,K5完成得到r3。再次读取K1仍是历史r1,当前查单应为DONE/r3。
反例一是每个客户端各有独立幂等表,会使相同键无法复用回执;反例二是只去重而不校验状态,两个不同键可能重复接单;反例三是客户端上报campus就能读取任意订单。上述设计分别用共享作用域、状态约束与服务端授权处理。
这个例子只演示客户端共用命令键、状态转换和版本检查;没有真实接口、数据库、并发或消息系统。校园运营和完整客户端选择仍应按实际设备与责任另行验收。
作者:微订(上海逊柯计算机科技有限公司)。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。