201 lines
8.1 KiB
TypeScript
201 lines
8.1 KiB
TypeScript
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,
|
||
Crud,
|
||
SaasActions,
|
||
type OrdersCancelOrderInput,
|
||
type OrdersCancelOrderOutput,
|
||
type OrdersCreateOrderInput,
|
||
type OrdersCreateOrderOutput,
|
||
type OrdersCrudBeforeChange,
|
||
type OrdersDetailWithGoodsInput,
|
||
type OrdersDetailWithGoodsOutput,
|
||
} from '../saas/generated/order';
|
||
|
||
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>;
|
||
|
||
@InjectSaasAction(SaasActions.Demo.GoodsBatchInfo)
|
||
private readonly batchInfo!: SaasActionCaller<
|
||
typeof SaasActions.Demo.GoodsBatchInfo
|
||
>;
|
||
|
||
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(
|
||
input: OrdersCreateOrderInput
|
||
): Promise<OrdersCreateOrderOutput> {
|
||
const goods = await this.requireGoods(input.goodsId);
|
||
if (integer(goods.status, '商品状态') !== 1) {
|
||
throw new SaasBusinessReject('GOODS_UNAVAILABLE', '商品未上架,不能下单');
|
||
}
|
||
|
||
const unitPrice = decimal(goods.price, '商品价格');
|
||
const amount = multiplyDecimal(unitPrice, input.quantity);
|
||
|
||
const order = await this.orders.add({
|
||
order_no: input.orderNo,
|
||
goods_id: String(input.goodsId),
|
||
quantity: input.quantity,
|
||
unit_price: unitPrice,
|
||
amount,
|
||
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 {
|
||
id: safeInteger(order.id, '订单 ID'),
|
||
orderNo: order.order_no,
|
||
amount: finiteNumber(order.amount, '订单金额'),
|
||
status: order.status,
|
||
};
|
||
}
|
||
|
||
@SaasAction(Actions.OrdersDetailWithGoods)
|
||
async detailWithGoods(
|
||
input: OrdersDetailWithGoodsInput
|
||
): Promise<OrdersDetailWithGoodsOutput> {
|
||
const order = await this.requireOrder(input.id);
|
||
const goods = await this.requireGoods(safeInteger(order.goods_id, '商品 ID'));
|
||
return { order, goods };
|
||
}
|
||
|
||
@SaasAction(Actions.OrdersCancelOrder)
|
||
async cancelOrder(
|
||
input: OrdersCancelOrderInput
|
||
): Promise<OrdersCancelOrderOutput> {
|
||
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.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'}},
|
||
});
|
||
return {id:input.id,status:'cancel_pending',restoredStock:0};
|
||
}
|
||
|
||
private async requireOrder(id: number) {
|
||
// 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 new SaasBusinessReject('GOODS_NOT_FOUND', `商品不存在: ${goodsId}`);
|
||
return goods;
|
||
}
|
||
}
|
||
|
||
function decimal(value: unknown, label: string): string {
|
||
const normalized = String(value ?? '').trim();
|
||
if (!/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(normalized)) {
|
||
throw conflict(`${label}非法`);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function multiplyDecimal(value: string, multiplier: number): string {
|
||
const negative = value.startsWith('-');
|
||
const unsigned = negative ? value.slice(1) : value;
|
||
const [integerPart, fractionPart = ''] = unsigned.split('.');
|
||
const scale = fractionPart.length;
|
||
const digits = BigInt(`${integerPart}${fractionPart}`);
|
||
const product = digits * BigInt(multiplier);
|
||
const padded = product.toString().padStart(scale + 1, '0');
|
||
const absolute = scale
|
||
? `${padded.slice(0, -scale)}.${padded.slice(-scale)}`
|
||
: padded;
|
||
return negative && product !== BigInt(0) ? `-${absolute}` : absolute;
|
||
}
|
||
|
||
function integer(value: unknown, label: string): number {
|
||
const normalized = Number(value);
|
||
if (!Number.isInteger(normalized)) throw conflict(`${label}非法`);
|
||
return normalized;
|
||
}
|
||
|
||
function safeInteger(value: unknown, label: string): number {
|
||
const normalized = integer(value, label);
|
||
if (!Number.isSafeInteger(normalized) || normalized < 1) {
|
||
throw conflict(`${label}超出安全范围`);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function finiteNumber(value: unknown, label: string): number {
|
||
const normalized = Number(value);
|
||
if (!Number.isFinite(normalized)) throw conflict(`${label}非法`);
|
||
return normalized;
|
||
}
|
||
|
||
function conflict(message: string) {
|
||
return new ModuleRuntimeError('MODULE_ACTION_INPUT_INVALID', message, 409);
|
||
}
|