3 Commits

13 changed files with 5171 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"moduleCode": "order",
"candidateVersion": "1.0.0-rc.1",
"buildRevision": 1,
"buildChecksum": "5cf032fdac2b0403fbcad8b44b326a29141ba556bbbb9710ac9329b5d7c031dd",
"schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23",
"branchName": "release/1.0.0-rc.1"
}

View File

@@ -25,6 +25,7 @@
"@midwayjs/bootstrap": "^3.20.3",
"@midwayjs/core": "^3.20.3",
"@midwayjs/koa": "^3.20.3",
"@midwayjs/logger": "^3.4.2",
"@midwayjs/validate": "^3.20.3",
"mysql2": "^3.12.0",
"reflect-metadata": "^0.2.2",

18
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@midwayjs/koa':
specifier: ^3.20.3
version: 3.20.24
'@midwayjs/logger':
specifier: ^3.4.2
version: 3.4.2
'@midwayjs/validate':
specifier: ^3.20.3
version: 3.20.24
@@ -221,6 +224,10 @@ packages:
resolution: {integrity: sha512-vmfsVCRPxnVrG9p3I/ZKSWpucKRfbnBoGVzelk3UQuL3Fh7FbfwP6lsbkecGT6FSfGuZmD4hPU84gIACyKX9jw==}
engines: {node: '>=12'}
'@midwayjs/logger@3.4.2':
resolution: {integrity: sha512-BxSdSMog4jxeqpHwgauuKZT6iAiu/Vr1HDjO9l81iqU+l8c9veAJVeLT08nTqKdjUiQXnxTaW6nYlvnzF2HdMA==}
engines: {node: '>=10'}
'@midwayjs/session@3.20.24':
resolution: {integrity: sha512-1zGRWhaa1N+73o/nQ17/MGNFS+CxgcgkdFHcT7LokSWsgpUAKrLrQoTc/dzLP9yB3sGAMP9cLsgn/SR60s3dYw==}
engines: {node: '>=12'}
@@ -2020,6 +2027,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -2648,6 +2659,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@midwayjs/logger@3.4.2':
dependencies:
dayjs: 1.11.23
safe-stable-stringify: 2.5.0
'@midwayjs/session@3.20.24':
dependencies:
'@midwayjs/cookies': 1.3.0
@@ -4538,6 +4554,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
sax@1.6.1: {}

View File

@@ -0,0 +1 @@
export * from './order';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export * from './models';
export * from './contracts';
export * from './saas-actions';
export * from './saas-events';
export * from './module';

View File

@@ -0,0 +1,100 @@
// Generated by cool saas-model generate. DO NOT EDIT.
import {
SaasReadModel,
SaasTableModel,
defineSaasReadModel,
defineSaasTableModel
} from '@cool-midway/module-runtime';
export interface OrdersRow extends Record<string, unknown> {
id: string;
order_no: string;
goods_id: string;
quantity: number;
unit_price: string;
amount: string;
status: string;
}
export class Orders extends SaasTableModel<OrdersRow> {
static readonly definition = defineSaasTableModel({
"kind": "table",
"moduleCode": "order",
"revision": 1,
"schemaVersion": 1,
"schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23",
"tableCode": "orders",
"fields": [
{
"code": "id",
"type": "bigint",
"nullable": false,
"primary": true,
"readable": true,
"writable": false,
"filterable": true,
"sortable": true
},
{
"code": "order_no",
"type": "varchar",
"nullable": false,
"primary": false,
"readable": true,
"writable": true,
"filterable": true,
"sortable": true
},
{
"code": "goods_id",
"type": "bigint",
"nullable": false,
"primary": false,
"readable": true,
"writable": true,
"filterable": true,
"sortable": true
},
{
"code": "quantity",
"type": "int",
"nullable": false,
"primary": false,
"readable": true,
"writable": true,
"filterable": true,
"sortable": true
},
{
"code": "unit_price",
"type": "decimal",
"nullable": false,
"primary": false,
"readable": true,
"writable": true,
"filterable": true,
"sortable": true
},
{
"code": "amount",
"type": "decimal",
"nullable": false,
"primary": false,
"readable": true,
"writable": true,
"filterable": true,
"sortable": true
},
{
"code": "status",
"type": "varchar",
"nullable": false,
"primary": false,
"readable": true,
"writable": true,
"filterable": true,
"sortable": true
}
]
});
}

View File

@@ -0,0 +1,12 @@
// Generated by cool saas-model generate. DO NOT EDIT.
import { defineGeneratedSaasModule } from '@cool-midway/module-runtime';
export const GeneratedSaasModule = defineGeneratedSaasModule({
"formatVersion": 2,
"moduleCode": "order",
"revision": 1,
"schemaVersion": 1,
"schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23",
"runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce",
"implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b"
});

View File

@@ -0,0 +1,218 @@
// Generated by cool saas-model generate. DO NOT EDIT.
import { defineInjectedSaasAction } from '@cool-midway/module-runtime';
export type DemoGoodsBatchInfoInput = {
ids: number[];
};
export type DemoGoodsBatchInfoOutput = {
list: Record<string, unknown>[];
};
export type DemoGoodsReleaseStockInput = {
goodsId: number;
orderNo: string;
quantity: number;
};
export type DemoGoodsReleaseStockOutput = {
currentStock: number;
goodsId: number;
};
export type DemoGoodsReserveStockInput = {
goodsId: number;
quantity: number;
};
export type DemoGoodsReserveStockOutput = {
goodsId: number;
remainingStock: number;
};
export const SaasActions = {
Demo: {
GoodsBatchInfo: defineInjectedSaasAction<DemoGoodsBatchInfoInput, DemoGoodsBatchInfoOutput>({
"kind": "action",
"name": "goods.batchInfo",
"access": "read",
"actionName": "goods.batchInfo",
"idempotent": false,
"moduleCode": "demo",
"inputSchema": {
"type": "object",
"required": [
"ids"
],
"properties": {
"ids": {
"type": "array",
"items": {
"type": "integer",
"minimum": 1
},
"minItems": 1
}
},
"additionalProperties": false
},
"callerPolicy": {
"kind": "module",
"allowedModules": [
"order"
]
},
"contractKind": "saas.action.import",
"outputSchema": {
"type": "object",
"required": [
"list"
],
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
},
"additionalProperties": false
},
"inputChecksum": "b2e0c5fd68adc75e2687c6a1ce01b8270240b42df6511efd6d922ea3242bc264",
"actionChecksum": "3978a7ba9a3e0f91842031fd323ec63b3816eef68cb8dc82ffad6d187de8b7ea",
"outputChecksum": "fe644424ef6a9e462b56edc9f70f48bb218783c69469469d4310de48589a0df5",
"contractVersion": 1,
"providerReleaseId": 38,
"consumerModuleCode": "order",
"providerModuleCode": "demo",
"providerReleaseVersion": "1.0.0-rc.1"
}),
GoodsReleaseStock: defineInjectedSaasAction<DemoGoodsReleaseStockInput, DemoGoodsReleaseStockOutput>({
"kind": "action",
"name": "goods.releaseStock",
"access": "write",
"actionName": "goods.releaseStock",
"idempotent": true,
"moduleCode": "demo",
"inputSchema": {
"type": "object",
"required": [
"goodsId",
"quantity",
"orderNo"
],
"properties": {
"goodsId": {
"type": "integer",
"minimum": 1
},
"orderNo": {
"type": "string",
"maxLength": 64,
"minLength": 1
},
"quantity": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false
},
"callerPolicy": {
"kind": "module",
"allowedModules": [
"order"
]
},
"contractKind": "saas.action.import",
"outputSchema": {
"type": "object",
"required": [
"goodsId",
"currentStock"
],
"properties": {
"goodsId": {
"type": "integer"
},
"currentStock": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"inputChecksum": "c363d259b5108d069650a2554309eb3eb165d98723596595958e00acf8410abe",
"actionChecksum": "63423abe8557075caf668a91795f95a45195759a6b82e755d8af51ddfc71996e",
"outputChecksum": "a632b9f89518c3c17861454b74173a4880d5e8482639d3ff7f761aee4d061bd9",
"contractVersion": 1,
"commandEventType": "saas.workflow.demo.goods.releaseStock",
"providerReleaseId": 38,
"consumerModuleCode": "order",
"providerModuleCode": "demo",
"providerReleaseVersion": "1.0.0-rc.1",
"commandEventContractChecksum": "18a727b91cf9410c3b7ac14e3a8799dbbfa8937f84667bfe7e31e21e662396be"
}),
GoodsReserveStock: defineInjectedSaasAction<DemoGoodsReserveStockInput, DemoGoodsReserveStockOutput>({
"kind": "action",
"name": "goods.reserveStock",
"access": "write",
"actionName": "goods.reserveStock",
"idempotent": true,
"moduleCode": "demo",
"inputSchema": {
"type": "object",
"required": [
"goodsId",
"quantity"
],
"properties": {
"goodsId": {
"type": "integer",
"minimum": 1
},
"quantity": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false
},
"callerPolicy": {
"kind": "module",
"allowedModules": [
"order"
]
},
"contractKind": "saas.action.import",
"outputSchema": {
"type": "object",
"required": [
"goodsId",
"remainingStock"
],
"properties": {
"goodsId": {
"type": "integer"
},
"remainingStock": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"inputChecksum": "6fbfa4d2324c4c0bdc621cbfd256353284e27495f59f47231b7d897b82a4e7b6",
"actionChecksum": "773465c93790e8df9119149e1efb4c4d8d2510298a24af7aa825662540ff37d5",
"outputChecksum": "545bd8b0bf6eb8670b21497236038187ee0f1df1544a318b16a399458fcb796e",
"contractVersion": 1,
"commandEventType": "saas.workflow.demo.goods.reserveStock",
"providerReleaseId": 38,
"consumerModuleCode": "order",
"providerModuleCode": "demo",
"providerReleaseVersion": "1.0.0-rc.1",
"commandEventContractChecksum": "ffbecad3a3d63b8bf92fde701dfaa11c8502a2c5f9f6bbb89b755e214024c3ae"
})
}
} as const;

View File

@@ -0,0 +1,25 @@
// Generated by cool saas-model generate. DO NOT EDIT.
import { defineSaasEventHandler } from '@cool-midway/module-runtime';
import type { SaasEventEnvelope } from '@cool-midway/module-runtime';
export type DemoGoodsStockReservedV1Payload = {
goodsId: number;
quantity: number;
remainingStock: number;
};
export type DemoGoodsStockReservedV1Event = SaasEventEnvelope<DemoGoodsStockReservedV1Payload>;
export const SaasEvents = {
Demo: {
GoodsStockReservedV1: defineSaasEventHandler({
"kind": "event_handler",
"eventType": "demo.goods.stockReserved",
"eventVersion": 1,
"eventContractChecksum": "4d8a47d64f7d9fa43e0253228d05a2bb28db7efe3181e96d58dbff2163b9b752",
"sourceModule": "demo",
"tables": [],
"maxAttempts": 10
})
}
} as const;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

195
src/service/orders.ts Normal file
View File

@@ -0,0 +1,195 @@
import { Provide } from '@midwayjs/core';
import { createHash } from 'node:crypto';
import {
InjectSaasAction,
InjectSaasCrud,
ModuleRuntimeError,
SaasAction,
type SaasActionCaller,
type SaasCrudCaller,
SaasCrudService,
SaasCrudServiceBase,
} from '@cool-midway/module-runtime';
import {
Actions,
Crud,
SaasActions,
type OrdersCancelOrderInput,
type OrdersCancelOrderOutput,
type OrdersCreateOrderInput,
type OrdersCreateOrderOutput,
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> {
@InjectSaasCrud(Crud.Orders)
private readonly orders!: SaasCrudCaller<typeof Crud.Orders>;
@InjectSaasAction(SaasActions.Demo.GoodsBatchInfo)
private readonly batchInfo!: SaasActionCaller<
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
>;
@SaasAction(Actions.OrdersCreateOrder)
async createOrder(
input: OrdersCreateOrderInput
): Promise<OrdersCreateOrderOutput> {
const goods = await this.requireGoods(input.goodsId);
if (integer(goods.status, '商品状态') !== 1) {
throw conflict('商品未上架,不能下单');
}
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',
});
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.requireOrder(input.id);
if (order.status === 'cancelled') {
return { id: input.id, status: 'cancelled', restoredStock: 0 };
}
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',
});
if (update.affected === 0) {
return { id: input.id, status: 'cancelled', restoredStock: 0 };
}
return {
id: input.id,
status: 'cancelled',
restoredStock: order.quantity,
};
}
private async requireOrder(id: number) {
const order = await this.orders.info({ id: String(id) });
if (!order) throw conflict(`订单不存在: ${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}`);
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)) {
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);
}