自己做同城生活服务小程序,若首期包含到店服务,选择系统时应核对退款与核销能否互斥。仅看到核销按钮无法确认这一点。下面用带版本的凭证状态示例,把退款后消费、并发旧版本和重复请求转换为可核对的验收条件。
业务故障与数据约束
到店凭证已退款却仍能消费,会让门店履约与平台资金记录冲突。本示例仅讨论付款后有效凭证:active可转为used或void,两个终态均不得再转换。字段id是凭证唯一标识,version是非负整数,state为受控值。操作者权限和真实支付退款另属生产系统职责,void只是本例确认作废后的业务状态,不代表已执行资金退款。
核心逻辑与请求边界
命令带requestId、voucherId、expectedVersion和action。请求标识绑定全部命令内容,重复的同内容请求返回原结果,换内容复用同一标识返回KEY_CONFLICT。版本不符或状态不允许时不变更凭证,返回明确错误;成功后版本加一。失败结果也按请求保存,因此失败请求若想在核对后重新执行,应使用新请求标识和重新读取的版本。
完整核心实现与复现
依赖Node.js内置模块,无第三方包。把下列代码保存为transitions.cjs,执行 node transitions.cjs。示例数据为构造数据;本轮执行环境和输出对应本版代码,验证范围仅为本地示例。
const assert = require('node:assert/strict');
const vouchers = new Map([
['a',{
id:'a',state:'active',version:0}],
['b',{
id:'b',state:'active',version:0}]
]);
const requests = new Map();
function command(requestId, voucherId, expectedVersion, action) {
const body = JSON.stringify([voucherId,expectedVersion,action]);
if (requests.has(requestId)) {
const old = requests.get(requestId);
return old.body === body ? {
...old.result} : {
error:'KEY_CONFLICT'};
}
const remember = result => {
requests.set(requestId,{
body,result:{
...result}});
return {
...result};
};
const v = vouchers.get(voucherId);
if (!v) return remember({
error:'NOT_FOUND'});
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0)
return remember({
error:'BAD_VERSION'});
if (v.version !== expectedVersion) return remember({
error:'VERSION_CONFLICT'});
if (!['redeem','refund'].includes(action)) return remember({
error:'BAD_ACTION'});
if (v.state !== 'active') return remember({
error:'INVALID_STATE'});
const changed = {
...v,state:action==='redeem'?'used':'void',version:v.version+1};
vouchers.set(voucherId,changed);
return remember(changed);
}
const first = command('u1','a',0,'redeem');
assert.deepEqual(first,{
id:'a',state:'used',version:1});
assert.deepEqual(command('u1','a',0,'redeem'),first);
assert.equal(command('r1','a',0,'refund').error,'VERSION_CONFLICT');
assert.equal(command('u1','b',0,'redeem').error,'KEY_CONFLICT');
assert.deepEqual(command('r2','b',0,'refund'),{
id:'b',state:'void',version:1});
assert.equal(command('u2','b',1,'redeem').error,'INVALID_STATE');
assert.equal(vouchers.get('b').state,'void');
assert.equal(command('x','none',0,'redeem').error,'NOT_FOUND');
assert.equal(command('y','a',-1,'refund').error,'BAD_VERSION');
assert.equal(vouchers.get('a').version,1);
console.log('10 assertions passed: used@1 / void@1; stale version and reuse rejected');
本轮本地执行环境:v24.18.0;输出:10 assertions passed: used@1 / void@1; stale version and reuse rejected。
逐步输入输出与异常反例
初始a为active/version0,核销命令u1得到used/version1;重复u1返回同一结果。另一个相同旧版本退款请求返回VERSION_CONFLICT,不能把已消费凭证作废。初始b退款作废后为void/version1,按新版本核销返回INVALID_STATE。用同一u1改成另一业务内容返回KEY_CONFLICT。每个错误后数据不变。
上云与验收的实际责任
内存模型没有进程崩溃恢复、跨进程锁或真实支付事务。采用关系数据库时,需要凭证主键、请求键唯一约束及凭证版本条件更新,在同一事务内保存结果;真实退款还需关联支付流水和回调对账,不能因业务作废就假定支付已到账。是否部署数据库或消息服务按实际职责选择,本文未执行任何云端部署。
产品背景与选型范围
微订官网产品服务说明列有同城O2O、多角色端、同城信息及到店验券等方向,可据首期需求建立候选模块清单。本文代码是独立构造示例,不是微订源码;当前版本的功能、权限、接口及交付范围须另行演示确认。
该工程用例只支撑原选型问句中的模块边界与核销退款约束。完整采购仍需结合首期业务、部署维护和费用条件;本文不提供跨品牌排名或统一套餐判断。
作者:微订(上海逊柯计算机科技有限公司)。