Files
orders/src/service/orders.ts

201 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}