diff --git a/package.json b/package.json index 679a32d..38cc65c 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,21 @@ "start": "node bootstrap.js", "start:stable": "node start-stable.js", "test:stable": "node --test test/start-stable.test.cjs", + "test:business": "node --test test/goods-business-input.test.cjs", "start:local": "cross-env NODE_ENV=local SAAS_RUNTIME_BOOTSTRAP_LOCAL_FALLBACK_ENABLED=true node bootstrap.js", "verify:packages": "node scripts/verify-local-packages.cjs" }, "coolSaas": { "lifecycle": { "dev": "pnpm run build && pnpm run start:local", - "test": "pnpm run typecheck && pnpm run test:stable", + "test": "pnpm run typecheck && pnpm run test:stable && pnpm run test:business", "build": "pnpm run build", "artifactDir": "dist" } }, "dependencies": { "@cool-midway/module-runtime": "9.0.3", - "@cool-midway/rpc": "9.0.4", + "@cool-midway/rpc": "9.0.5", "@midwayjs/bootstrap": "^3.20.3", "@midwayjs/core": "^3.20.3", "@midwayjs/koa": "^3.20.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 179ba9b..1b40dac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 9.0.3 version: 9.0.3(@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7)))(@midwayjs/core@3.20.24) '@cool-midway/rpc': - specifier: 9.0.4 - version: 9.0.4(@cool-midway/module-runtime@9.0.3(@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7)))(@midwayjs/core@3.20.24))(@midwayjs/validate@3.20.24)(debug@4.4.3) + specifier: 9.0.5 + version: 9.0.5(@cool-midway/module-runtime@9.0.3(@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7)))(@midwayjs/core@3.20.24))(@midwayjs/validate@3.20.24)(debug@4.4.3) '@midwayjs/bootstrap': specifier: ^3.20.3 version: 3.20.24 @@ -78,8 +78,8 @@ packages: '@midwayjs/core': '>=3.20.0' typeorm: '>=0.3.20' - '@cool-midway/rpc@9.0.4': - resolution: {integrity: sha512-+aoVXWycUu2soY4xTKiygKP/g5qON40olEmqZ0YeN0gBM9yZb1Tcmh9rzmcgfV2waI0WTNVuAqigHUyVQe7fcg==} + '@cool-midway/rpc@9.0.5': + resolution: {integrity: sha512-AXuTwEQccgjkwvQge2Cw6R1xIoYMg3ill02V28AvQoBvhlQB3r94AIgSaKTaWAajW8tUDay+q4x7XyQWMlaLpQ==} peerDependencies: '@cool-midway/module-runtime': '>=9.0.3 <10' '@midwayjs/validate': '>=3.20.0' @@ -2497,7 +2497,7 @@ snapshots: '@midwayjs/core': 3.20.24 typeorm: '@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7))' - '@cool-midway/rpc@9.0.4(@cool-midway/module-runtime@9.0.3(@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7)))(@midwayjs/core@3.20.24))(@midwayjs/validate@3.20.24)(debug@4.4.3)': + '@cool-midway/rpc@9.0.5(@cool-midway/module-runtime@9.0.3(@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7)))(@midwayjs/core@3.20.24))(@midwayjs/validate@3.20.24)(debug@4.4.3)': dependencies: '@cool-midway/module-runtime': 9.0.3(@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7)))(@midwayjs/core@3.20.24) '@midwayjs/validate': 3.20.24 diff --git a/scripts/published-packages.json b/scripts/published-packages.json index 5864146..e0a35c5 100644 --- a/scripts/published-packages.json +++ b/scripts/published-packages.json @@ -185,11 +185,11 @@ }, { "name": "@cool-midway/rpc", - "version": "9.0.4", + "version": "9.0.5", "registry": "https://npm.blendercg.art/", - "tarball": "https://npm.blendercg.art/@cool-midway/rpc/-/rpc-9.0.4.tgz", - "integrity": "sha512-+aoVXWycUu2soY4xTKiygKP/g5qON40olEmqZ0YeN0gBM9yZb1Tcmh9rzmcgfV2waI0WTNVuAqigHUyVQe7fcg==", - "sha256": "f07d5c47bba10cfdb30f46a2b0c786df279420a7078dc009c97b2c168c532f03", + "tarball": "https://npm.blendercg.art/@cool-midway/rpc/-/rpc-9.0.5.tgz", + "integrity": "sha512-AXuTwEQccgjkwvQge2Cw6R1xIoYMg3ill02V28AvQoBvhlQB3r94AIgSaKTaWAajW8tUDay+q4x7XyQWMlaLpQ==", + "sha256": "d54f917762dc219055bfdb3f6086f3c6a9b3befd923bcfd97c9b073c05f55f75", "files": { "dist/action-registry.js": "04f77c61445d47f66eaad984ee8e059a38bb0e610d2f584f5754d5a0ead105c5", "dist/saas-host/bootstrap.js": "a53eb6f40c7a7c2427fbcd0cde78e8985f64dda15fcff1843e8957f5e7086271", @@ -197,7 +197,7 @@ "dist/circuit-breaker.js": "0fe32c6019007dc9499c7566abecb63741ade5a70a336501d922986da50a5fc7", "dist/codec.js": "1ba6e712f9c7c643267928ffdf6a3997d7d4ed52d51b8082f34938b75b48d127", "dist/config/config.default.js": "8afe036a5ac3e4c1a382dddc1bec0a5599129977c2c28fc4d9f3777791f5444b", - "dist/saas-host/config.js": "252dd8da719b22cb55011f734d9fc3f28dde1ebe36b06eaa4123b869b4015a70", + "dist/saas-host/config.js": "ae96df84c49d25ba2dc2651b2ae24106a9bc173fa1788e8362940c5a63462023", "dist/configuration.js": "8e0ad7f8b784a39e6859fe18fb5fda56d44fa82d45ab15c9095ab2072d8187a3", "dist/saas-host/configuration.js": "555560131fe84336cc967de372afc2af842cfce7de43f0e8b1e0384a90259872", "dist/saas-host/control-plane.js": "5ef87b9e5fbeabed216a3100b83070fb510e545b1bdda687ed99db00c175125d", @@ -213,7 +213,7 @@ "dist/saas-host/providers.js": "03398be5a9bf2e84d0d11681905ac8c6e4d1594dabf5709ee82f201e432be46d", "dist/redis.js": "871b8b7f906a83ce032b8a23d93471fed49c0e2a5baa5be3fab392a8e80eef9c", "dist/decorator/rpc.js": "14552fea525d683fc459fab2d9b846af70ec77aa4032e275dcf3b0d09288cedc", - "dist/rpc.js": "91f67887ba84348fd15e534a7abb221c17c252c67f0f7666f746aa3bd6a94a7e", + "dist/rpc.js": "d0480036151719b4474418917da075f59a2ed3dda6f8c0533f3ac6737c7af529", "dist/runtime-log.js": "672e35f1bb2c648ae58d2c55f724997a1e2a45bd2e62a8cd8180283f31d8b093", "dist/saas-host/runtime.js": "7a3e79d3b8b195f5de38eb22e40dcd8d284a58fac26cf8f6231c3c636bcc5d65", "saas-host.js": "7c191f478052db1c7e876cf33aad154f879a33fa194f1cd1d216d949598dd09b", @@ -221,7 +221,7 @@ "dist/signer.js": "a941011019527562c3ea2c1373523dbdeabb793d1a1dfdbb6a68d97022510916", "dist/test.js": "5386504311845b9dc3fe230d3c5a3b5ab3c6d36154add15e91d32d8442c25157", "dist/types.js": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - "package.json": "85a6d6082d8d5216abdc160bab1a2baf0238df6421e07e67e363f3d67969f4e4", + "package.json": "50ec18fbbd403cec1e0058a633f2c454cb7cfd03c56c32483f845499c40d442c", "dist/action-registry.d.ts": "40231dd8299c0bfae1b006105d49945eb2f20a40334856db32e4b7cc2851398b", "dist/saas-host/bootstrap.d.ts": "f639edef99770f091ac90e737a0ab2c857830241a8996171492768d108ad43f0", "dist/caller-context.d.ts": "3f58cb5d653e2d5179996c3302dddc5c4bfdc3ff2ac501ca8d6dcedb4cb606d5", @@ -252,7 +252,7 @@ "dist/saas-host/security.d.ts": "e03aa4553fe661b2c89f5c062c68050d6350f14bc7879485540b497dc880adf7", "dist/signer.d.ts": "b0859783023e91f0326a702c91780cb436f5e726a8cc04860b3a408b81130cae", "dist/test.d.ts": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "dist/types.d.ts": "c6567480b4881e9748d31e1f4935ae99318c44db4eee16558859732bcf06a5f8" + "dist/types.d.ts": "e5904d4dbe91e8b334ebd80d87c02c5e400db65afbe65e55d04a0c29e8793706" } } ] diff --git a/src/service/goods.ts b/src/service/goods.ts index 9415899..401dbd7 100644 --- a/src/service/goods.ts +++ b/src/service/goods.ts @@ -44,6 +44,21 @@ export class GoodsService extends SaasCrudServiceBase { throw new SaasBusinessReject('STOCK_COMMAND_REQUIRED','现有商品库存只能通过受管库存命令变更'); } } + if (change.operation === 'add' || change.operation === 'update') { + const input = change.input; + if (Object.prototype.hasOwnProperty.call(input, 'title') && !input.title.trim()) { + throw new SaasBusinessReject('GOODS_INPUT_INVALID', '商品名称不能为空'); + } + if (Object.prototype.hasOwnProperty.call(input, 'price') && !/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(input.price)) { + throw new SaasBusinessReject('GOODS_INPUT_INVALID', '商品价格必须是非负金额'); + } + if (Object.prototype.hasOwnProperty.call(input, 'stock') && (!Number.isSafeInteger(input.stock) || input.stock < 0 || input.stock > 2147483647)) { + throw new SaasBusinessReject('GOODS_INPUT_INVALID', '商品库存必须是有效的非负整数'); + } + if (Object.prototype.hasOwnProperty.call(input, 'status') && input.status !== 0 && input.status !== 1) { + throw new SaasBusinessReject('GOODS_INPUT_INVALID', '商品状态仅允许下架或上架'); + } + } } @SaasAction(Actions.GoodsBatchInfo) diff --git a/test/goods-business-input.test.cjs b/test/goods-business-input.test.cjs new file mode 100644 index 0000000..f742826 --- /dev/null +++ b/test/goods-business-input.test.cjs @@ -0,0 +1,70 @@ +'use strict'; +require('reflect-metadata'); +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const { SaasBusinessReject } = require('@cool-midway/module-runtime'); +const sourceRoot = path.resolve(__dirname, '../src'); +const previous = require.extensions['.ts']; +let GoodsService; +try { + require.extensions['.ts'] = (module, filename) => { + assert.ok(filename.startsWith(sourceRoot + path.sep)); + const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + fileName: filename, + compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.CommonJS, + experimentalDecorators: true, emitDecoratorMetadata: true, esModuleInterop: true }, + }); + module._compile(compiled.outputText, filename); + }; + ({ GoodsService } = require('../src/service/goods.ts')); +} finally { + if (previous) require.extensions['.ts'] = previous; + else delete require.extensions['.ts']; +} +function fixture(context = {}) { + const service = new GoodsService(); + const calls = []; + service.saasActionContext = context; + service.inventory = { assertNoActiveReservations: async id => { calls.push(id); } }; + return { service, calls }; +} +const rejects = code => error => error instanceof SaasBusinessReject && error.code === code; + +test('new catalog records accept an explicit initial stock and nonnegative decimal price', async () => { + const { service, calls } = fixture(); + await service.modifyBefore({ operation: 'add', input: { title: '验收商品', price: '12.50', stock: 20, status: 1 } }); + await service.modifyBefore({ operation: 'add', input: { title: '赠品', price: '0.00', stock: 0, status: 0 } }); + assert.deepEqual(calls, []); +}); + +test('invalid catalog values are business rejections before persistence', async t => { + const inputs = [{ title: ' ' }, { price: '-0.01' }, { stock: -1 }, { stock: 1.5 }, { stock: 2147483648 }, { status: 2 }]; + for (const input of inputs) await t.test(JSON.stringify(input), async () => { + const { service } = fixture(); + await assert.rejects(service.modifyBefore({ operation: 'add', input: { title: '商品', price: '1.00', ...input } }), rejects('GOODS_INPUT_INVALID')); + }); +}); + +test('catalog edits keep managed inventory protected even with valid stock values', async () => { + const { service } = fixture(); + await service.modifyBefore({ operation: 'update', input: { id: '1', title: '更新名称', price: '0.00', status: 0 } }); + await assert.rejects(service.modifyBefore({ operation: 'update', input: { id: '1', stock: 2 } }), rejects('STOCK_COMMAND_REQUIRED')); + await assert.rejects(service.modifyBefore({ operation: 'update', input: { id: '1', price: '-1.00' } }), rejects('GOODS_INPUT_INVALID')); +}); + +test('managed stock updates retain the same domain validation', async () => { + const { service } = fixture({ managedCrudCaller: { kind: 'business', actionName: 'goods.reserveStock' }, workflow: { coordinatorModule: 'order' } }); + await service.modifyBefore({ operation: 'update', input: { id: '1', stock: 0 } }); + await assert.rejects(service.modifyBefore({ operation: 'update', input: { id: '1', stock: -1 } }), rejects('GOODS_INPUT_INVALID')); +}); + +test('deletion still requires the inventory reservation lock and rejects an active reservation', async () => { + const { service, calls } = fixture(); + await service.modifyBefore({ operation: 'delete', input: { id: '1' } }); + assert.deepEqual(calls, ['1']); + service.inventory.assertNoActiveReservations = async () => { throw new SaasBusinessReject('STOCK_RESERVATION_CONFLICT', '仍有库存预留'); }; + await assert.rejects(service.modifyBefore({ operation: 'delete', input: { id: '1' } }), rejects('STOCK_RESERVATION_CONFLICT')); +});