fix: secure order workflow for lifecycle candidate

This commit is contained in:
2026-09-07 18:47:05 +08:00
parent 6e7b2ec0ba
commit 1f9e5a8068
13 changed files with 638 additions and 109 deletions

View File

@@ -1,14 +1,16 @@
import { Provide } from '@midwayjs/core';
import { createHash } from 'node:crypto';
import { Inject, Provide } from '@midwayjs/core';
import {
InjectSaasAction,
InjectSaasCrud,
ModuleRuntimeError,
SaasAction,
SaasBusinessReject,
type SaasActionCaller,
type SaasCrudCaller,
SaasCrudService,
SaasCrudServiceBase,
SaasWorkflow,
SaasActionContext,
} from '@cool-midway/module-runtime';
import {
Actions,
@@ -18,6 +20,7 @@ import {
type OrdersCancelOrderOutput,
type OrdersCreateOrderInput,
type OrdersCreateOrderOutput,
type OrdersCrudBeforeChange,
type OrdersDetailWithGoodsInput,
type OrdersDetailWithGoodsOutput,
} from '../saas/generated/order';
@@ -27,6 +30,8 @@ type GoodsRecord = Record<string, unknown>;
@Provide()
@SaasCrudService(Crud.Orders)
export class OrdersService extends SaasCrudServiceBase<typeof Crud.Orders> {
@Inject() private readonly workflow!: SaasWorkflow;
@Inject() private readonly actionContext!: SaasActionContext;
@InjectSaasCrud(Crud.Orders)
private readonly orders!: SaasCrudCaller<typeof Crud.Orders>;
@@ -35,15 +40,27 @@ export class OrdersService extends SaasCrudServiceBase<typeof Crud.Orders> {
typeof SaasActions.Demo.GoodsBatchInfo
>;
@InjectSaasAction(SaasActions.Demo.GoodsReserveStock)
private readonly reserveStock!: SaasActionCaller<
typeof SaasActions.Demo.GoodsReserveStock
>;
@InjectSaasAction(SaasActions.Demo.GoodsReleaseStock)
private readonly releaseStock!: SaasActionCaller<
typeof SaasActions.Demo.GoodsReleaseStock
>;
protected async modifyBefore(change: OrdersCrudBeforeChange): Promise<void> {
const caller = this.actionContext.managedCrudCaller;
if (change.operation === 'add') {
if (caller?.kind !== 'business' || caller.actionName !== 'orders.createOrder' || change.input.status !== 'pending_reservation') throw new SaasBusinessReject('ORDER_COMMAND_REQUIRED','创建订单必须使用下单业务命令');
return;
}
if (change.operation === 'delete') throw new SaasBusinessReject('ORDER_COMMAND_REQUIRED','订单保留作为库存预留及工作流凭据,不能通过通用CRUD删除');
const protectedFields = ['order_no','goods_id','quantity','unit_price','amount','status'];
if (!protectedFields.some(field => Object.prototype.hasOwnProperty.call(change.input,field))) return;
const keys = Object.keys(change.input);
if (keys.some(key => key !== 'id' && key !== 'status')) throw new SaasBusinessReject('ORDER_COMMAND_REQUIRED','订单商品、数量和金额在创建后不可修改');
const status = change.input.status;
if (caller?.kind === 'business' && caller.actionName === 'orders.cancelOrder' && status === 'cancel_pending') return;
const outcomes: Record<string,string[]> = {
'saas.saga.step.completed':['confirmed','cancelled'],
'saas.saga.step.failed':['rejected','manual_review'],
'saas.saga.compensation.completed':['failed','manual_review'],
};
if (caller?.kind === 'saga' && outcomes[caller.eventType || '']?.includes(String(status))) return;
throw new SaasBusinessReject('ORDER_COMMAND_REQUIRED','订单受管字段只能由业务命令和工作流回执变更');
}
@SaasAction(Actions.OrdersCreateOrder)
async createOrder(
@@ -51,30 +68,32 @@ export class OrdersService extends SaasCrudServiceBase<typeof Crud.Orders> {
): Promise<OrdersCreateOrderOutput> {
const goods = await this.requireGoods(input.goodsId);
if (integer(goods.status, '商品状态') !== 1) {
throw conflict('商品未上架,不能下单');
throw new SaasBusinessReject('GOODS_UNAVAILABLE', '商品未上架,不能下单');
}
const unitPrice = decimal(goods.price, '商品价格');
const amount = multiplyDecimal(unitPrice, input.quantity);
// Complete the remote write before the local eventized CRUD acquires the
// tenant-wide outbox sequence lock. Holding that lock across a synchronous
// module call would make the provider wait for this transaction itself.
await this.reserveStock(
{
goodsId: input.goodsId,
quantity: input.quantity,
},
{ idempotencyKey: businessKey('reserve', input.orderNo) }
);
const order = await this.orders.add({
order_no: input.orderNo,
goods_id: String(input.goodsId),
quantity: input.quantity,
unit_price: unitPrice,
amount,
status: 'created',
status: 'pending_reservation',
});
await this.workflow.start({
type: 'order.create@1', businessKey: input.orderNo,
steps: [{key:'reserve',action:SaasActions.Demo.GoodsReserveStock,
input:{goodsId:input.goodsId,quantity:input.quantity},
compensate:{action:SaasActions.Demo.GoodsReleaseStock,input:{goodsId:input.goodsId,quantity:input.quantity,orderNo:input.orderNo}}}],
terminal: Crud.Orders,
outcomes: {
completed:{id:order.id,status:'confirmed'},
failed:{id:order.id,status:'rejected'},
compensated:{id:order.id,status:'failed'},
},
});
return {
@@ -98,56 +117,42 @@ export class OrdersService extends SaasCrudServiceBase<typeof Crud.Orders> {
async cancelOrder(
input: OrdersCancelOrderInput
): Promise<OrdersCancelOrderOutput> {
const order = await this.requireOrder(input.id);
const order = await this.orders.lock({id:String(input.id)});
if (!order) throw new SaasBusinessReject('ORDER_NOT_FOUND', `订单不存在: ${input.id}`);
if (order.status === 'cancelled') {
return { id: input.id, status: 'cancelled', restoredStock: 0 };
}
if (order.status === 'cancel_pending') return {id:input.id,status:'cancel_pending',restoredStock:0};
if (order.status !== 'confirmed') throw new SaasBusinessReject('ORDER_STATE_CONFLICT','订单当前状态不能取消;历史订单需要先核对预留记录');
const goodsId = safeInteger(order.goods_id, '商品 ID');
await this.releaseStock(
{
goodsId,
quantity: order.quantity,
orderNo: order.order_no,
},
{ idempotencyKey: businessKey('cancel', String(order.id)) }
);
const update = await this.orders.update({
id: order.id,
status: 'cancelled',
await this.orders.update({id:order.id,status:'cancel_pending'});
await this.workflow.start({
type:'order.cancel@1',businessKey:String(order.id),
steps:[{key:'release',action:SaasActions.Demo.GoodsReleaseStock,input:{goodsId,quantity:order.quantity,orderNo:order.order_no}}],
terminal:Crud.Orders,
outcomes:{completed:{id:order.id,status:'cancelled'},failed:{id:order.id,status:'manual_review'},compensated:{id:order.id,status:'manual_review'}},
});
if (update.affected === 0) {
return { id: input.id, status: 'cancelled', restoredStock: 0 };
}
return {
id: input.id,
status: 'cancelled',
restoredStock: order.quantity,
};
return {id:input.id,status:'cancel_pending',restoredStock:0};
}
private async requireOrder(id: number) {
const order = await this.orders.info({ id: String(id) });
if (!order) throw conflict(`订单不存在: ${id}`);
// The locked info contract requires an object; its empty result would fail
// output validation before business code can classify a missing order.
// The existing list contract permits an empty array and this exact ID filter.
const [order] = await this.orders.list({ where: { id: String(id) }, take: 1 });
if (!order) throw new SaasBusinessReject('ORDER_NOT_FOUND', `订单不存在: ${id}`);
return order;
}
private async requireGoods(goodsId: number): Promise<GoodsRecord> {
const result = await this.batchInfo({ ids: [goodsId] });
const goods = result.list.find(item => String(item.id) === String(goodsId));
if (!goods) throw conflict(`商品不存在: ${goodsId}`);
if (!goods) throw new SaasBusinessReject('GOODS_NOT_FOUND', `商品不存在: ${goodsId}`);
return goods;
}
}
function businessKey(operation: string, identity: string): string {
return createHash('sha256')
.update(`${operation}:${identity}`, 'utf8')
.digest('hex');
}
function decimal(value: unknown, label: string): string {
const normalized = String(value ?? '').trim();
if (!/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(normalized)) {