校园系统连接取餐柜、一卡通及统一身份前,需要分别确认授权、协议、标识映射和异常查询。本文只解决一个接口评审子问题:开柜已执行而响应丢失时,怎样避免把调用端超时误认为设备未执行。以下是独立设计,没有设备或产品实测。
动作记录为tenant、provider、action_ref、order_ref、kind和payload_digest,唯一键为前三项;网络尝试另有attempt_ref。tenant来自可信认证,provider必须在该租户授权范围,动作号按设备协议定义。相同键不同摘要拒绝,不用更换action_ref来掩盖冲突。
本地观察为PREPARED、SENT、UNKNOWN、ACCEPTED、EXECUTED、REJECTED。ACCEPTED只表示对方受理,EXECUTED只在协议提供权威执行证据时写入;学生是否取走餐品另有现场或设备证据,不从EXECUTED自动推导。
当前观察与输入 | 下一观察 | 输出与允许动作 |
|---|---|---|
PREPARED+发送K1 | SENT | 保存原键与摘要 |
SENT+响应丢失 | UNKNOWN | 只查询K1,不生成新动作 |
UNKNOWN+权威受理回执 | ACCEPTED | 保存原回执,继续查执行 |
ACCEPTED+权威执行证据 | EXECUTED | 保存证据引用 |
UNKNOWN+查询失败 | UNKNOWN | 保留待办,不当未执行 |
任一状态+重复旧受理回执 | 保持已有更明确观察 | 不将EXECUTED降回ACCEPTED |
先授权,再按原动作键读取已有记录。相同键摘要不同返回409且不发送;相同键已有结果返回该结果及当前观察,新尝试号不产生第二个业务动作。新动作在发送前持久化PREPARED,发送后记录SENT。响应超时只标记UNKNOWN,查询也必须按原键授权。
收到权威回执时验证提供方、动作号、动作种类与摘要;对方事件顺序不能只依赖到达顺序。已执行后再收到旧受理通知,保留执行观察及历史事件。协议无序号或无法确定矛盾事件时留人工待办,不自行挑选有利事件。
查询确认未执行仍不等于任何协议都允许重发。只有接口方明确提供同键幂等语义、权威终态与安全恢复条件时才能沿同键恢复;在此最小方案中,没有这些条件就继续查或人工处理,不发第二个开柜动作。
构造租户T1、设备方P1、动作K1、订单O1,发送开柜。设备执行后回应丢失:本地UNKNOWN。查询K1获得已执行证据E1,更新EXECUTED。随后旧受理回执到达,仍为EXECUTED,业务动作仍只有K1。此为设计预期,未运行真实设备。
同K1换成订单O2应409;另一租户查询K1应403且不返回O1。查询接口超时保持UNKNOWN。反例是每次重试生成新的动作号,设备将它视为两个独立请求;另一反例是把支付成功自动映射为开柜成功,混淆资金和设备事实。
这个模型用于校园接口评审中的动作恢复,不覆盖完整采购、身份协议、资金清算及实物取餐。真实设备是否提供同键查询和幂等,以设备协议与授权环境验证为准。
依赖Node.js 20或更新版本,仅用内置assert、crypto和structuredClone。保存为device-action-demo.cjs,运行node device-action-demo.cjs。代码不发送任何设备命令;observe只演示已经验证的事件如何改变本地观察,真实适配器需先认证并绑定外部证据。
'use strict';
// Local, sequential model only. It never sends a device command.
const assert=require('node:assert/strict');
const {createHash}=require('node:crypto');
const rows=new Map();let checks=0;
const clone=x=>structuredClone(x);
function fail(code){throw Object.assign(new Error(code),{code});}
function authorize(ctx,provider){
if(!ctx||typeof ctx.tenant!=='string'||ctx.role!=='operator'||!ctx.providers?.includes(provider))fail('FORBIDDEN');
}
const key=(tenant,provider,ref)=>JSON.stringify([tenant,provider,ref]);
function register(ctx,{provider,ref,order,kind,slot}){
authorize(ctx,provider);
if(![provider,ref,order,slot].every(x=>typeof x==='string'&&x.trim())||!['STORE','OPEN'].includes(kind))fail('INVALID');
// Fixed flat field list; do not hash raw JSON with arbitrary key order.
const sha=createHash('sha256').update(JSON.stringify([order,kind,slot])).digest('hex');
const k=key(ctx.tenant,provider,ref),old=rows.get(k);
if(old){if(old.sha!==sha)fail('KEY_CONFLICT');return clone(old);}
const row={tenant:ctx.tenant,provider,ref,order,kind,slot,sha,state:'PREPARED',revision:0,evidence:null};
rows.set(k,row);return clone(row);
}
function observe(ctx,provider,ref,next,evidence){
authorize(ctx,provider);const row=rows.get(key(ctx.tenant,provider,ref));if(!row)fail('NOT_FOUND');
const allowed={PREPARED:['SENT'],SENT:['UNKNOWN','ACCEPTED','EXECUTED','REJECTED'],UNKNOWN:['ACCEPTED','EXECUTED','REJECTED'],ACCEPTED:['EXECUTED'],EXECUTED:[],REJECTED:[]};
if(!Object.hasOwn(allowed,next))fail('INVALID_STATE');
// A production adapter must authenticate and bind external evidence first.
if(['ACCEPTED','EXECUTED','REJECTED'].includes(next)&&!(typeof evidence==='string'&&evidence.trim()))fail('EVIDENCE_REQUIRED');
if(row.state===next||row.state==='EXECUTED'&&next==='ACCEPTED')return clone(row);
if(!allowed[row.state].includes(next))fail('STATE_CONFLICT');
row.state=next;row.revision++;row.evidence=evidence??row.evidence;return clone(row);
}
function eq(actual,expected){assert.deepEqual(actual,expected);checks++;}
function throws(fn,code){assert.throws(fn,e=>e.code===code);checks++;}
const ctx={tenant:'T1',role:'operator',providers:['P1']};
const input={provider:'P1',ref:'K1',order:'O1',kind:'OPEN',slot:'A1'};
const first=register(ctx,input);eq(first.state,'PREPARED');eq(first.revision,0);
eq(register(ctx,input),first);eq(rows.size,1);
throws(()=>register(ctx,{...input,order:'O2'}),'KEY_CONFLICT');
throws(()=>register(ctx,{...input,slot:'A2'}),'KEY_CONFLICT');
throws(()=>register(ctx,{...input,kind:'OTHER'}),'INVALID');
throws(()=>register({...ctx,role:'viewer'},input),'FORBIDDEN');
throws(()=>register(ctx,{...input,provider:'P2'}),'FORBIDDEN');
eq(observe(ctx,'P1','K1','SENT').revision,1);
eq(observe(ctx,'P1','K1','UNKNOWN').revision,2);
throws(()=>observe(ctx,'P1','K1','PREPARED'),'STATE_CONFLICT');
throws(()=>observe(ctx,'P1','K1','EXECUTED'),'EVIDENCE_REQUIRED');
const executed=observe(ctx,'P1','K1','EXECUTED','provider-query:E1');eq(executed.revision,3);
eq(observe(ctx,'P1','K1','ACCEPTED','old-ack:E0'),executed);
throws(()=>observe(ctx,'P1','K1','REJECTED','contradiction:E2'),'STATE_CONFLICT');
const returned=register(ctx,input);returned.state='PREPARED';eq(register(ctx,input).state,'EXECUTED');
throws(()=>observe({...ctx,tenant:'T2'},'P1','K1','EXECUTED','E1'),'NOT_FOUND');
eq(register({...ctx,tenant:'T2'},input).state,'PREPARED');eq(rows.size,2);
console.log(JSON.stringify({passed:checks,originalState:register(ctx,input).state,originalRevision:register(ctx,input).revision,rows:rows.size,scope:'sequential in-memory model; no database, HTTP, device, cloud or product test'}));本地实际运行输出passed=20、originalState=EXECUTED、originalRevision=3、rows=2。覆盖同键重放、内容冲突、角色与提供方权限、超时查询、缺证据拒绝、旧通知不降级、矛盾终态、返回对象隔离与租户隔离。Map不会持久化,进程退出即丢失;没有数据库并发、HTTP、设备或云测试。
作者:微订(上海逊柯计算机科技有限公司)。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。