fix: secure order workflow for lifecycle candidate
This commit is contained in:
@@ -20,9 +20,9 @@ export class Orders extends SaasTableModel<OrdersRow> {
|
||||
static readonly definition = defineSaasTableModel({
|
||||
"kind": "table",
|
||||
"moduleCode": "order",
|
||||
"revision": 2,
|
||||
"schemaVersion": 2,
|
||||
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||
"revision": 3,
|
||||
"schemaVersion": 3,
|
||||
"schemaInputChecksum": "d0a174052b88d7018d5bd7f175cb030a1c39f1a711d37114518ebd5dd9e8e920",
|
||||
"tableCode": "orders",
|
||||
"fields": [
|
||||
{
|
||||
|
||||
@@ -4,9 +4,9 @@ import { defineGeneratedSaasModule } from '@cool-midway/module-runtime';
|
||||
export const GeneratedSaasModule = defineGeneratedSaasModule({
|
||||
"formatVersion": 2,
|
||||
"moduleCode": "order",
|
||||
"revision": 2,
|
||||
"schemaVersion": 2,
|
||||
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||
"runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194",
|
||||
"revision": 3,
|
||||
"schemaVersion": 3,
|
||||
"schemaInputChecksum": "d0a174052b88d7018d5bd7f175cb030a1c39f1a711d37114518ebd5dd9e8e920",
|
||||
"runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce",
|
||||
"implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b"
|
||||
});
|
||||
|
||||
@@ -83,10 +83,10 @@ export const SaasActions = {
|
||||
"actionChecksum": "3978a7ba9a3e0f91842031fd323ec63b3816eef68cb8dc82ffad6d187de8b7ea",
|
||||
"outputChecksum": "fe644424ef6a9e462b56edc9f70f48bb218783c69469469d4310de48589a0df5",
|
||||
"contractVersion": 1,
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseId": 38,
|
||||
"consumerModuleCode": "order",
|
||||
"providerModuleCode": "demo",
|
||||
"providerReleaseVersion": "1.0.1"
|
||||
"providerReleaseVersion": "1.0.0-rc.1"
|
||||
}),
|
||||
GoodsReleaseStock: defineInjectedSaasAction<DemoGoodsReleaseStockInput, DemoGoodsReleaseStockOutput>({
|
||||
"kind": "action",
|
||||
@@ -148,10 +148,10 @@ export const SaasActions = {
|
||||
"outputChecksum": "a632b9f89518c3c17861454b74173a4880d5e8482639d3ff7f761aee4d061bd9",
|
||||
"contractVersion": 1,
|
||||
"commandEventType": "saas.workflow.demo.goods.releaseStock",
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseId": 38,
|
||||
"consumerModuleCode": "order",
|
||||
"providerModuleCode": "demo",
|
||||
"providerReleaseVersion": "1.0.1",
|
||||
"providerReleaseVersion": "1.0.0-rc.1",
|
||||
"commandEventContractChecksum": "18a727b91cf9410c3b7ac14e3a8799dbbfa8937f84667bfe7e31e21e662396be"
|
||||
}),
|
||||
GoodsReserveStock: defineInjectedSaasAction<DemoGoodsReserveStockInput, DemoGoodsReserveStockOutput>({
|
||||
@@ -208,10 +208,10 @@ export const SaasActions = {
|
||||
"outputChecksum": "545bd8b0bf6eb8670b21497236038187ee0f1df1544a318b16a399458fcb796e",
|
||||
"contractVersion": 1,
|
||||
"commandEventType": "saas.workflow.demo.goods.reserveStock",
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseId": 38,
|
||||
"consumerModuleCode": "order",
|
||||
"providerModuleCode": "demo",
|
||||
"providerReleaseVersion": "1.0.1",
|
||||
"providerReleaseVersion": "1.0.0-rc.1",
|
||||
"commandEventContractChecksum": "ffbecad3a3d63b8bf92fde701dfaa11c8502a2c5f9f6bbb89b755e214024c3ae"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"moduleCode": "order",
|
||||
"revision": 2,
|
||||
"revision": 3,
|
||||
"schemaChecksum": null,
|
||||
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||
"schemaInputChecksum": "d0a174052b88d7018d5bd7f175cb030a1c39f1a711d37114518ebd5dd9e8e920",
|
||||
"actionManifestChecksum": null,
|
||||
"runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194",
|
||||
"runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce",
|
||||
"implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b",
|
||||
"actions": [
|
||||
"OrdersAdd",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"formatVersion": 2,
|
||||
"moduleCode": "order",
|
||||
"revision": 2,
|
||||
"revision": 3,
|
||||
"releaseVersion": null,
|
||||
"schemaVersion": 2,
|
||||
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||
"schemaVersion": 3,
|
||||
"schemaInputChecksum": "d0a174052b88d7018d5bd7f175cb030a1c39f1a711d37114518ebd5dd9e8e920",
|
||||
"schemaChecksum": null,
|
||||
"actionManifestChecksum": null,
|
||||
"runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194",
|
||||
"runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce",
|
||||
"implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b",
|
||||
"models": [
|
||||
{
|
||||
@@ -2031,10 +2031,10 @@
|
||||
"actionChecksum": "3978a7ba9a3e0f91842031fd323ec63b3816eef68cb8dc82ffad6d187de8b7ea",
|
||||
"outputChecksum": "fe644424ef6a9e462b56edc9f70f48bb218783c69469469d4310de48589a0df5",
|
||||
"contractVersion": 1,
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseId": 38,
|
||||
"consumerModuleCode": "order",
|
||||
"providerModuleCode": "demo",
|
||||
"providerReleaseVersion": "1.0.1"
|
||||
"providerReleaseVersion": "1.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2101,10 +2101,10 @@
|
||||
"outputChecksum": "a632b9f89518c3c17861454b74173a4880d5e8482639d3ff7f761aee4d061bd9",
|
||||
"contractVersion": 1,
|
||||
"commandEventType": "saas.workflow.demo.goods.releaseStock",
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseId": 38,
|
||||
"consumerModuleCode": "order",
|
||||
"providerModuleCode": "demo",
|
||||
"providerReleaseVersion": "1.0.1",
|
||||
"providerReleaseVersion": "1.0.0-rc.1",
|
||||
"commandEventContractChecksum": "18a727b91cf9410c3b7ac14e3a8799dbbfa8937f84667bfe7e31e21e662396be"
|
||||
}
|
||||
},
|
||||
@@ -2166,10 +2166,10 @@
|
||||
"outputChecksum": "545bd8b0bf6eb8670b21497236038187ee0f1df1544a318b16a399458fcb796e",
|
||||
"contractVersion": 1,
|
||||
"commandEventType": "saas.workflow.demo.goods.reserveStock",
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseId": 38,
|
||||
"consumerModuleCode": "order",
|
||||
"providerModuleCode": "demo",
|
||||
"providerReleaseVersion": "1.0.1",
|
||||
"providerReleaseVersion": "1.0.0-rc.1",
|
||||
"commandEventContractChecksum": "ffbecad3a3d63b8bf92fde701dfaa11c8502a2c5f9f6bbb89b755e214024c3ae"
|
||||
}
|
||||
}
|
||||
@@ -2209,8 +2209,8 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"providerReleaseId": 41,
|
||||
"providerReleaseVersion": "1.0.1"
|
||||
"providerReleaseId": 38,
|
||||
"providerReleaseVersion": "1.0.0-rc.1"
|
||||
}
|
||||
],
|
||||
"crud": [
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user