refactor: strengthen client boundaries and quality gates

This commit is contained in:
hectorzhao
2026-08-28 14:26:58 +08:00
parent 3af145abe5
commit ad27acad7e
51 changed files with 7703 additions and 697 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
api/dist
api/vendor
package-lock.json
docs
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}
+17
View File
@@ -0,0 +1,17 @@
const base = require('./jest.config.cjs');
module.exports = {
...base,
collectCoverageFrom: [
'src/auth/auth.service.ts',
'src/auth/session.service.ts',
'src/common/bounded-json-object.validator.ts',
'src/files/client-files.dto.ts',
'src/open-api/client-open-api.dto.ts',
'src/operations/client-operations.dto.ts',
'src/users/client-user.dto.ts',
],
coverageThreshold: {
global: { statements: 80, branches: 70, functions: 80, lines: 80 },
},
};
+79 -127
View File
@@ -12,7 +12,7 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28", "@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28", "@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0", "@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0", "@prisma/client": "^7.9.0",
"brace-expansion": "file:vendor/brace-expansion-compat", "brace-expansion": "file:vendor/brace-expansion-compat",
@@ -67,6 +67,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -591,7 +592,8 @@
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
"integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0",
"peer": true
}, },
"node_modules/@electric-sql/pglite-socket": { "node_modules/@electric-sql/pglite-socket": {
"version": "0.1.3", "version": "0.1.3",
@@ -616,33 +618,10 @@
"@electric-sql/pglite": "0.4.3" "@electric-sql/pglite": "0.4.3"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -732,30 +711,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/schema": { "node_modules/@istanbuljs/schema": {
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -1329,6 +1284,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz",
"integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"file-type": "21.3.4", "file-type": "21.3.4",
"iterare": "1.2.1", "iterare": "1.2.1",
@@ -1375,6 +1331,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz",
"integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==", "integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-safe-stringify": "2.1.1", "fast-safe-stringify": "2.1.1",
"iterare": "1.2.1", "iterare": "1.2.1",
@@ -1434,6 +1391,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz",
"integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==", "integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cors": "2.8.6", "cors": "2.8.6",
"express": "5.2.1", "express": "5.2.1",
@@ -1451,20 +1409,20 @@
} }
}, },
"node_modules/@nestjs/swagger": { "node_modules/@nestjs/swagger": {
"version": "11.4.5", "version": "11.4.7",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==", "integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@microsoft/tsdoc": "0.16.0", "@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1", "@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.3.0", "js-yaml": "5.3.0",
"lodash": "4.18.1", "lodash": "4.18.1",
"path-to-regexp": "8.4.2", "path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.32.8" "swagger-ui-dist": "5.32.13"
}, },
"peerDependencies": { "peerDependencies": {
"@fastify/static": "^8.0.0 || ^9.0.0", "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"class-transformer": "*", "class-transformer": "*",
@@ -2177,6 +2135,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": ">=7.24.0 <7.24.7" "undici-types": ">=7.24.0 <7.24.7"
} }
@@ -2198,6 +2157,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2345,9 +2305,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2362,9 +2319,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2379,9 +2333,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2396,9 +2347,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2413,9 +2361,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2430,9 +2375,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2447,9 +2389,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2464,9 +2403,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2481,9 +2417,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2498,9 +2431,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2540,6 +2470,40 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
@@ -3263,9 +3227,9 @@
}, },
"node_modules/brace-expansion-safe": { "node_modules/brace-expansion-safe": {
"name": "brace-expansion", "name": "brace-expansion",
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@@ -3300,6 +3264,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.38", "baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001799",
@@ -3649,13 +3614,15 @@
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.4", "version": "0.14.4",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz",
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/validator": "^13.15.3", "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1", "libphonenumber-js": "^1.11.1",
@@ -4510,20 +4477,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4742,9 +4695,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.4", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"devOptional": true, "devOptional": true,
"funding": [ "funding": [
{ {
@@ -5561,6 +5514,7 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.4.2", "@jest/core": "30.4.2",
"@jest/types": "30.4.1", "@jest/types": "30.4.1",
@@ -6170,9 +6124,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -7228,6 +7182,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.14.0", "pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0", "pg-pool": "^3.14.0",
@@ -7465,6 +7420,7 @@
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "7.9.0", "@prisma/config": "7.9.0",
"@prisma/dev": "0.24.14", "@prisma/dev": "0.24.14",
@@ -7740,7 +7696,8 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0" "license": "Apache-2.0",
"peer": true
}, },
"node_modules/remeda": { "node_modules/remeda": {
"version": "2.33.4", "version": "2.33.4",
@@ -7894,6 +7851,7 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -7973,8 +7931,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
@@ -8208,13 +8165,6 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sqlstring": { "node_modules/sqlstring": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -8520,9 +8470,9 @@
} }
}, },
"node_modules/swagger-ui-dist": { "node_modules/swagger-ui-dist": {
"version": "5.32.8", "version": "5.32.13",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@scarf/scarf": "=1.4.0" "@scarf/scarf": "=1.4.0"
@@ -8750,6 +8700,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -8860,6 +8811,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -9523,10 +9475,10 @@
"node_modules/zip-stream/node_modules/minimatch/vendor/brace-expansion-compat": {}, "node_modules/zip-stream/node_modules/minimatch/vendor/brace-expansion-compat": {},
"vendor/brace-expansion-compat": { "vendor/brace-expansion-compat": {
"name": "brace-expansion", "name": "brace-expansion",
"version": "5.0.8-compat.1", "version": "5.0.9-compat.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"brace-expansion-safe": "npm:brace-expansion@5.0.8" "brace-expansion-safe": "npm:brace-expansion@5.0.9"
} }
} }
} }
+5 -2
View File
@@ -7,6 +7,7 @@
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand", "test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage", "test:coverage": "jest --runInBand --coverage",
"test:incremental-coverage": "jest --runInBand --coverage --config jest.incremental.config.cjs",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"start": "node dist/main.js", "start": "node dist/main.js",
"start:dev": "ts-node src/main.ts", "start:dev": "ts-node src/main.ts",
@@ -19,7 +20,7 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28", "@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28", "@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0", "@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0", "@prisma/client": "^7.9.0",
"bullmq": "^5.79.2", "bullmq": "^5.79.2",
@@ -47,6 +48,8 @@
"exceljs": { "exceljs": {
"uuid": "11.1.1" "uuid": "11.1.1"
}, },
"find-my-way": "9.7.0" "find-my-way": "9.7.0",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1"
} }
} }
+63 -17
View File
@@ -20,19 +20,29 @@ type CookieResponse = {
@ApiTags('auth') @ApiTags('auth')
@Controller() @Controller()
export class AuthController { export class AuthController {
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {} constructor(
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly sessions: SessionService,
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
) {}
@Get('admin/auth/captcha') @Get('admin/auth/captcha')
adminCaptcha() { adminCaptcha(@Req() request: SessionRequest) {
return this.auth.createCaptcha(); return this.auth.createCaptcha(this.sourceIp(request));
} }
@Post('admin/auth/login') @Post('admin/auth/login')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { async adminLogin(
@Body() body: LoginDto,
@Req() request: SessionRequest,
@Res({ passthrough: true }) response: CookieResponse,
) {
let result: Awaited<ReturnType<AuthService['login']>>; let result: Awaited<ReturnType<AuthService['login']>>;
try { try {
result = await this.auth.login(body, 'admin'); result = await this.auth.login(body, 'admin', this.sourceIp(request));
} catch (error) { } catch (error) {
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined); await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
throw error; throw error;
@@ -41,16 +51,20 @@ export class AuthController {
} }
@Get('client/auth/captcha') @Get('client/auth/captcha')
clientCaptcha() { clientCaptcha(@Req() request: SessionRequest) {
return this.auth.createCaptcha(); return this.auth.createCaptcha(this.sourceIp(request));
} }
@Post('client/auth/login') @Post('client/auth/login')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { async clientLogin(
@Body() body: LoginDto,
@Req() request: SessionRequest,
@Res({ passthrough: true }) response: CookieResponse,
) {
let result: Awaited<ReturnType<AuthService['login']>>; let result: Awaited<ReturnType<AuthService['login']>>;
try { try {
result = await this.auth.login(body, 'client'); result = await this.auth.login(body, 'client', this.sourceIp(request));
} catch (error) { } catch (error) {
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined); await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
throw error; throw error;
@@ -94,17 +108,23 @@ export class AuthController {
async lock(@Req() request: SessionRequest) { async lock(@Req() request: SessionRequest) {
this.assertSession(request); this.assertSession(request);
const record = await this.sessions.lock(request.sessionToken!); const record = await this.sessions.lock(request.sessionToken!);
if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' }); if (record)
await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
return { locked: Boolean(record) }; return { locked: Boolean(record) };
} }
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock']) @Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) { async unlock(
@Req() request: SessionRequest,
@Body() body: PasswordVerificationDto,
@Res({ passthrough: true }) response: CookieResponse,
) {
const { password } = body; const { password } = body;
this.assertSession(request); this.assertSession(request);
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password); const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' }); if (result.status !== 'active' || !('token' in result))
throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
this.setCookie(response, result.record.portal, result.token); this.setCookie(response, result.record.portal, result.token);
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal }); await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
return this.sessions.publicSession(result.record); return this.sessions.publicSession(result.record);
@@ -124,7 +144,8 @@ export class AuthController {
@Post(['admin/auth/logout', 'client/auth/logout']) @Post(['admin/auth/logout', 'client/auth/logout'])
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
if (request.sessionToken) await this.sessions.remove(request.sessionToken); if (request.sessionToken) await this.sessions.remove(request.sessionToken);
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal }); if (request.sessionUserId)
await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
this.clearCookie(response, request.authSession?.portal); this.clearCookie(response, request.authSession?.portal);
return { success: true }; return { success: true };
} }
@@ -136,17 +157,32 @@ export class AuthController {
return this.users.changeOwnPassword(userId, body.currentPassword, body.password); return this.users.changeOwnPassword(userId, body.currentPassword, body.password);
} }
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) { private async finishLogin(
result: Awaited<ReturnType<AuthService['login']>>,
request: SessionRequest,
response: CookieResponse,
) {
this.setCookie(response, result.portal, result.sessionToken); this.setCookie(response, result.portal, result.sessionToken);
this.clearLegacyCookie(response); this.clearLegacyCookie(response);
await this.prisma.operationLog.create({ await this.prisma.operationLog.create({
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } }, data: {
userId: result.user.id,
tenantId: result.user.tenantId,
action: 'auth.session_created',
resource: 'auth_session',
userAgent: request.header('user-agent'),
detail: { portal: result.portal },
},
}); });
const { sessionToken: _, ...publicResult } = result; const { sessionToken: _, ...publicResult } = result;
return publicResult; return publicResult;
} }
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) { private recordLoginFailure(
ruleCode: 'admin_login_failure' | 'client_login_failure',
account: string,
request: SessionRequest,
) {
return this.security.recordEvent({ return this.security.recordEvent({
ruleCode, ruleCode,
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1', sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
@@ -157,9 +193,19 @@ export class AuthController {
}); });
} }
private sourceIp(request: SessionRequest) {
return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
}
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) { private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({ return this.prisma.operationLog.create({
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue }, data: {
userId: request.sessionUserId,
action,
resource: 'auth_session',
userAgent: request.header('user-agent'),
detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue,
},
}); });
} }
+117 -16
View File
@@ -23,6 +23,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'), verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(), recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(), recordLoginFailure: jest.fn(),
verifyCurrentPassword: jest.fn(),
}; };
} }
@@ -30,54 +31,154 @@ function createSessionsMock() {
const captchas = new Map<string, string>(); const captchas = new Map<string, string>();
const failures = new Map<string, number>(); const failures = new Map<string, number>();
const record = { const record = {
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1, userId: 'user-1',
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000, portal: 'admin',
sessionVersion: 0,
createdAt: 1,
lastActivityAt: 1,
lastAuthenticatedAt: 1,
absoluteExpiresAt: Date.now() + 1000,
}; };
return { return {
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }), storeCaptcha: jest.fn(async (id: string, answer: string) => {
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }), captchas.set(id, answer);
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5), }),
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }), consumeCaptcha: jest.fn(async (id: string) => {
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }), const answer = captchas.get(id) ?? null;
captchas.delete(id);
return answer;
}),
assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true),
anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)),
recordAnonymousLoginFailure: jest.fn(async (login: string) => {
const count = (failures.get(login) ?? 0) + 1;
failures.set(login, count);
return [count, count, count];
}),
clearAnonymousLoginFailures: jest.fn(async (login: string) => {
failures.delete(login);
}),
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }), create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }), publicSession: jest
.fn()
.mockReturnValue({
idleTimeoutSeconds: 3600,
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
}),
unlock: jest.fn().mockResolvedValue({ status: 'active' }),
markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }),
}; };
} }
function createMetricsMock() {
return { recordAuthProtectionResult: jest.fn() };
}
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') { async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
const captcha = await service.createCaptcha(); const captcha = await service.createCaptcha('203.0.113.10');
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0); const answer = captcha.challenge
return service.login({ .split('=')[0]
.split('+')
.map((part) => Number(part.trim()))
.reduce((sum, value) => sum + value, 0);
return service.login(
{
login: 'user@example.com', login: 'user@example.com',
password, password,
captchaId: captcha.captchaId, captchaId: captcha.captchaId,
captchaText: String(answer), captchaText: String(answer),
}, portal); },
portal,
'203.0.113.10',
);
} }
describe('AuthService', () => { describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => { it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin'); const users = createUsersMock('platform_admin');
const sessions = createSessionsMock(); const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never); const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' })); await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(
expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }),
);
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1'); expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0); expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
}); });
it('rejects enterprise admins on admin portal', async () => { it('rejects enterprise admins on admin portal', async () => {
const users = createUsersMock('enterprise_admin'); const users = createUsersMock('enterprise_admin');
const service = new AuthService(users as never, createSessionsMock() as never); const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException); await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1'); expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
}); });
it('locks user after five failed password attempts', async () => { it('locks user after five failed password attempts', async () => {
const users = createUsersMock('platform_admin'); const users = createUsersMock('platform_admin');
const service = new AuthService(users as never, createSessionsMock() as never); const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException); await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
} }
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5); expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
}); });
it('rejects captcha bursts before allocating a captcha', async () => {
const sessions = createSessionsMock();
sessions.assertCaptchaRequestAllowed.mockResolvedValue(false);
const metrics = createMetricsMock();
const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never);
await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 });
expect(sessions.storeCaptcha).not.toHaveBeenCalled();
expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected');
});
it('allows a tenant-bound enterprise admin to login to the client portal', async () => {
const service = new AuthService(
createUsersMock('enterprise_admin') as never,
createSessionsMock() as never,
createMetricsMock() as never,
);
await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' }));
});
it('records anonymous failures without disclosing whether an account exists', async () => {
const users = createUsersMock('platform_admin');
users.findByLogin.mockResolvedValue(null);
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10');
});
it('rejects expired and incorrect one-time captchas', async () => {
const sessions = createSessionsMock();
const service = new AuthService(
createUsersMock('platform_admin') as never,
sessions as never,
createMetricsMock() as never,
);
await expect(
service.login(
{ login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' },
'admin',
'203.0.113.10',
),
).rejects.toMatchObject({ status: 400 });
await sessions.storeCaptcha('captcha-wrong', '7');
await expect(
service.login(
{ login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' },
'admin',
'203.0.113.10',
),
).rejects.toMatchObject({ status: 400 });
});
it('delegates unlock and recent reauthentication to password and session services', async () => {
const users = createUsersMock('platform_admin');
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2);
});
}); });
+32 -11
View File
@@ -1,16 +1,26 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto'; import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
import { MetricsService } from '../metrics/metrics.service';
type LoginPortal = 'admin' | 'client'; type LoginPortal = 'admin' | 'client';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {} constructor(
private readonly users: UsersService,
private readonly sessions: SessionService,
private readonly metrics: MetricsService,
) {}
async createCaptcha() { async createCaptcha(sourceIp: string) {
if (!(await this.sessions.assertCaptchaRequestAllowed(sourceIp))) {
this.metrics.recordAuthProtectionResult('captcha_rejected');
throw new HttpException('验证码请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
}
this.metrics.recordAuthProtectionResult('captcha_allowed');
const left = Math.floor(10 + Math.random() * 40); const left = Math.floor(10 + Math.random() * 40);
const right = Math.floor(1 + Math.random() * 9); const right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID(); const captchaId = randomUUID();
@@ -22,43 +32,48 @@ export class AuthService {
}; };
} }
async login(data: LoginDto, portal: LoginPortal) { async login(data: LoginDto, portal: LoginPortal, sourceIp: string) {
const login = data.login?.trim(); const login = data.login?.trim();
if (!login || !data.password) { if (!login || !data.password) {
throw new BadRequestException('login and password are required'); throw new BadRequestException('login and password are required');
} }
await this.verifyCaptcha(data.captchaId, data.captchaText); await this.verifyCaptcha(data.captchaId, data.captchaText);
await this.assertAnonymousNotLocked(login); await this.assertAnonymousNotLocked(login, sourceIp);
const user = await this.users.findByLogin(login); const user = await this.users.findByLogin(login);
if (!user) { if (!user) {
await this.sessions.recordAnonymousLoginFailure(login); await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('Invalid login or password'); throw new UnauthorizedException('Invalid login or password');
} }
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('User is locked for 24 hours after repeated failures'); throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
} }
if (user.status !== 'active' || user.deletedAt) { if (user.status !== 'active' || user.deletedAt) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id); await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted'); throw new UnauthorizedException('User is disabled or deleted');
} }
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) { if (!(await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash))) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id); await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password'); throw new UnauthorizedException('Invalid login or password');
} }
const roleCodes = user.roles.map((item) => item.role.code); const roleCodes = user.roles.map((item) => item.role.code);
if (portal === 'admin' && !roleCodes.includes('platform_admin')) { if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id); await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only platform admins can login to admin portal'); throw new UnauthorizedException('Only platform admins can login to admin portal');
} }
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) { if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id); await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal'); throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
} }
await this.users.recordLoginSuccess(user.id); await this.users.recordLoginSuccess(user.id);
await this.sessions.clearAnonymousLoginFailures(login); await this.sessions.clearAnonymousLoginFailures(login, sourceIp);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0); const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return { return {
@@ -98,9 +113,15 @@ export class AuthService {
} }
} }
private async assertAnonymousNotLocked(login: string) { private async assertAnonymousNotLocked(login: string, sourceIp: string) {
if (await this.sessions.isAnonymousLoginLocked(login)) { const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
throw new UnauthorizedException('User is locked for 24 hours after repeated failures'); if (scope) {
this.metrics.recordAuthProtectionResult('login_locked', scope);
throw new UnauthorizedException(
scope === 'account'
? 'User is locked for 24 hours after repeated failures'
: 'Too many login attempts from this source, try again later',
);
} }
} }
} }
+103 -6
View File
@@ -1,8 +1,36 @@
const values = new Map<string, string>(); const values = new Map<string, string>();
const redis = { const redis = {
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)), get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }), getdel: jest.fn((key: string) => {
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }), const value = values.get(key) ?? null;
values.delete(key);
return Promise.resolve(value);
}),
mget: jest.fn((...keys: string[]) => Promise.resolve(keys.map((key) => values.get(key) ?? null))),
set: jest.fn((key: string, value: string) => {
values.set(key, value);
return Promise.resolve('OK');
}),
del: jest.fn((...keys: string[]) => {
keys.forEach((key) => values.delete(key));
return Promise.resolve(keys.length);
}),
eval: jest.fn((_script: string, keyCount: number, ...parts: Array<string | number>) => {
const keys = parts.slice(0, keyCount).map(String);
const args = parts.slice(keyCount).map(Number);
if (keyCount === 1) {
const count = Number(values.get(keys[0]) ?? 0) + 1;
values.set(keys[0], String(count));
return Promise.resolve(count);
}
const counts = keys.slice(0, 3).map((key, index) => {
const count = Number(values.get(key) ?? 0) + 1;
values.set(key, String(count));
if (count >= args[index + 3]) values.set(keys[index + 3], '1');
return count;
});
return Promise.resolve(counts);
}),
disconnect: jest.fn(), disconnect: jest.fn(),
}; };
@@ -47,7 +75,9 @@ describe('SessionService', () => {
const service = new SessionService(); const service = new SessionService();
const created = await service.create('user-1', 'client', 2); const created = await service.create('user-1', 'client', 2);
now += 90 * 60 * 1000; now += 90 * 60 * 1000;
await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' })); await expect(service.validate(created.token, false)).resolves.toEqual(
expect.objectContaining({ status: 'active' }),
);
}); });
it('requires a full login after a session stays locked for four hours', async () => { it('requires a full login after a session stays locked for four hours', async () => {
@@ -55,7 +85,10 @@ describe('SessionService', () => {
const created = await service.create('user-1', 'admin', 2); const created = await service.create('user-1', 'admin', 2);
await service.lock(created.token); await service.lock(created.token);
now += 4 * 60 * 60 * 1000 + 1; now += 4 * 60 * 60 * 1000 + 1;
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' }); await expect(service.validate(created.token, false)).resolves.toEqual({
status: 'expired',
code: 'SESSION_LOCK_TIMEOUT',
});
}); });
it('rotates the opaque token when a password unlock succeeds', async () => { it('rotates the opaque token when a password unlock succeeds', async () => {
@@ -66,8 +99,13 @@ describe('SessionService', () => {
expect(result.status).toBe('active'); expect(result.status).toBe('active');
if (result.status === 'active' && 'token' in result) { if (result.status === 'active' && 'token' in result) {
expect(result.token).not.toBe(created.token); expect(result.token).not.toBe(created.token);
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' }); await expect(service.validate(created.token, false)).resolves.toEqual({
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' })); status: 'expired',
code: 'SESSION_INVALID',
});
await expect(service.validate(result.token, false)).resolves.toEqual(
expect.objectContaining({ status: 'active' }),
);
} }
}); });
@@ -78,4 +116,63 @@ describe('SessionService', () => {
expect(service.cookieName('client')).toBe('cmpp_client_session'); expect(service.cookieName('client')).toBe('cmpp_client_session');
delete process.env.SESSION_COOKIE_SECURE; delete process.env.SESSION_COOKIE_SECURE;
}); });
it('stores and consumes a captcha exactly once', async () => {
const service = new SessionService();
await service.storeCaptcha('captcha-1', '9', 300);
await expect(service.consumeCaptcha('captcha-1')).resolves.toBe('9');
await expect(service.consumeCaptcha('captcha-1')).resolves.toBeNull();
});
it('expires an absolute session and supports touch plus recent authentication', async () => {
const service = new SessionService();
const expired = await service.create('expired-user', 'admin', 1);
now += 12 * 60 * 60 * 1000 + 1;
await expect(service.validate(expired.token, false)).resolves.toEqual({
status: 'expired',
code: 'SESSION_ABSOLUTE_TIMEOUT',
});
now = 1_700_000_000_000;
const active = await service.create('active-user', 'client', 1);
now += 31_000;
const touched = await service.touch(active.token);
expect(touched.status).toBe('active');
const reauthenticated = await service.markReauthenticated(active.token);
expect(reauthenticated.status).toBe('active');
if (reauthenticated.status === 'active') {
expect(service.isRecentlyAuthenticated(reauthenticated.record)).toBe(true);
expect(service.publicSession(reauthenticated.record)).toEqual(
expect.objectContaining({ idleTimeoutSeconds: 7200 }),
);
}
});
it('uses host-prefixed cookie names when secure cookies are enabled', () => {
process.env.SESSION_COOKIE_SECURE = 'true';
const service = new SessionService();
expect(service.cookieName('admin')).toBe('__Host-cmpp_admin_session');
expect(service.cookieName('client')).toBe('__Host-cmpp_client_session');
delete process.env.SESSION_COOKIE_SECURE;
});
it('rate limits captcha allocation by hashed source IP', async () => {
const service = new SessionService();
for (let index = 0; index < 30; index += 1) {
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(true);
}
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(false);
expect([...values.keys()].some((key) => key.includes('203.0.113.10'))).toBe(false);
});
it('locks anonymous failures independently by account, IP and account-IP pair', async () => {
const service = new SessionService();
for (let index = 0; index < 5; index += 1) {
await service.recordAnonymousLoginFailure('user@example.com', '203.0.113.10');
}
await expect(service.anonymousLoginLockScope('user@example.com', '198.51.100.7')).resolves.toBe('account');
await expect(service.anonymousLoginLockScope('other@example.com', '203.0.113.10')).resolves.toBeNull();
await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10');
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull();
});
}); });
+86 -27
View File
@@ -22,8 +22,13 @@ export type SessionValidationResult =
const SESSION_PREFIX = 'cmpp:auth:session:'; const SESSION_PREFIX = 'cmpp:auth:session:';
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:'; const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:';
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:'; const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:'; const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
const ANONYMOUS_IP_FAILURE_PREFIX = 'cmpp:auth:failure:ip:';
const ANONYMOUS_IP_LOCK_PREFIX = 'cmpp:auth:lock:ip:';
const ANONYMOUS_PAIR_FAILURE_PREFIX = 'cmpp:auth:failure:pair:';
const ANONYMOUS_PAIR_LOCK_PREFIX = 'cmpp:auth:lock:pair:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session'; export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session'; export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session'; export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
@@ -142,40 +147,87 @@ export class SessionService implements OnModuleDestroy {
} }
} }
async isAnonymousLoginLocked(login: string) { async assertCaptchaRequestAllowed(sourceIp: string) {
const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`;
try { try {
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`)); const count = Number(
} catch { await this.client.eval(
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async recordAnonymousLoginFailure(login: string) {
const digest = this.loginDigest(login);
const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
try {
const count = Number(await this.client.eval(
`local count = redis.call('INCR', KEYS[1]) `local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
return count`, return count`,
2, 1,
failureKey, key,
lockKey, 5 * 60,
24 * 60 * 60, ),
5, );
)); return count <= 30;
return count; } catch {
throw new ServiceUnavailableException('验证码服务暂不可用');
}
}
async anonymousLoginLockScope(login: string, sourceIp: string) {
const accountDigest = this.loginDigest(login);
const ipDigest = this.valueDigest(sourceIp);
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
try {
const locks = await this.client.mget(
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
);
if (locks[0]) return 'account' as const;
if (locks[1]) return 'ip' as const;
if (locks[2]) return 'pair' as const;
return null;
} catch { } catch {
throw new ServiceUnavailableException('登录保护服务暂不可用'); throw new ServiceUnavailableException('登录保护服务暂不可用');
} }
} }
async clearAnonymousLoginFailures(login: string) { async recordAnonymousLoginFailure(login: string, sourceIp: string) {
const digest = this.loginDigest(login); const accountDigest = this.loginDigest(login);
const ipDigest = this.valueDigest(sourceIp);
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
try { try {
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`); const result = await this.client.eval(
`local counts = {}
for i = 1, 3 do
counts[i] = redis.call('INCR', KEYS[i])
if counts[i] == 1 then redis.call('EXPIRE', KEYS[i], ARGV[i]) end
if counts[i] >= tonumber(ARGV[i + 3]) then redis.call('SET', KEYS[i + 3], '1', 'EX', ARGV[i]) end
end
return counts`,
6,
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
`${ANONYMOUS_IP_FAILURE_PREFIX}${ipDigest}`,
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
24 * 60 * 60,
15 * 60,
24 * 60 * 60,
5,
30,
5,
);
return (result as number[]).map(Number);
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async clearAnonymousLoginFailures(login: string, sourceIp: string) {
const accountDigest = this.loginDigest(login);
const pairDigest = this.valueDigest(`${accountDigest}:${this.valueDigest(sourceIp)}`);
try {
await this.client.del(
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
);
} catch { } catch {
throw new ServiceUnavailableException('登录保护服务暂不可用'); throw new ServiceUnavailableException('登录保护服务暂不可用');
} }
@@ -196,7 +248,10 @@ export class SessionService implements OnModuleDestroy {
} }
get cookieSecure() { get cookieSecure() {
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false'); return (
process.env.SESSION_COOKIE_SECURE === 'true' ||
(process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false')
);
} }
cookieName(portal: SessionPortal) { cookieName(portal: SessionPortal) {
@@ -230,7 +285,7 @@ export class SessionService implements OnModuleDestroy {
private async read(token: string): Promise<AuthSessionRecord | null> { private async read(token: string): Promise<AuthSessionRecord | null> {
try { try {
const value = await this.client.get(this.key(token)); const value = await this.client.get(this.key(token));
return value ? JSON.parse(value) as AuthSessionRecord : null; return value ? (JSON.parse(value) as AuthSessionRecord) : null;
} catch { } catch {
throw new ServiceUnavailableException('登录会话服务暂不可用'); throw new ServiceUnavailableException('登录会话服务暂不可用');
} }
@@ -254,7 +309,11 @@ export class SessionService implements OnModuleDestroy {
} }
private loginDigest(login: string) { private loginDigest(login: string) {
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex'); return this.valueDigest(login.trim().toLocaleLowerCase('en-US'));
}
private valueDigest(value: string) {
return createHash('sha256').update(value.trim()).digest('hex');
} }
private get client() { private get client() {
@@ -0,0 +1,55 @@
import { registerDecorator, type ValidationArguments, type ValidationOptions } from 'class-validator';
type BoundedJsonOptions = {
maxDepth?: number;
maxKeys?: number;
maxStringLength?: number;
};
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
export function IsBoundedJsonObject(options: BoundedJsonOptions = {}, validationOptions?: ValidationOptions) {
return (target: object, propertyName: string) =>
registerDecorator({
name: 'isBoundedJsonObject',
target: target.constructor,
propertyName,
constraints: [options],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
if (value === undefined || value === null) return true;
const [constraints] = args.constraints as [BoundedJsonOptions];
return isBoundedJsonValue(value, {
maxDepth: constraints.maxDepth ?? 4,
maxKeys: constraints.maxKeys ?? 100,
maxStringLength: constraints.maxStringLength ?? 2_000,
});
},
defaultMessage(args: ValidationArguments) {
return `${args.property} contains too many, too deeply nested, or unsafe values`;
},
},
});
}
function isBoundedJsonValue(value: unknown, limits: Required<BoundedJsonOptions>) {
let keyCount = 0;
const visit = (current: unknown, depth: number): boolean => {
if (depth > limits.maxDepth) return false;
if (current == null || typeof current === 'boolean' || typeof current === 'number') return true;
if (typeof current === 'string') return current.length <= limits.maxStringLength;
if (Array.isArray(current)) {
keyCount += current.length;
return keyCount <= limits.maxKeys && current.every((item) => visit(item, depth + 1));
}
if (typeof current !== 'object') return false;
const entries = Object.entries(current as Record<string, unknown>);
keyCount += entries.length;
return (
keyCount <= limits.maxKeys &&
entries.every(([key, item]) => key.length <= 128 && !FORBIDDEN_KEYS.has(key) && visit(item, depth + 1))
);
};
return visit(value, 0);
}
+28 -5
View File
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto'; import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientImportConfirmDto } from './client-write.dto';
import { strictValidationPipe } from './strict-validation.pipe'; import { strictValidationPipe } from './strict-validation.pipe';
function validate<T>(metatype: new () => T, value: unknown) { function validate<T>(metatype: new () => T, value: unknown) {
@@ -8,10 +8,12 @@ function validate<T>(metatype: new () => T, value: unknown) {
describe('strict client write DTOs', () => { describe('strict client write DTOs', () => {
it('accepts an import confirmation without a client-supplied phones array', async () => { it('accepts an import confirmation without a client-supplied phones array', async () => {
await expect(validate(ClientImportConfirmDto, { await expect(
validate(ClientImportConfirmDto, {
content: '【测试】验证码 ${code}', content: '【测试】验证码 ${code}',
importContent: 'phone,code\n13800000001,1234', importContent: 'phone,code\n13800000001,1234',
})).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) })); }),
).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
}); });
it('rejects a direct batch task without validated phone numbers', async () => { it('rejects a direct batch task without validated phone numbers', async () => {
@@ -19,7 +21,28 @@ describe('strict client write DTOs', () => {
}); });
it('rejects a client-supplied operator identity', async () => { it('rejects a client-supplied operator identity', async () => {
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' })) await expect(
.rejects.toBeInstanceOf(BadRequestException); validate(ClientDeleteResourceDto, { status: 'deleted', operatorId: 'another-user' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a client-supplied tenant identity', async () => {
await expect(
validate(ClientBatchTaskDto, {
tenantId: 'other-tenant',
content: '【测试】通知',
phones: ['13800000001'],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects deeply nested or prototype-like dynamic values', async () => {
await expect(
validate(ClientBatchTaskDto, {
content: '【测试】通知',
phones: ['13800000001'],
variables: { safe: { nested: { too: { deep: { value: 'x' } } } } },
}),
).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });
+43 -14
View File
@@ -10,6 +10,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
IsDateString,
Matches, Matches,
Max, Max,
MaxLength, MaxLength,
@@ -17,26 +18,25 @@ import {
MinLength, MinLength,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { IsBoundedJsonObject } from './bounded-json-object.validator';
export class ClientCertificationSubmissionDto { export class ClientCertificationSubmissionDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(200) companyName!: string; @IsString() @MinLength(1) @MaxLength(200) companyName!: string;
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string; @IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
@IsOptional() @IsString() @MaxLength(100) contactName?: string; @IsOptional() @IsString() @MaxLength(100) contactName?: string;
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string; @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
@IsOptional() @IsObject() materials?: Record<string, unknown>; @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) materials?: Record<string, unknown>;
} }
export class ClientTaskBaseDto { export class ClientTaskBaseDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string; @IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsOptional() @IsString() @MaxLength(64) templateId?: string; @IsOptional() @IsString() @MaxLength(64) templateId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string; @IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string; @IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled'; @IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
@IsOptional() @IsString() @MaxLength(64) scheduledAt?: string; @IsOptional() @IsDateString({ strict: true }) scheduledAt?: string;
@IsOptional() @IsObject() variables?: Record<string, unknown>; @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) variables?: Record<string, unknown>;
@IsOptional() @IsString() @MaxLength(64) requestedAt?: string; @IsOptional() @IsDateString({ strict: true }) requestedAt?: string;
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string; @IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
} }
@@ -45,7 +45,6 @@ export class ClientBatchTaskDto extends ClientTaskBaseDto {
} }
export class ClientImportPreviewDto { export class ClientImportPreviewDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string; @IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string; @IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
@IsOptional() @IsString() @MaxLength(255) fileName?: string; @IsOptional() @IsString() @MaxLength(255) fileName?: string;
@@ -60,7 +59,6 @@ export class ClientImportConfirmDto extends ClientTaskBaseDto {
} }
export class ClientBillingEstimateDto { export class ClientBillingEstimateDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string; @IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string; @IsString() @MinLength(1) @MaxLength(5000) content!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number; @Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
@@ -69,7 +67,6 @@ export class ClientBillingEstimateDto {
} }
export class ClientSmsApplicationDto { export class ClientSmsApplicationDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string; @IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) scene?: string; @IsOptional() @IsString() @MaxLength(500) scene?: string;
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string; @IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
@@ -93,11 +90,10 @@ export class ClientSmsApplicationDto {
} }
export class ClientSmsSignatureDto { export class ClientSmsSignatureDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string; @IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string; @IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) purpose?: string; @IsOptional() @IsString() @MaxLength(500) purpose?: string;
@IsOptional() @IsObject() drainageInfo?: Record<string, unknown>; @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) drainageInfo?: Record<string, unknown>;
} }
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) { export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
@@ -108,7 +104,7 @@ export class ClientDrainageInfoDto {
@IsString() @MinLength(1) @MaxLength(200) siteName!: string; @IsString() @MinLength(1) @MaxLength(200) siteName!: string;
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string; @IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
@IsOptional() @IsString() @MaxLength(1000) remark?: string; @IsOptional() @IsString() @MaxLength(1000) remark?: string;
@IsOptional() @IsObject() reportValues?: Record<string, unknown>; @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
} }
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {} export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
@@ -127,13 +123,17 @@ class TemplateVariableDto {
} }
export class ClientSmsTemplateDto { export class ClientSmsTemplateDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MaxLength(64) applicationId!: string; @IsString() @MaxLength(64) applicationId!: string;
@IsOptional() @IsString() @MaxLength(64) signatureId?: string; @IsOptional() @IsString() @MaxLength(64) signatureId?: string;
@IsString() @MinLength(1) @MaxLength(200) name!: string; @IsString() @MinLength(1) @MaxLength(200) name!: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string; @IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string; @IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[]; @IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => TemplateVariableDto)
variables?: TemplateVariableDto[];
} }
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) { export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
@@ -152,3 +152,32 @@ export class ClientStatusChangeDto {
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean; @IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean; @IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
} }
export class ClientSecretResetDto {
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
}
export class ClientApplicationStatusDto {
@IsOptional() @IsIn(['active', 'disabled', 'disabling', 'deleted']) status?: string;
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
@IsOptional() @IsBoolean() force?: boolean;
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
}
export class ClientDeleteResourceDto {
@IsIn(['deleted']) status!: 'deleted';
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
@IsOptional() @IsBoolean() force?: boolean;
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
}
+18 -4
View File
@@ -1,8 +1,22 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientFileUploadDto } from './client-files.dto';
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer }; type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void }; type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@@ -14,14 +28,14 @@ export class ClientFilesController {
@Post('upload') @Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
@UsePipes(strictValidationPipe)
upload( upload(
@CurrentSessionUserId() userId: string | undefined, @CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile, @UploadedFile() file: UploadedMultipartFile,
@Body('purpose') purpose: string, @Body() body: ClientFileUploadDto,
@Body('prefix') prefix?: string,
) { ) {
if (!file) throw new BadRequestException('Upload file is required'); if (!file) throw new BadRequestException('Upload file is required');
return this.files.uploadForClient(userId, { purpose, prefix }, file); return this.files.uploadForClient(userId, body, file);
} }
@Get(':id/download') @Get(':id/download')
+28
View File
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientFileUploadDto } from './client-files.dto';
describe('ClientFileUploadDto', () => {
it('accepts bounded client material paths and rejects traversal before storage', async () => {
await expect(
strictValidationPipe.transform(
{ purpose: 'drainage_report_material', prefix: 'drainage-materials/item-1' },
{
type: 'body',
metatype: ClientFileUploadDto,
data: undefined,
},
),
).resolves.toEqual(expect.objectContaining({ purpose: 'drainage_report_material' }));
await expect(
strictValidationPipe.transform(
{ purpose: 'enterprise_certification', prefix: '../admin' },
{
type: 'body',
metatype: ClientFileUploadDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class ClientFileUploadDto {
@IsString()
@IsIn(['enterprise_certification', 'signature_report_material', 'drainage_report_material'])
purpose!: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/)
prefix?: string;
}
+47 -7
View File
@@ -54,7 +54,9 @@ function escapeLabel(value: string) {
function metricLine(name: string, value: number, labels?: Record<string, string>) { function metricLine(name: string, value: number, labels?: Record<string, string>) {
const suffix = labels const suffix = labels
? `{${Object.entries(labels).map(([key, item]) => `${key}="${escapeLabel(item)}"`).join(',')}}` ? `{${Object.entries(labels)
.map(([key, item]) => `${key}="${escapeLabel(item)}"`)
.join(',')}}`
: ''; : '';
return `${name}${suffix} ${Number.isFinite(value) ? value : 0}`; return `${name}${suffix} ${Number.isFinite(value) ? value : 0}`;
} }
@@ -78,6 +80,7 @@ export class MetricsService implements OnModuleDestroy {
private inboundWorkflowConfiguredSlots = 0; private inboundWorkflowConfiguredSlots = 0;
private inboundWorkflowInFlightSlots = 0; private inboundWorkflowInFlightSlots = 0;
private readonly inboundWorkflowResults = new Map<string, number>(); private readonly inboundWorkflowResults = new Map<string, number>();
private readonly authProtectionResults = new Map<string, number>();
constructor() { constructor() {
this.eventLoopDelay.enable(); this.eventLoopDelay.enable();
@@ -176,6 +179,14 @@ export class MetricsService implements OnModuleDestroy {
this.inboundWorkflowResults.set(result, (this.inboundWorkflowResults.get(result) ?? 0) + 1); this.inboundWorkflowResults.set(result, (this.inboundWorkflowResults.get(result) ?? 0) + 1);
} }
recordAuthProtectionResult(
event: 'captcha_allowed' | 'captcha_rejected' | 'login_locked',
scope: 'none' | 'account' | 'ip' | 'pair' = 'none',
) {
const key = `${event}\u0000${scope}`;
this.authProtectionResults.set(key, (this.authProtectionResults.get(key) ?? 0) + 1);
}
render() { render() {
const memory = process.memoryUsage(); const memory = process.memoryUsage();
const uptime = Number(process.hrtime.bigint() - this.startedAt) / 1_000_000_000; const uptime = Number(process.hrtime.bigint() - this.startedAt) / 1_000_000_000;
@@ -194,7 +205,10 @@ export class MetricsService implements OnModuleDestroy {
metricLine('cmpp_api_nodejs_heap_total_bytes', memory.heapTotal), metricLine('cmpp_api_nodejs_heap_total_bytes', memory.heapTotal),
'# HELP cmpp_api_nodejs_event_loop_lag_p99_seconds Event loop delay p99 since the previous scrape.', '# HELP cmpp_api_nodejs_event_loop_lag_p99_seconds Event loop delay p99 since the previous scrape.',
'# TYPE cmpp_api_nodejs_event_loop_lag_p99_seconds gauge', '# TYPE cmpp_api_nodejs_event_loop_lag_p99_seconds gauge',
metricLine('cmpp_api_nodejs_event_loop_lag_p99_seconds', this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0), metricLine(
'cmpp_api_nodejs_event_loop_lag_p99_seconds',
this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0,
),
'# HELP cmpp_api_http_requests_in_flight Current API requests in flight.', '# HELP cmpp_api_http_requests_in_flight Current API requests in flight.',
'# TYPE cmpp_api_http_requests_in_flight gauge', '# TYPE cmpp_api_http_requests_in_flight gauge',
metricLine('cmpp_api_http_requests_in_flight', this.inFlight), metricLine('cmpp_api_http_requests_in_flight', this.inFlight),
@@ -226,16 +240,26 @@ export class MetricsService implements OnModuleDestroy {
metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowInFlightSlots, { state: 'in_flight' }), metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowInFlightSlots, { state: 'in_flight' }),
'# HELP cmpp_worker_inbound_workflow_oldest_pending_age_seconds Age of the oldest pending durable workflow.', '# HELP cmpp_worker_inbound_workflow_oldest_pending_age_seconds Age of the oldest pending durable workflow.',
'# TYPE cmpp_worker_inbound_workflow_oldest_pending_age_seconds gauge', '# TYPE cmpp_worker_inbound_workflow_oldest_pending_age_seconds gauge',
metricLine('cmpp_worker_inbound_workflow_oldest_pending_age_seconds', this.inboundWorkflowOldestPendingAgeSeconds), metricLine(
'cmpp_worker_inbound_workflow_oldest_pending_age_seconds',
this.inboundWorkflowOldestPendingAgeSeconds,
),
'# HELP cmpp_worker_inbound_workflow_results_total Durable workflow processing outcomes.', '# HELP cmpp_worker_inbound_workflow_results_total Durable workflow processing outcomes.',
'# TYPE cmpp_worker_inbound_workflow_results_total counter', '# TYPE cmpp_worker_inbound_workflow_results_total counter',
'# HELP cmpp_api_auth_protection_events_total Authentication protection outcomes by bounded event and scope.',
'# TYPE cmpp_api_auth_protection_events_total counter',
]; ];
for (const [key, metric] of this.http) { for (const [key, metric] of this.http) {
const [method, route, status] = key.split('\u0000'); const [method, route, status] = key.split('\u0000');
const labels = { method, route, status }; const labels = { method, route, status };
lines.push(metricLine('cmpp_api_http_requests_total', metric.count, labels)); lines.push(metricLine('cmpp_api_http_requests_total', metric.count, labels));
HTTP_DURATION_BUCKETS.forEach((bucket, index) => { HTTP_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) })); lines.push(
metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], {
...labels,
le: String(bucket),
}),
);
}); });
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' })); lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_api_http_request_duration_seconds_sum', metric.durationSum, labels)); lines.push(metricLine('cmpp_api_http_request_duration_seconds_sum', metric.durationSum, labels));
@@ -245,9 +269,16 @@ export class MetricsService implements OnModuleDestroy {
const [stage, result] = key.split('\u0000'); const [stage, result] = key.split('\u0000');
const labels = { stage, result }; const labels = { stage, result };
CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => { CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) })); lines.push(
metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], {
...labels,
le: String(bucket),
}),
);
}); });
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' })); lines.push(
metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }),
);
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_sum', metric.durationSum, labels)); lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_sum', metric.durationSum, labels));
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_count', metric.count, labels)); lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_count', metric.count, labels));
} }
@@ -255,7 +286,12 @@ export class MetricsService implements OnModuleDestroy {
const [stage, result] = key.split('\u0000'); const [stage, result] = key.split('\u0000');
const labels = { stage, result }; const labels = { stage, result };
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => { SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) })); lines.push(
metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], {
...labels,
le: String(bucket),
}),
);
}); });
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' })); lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_sum', metric.durationSum, labels)); lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_sum', metric.durationSum, labels));
@@ -273,6 +309,10 @@ export class MetricsService implements OnModuleDestroy {
for (const [result, count] of this.inboundWorkflowResults) { for (const [result, count] of this.inboundWorkflowResults) {
lines.push(metricLine('cmpp_worker_inbound_workflow_results_total', count, { result })); lines.push(metricLine('cmpp_worker_inbound_workflow_results_total', count, { result }));
} }
for (const [key, count] of this.authProtectionResults) {
const [event, scope] = key.split('\u0000');
lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope }));
}
this.eventLoopDelay.reset(); this.eventLoopDelay.reset();
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
} }
+54 -10
View File
@@ -1,22 +1,66 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseEnumPipe, Post, Put, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator'; import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { OpenApiService } from './open-api.service'; import { OpenApiService } from './open-api.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientHttpCredentialDto, ClientWebhookDto, ClientWebhookEventType } from './client-open-api.dto';
@ApiTags('client-http-open-api-management') @ApiTags('client-http-open-api-management')
@Controller('client/applications/:applicationId/http-api') @Controller('client/applications/:applicationId/http-api')
export class ClientOpenApiController { export class ClientOpenApiController {
constructor(private readonly service: OpenApiService) {} constructor(private readonly service: OpenApiService) {}
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); } @Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listCredentials(applicationId, tenantId); } return this.service.getConfig(applicationId, tenantId);
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById?: string) { return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true); } }
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @CurrentTenantId() tenantId: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); } @Get('credentials') listCredentials(
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); } @Param('applicationId') applicationId: string,
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @CurrentTenantId() tenantId: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); } @CurrentTenantId() tenantId: string,
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listRequestLogs(applicationId, tenantId); } ) {
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); } return this.service.listCredentials(applicationId, tenantId);
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @CurrentTenantId() tenantId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); } }
@Post('credentials') @RequireRecentAuthentication() @UsePipes(strictValidationPipe) createCredential(
@Param('applicationId') applicationId: string,
@Body() body: ClientHttpCredentialDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() createdById?: string,
) {
return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true);
}
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(
@Param('applicationId') applicationId: string,
@Param('credentialId') credentialId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.revokeCredential(applicationId, credentialId, tenantId);
}
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.service.getWebhookEndpoints(applicationId, tenantId);
}
@Put('webhooks/:eventType') @RequireRecentAuthentication() @UsePipes(strictValidationPipe) upsertWebhook(
@Param('applicationId') applicationId: string,
@Param('eventType', new ParseEnumPipe(ClientWebhookEventType)) eventType: ClientWebhookEventType,
@Body() body: ClientWebhookDto,
@CurrentTenantId() tenantId: string,
) {
return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId);
}
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.service.listRequestLogs(applicationId, tenantId);
}
@Get('webhook-deliveries') listDeliveries(
@Param('applicationId') applicationId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.listWebhookDeliveries(applicationId, tenantId);
}
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(
@Param('applicationId') applicationId: string,
@Param('deliveryId') deliveryId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId);
}
} }
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientHttpCredentialDto, ClientWebhookDto } from './client-open-api.dto';
function validate<T>(metatype: new () => T, value: unknown) {
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
}
describe('client HTTP API DTOs', () => {
it('rejects client-supplied operator identity and malformed expiry dates', async () => {
await expect(validate(ClientHttpCredentialDto, { name: '凭据', createdById: 'spoofed' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(validate(ClientHttpCredentialDto, { expiresAt: 'tomorrow' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('accepts a blank webhook URL for deletion and rejects unsafe fields', async () => {
await expect(validate(ClientWebhookDto, { url: ' ' })).resolves.toEqual(expect.objectContaining({ url: '' }));
await expect(validate(ClientWebhookDto, { url: 'javascript:alert(1)' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
validate(ClientWebhookDto, { url: 'https://example.com/hook', status: 'approved' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
export enum ClientWebhookEventType {
Receipt = 'receipt',
Uplink = 'uplink',
}
export class ClientHttpCredentialDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsDateString({ strict: true })
expiresAt?: string;
}
export class ClientWebhookDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(2048)
@ValidateIf(({ url }) => url !== '')
@IsUrl({ require_protocol: true, require_tld: false, protocols: ['http', 'https'] })
url!: string;
@IsOptional()
@IsBoolean()
rotateSecret?: boolean;
@IsOptional()
@IsIn(['active', 'inactive'])
status?: 'active' | 'inactive';
}
+163 -31
View File
@@ -3,7 +3,9 @@ import { decryptSecret, encryptSecret } from './open-api.crypto';
import { OpenApiService } from './open-api.service'; import { OpenApiService } from './open-api.service';
describe('OpenApiService', () => { describe('OpenApiService', () => {
beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; }); beforeAll(() => {
process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters';
});
it('encrypts secrets with authenticated encryption', () => { it('encrypts secrets with authenticated encryption', () => {
const encrypted = encryptSecret('customer-secret'); const encrypted = encryptSecret('customer-secret');
@@ -15,11 +17,15 @@ describe('OpenApiService', () => {
const previous = process.env.HTTP_API_PUBLIC_ORIGIN; const previous = process.env.HTTP_API_PUBLIC_ORIGIN;
process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/'; process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/';
const prisma = { const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) }, smsApplication: {
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
},
}; };
try { try {
const service = new OpenApiService(prisma as never, {} as never); const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' })); await expect(service.getConfig('app-1')).resolves.toEqual(
expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }),
);
} finally { } finally {
if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN; if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previous; else process.env.HTTP_API_PUBLIC_ORIGIN = previous;
@@ -32,13 +38,17 @@ describe('OpenApiService', () => {
process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/'; process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/';
delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN; delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
const prisma = { const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) }, smsApplication: {
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
},
}; };
try { try {
const service = new OpenApiService(prisma as never, {} as never); const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN'); await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN');
process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true'; process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true';
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' })); await expect(service.getConfig('app-1')).resolves.toEqual(
expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }),
);
} finally { } finally {
if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN; if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin; else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin;
@@ -49,25 +59,62 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => { it('replays a completed request for the same idempotency key and body', async () => {
const prisma = { const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) }, openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
bodyHash: 'same',
status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
}),
},
}; };
const sendChain = { createHttpBatchTask: jest.fn() }; const sendChain = { createHttpBatchTask: jest.fn() };
const service = new OpenApiService(prisma as never, sendChain as never); const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' }); const result = await service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信' },
{ idempotencyKey: 'idem-0001', bodyHash: 'same' },
);
expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' }); expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' });
expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled(); expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled();
}); });
it('rejects reuse of an idempotency key with a different body', async () => { it('rejects reuse of an idempotency key with a different body', async () => {
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } }; const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) },
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never); const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException); await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信' },
{ idempotencyKey: 'idem-0001', bodyHash: 'new' },
),
).rejects.toBeInstanceOf(ConflictException);
}); });
it('replays the same persisted business rejection', async () => { it('replays the same persisted business rejection', async () => {
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } }; const prisma = {
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
bodyHash: 'same',
status: 'failed',
httpStatus: 422,
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
}),
},
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never); const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 }); await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信' },
{ idempotencyKey: 'idem-0001', bodyHash: 'same' },
),
).rejects.toMatchObject({ status: 422 });
}); });
it('uses the real send chain and persists the accepted response', async () => { it('uses the real send chain and persists the accepted response', async () => {
@@ -79,47 +126,107 @@ describe('OpenApiService', () => {
}, },
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
}; };
const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) }; const sendChain = {
createHttpBatchTask: jest
.fn()
.mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
};
const service = new OpenApiService(prisma as never, sendChain as never); const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' }); const result = await service.sendMessage(
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' })); auth() as never,
expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' })); { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) })); { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
);
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
);
expect(result).toEqual(
expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
);
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
}),
);
}); });
it('persists a 422 result when the real send chain rejects the business request', async () => { it('persists a 422 result when the real send chain rejects the business request', async () => {
const prisma = { const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) }, openApiRequest: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
update: jest.fn().mockResolvedValue({}),
},
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
}; };
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never); const service = new OpenApiService(
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 }); prisma as never,
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) })); { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never,
);
await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '未匹配模板' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
),
).rejects.toMatchObject({ status: 422 });
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }),
}),
);
}); });
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => { it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = { const prisma = {
smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) }, smsApplication: {
findUnique: jest
.fn()
.mockResolvedValue({
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
}),
},
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) }, httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) }, httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) }, httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
}; };
const service = new OpenApiService(prisma as never, {} as never); const service = new OpenApiService(prisma as never, {} as never);
const input = { tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt' as const, messageRecordId: 'record-1', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } }; const input = {
tenantId: 'tenant-1',
applicationId: 'app-1',
eventType: 'receipt' as const,
messageRecordId: 'record-1',
messageId: 'MSG-1',
payload: { receiptStatus: 'delivered' },
};
await service.queueWebhookEvent(input); await service.queueWebhookEvent(input);
await service.queueWebhookEvent(input); await service.queueWebhookEvent(input);
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { eventId: 'evt_receipt_record-1' }, where: { eventId: 'evt_receipt_record-1' },
})); }),
);
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2); expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' }, create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
})); }),
);
}); });
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => { it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = { const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: null, httpIpAllowlist: [] }) }, smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: null,
httpIpAllowlist: [],
}),
},
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) }, smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() }, smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
$transaction: jest.fn((operations) => Promise.all(operations)), $transaction: jest.fn((operations) => Promise.all(operations)),
@@ -128,7 +235,8 @@ describe('OpenApiService', () => {
await service.updateConfig('app-1', { enabled: true }); await service.updateConfig('app-1', { enabled: true });
expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ create: expect.objectContaining({
enabled: true, enabled: true,
sendEnabled: true, sendEnabled: true,
@@ -140,7 +248,8 @@ describe('OpenApiService', () => {
receiptDeliveryMode: 'both', receiptDeliveryMode: 'both',
uplinkDeliveryMode: 'both', uplinkDeliveryMode: 'both',
}), }),
})); }),
);
}); });
it('removes a webhook endpoint when an operator saves a blank address', async () => { it('removes a webhook endpoint when an operator saves a blank address', async () => {
@@ -160,12 +269,35 @@ describe('OpenApiService', () => {
}; };
const service = new OpenApiService(prisma as never, {} as never); const service = new OpenApiService(prisma as never, {} as never);
await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' })) await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' })).resolves.toEqual(
.resolves.toEqual(expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true })); expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }),
);
expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({ expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({
where: { applicationId: 'app-1', eventType: 'receipt' }, where: { applicationId: 'app-1', eventType: 'receipt' },
}); });
}); });
it('rejects an already expired credential before writing a secret', async () => {
const prisma = {
smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
httpIpAllowlist: [],
}),
},
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
};
const service = new OpenApiService(prisma as never, {} as never);
await expect(
service.createCredential('app-1', { expiresAt: '2020-01-01T00:00:00.000Z' }, 'tenant-1', true),
).rejects.toThrow('凭据过期时间必须晚于当前时间');
expect(prisma.httpApiCredential.create).not.toHaveBeenCalled();
});
}); });
function auth() { function auth() {
+502 -94
View File
@@ -1,4 +1,17 @@
import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, Optional, UnprocessableEntityException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
HttpException,
Inject,
Injectable,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
@@ -59,7 +72,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity // Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints. // cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return; if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 }); this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
connection,
concurrency: 10,
});
} }
async onModuleDestroy() { async onModuleDestroy() {
@@ -89,7 +105,13 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
update: data, update: data,
}), }),
this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }), this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }),
...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []), ...(ipAllowlist.length > 0
? [
this.prisma.smsApplicationHttpIpAllowlist.createMany({
data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })),
}),
]
: []),
]); ]);
return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist }; return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist };
} }
@@ -98,37 +120,69 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.requireApplication(applicationId, tenantId); await this.requireApplication(applicationId, tenantId);
return this.prisma.httpApiCredential.findMany({ return this.prisma.httpApiCredential.findMany({
where: { applicationId }, where: { applicationId },
select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, lastUsedAt: true, lastUsedIp: true, createdAt: true, revokedAt: true }, select: {
id: true,
name: true,
accessKey: true,
secretLast4: true,
status: true,
expiresAt: true,
lastUsedAt: true,
lastUsedIp: true,
createdAt: true,
revokedAt: true,
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async createCredential(applicationId: string, data: { name?: string; expiresAt?: string; createdById?: string }, tenantId?: string, selfService = false) { async createCredential(
applicationId: string,
data: { name?: string; expiresAt?: string; createdById?: string },
tenantId?: string,
selfService = false,
) {
const application = await this.requireApplication(applicationId, tenantId); const application = await this.requireApplication(applicationId, tenantId);
const config = application.httpConfig; const config = application.httpConfig;
if (!config?.enabled) throw new BadRequestException('请先开通该应用的HTTP接口'); if (!config?.enabled) throw new BadRequestException('请先开通该应用的HTTP接口');
if (selfService && !config.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理'); if (selfService && !config.credentialSelfServiceEnabled)
throw new ForbiddenException('该应用未开通客户端凭据自助管理');
const activeCount = await this.prisma.httpApiCredential.count({ where: { applicationId, status: 'active' } }); const activeCount = await this.prisma.httpApiCredential.count({ where: { applicationId, status: 'active' } });
if (activeCount >= config.maxCredentialCount) throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount}`); if (activeCount >= config.maxCredentialCount)
throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount}`);
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : undefined;
if (expiresAt && expiresAt.getTime() <= Date.now()) throw new BadRequestException('凭据过期时间必须晚于当前时间');
const secret = randomBytes(32).toString('base64url'); const secret = randomBytes(32).toString('base64url');
const credential = await this.prisma.httpApiCredential.create({ const credential = await this.prisma.httpApiCredential.create({
data: { data: {
applicationId, applicationId,
name: String(data.name ?? '默认凭据').trim().slice(0, 100) || '默认凭据', name:
String(data.name ?? '默认凭据')
.trim()
.slice(0, 100) || '默认凭据',
accessKey: `ak_${randomBytes(18).toString('base64url')}`, accessKey: `ak_${randomBytes(18).toString('base64url')}`,
secretEncrypted: encryptSecret(secret), secretEncrypted: encryptSecret(secret),
secretLast4: secret.slice(-4), secretLast4: secret.slice(-4),
expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined, expiresAt,
createdById: data.createdById, createdById: data.createdById,
}, },
select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, createdAt: true }, select: {
id: true,
name: true,
accessKey: true,
secretLast4: true,
status: true,
expiresAt: true,
createdAt: true,
},
}); });
return { ...credential, secret, secretShownOnce: true }; return { ...credential, secret, secretShownOnce: true };
} }
async revokeCredential(applicationId: string, credentialId: string, tenantId?: string) { async revokeCredential(applicationId: string, credentialId: string, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId); const application = await this.requireApplication(applicationId, tenantId);
if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理'); if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled)
throw new ForbiddenException('该应用未开通客户端凭据自助管理');
const result = await this.prisma.httpApiCredential.updateMany({ const result = await this.prisma.httpApiCredential.updateMany({
where: { id: credentialId, applicationId, status: 'active' }, where: { id: credentialId, applicationId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() }, data: { status: 'revoked', revokedAt: new Date() },
@@ -141,14 +195,29 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.requireApplication(applicationId, tenantId); await this.requireApplication(applicationId, tenantId);
return this.prisma.httpWebhookEndpoint.findMany({ return this.prisma.httpWebhookEndpoint.findMany({
where: { applicationId }, where: { applicationId },
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, lastTestAt: true, lastTestStatus: true, updatedAt: true }, select: {
id: true,
eventType: true,
url: true,
secretLast4: true,
status: true,
lastTestAt: true,
lastTestStatus: true,
updatedAt: true,
},
orderBy: { eventType: 'asc' }, orderBy: { eventType: 'asc' },
}); });
} }
async upsertWebhookEndpoint(applicationId: string, eventType: string, data: { url: string; rotateSecret?: boolean; status?: string }, tenantId?: string) { async upsertWebhookEndpoint(
applicationId: string,
eventType: string,
data: { url: string; rotateSecret?: boolean; status?: string },
tenantId?: string,
) {
const application = await this.requireApplication(applicationId, tenantId); const application = await this.requireApplication(applicationId, tenantId);
if (!['receipt', 'uplink'].includes(eventType)) throw new BadRequestException('eventType only supports receipt or uplink'); if (!['receipt', 'uplink'].includes(eventType))
throw new BadRequestException('eventType only supports receipt or uplink');
if (!String(data.url ?? '').trim()) { if (!String(data.url ?? '').trim()) {
await this.prisma.httpWebhookEndpoint.deleteMany({ where: { applicationId, eventType } }); await this.prisma.httpWebhookEndpoint.deleteMany({ where: { applicationId, eventType } });
return { return {
@@ -162,49 +231,103 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}; };
} }
const url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true); const url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true);
const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } }); const existing = await this.prisma.httpWebhookEndpoint.findUnique({
where: { applicationId_eventType: { applicationId, eventType } },
});
const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined; const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined;
const endpoint = await this.prisma.httpWebhookEndpoint.upsert({ const endpoint = await this.prisma.httpWebhookEndpoint.upsert({
where: { applicationId_eventType: { applicationId, eventType } }, where: { applicationId_eventType: { applicationId, eventType } },
create: { applicationId, eventType, url, status: data.status ?? 'active', secretEncrypted: encryptSecret(secret!), secretLast4: secret!.slice(-4) }, create: {
update: { url, status: data.status ?? existing?.status ?? 'active', ...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}) }, applicationId,
eventType,
url,
status: data.status ?? 'active',
secretEncrypted: encryptSecret(secret!),
secretLast4: secret!.slice(-4),
},
update: {
url,
status: data.status ?? existing?.status ?? 'active',
...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}),
},
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, updatedAt: true }, select: { id: true, eventType: true, url: true, secretLast4: true, status: true, updatedAt: true },
}); });
return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) }; return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) };
} }
async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) { async sendMessage(
if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' }); auth: OpenApiAuthContext,
input: { mobile?: string; content?: string; clientMessageId?: string },
meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string },
) {
if (!auth.config.sendEnabled)
throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim(); const mobile = String(input.mobile ?? '').trim();
const content = String(input.content ?? ''); const content = String(input.content ?? '');
if (!/^1[3-9]\d{9}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' }); if (!/^1[3-9]\d{9}$/.test(mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' }); if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim(); const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'Idempotency-Key 必填且长度为8至128位' }); if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
const existing = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } }); throw new BadRequestException({
code: 'IDEMPOTENCY_KEY_INVALID',
message: 'Idempotency-Key 必填且长度为8至128位',
});
const existing = await this.prisma.openApiRequest.findUnique({
where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } },
});
if (existing) { if (existing) {
if (existing.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' }); if (existing.bodyHash !== meta.bodyHash)
throw new ConflictException({
code: 'IDEMPOTENCY_CONFLICT',
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody; if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus); if (existing.status === 'failed' && existing.responseBody && existing.httpStatus)
throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
} }
if (input.clientMessageId) { if (input.clientMessageId) {
const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({ where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId }, select: { messageId: true } }); const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({
if (duplicateClientMessage) throw new ConflictException({ code: 'CLIENT_MESSAGE_ID_CONFLICT', message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}` }); where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId },
select: { messageId: true },
});
if (duplicateClientMessage)
throw new ConflictException({
code: 'CLIENT_MESSAGE_ID_CONFLICT',
message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}`,
});
} }
const requestId = `req_${randomUUID()}`; const requestId = `req_${randomUUID()}`;
const startedAt = Date.now(); const startedAt = Date.now();
let request; let request;
try { try {
request = await this.prisma.openApiRequest.create({ request = await this.prisma.openApiRequest.create({
data: { tenantId: auth.application.tenantId, applicationId: auth.application.id, credentialId: auth.credentialId, requestId, idempotencyKey, bodyHash: meta.bodyHash, clientMessageId: input.clientMessageId, sourceIp: auth.sourceIp, userAgent: meta.userAgent }, data: {
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
credentialId: auth.credentialId,
requestId,
idempotencyKey,
bodyHash: meta.bodyHash,
clientMessageId: input.clientMessageId,
sourceIp: auth.sourceIp,
userAgent: meta.userAgent,
},
}); });
} catch (error) { } catch (error) {
if ((error as { code?: string }).code === 'P2002') { if ((error as { code?: string }).code === 'P2002') {
const raced = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } }); const raced = await this.prisma.openApiRequest.findUnique({
if (raced?.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' }); where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } },
});
if (raced?.bodyHash !== meta.bodyHash)
throw new ConflictException({
code: 'IDEMPOTENCY_CONFLICT',
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody; if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus); if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus)
throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
} }
throw error; throw error;
@@ -221,10 +344,31 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}); });
const message = task.messages?.[0]; const message = task.messages?.[0];
if (task.status === 'rejected' || message?.status === 'rejected') { if (task.status === 'rejected' || message?.status === 'rejected') {
throw new UnprocessableEntityException({ code: 'SEND_REJECTED', message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验' }); throw new UnprocessableEntityException({
code: 'SEND_REJECTED',
message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
});
} }
const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() }; const response = {
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } }); code: 'ACCEPTED',
requestId,
messageId: message?.messageId,
clientMessageId: input.clientMessageId ?? null,
status: message?.status ?? task.status,
acceptedAt: new Date().toISOString(),
};
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'completed',
httpStatus: 202,
businessCode: 'ACCEPTED',
responseBody: response,
messageRecordId: message?.id,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({ this.protocolLogs?.record({
protocol: 'http', protocol: 'http',
direction: 'client_to_platform', direction: 'client_to_platform',
@@ -245,11 +389,24 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let outwardError = error; let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) { if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse(); const response = error.getResponse();
const message = typeof response === 'object' && response && 'message' in response ? (response as { message: unknown }).message : error.message; const message =
typeof response === 'object' && response && 'message' in response
? (response as { message: unknown }).message
: error.message;
outwardError = new UnprocessableEntityException({ code: 'SEND_REJECTED', message }); outwardError = new UnprocessableEntityException({ code: 'SEND_REJECTED', message });
} }
const failure = normalizeOpenApiFailure(outwardError); const failure = normalizeOpenApiFailure(outwardError);
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } }); await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'failed',
httpStatus: failure.httpStatus,
businessCode: failure.code,
responseBody: failure.responseBody,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({ this.protocolLogs?.record({
protocol: 'http', protocol: 'http',
direction: 'client_to_platform', direction: 'client_to_platform',
@@ -268,21 +425,41 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
} }
async getMessage(auth: OpenApiAuthContext, messageId: string) { async getMessage(auth: OpenApiAuthContext, messageId: string) {
if (!auth.config.messageQueryEnabled) throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' }); if (!auth.config.messageQueryEnabled)
throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' });
const message = await this.prisma.smsMessageRecord.findFirst({ const message = await this.prisma.smsMessageRecord.findFirst({
where: { applicationId: auth.application.id, OR: [{ messageId }, { clientMessageId: messageId }] }, where: { applicationId: auth.application.id, OR: [{ messageId }, { clientMessageId: messageId }] },
select: { messageId: true, clientMessageId: true, phoneNumber: true, status: true, submitStatus: true, receiptStatus: true, errorCode: true, errorMessage: true, queuedAt: true, submittedAt: true, deliveredAt: true, updatedAt: true }, select: {
messageId: true,
clientMessageId: true,
phoneNumber: true,
status: true,
submitStatus: true,
receiptStatus: true,
errorCode: true,
errorMessage: true,
queuedAt: true,
submittedAt: true,
deliveredAt: true,
updatedAt: true,
},
}); });
if (!message) throw new NotFoundException({ code: 'MESSAGE_NOT_FOUND', message: '短信记录不存在' }); if (!message) throw new NotFoundException({ code: 'MESSAGE_NOT_FOUND', message: '短信记录不存在' });
return message; return message;
} }
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) { async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const endTime = query.endTime ? new Date(query.endTime) : new Date(); const endTime = query.endTime ? new Date(query.endTime) : new Date();
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000); const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' }); if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) throw new BadRequestException({ code: 'TIME_RANGE_TOO_LARGE', message: `单次查询不能超过${auth.config.maxQueryRangeDays}` }); throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000)
throw new BadRequestException({
code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}`,
});
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize); const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor); const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({ const rows = await this.prisma.smsUplinkMessage.findMany({
@@ -293,9 +470,22 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: query.mobile, phoneNumber: query.mobile,
destId: query.accessNumber, destId: query.accessNumber,
content: query.keyword ? { contains: query.keyword } : undefined, content: query.keyword ? { contains: query.keyword } : undefined,
...(cursor ? { OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }] } : {}), ...(cursor
? {
OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }],
}
: {}),
},
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
matchStatus: true,
matchReason: true,
receivedAt: true,
}, },
select: { id: true, messageId: true, phoneNumber: true, destId: true, content: true, matchStatus: true, matchReason: true, receivedAt: true },
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }], orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
take: limit + 1, take: limit + 1,
}); });
@@ -306,21 +496,38 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
} }
async getUplink(auth: OpenApiAuthContext, uplinkId: string) { async getUplink(auth: OpenApiAuthContext, uplinkId: string) {
if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); if (!auth.config.uplinkQueryEnabled)
const row = await this.prisma.smsUplinkMessage.findFirst({ where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' } }); throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({
where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' },
});
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' }); if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row; return row;
} }
async queueWebhookEvent(data: { tenantId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; uplinkMessageId?: string | null; eventType: 'receipt' | 'uplink'; payload: Record<string, unknown> }) { async queueWebhookEvent(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
uplinkMessageId?: string | null;
eventType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
if (!data.applicationId) return null; if (!data.applicationId) return null;
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } }); const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId },
include: { httpConfig: true },
});
const config = application?.httpConfig; const config = application?.httpConfig;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled; const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
if (!config?.enabled || !enabled) return null; if (!config?.enabled || !enabled) return null;
const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } } }); const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({
where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } },
});
if (!endpoint || endpoint.status !== 'active') return null; if (!endpoint || endpoint.status !== 'active') return null;
const eventId = data.eventType === 'receipt' && data.messageRecordId const eventId =
data.eventType === 'receipt' && data.messageRecordId
? `evt_receipt_${data.messageRecordId}` ? `evt_receipt_${data.messageRecordId}`
: data.eventType === 'uplink' && data.uplinkMessageId : data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}` ? `evt_uplink_${data.uplinkMessageId}`
@@ -328,7 +535,16 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
const event = await this.prisma.httpWebhookEvent.upsert({ const event = await this.prisma.httpWebhookEvent.upsert({
where: { eventId }, where: { eventId },
update: {}, update: {},
create: { eventId, tenantId: data.tenantId, applicationId: data.applicationId, eventType: data.eventType, messageRecordId: data.messageRecordId, messageId: data.messageId, uplinkMessageId: data.uplinkMessageId, payload: data.payload as Prisma.InputJsonValue }, create: {
eventId,
tenantId: data.tenantId,
applicationId: data.applicationId,
eventType: data.eventType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: data.uplinkMessageId,
payload: data.payload as Prisma.InputJsonValue,
},
}); });
const delivery = await this.prisma.httpWebhookDelivery.upsert({ const delivery = await this.prisma.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } }, where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
@@ -336,57 +552,135 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
create: { eventId: event.id, endpointId: endpoint.id }, create: { eventId: event.id, endpointId: endpoint.id },
}); });
if (delivery.status === 'delivered') return delivery; if (delivery.status === 'delivered') return delivery;
await this.queue?.add('deliver', { deliveryId: delivery.id }, { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 }); await this.queue?.add(
'deliver',
{ deliveryId: delivery.id },
{ jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 },
);
return delivery; return delivery;
} }
async listRequestLogs(applicationId: string, tenantId?: string) { async listRequestLogs(applicationId: string, tenantId?: string) {
await this.requireApplication(applicationId, tenantId); await this.requireApplication(applicationId, tenantId);
return this.prisma.openApiRequest.findMany({ where: { applicationId }, select: { id: true, requestId: true, clientMessageId: true, sourceIp: true, httpStatus: true, businessCode: true, status: true, durationMs: true, createdAt: true, completedAt: true }, orderBy: { createdAt: 'desc' }, take: 100 }); return this.prisma.openApiRequest.findMany({
where: { applicationId },
select: {
id: true,
requestId: true,
clientMessageId: true,
sourceIp: true,
httpStatus: true,
businessCode: true,
status: true,
durationMs: true,
createdAt: true,
completedAt: true,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
} }
async listWebhookDeliveries(applicationId: string, tenantId?: string) { async listWebhookDeliveries(applicationId: string, tenantId?: string) {
await this.requireApplication(applicationId, tenantId); await this.requireApplication(applicationId, tenantId);
return this.prisma.httpWebhookDelivery.findMany({ where: { event: { applicationId } }, include: { event: true, endpoint: { select: { eventType: true, url: true } }, attempts: { orderBy: { attemptNo: 'desc' }, take: 5 } }, orderBy: { createdAt: 'desc' }, take: 100 }); return this.prisma.httpWebhookDelivery.findMany({
where: { event: { applicationId } },
include: {
event: true,
endpoint: { select: { eventType: true, url: true } },
attempts: { orderBy: { attemptNo: 'desc' }, take: 5 },
},
orderBy: { createdAt: 'desc' },
take: 100,
});
} }
async retryWebhookDelivery(applicationId: string, deliveryId: string, tenantId?: string) { async retryWebhookDelivery(applicationId: string, deliveryId: string, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId); const application = await this.requireApplication(applicationId, tenantId);
if (tenantId && !application.httpConfig?.allowClientManualRetry) throw new ForbiddenException('该应用未开通客户端手动重投'); if (tenantId && !application.httpConfig?.allowClientManualRetry)
const delivery = await this.prisma.httpWebhookDelivery.findFirst({ where: { id: deliveryId, event: { applicationId } } }); throw new ForbiddenException('该应用未开通客户端手动重投');
const delivery = await this.prisma.httpWebhookDelivery.findFirst({
where: { id: deliveryId, event: { applicationId } },
});
if (!delivery) throw new NotFoundException('Webhook投递记录不存在'); if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
await this.prisma.httpWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'pending', nextRetryAt: null, lastError: null } }); await this.prisma.httpWebhookDelivery.update({
await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 }); where: { id: delivery.id },
data: { status: 'pending', nextRetryAt: null, lastError: null },
});
await this.queue?.add(
'deliver',
{ deliveryId },
{ jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 },
);
return { id: deliveryId, status: 'pending' }; return { id: deliveryId, status: 'pending' };
} }
private async deliverWebhook(deliveryId: string) { private async deliverWebhook(deliveryId: string) {
const delivery = await this.prisma.httpWebhookDelivery.findUnique({ where: { id: deliveryId }, include: { event: true, endpoint: true } }); const delivery = await this.prisma.httpWebhookDelivery.findUnique({
where: { id: deliveryId },
include: { event: true, endpoint: true },
});
if (!delivery || delivery.status === 'delivered') return; if (!delivery || delivery.status === 'delivered') return;
const config = await this.prisma.smsApplicationHttpConfig.findUnique({ where: { applicationId: delivery.event.applicationId } }); const config = await this.prisma.smsApplicationHttpConfig.findUnique({
where: { applicationId: delivery.event.applicationId },
});
if (!config) return; if (!config) return;
const attemptNo = delivery.attemptCount + 1; const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000)); const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({ eventId: delivery.event.eventId, eventType: delivery.event.eventType, occurredAt: delivery.event.createdAt.toISOString(), data: delivery.event.payload }); const body = JSON.stringify({
const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted)).update(`${timestamp}\n${body}`).digest('hex'); eventId: delivery.event.eventId,
eventType: delivery.event.eventType,
occurredAt: delivery.event.createdAt.toISOString(),
data: delivery.event.payload,
});
const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted))
.update(`${timestamp}\n${body}`)
.digest('hex');
const startedAt = Date.now(); const startedAt = Date.now();
let responseStatus: number | undefined; let responseStatus: number | undefined;
let responseSummary: string | undefined; let responseSummary: string | undefined;
let errorMessage: string | undefined; let errorMessage: string | undefined;
try { try {
const response = await postWebhook(delivery.endpoint.url, body, { const response = await postWebhook(
delivery.endpoint.url,
body,
{
'content-type': 'application/json', 'content-type': 'application/json',
'x-event-id': delivery.event.eventId, 'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType, 'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp, 'x-timestamp': timestamp,
'x-signature': `sha256=${signature}`, 'x-signature': `sha256=${signature}`,
}, config.webhookTimeoutSeconds * 1000, config.requireHttps); },
config.webhookTimeoutSeconds * 1000,
config.requireHttps,
);
responseStatus = response.status; responseStatus = response.status;
responseSummary = response.body; responseSummary = response.body;
} catch (error) { errorMessage = error instanceof Error ? error.message : 'Webhook request failed'; } } catch (error) {
errorMessage = error instanceof Error ? error.message : 'Webhook request failed';
}
const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300; const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300;
const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500); const retryable =
await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } }); errorMessage !== undefined ||
responseStatus === 408 ||
responseStatus === 429 ||
(responseStatus !== undefined && responseStatus >= 500);
await this.prisma.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
this.protocolLogs?.record({ this.protocolLogs?.record({
protocol: 'http', protocol: 'http',
direction: 'platform_to_client', direction: 'platform_to_client',
@@ -403,22 +697,62 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
detail: { deliveryId, attemptNo, error: errorMessage }, detail: { deliveryId, attemptNo, error: errorMessage },
}); });
if (success) { if (success) {
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } }); await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'delivered',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: null,
deliveredAt: new Date(),
nextRetryAt: null,
},
});
return; return;
} }
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length); const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) { if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) {
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!; const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000); const nextRetryAt = new Date(Date.now() + delaySeconds * 1000);
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'retrying', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt } }); await this.prisma.httpWebhookDelivery.update({
await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:${attemptNo + 1}`, delay: delaySeconds * 1000, removeOnComplete: 1000, removeOnFail: 1000 }); where: { id: deliveryId },
data: {
status: 'retrying',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt,
},
});
await this.queue?.add(
'deliver',
{ deliveryId },
{
jobId: `${deliveryId}:${attemptNo + 1}`,
delay: delaySeconds * 1000,
removeOnComplete: 1000,
removeOnFail: 1000,
},
);
return; return;
} }
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'failed', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt: null } }); await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt: null,
},
});
} }
private async requireApplication(applicationId: string, tenantId?: string) { private async requireApplication(applicationId: string, tenantId?: string) {
const application = await this.prisma.smsApplication.findFirst({ where: { id: applicationId, tenantId }, include: { httpConfig: true, httpIpAllowlist: true } }); const application = await this.prisma.smsApplication.findFirst({
where: { id: applicationId, tenantId },
include: { httpConfig: true, httpIpAllowlist: true },
});
if (!application) throw new NotFoundException('企业应用不存在'); if (!application) throw new NotFoundException('企业应用不存在');
return application; return application;
} }
@@ -428,12 +762,22 @@ function httpApiPublicOrigin() {
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, ''); const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
if (!configured) return undefined; if (!configured) return undefined;
const url = new URL(configured); const url = new URL(configured);
const insecureHttpExplicitlyAllowed = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:'; const insecureHttpExplicitlyAllowed =
if ((url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:';
if (
(url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
) {
// This value is copied into customer integration parameters, so fail closed instead of // This value is copied into customer integration parameters, so fail closed instead of
// publishing an insecure or path-dependent endpoint unless an isolated test environment // publishing an insecure or path-dependent endpoint unless an isolated test environment
// has explicitly opted into plain HTTP. // has explicitly opted into plain HTTP.
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN'); throw new Error(
'HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN',
);
} }
return url.origin; return url.origin;
} }
@@ -441,15 +785,22 @@ function httpApiPublicOrigin() {
function normalizeOpenApiFailure(error: unknown) { function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) { if (error instanceof HttpException) {
const value = error.getResponse(); const value = error.getResponse();
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {}; const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const rawMessage = object.message ?? error.message; const rawMessage = object.message ?? error.message;
return { return {
httpStatus: error.getStatus(), httpStatus: error.getStatus(),
code: String(object.code ?? 'SEND_REJECTED'), code: String(object.code ?? 'SEND_REJECTED'),
responseBody: { code: String(object.code ?? 'SEND_REJECTED'), message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage) } as Prisma.InputJsonValue, responseBody: {
code: String(object.code ?? 'SEND_REJECTED'),
message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage),
} as Prisma.InputJsonValue,
}; };
} }
return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue }; return {
httpStatus: 500,
code: 'INTERNAL_ERROR',
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
};
} }
function normalizeConfig( function normalizeConfig(
@@ -458,7 +809,8 @@ function normalizeConfig(
cmppEnabled: boolean, cmppEnabled: boolean,
) { ) {
const enabling = input.enabled === true && existing?.enabled !== true; const enabling = input.enabled === true && existing?.enabled !== true;
const effective = enabling ? { const effective = enabling
? {
sendEnabled: true, sendEnabled: true,
messageQueryEnabled: true, messageQueryEnabled: true,
receiptWebhookEnabled: true, receiptWebhookEnabled: true,
@@ -466,7 +818,8 @@ function normalizeConfig(
uplinkQueryEnabled: true, uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true, credentialSelfServiceEnabled: true,
...input, ...input,
} : input; }
: input;
const httpEnabled = effective.enabled ?? existing?.enabled ?? false; const httpEnabled = effective.enabled ?? existing?.enabled ?? false;
const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled); const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled);
return { return {
@@ -496,22 +849,31 @@ function normalizeConfig(
function bounded(value: number | undefined, min: number, max: number, label: string) { function bounded(value: number | undefined, min: number, max: number, label: string) {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (!Number.isInteger(value) || value < min || value > max) throw new BadRequestException(`${label}必须在${min}${max}之间`); if (!Number.isInteger(value) || value < min || value > max)
throw new BadRequestException(`${label}必须在${min}${max}之间`);
return value; return value;
} }
function normalizeIpAllowlist(values?: string[]) { function normalizeIpAllowlist(values?: string[]) {
return [...new Set((values ?? []).map((item) => item.trim()).filter(Boolean).map((item) => { return [
...new Set(
(values ?? [])
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const [ip, prefix] = item.split('/'); const [ip, prefix] = item.split('/');
const version = isIP(ip); const version = isIP(ip);
if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`); if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
if (prefix !== undefined) { if (prefix !== undefined) {
const bits = Number(prefix); const bits = Number(prefix);
const max = version === 4 ? 32 : 128; const max = version === 4 ? 32 : 128;
if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException(`CIDR格式非法:${item}`); if (!Number.isInteger(bits) || bits < 0 || bits > max)
throw new BadRequestException(`CIDR格式非法:${item}`);
} }
return item; return item;
}))]; }),
),
];
} }
async function validateWebhookUrl(value: string, requireHttps: boolean) { async function validateWebhookUrl(value: string, requireHttps: boolean) {
@@ -520,26 +882,40 @@ async function validateWebhookUrl(value: string, requireHttps: boolean) {
async function resolveWebhookTarget(value: string, requireHttps: boolean) { async function resolveWebhookTarget(value: string, requireHttps: boolean) {
let url: URL; let url: URL;
try { url = new URL(String(value ?? '').trim()); } catch { throw new BadRequestException('Webhook URL格式非法'); } try {
url = new URL(String(value ?? '').trim());
} catch {
throw new BadRequestException('Webhook URL格式非法');
}
if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS'); if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS');
if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS'); if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码'); if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码');
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }); const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
if (addresses.some(({ address }) => isPrivateAddress(address))) throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址'); if (addresses.some(({ address }) => isPrivateAddress(address)))
throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
const selected = addresses[0]; const selected = addresses[0];
if (!selected) throw new BadRequestException('Webhook域名未解析到可用地址'); if (!selected) throw new BadRequestException('Webhook域名未解析到可用地址');
return { url, address: selected.address, family: isIP(selected.address) }; return { url, address: selected.address, family: isIP(selected.address) };
} }
async function postWebhook(urlText: string, body: string, headers: Record<string, string>, timeoutMs: number, requireHttps: boolean) { async function postWebhook(
urlText: string,
body: string,
headers: Record<string, string>,
timeoutMs: number,
requireHttps: boolean,
) {
const target = await resolveWebhookTarget(urlText, requireHttps); const target = await resolveWebhookTarget(urlText, requireHttps);
return new Promise<{ status: number; body: string }>((resolve, reject) => { return new Promise<{ status: number; body: string }>((resolve, reject) => {
const requestFn = target.url.protocol === 'https:' ? httpsRequest : httpRequest; const requestFn = target.url.protocol === 'https:' ? httpsRequest : httpRequest;
const request = requestFn(target.url, { const request = requestFn(
target.url,
{
method: 'POST', method: 'POST',
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) }, headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family), lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
}, (response) => { },
(response) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
let size = 0; let size = 0;
response.on('data', (chunk: Buffer) => { response.on('data', (chunk: Buffer) => {
@@ -549,8 +925,11 @@ async function postWebhook(urlText: string, body: string, headers: Record<string
size += buffer.length; size += buffer.length;
} }
}); });
response.on('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); response.on('end', () =>
}); resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
);
},
);
request.setTimeout(timeoutMs, () => request.destroy(new Error('Webhook request timed out'))); request.setTimeout(timeoutMs, () => request.destroy(new Error('Webhook request timed out')));
request.on('error', reject); request.on('error', reject);
request.end(body); request.end(body);
@@ -559,13 +938,33 @@ async function postWebhook(urlText: string, body: string, headers: Record<string
function isPrivateAddress(address: string) { function isPrivateAddress(address: string) {
const normalized = address.replace(/^::ffff:/, ''); const normalized = address.replace(/^::ffff:/, '');
if (normalized === '::1' || normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) return true; if (
normalized === '::1' ||
normalized === '::' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe8') ||
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
)
return true;
if (isIP(normalized) !== 4) return false; if (isIP(normalized) !== 4) return false;
const [a, b] = normalized.split('.').map(Number); const [a, b] = normalized.split('.').map(Number);
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127); return (
a === 10 ||
a === 127 ||
a === 0 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127)
);
} }
function encodeCursor(receivedAt: Date, id: string) { return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url'); } function encodeCursor(receivedAt: Date, id: string) {
return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url');
}
function decodeCursor(value?: string) { function decodeCursor(value?: string) {
if (!value) return null; if (!value) return null;
try { try {
@@ -573,10 +972,19 @@ function decodeCursor(value?: string) {
const receivedAt = new Date(date); const receivedAt = new Date(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error(); if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id }; return { receivedAt, id };
} catch { throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' }); } } catch {
throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' });
}
} }
function bullmqConnection() { function bullmqConnection() {
const url = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); const url = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return { host: url.hostname, port: Number(url.port || 6379), username: url.username || undefined, password: url.password || undefined, db: Number(url.pathname.slice(1) || 0), maxRetriesPerRequest: null as null }; return {
host: url.hostname,
port: Number(url.port || 6379),
username: url.username || undefined,
password: url.password || undefined,
db: Number(url.pathname.slice(1) || 0),
maxRetriesPerRequest: null as null,
};
} }
@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator'; import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { OperationsService } from './operations.service'; import { OperationsService } from './operations.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientSystemLogExportDto } from './client-operations.dto';
@ApiTags('client-operations') @ApiTags('client-operations')
@Controller('client/operations') @Controller('client/operations')
@@ -15,7 +17,11 @@ export class ClientOperationsController {
} }
@Get('batch-tasks/:id/messages') @Get('batch-tasks/:id/messages')
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) { listTaskMessages(
@CurrentTenantId() tenantId: string,
@Param('id') taskId: string,
@Query('phoneNumber') phoneNumber?: string,
) {
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber }); return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
} }
@@ -49,9 +55,31 @@ export class ClientOperationsController {
} }
@Get('uplink-messages') @Get('uplink-messages')
listUplinkMessages(@CurrentTenantId() tenantId: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { listUplinkMessages(
@CurrentTenantId() tenantId: string,
@Query('channelId') channelId?: string,
@Query('applicationId') applicationId?: string,
@Query('phoneNumber') phoneNumber?: string,
@Query('keyword') keyword?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize return page || pageSize
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true) ? this.operations.listUplinkMessagesPage(
{
tenantId,
applicationId,
phoneNumber,
keyword,
startTime,
endTime,
page: Number(page),
pageSize: Number(pageSize),
},
true,
)
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime }); : this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
} }
@@ -72,14 +100,25 @@ export class ClientOperationsController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
) { ) {
return this.operations.systemLogs({ tenantId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }); return this.operations.systemLogs({
tenantId,
keyword,
level,
module,
range,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
} }
@Post('system-logs/exports') @Post('system-logs/exports')
@UsePipes(strictValidationPipe)
exportSystemLogs( exportSystemLogs(
@CurrentSessionUserId() userId: string | undefined, @CurrentSessionUserId() userId: string | undefined,
@CurrentTenantId() tenantId: string, @CurrentTenantId() tenantId: string,
@Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }, @Body() body: ClientSystemLogExportDto,
) { ) {
return this.operations.exportSystemLogs({ ...body, tenantId }, userId); return this.operations.exportSystemLogs({ ...body, tenantId }, userId);
} }
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientSystemLogExportDto } from './client-operations.dto';
describe('ClientSystemLogExportDto', () => {
it('accepts the supported date range and rejects extra or malformed fields', async () => {
await expect(
strictValidationPipe.transform(
{ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-28', level: 'error' },
{
type: 'body',
metatype: ClientSystemLogExportDto,
data: undefined,
},
),
).resolves.toEqual(expect.objectContaining({ level: 'error' }));
await expect(
strictValidationPipe.transform(
{ createdAtFrom: 'last-week', tenantId: 'spoofed' },
{
type: 'body',
metatype: ClientSystemLogExportDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,10 @@
import { IsDateString, IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
export class ClientSystemLogExportDto {
@IsOptional() @IsString() @MaxLength(200) keyword?: string;
@IsOptional() @IsIn(['all', 'debug', 'info', 'warning', 'error']) level?: string;
@IsOptional() @IsString() @MaxLength(100) module?: string;
@IsOptional() @IsIn(['7d', '30d']) range?: string;
@IsOptional() @IsDateString({ strict: true }) createdAtFrom?: string;
@IsOptional() @IsDateString({ strict: true }) createdAtTo?: string;
}
@@ -3,7 +3,19 @@ import { ApiTags } from '@nestjs/swagger';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator'; import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto'; import {
ClientApplicationStatusDto,
ClientDeleteResourceDto,
ClientDrainageInfoDto,
ClientDrainageInfoUpdateDto,
ClientSecretResetDto,
ClientSignatureMaterialDto,
ClientSmsApplicationDto,
ClientSmsSignatureDto,
ClientSmsSignatureUpdateDto,
ClientSmsTemplateDto,
ClientSmsTemplateUpdateDto,
} from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe'; import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service'; import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import { SmsConfigService } from './sms-config.service'; import { SmsConfigService } from './sms-config.service';
@@ -11,10 +23,17 @@ import { SmsConfigService } from './sms-config.service';
@ApiTags('client-sms-config') @ApiTags('client-sms-config')
@Controller('client') @Controller('client')
export class ClientSmsConfigController { export class ClientSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {} constructor(
private readonly smsConfig: SmsConfigService,
private readonly deletions: DeletionGovernanceService,
) {}
@Get('applications') @Get('applications')
listApplications(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { listApplications(
@CurrentTenantId() tenantId: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize return page || pageSize
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) }) ? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listApplications(tenantId); : this.smsConfig.listApplications(tenantId);
@@ -37,8 +56,14 @@ export class ClientSmsConfigController {
} }
@Get('applications/:id/report-fields') @Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @CurrentTenantId() tenantId: string) { getApplicationReportFields(
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType)); @Param('id') applicationId: string,
@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage',
@CurrentTenantId() tenantId: string,
) {
return this.smsConfig
.getApplication(applicationId, tenantId)
.then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
} }
@Get('report-fields/common') @Get('report-fields/common')
@@ -49,14 +74,24 @@ export class ClientSmsConfigController {
@Post('applications/:id/secret/reset') @Post('applications/:id/secret/reset')
@RequireRecentAuthentication() @RequireRecentAuthentication()
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) { resetApplicationSecret(
@Param('id') applicationId: string,
@Body() body: ClientSecretResetDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId); return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId);
} }
@Post('applications/:id/status') @Post('applications/:id/status')
@RequireRecentAuthentication() @RequireRecentAuthentication()
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) { changeApplicationStatus(
@Param('id') applicationId: string,
@Body() body: ClientApplicationStatusDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId); return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
} }
@@ -71,8 +106,21 @@ export class ClientSmsConfigController {
} }
@Get('signatures-workspace') @Get('signatures-workspace')
getSignatureWorkspace(@CurrentTenantId() tenantId: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { getSignatureWorkspace(
return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) }); @CurrentTenantId() tenantId: string,
@Query('keyword') keyword?: string,
@Query('applicationId') applicationId?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.smsConfig.getClientSignatureWorkspace(tenantId, {
keyword,
applicationId,
status,
page: Number(page),
pageSize: Number(pageSize),
});
} }
@Post('signatures') @Post('signatures')
@@ -84,14 +132,22 @@ export class ClientSmsConfigController {
@Put('signatures/:id') @Put('signatures/:id')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async updateSignature(@Param('id') signatureId: string, @Body() body: ClientSmsSignatureUpdateDto, @CurrentTenantId() tenantId: string) { async updateSignature(
@Param('id') signatureId: string,
@Body() body: ClientSmsSignatureUpdateDto,
@CurrentTenantId() tenantId: string,
) {
await this.smsConfig.updateClientSignature(signatureId, body, tenantId); await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId); return this.smsConfig.getClientSignatureView(signatureId, tenantId);
} }
@Post('signatures/:id/materials') @Post('signatures/:id/materials')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) { createSignatureMaterial(
@Param('id') signatureId: string,
@Body() body: ClientSignatureMaterialDto,
@CurrentTenantId() tenantId: string,
) {
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId); return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
} }
@@ -102,21 +158,34 @@ export class ClientSmsConfigController {
@Post('signatures/:id/drainage-infos') @Post('signatures/:id/drainage-infos')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: ClientDrainageInfoDto, @CurrentTenantId() tenantId: string) { async createDrainageInfo(
@Param('id') signatureId: string,
@Body() body: ClientDrainageInfoDto,
@CurrentTenantId() tenantId: string,
) {
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId); const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId); return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
} }
@Put('drainage-infos/:id') @Put('drainage-infos/:id')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: ClientDrainageInfoUpdateDto, @CurrentTenantId() tenantId: string) { async updateDrainageInfo(
@Param('id') itemId: string,
@Body() body: ClientDrainageInfoUpdateDto,
@CurrentTenantId() tenantId: string,
) {
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId); await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId); return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
} }
@Post('drainage-infos/:id/status') @Post('drainage-infos/:id/status')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) { async changeDrainageInfoStatus(
@Param('id') itemId: string,
@Body() body: ClientDeleteResourceDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId); await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId);
if (body.status === 'deleted') return { id: itemId, status: 'deleted' }; if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId); return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
@@ -130,16 +199,34 @@ export class ClientSmsConfigController {
@Post('signatures/:id/status') @Post('signatures/:id/status')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) { async changeSignatureStatus(
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId); @Param('id') signatureId: string,
@Body() body: ClientDeleteResourceDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
if (body.status === 'deleted')
return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
await this.smsConfig.changeSignatureStatus(signatureId, { ...body, operatorId }, tenantId); await this.smsConfig.changeSignatureStatus(signatureId, { ...body, operatorId }, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId); return this.smsConfig.getClientSignatureView(signatureId, tenantId);
} }
@Get('templates') @Get('templates')
listTemplates(@CurrentTenantId() tenantId: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { listTemplates(
@CurrentTenantId() tenantId: string,
@Query('includeHistory') includeHistory?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize return page || pageSize
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) }) ? this.smsConfig.listTemplatesPage({
tenantId,
status: includeHistory === 'true' ? 'all' : 'approved',
keyword,
page: Number(page),
pageSize: Number(pageSize),
})
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true'); : this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
} }
@@ -151,7 +238,11 @@ export class ClientSmsConfigController {
@Put('templates/:id') @Put('templates/:id')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
updateTemplate(@Param('id') templateId: string, @Body() body: ClientSmsTemplateUpdateDto, @CurrentTenantId() tenantId: string) { updateTemplate(
@Param('id') templateId: string,
@Body() body: ClientSmsTemplateUpdateDto,
@CurrentTenantId() tenantId: string,
) {
return this.smsConfig.updateClientTemplate(templateId, body, tenantId); return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
} }
@@ -162,8 +253,14 @@ export class ClientSmsConfigController {
@Post('templates/:id/status') @Post('templates/:id/status')
@UsePipes(strictValidationPipe) @UsePipes(strictValidationPipe)
changeTemplateStatus(@Param('id') templateId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) { changeTemplateStatus(
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId); @Param('id') templateId: string,
@Body() body: ClientDeleteResourceDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
if (body.status === 'deleted')
return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
return this.smsConfig.changeTemplateStatus(templateId, { ...body, operatorId }, tenantId); return this.smsConfig.changeTemplateStatus(templateId, { ...body, operatorId }, tenantId);
} }
} }
+43
View File
@@ -0,0 +1,43 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientCreateUserDto, ClientUserPasswordDto } from './client-user.dto';
describe('client user DTOs', () => {
it('rejects tenant, role and operator identity supplied by a client', async () => {
await expect(
strictValidationPipe.transform(
{
displayName: '测试用户',
email: 'user@example.com',
password: 'StrongPass-2026!',
tenantId: 'other',
roleCode: 'platform_admin',
operatorId: 'other-user',
},
{ type: 'body', metatype: ClientCreateUserDto, data: undefined },
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('requires a bounded password', async () => {
await expect(
strictValidationPipe.transform(
{ password: '1234567' },
{
type: 'body',
metatype: ClientUserPasswordDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('normalizes optional blank login fields without rejecting the existing client form', async () => {
await expect(
strictValidationPipe.transform(
{ displayName: '测试用户', username: ' user ', email: ' ', phone: '', password: 'StrongPass-2026!' },
{ type: 'body', metatype: ClientCreateUserDto, data: undefined },
),
).resolves.toEqual(expect.objectContaining({ username: 'user', email: undefined, phone: undefined }));
});
});
+30
View File
@@ -0,0 +1,30 @@
import { Transform } from 'class-transformer';
import { IsEmail, IsIn, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
const emptyToUndefined = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() || undefined : value;
export class ClientCreateUserDto {
@Transform(emptyToUndefined) @IsOptional() @IsString() @MaxLength(100) username?: string;
@Transform(emptyToUndefined) @IsOptional() @IsEmail() @MaxLength(200) email?: string;
@Transform(emptyToUndefined) @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) phone?: string;
@IsString() @MinLength(1) @MaxLength(100) displayName!: string;
@IsString() @MinLength(8) @MaxLength(128) password!: string;
@IsOptional() @IsIn(['active', 'disabled']) status?: 'active' | 'disabled';
}
export class ClientUpdateUserDto {
@Transform(emptyToUndefined) @IsOptional() @IsString() @MaxLength(100) username?: string;
@Transform(emptyToUndefined) @IsOptional() @IsEmail() @MaxLength(200) email?: string;
@Transform(emptyToUndefined) @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) phone?: string;
@IsOptional() @IsString() @MinLength(1) @MaxLength(100) displayName?: string;
@IsOptional() @IsIn(['active', 'disabled']) status?: 'active' | 'disabled';
}
export class ClientUserStatusDto {
@IsIn(['active', 'disabled']) status!: 'active' | 'disabled';
}
export class ClientUserPasswordDto {
@IsString() @MinLength(8) @MaxLength(128) password!: string;
}
+50 -8
View File
@@ -1,9 +1,16 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UsePipes } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator'; import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto'; import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import {
ClientCreateUserDto,
ClientUpdateUserDto,
ClientUserPasswordDto,
ClientUserStatusDto,
} from './client-user.dto';
import { import {
AssignPermissionDto, AssignPermissionDto,
AssignRoleDto, AssignRoleDto,
@@ -53,13 +60,21 @@ export class UsersController {
@Post('admin/users/:id/status') @Post('admin/users/:id/status')
@RequireRecentAuthentication() @RequireRecentAuthentication()
changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) { changeStatus(
@Param('id') id: string,
@Body() body: ChangeUserStatusDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changeStatus(id, { ...body, operatorId }, undefined, operatorId); return this.users.changeStatus(id, { ...body, operatorId }, undefined, operatorId);
} }
@Post('admin/users/:id/password') @Post('admin/users/:id/password')
@RequireRecentAuthentication() @RequireRecentAuthentication()
changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) { changePassword(
@Param('id') id: string,
@Body() body: ChangePasswordDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changePassword(id, { ...body, operatorId }); return this.users.changePassword(id, { ...body, operatorId });
} }
@@ -82,31 +97,58 @@ export class UsersController {
@Post('client/users') @Post('client/users')
@RequireRecentAuthentication() @RequireRecentAuthentication()
createClient(@CurrentTenantId() tenantId: string, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) { @UsePipes(strictValidationPipe)
createClient(
@CurrentTenantId() tenantId: string,
@Body() body: ClientCreateUserDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId); return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
} }
@Put('client/users/:id') @Put('client/users/:id')
@RequireRecentAuthentication() @RequireRecentAuthentication()
updateClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) { @UsePipes(strictValidationPipe)
updateClient(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@Body() body: ClientUpdateUserDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId); return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
} }
@Post('client/users/:id/status') @Post('client/users/:id/status')
@RequireRecentAuthentication() @RequireRecentAuthentication()
changeClientStatus(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) { @UsePipes(strictValidationPipe)
changeClientStatus(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@Body() body: ClientUserStatusDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId); return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
} }
@Post('client/users/:id/password') @Post('client/users/:id/password')
@RequireRecentAuthentication() @RequireRecentAuthentication()
changeClientPassword(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) { @UsePipes(strictValidationPipe)
changeClientPassword(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@Body() body: ClientUserPasswordDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changePassword(id, { ...body, operatorId }, tenantId); return this.users.changePassword(id, { ...body, operatorId }, tenantId);
} }
@Delete('client/users/:id') @Delete('client/users/:id')
@RequireRecentAuthentication() @RequireRecentAuthentication()
removeClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) { removeClient(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.remove(id, operatorId, tenantId); return this.users.remove(id, operatorId, tenantId);
} }
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "brace-expansion", "name": "brace-expansion",
"version": "5.0.8-compat.1", "version": "5.0.9-compat.1",
"private": true, "private": true,
"description": "CommonJS compatibility adapter for the bounded brace-expansion 5.0.8 implementation", "description": "CommonJS compatibility adapter for the bounded brace-expansion 5.0.9 implementation",
"main": "index.cjs", "main": "index.cjs",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"brace-expansion-safe": "npm:brace-expansion@5.0.8" "brace-expansion-safe": "npm:brace-expansion@5.0.9"
} }
} }
@@ -0,0 +1,31 @@
# API 依赖公告治理矩阵(2026-08-28
## 结论
本轮重新执行 `npm audit --omit=dev --json`,审计对象从 9 项降至 5 项:Swagger、`js-yaml``fast-uri``brace-expansion` 的可安全升级路径已完成;剩余 5 项全部属于 Prisma CLI 的同一条开发/迁移工具链,不进入 API 运行时请求处理路径,且审计给出的唯一自动修复是把 Prisma 7.9.0 降级到 6.12.0,属于破坏性主版本变更,因此本轮不执行 `audit fix --force`
## 已修复公告
| 公告/包 | 原路径 | 处理 | 回归要求 |
|---|---|---|---|
| `js-yaml` / GHSA-5p4m-2wfm-xmqj | `@nestjs/swagger -> js-yaml < 4.3.1` | 升级 `@nestjs/swagger` 至 11.4.7,并锁定 `js-yaml` 4.3.1 | API build、Swagger 启动、全量 Jest |
| `fast-uri` / GHSA-7p8r-x3mc-p8w7 | OpenAPI/JSON schema 依赖树 | override 至 3.1.5 | API build、全量 Jest |
| `brace-expansion` / GHSA-rgw5-rvv9-x895 | Excel/归档工具的 minimatch 兼容链 | 兼容适配器升级到有界实现 5.0.9 | `security:verify` 中真实 require 与 legacy minimatch 验证 |
| `@nestjs/swagger` 聚合项 | 由 `js-yaml` 引起 | 随上述升级关闭 | 同上 |
## 暂留公告
| 审计项 | 实际路径 | 运行时暴露面 | 决策 |
|---|---|---|---|
| `prisma` | 根开发依赖 `prisma@7.9.0` | 仅生成客户端和执行迁移;API 运行时使用 `@prisma/client` | 暂留,等待 Prisma 7/8 提供无破坏升级 |
| `@prisma/config` | `prisma -> @prisma/config -> deepmerge-ts` | CLI 解析受控本地配置,不接受 HTTP 用户递归对象 | 暂留并监控上游 |
| `deepmerge-ts` / GHSA-ggr8-5vv4-36mx | 同上 | 不在 API 请求路径调用 | 暂留;禁止用不可信配置执行 Prisma CLI |
| `@prisma/dev` | `prisma -> @prisma/dev -> valibot` | Prisma CLI 开发工具链 | 暂留并监控上游 |
| `valibot` / GHSA-5qjj-4xww-7phc | 同上 | 不在 API 请求路径调用 | 暂留并监控上游 |
## 门禁与复核
- `tools/security/verify-dependency-mitigations.mjs` 固定验证修复版本及 brace CommonJS 兼容行为。
- 部署仍使用受控 `package-lock.json``npm ci`,不执行 `npm audit fix --force`
- 每次依赖升级重新运行两份 lock 的审计、API/前端构建、API 全量测试和真实测试环境健康检查。
- Prisma 上游出现保持主版本兼容的修复后,优先在独立分支验证 `prisma generate`、95 项迁移、PostgreSQL 连接和全量回归,再关闭暂留项。
@@ -0,0 +1,457 @@
# CMPP 平台代码质量持续优化整改方案
- 方案日期:2026-08-28
- 依据报告:`docs/code-quality-reassessment-20260828-v2.md`
- 当前代码基线:`3af145abe5eb8cae567bb18e29116b4940889258`
- 适用范围:React/Vite 客户端、NestJS API、依赖治理、测试与工程门禁
- 实施边界:先在本地完成代码和自动化验证;如需部署,只允许部署到测试环境 `100.93.204.60`,且部署前必须重新建立并校验恢复资产
- 禁止范围:未经新的明确授权,不得访问、部署、覆盖或回退预生产环境
## 1. 背景与当前结论
V2 复评确认上一轮两个 P0 已关闭,代码质量从 58 分提升至 82 分。当前不存在新的运行态 P0,但仍有以下持续优化空间:
1. 少量客户端写接口仍使用内联 TypeScript 类型,缺少运行时严格校验。
2. 前端没有自动化测试,登录、权限、懒加载失败和页面异常状态主要依赖人工验证。
3. API 全源覆盖率刚刚超过门槛,余量不足。
4. 客户端仍发送 `x-tenant-id` 并保留默认租户回退,与服务端可信会话租户的最终设计不一致。
5. 登录保护只有账号维度的主要限流,需要补充 IP 和验证码请求维度。
6. API 依赖树存在审计公告,需要按真实依赖路径和运行时可利用面逐项治理。
7. 当前 `lint` 不是完整 ESLint,格式检查也只依赖 `git diff --check`
8. 两个 SendChain Service 仍然过大,但直接拆分具有较高业务回归风险。
9. 工作区存在双锁文件口径,可能继续误导安装和依赖审计。
本方案遵循“先补小范围安全缺口和测试,再治理供应链和工程门禁,最后拆分发送链”的顺序。
## 2. 整改目标
本轮持续优化完成后应达到:
- 所有客户端写接口具有运行时 DTO 校验,不再使用裸内联 body 类型。
- 客户端租户授权完全依赖服务端认证会话,浏览器不再负责选择或回退租户。
- 建立可持续运行的前端测试框架,并覆盖核心登录、权限、异步路由和异常状态。
- API 全源覆盖率形成合理缓冲,新增代码不能依靠全局低门槛掩盖未测试分支。
- 登录防护具备账号、IP、IP+账号和验证码请求多维度限制与指标。
- API 依赖公告具有逐项路径、影响、处理方式和回归证据。
- `lint``format:check` 成为真实、稳定、可逐步扩展的工程门禁。
- SendChain 重构有行为锁定测试,拆分过程中不改变事务、锁、幂等和队列语义。
- 仓库只保留一种正式包管理器和一种正式锁文件。
## 3. 优先级和最小充分范围
| 批次 | 内容 | 风险 | 预估工作量 | 是否建议下一轮立即实施 |
|---|---|---:|---:|---|
| C1 | 剩余客户端 DTO 和负向测试 | 低 | 0.51.5 人日 | 是 |
| C2 | 删除客户端租户头和默认租户回退 | 中低 | 0.5~1 人日 | 是 |
| C3 | 最小前端测试框架和核心用例 | 中 | 1~3 人日 | 是 |
| C4 | 覆盖率缓冲和增量覆盖门禁 | 低 | 0.5~1 人日 | 是,依赖 C1/C3 |
| C5 | IP/账号/验证码多维登录保护 | 中 | 1~2 人日 | 建议 |
| C6 | API 依赖公告治理矩阵 | 中 | 1~3 人日 | 建议先诊断后升级 |
| C7 | ESLint、Prettier 和真实格式门禁 | 中 | 1~2 人日 | 建议分阶段启用 |
| C8 | SendChain Service 职责拆分 | 高 | 510 人日 | 单独专项 |
| C9 | 唯一包管理器和锁文件 | 中 | 0.5~1 人日 | 需先确认文件归属 |
下一轮最小充分范围为 C1~C4。它们投入较小,能够实质关闭 `CQ-API-001` 的客户端部分并改善 `CQ-TEST-001`,不需要数据库迁移,也不需要修改发送链业务规则。
## 4. C1:补齐客户端写接口 DTO
### 4.1 当前缺口
首批至少覆盖:
- `api/src/open-api/client-open-api.controller.ts`
- 创建 HTTP API 凭据。
- 新增或更新 Webhook。
- `api/src/operations/client-operations.controller.ts`
- 系统日志导出。
- `api/src/files/client-files.controller.ts`
- Multipart 上传的 `purpose``prefix`
### 4.2 实施要求
1. 新增独立 DTO class,不再使用运行时会被擦除的内联 TypeScript 类型。
2. 对上述接口启用现有 `strictValidationPipe`
3. DTO 规则至少包含:
- 凭据名称长度和空白处理。
- `expiresAt` 使用 ISO 日期时间校验,并拒绝已过期时间。
- Webhook `eventType` 只允许平台支持的事件。
- Webhook URL 限制协议、长度和格式;继续由 Service 执行 SSRF 与 HTTPS 策略。
- Webhook `status` 使用明确枚举。
- 日志级别、模块、日期区间使用明确格式和长度限制。
- 上传 `purpose` 使用白名单;`prefix` 限制长度、字符集和路径穿越。
4. 将当前过宽的 `ClientStatusChangeDto` 按应用、签名、模板和引流信息拆成更窄的 DTO,避免无关字段被不同接口接受。
5.`scheduledAt``requestedAt` 等时间字段使用日期格式校验。
6.`variables``materials``reportValues` 等对象补充:
- 最大键数量。
- 最大嵌套深度。
- 键名和字符串值长度。
- 禁止原型污染相关键。
### 4.3 自动化测试
每个接口至少覆盖:
- 合法最小请求成功。
- 额外字段返回 400。
- 超长字符串返回 400。
- 非法 URL、日期和状态返回 400。
- 客户端提交 `createdById/operatorId/tenantId` 等身份字段不能覆盖服务端身份。
- 校验失败时 Service 不被调用。
- 文件 purpose/prefix 校验失败时 MinIO 不发生写入。
### 4.4 出口标准
- 客户端 POST/PUT/PATCH 中不再存在未评审的裸 `@Body()` 内联类型。
- 对应控制器和 DTO 负向测试通过。
- 错误请求稳定返回 400,不返回 500。
- 不改变现有合法前端请求协议。
## 5. C2:移除客户端租户头和默认租户回退
### 5.1 目标设计
客户端租户范围只能来自服务端认证会话:
```text
客户端请求
-> session cookie
-> 服务端查询会话用户
-> request.sessionTenantId
-> CurrentTenantId
-> 数据库 tenantId 复合条件
```
浏览器不再通过 localStorage、默认常量或 `x-tenant-id` 参与客户端授权。
### 5.2 实施内容
1. 修改客户端 HTTP 请求封装,客户端路由默认不发送 `x-tenant-id`
2. 删除 `getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID` 的客户端回退路径。
3. 清理客户端 API 方法中仅为请求头服务的 `tenantId` 参数。
4. 管理端明确跨企业查询所需的租户参数继续保留,并与客户端请求封装分离。
5. 服务端暂时保留“收到客户端租户头时做一致性检查”的兼容逻辑,经过一个测试版本确认没有旧客户端后再决定移除。
6. 质量门禁增加:
- `src/api/client/**` 不得设置 `x-tenant-id`
- 客户端 API 不得引用 `DEFAULT_CLIENT_TENANT_ID`
- 管理端的显式租户参数不受此规则影响。
### 5.3 必测用例
- 不带租户头的客户端全部核心页面和接口正常。
- 浏览器 localStorage 中 tenantId 缺失、错误或过期,不影响服务端租户识别。
- 运营端跨租户查询仍按权限正常工作。
- 旧客户端携带正确租户头仍兼容;错误租户头仍返回 403 并记录安全事件。
- 双租户真实 API 隔离矩阵继续通过。
### 5.4 出口标准
- 客户端代码不再选择租户授权范围。
- 不出现登录后初始化阶段因默认租户错误导致的 403。
- 服务端可信租户门禁继续通过。
## 6. C3:建立最小前端测试集
### 6.1 技术方案
建议采用:
- Vitest:测试运行器。
- React Testing Library:组件和页面行为测试。
- jsdomDOM 环境。
- MSW:模拟真实 HTTP 协议的成功、空、失败和延迟响应。
测试必须围绕用户可见行为和真实请求契约,不以大量快照替代断言。
### 6.2 首批核心用例
1. 客户端登录:成功、密码错误、验证码错误、接口不可用。
2. 未登录访问客户端深层路由:跳转登录页。
3. 权限不足:显示稳定提示,不白屏。
4. `RouteLoadBoundary`:异步 chunk 加载失败、重试和重新加载。
5. 列表页:loading、empty、error、成功、分页和筛选。
6. 高风险操作:删除、重置密钥、创建凭据等确认流程。
7. API 返回 500:页面显示业务化错误,不直接显示裸 `Internal server error`
8. 会话失效:401 后清理展示会话并回登录页。
### 6.3 工程门禁
新增命令建议:
```text
npm run test:frontend
npm run test:frontend:coverage
```
并将 `test:frontend` 纳入 `verify:quality`。首轮覆盖率门槛以核心模块为主,不为追求全局数字测试纯展示组件。
### 6.4 出口标准
- 至少 6~10 个核心行为用例稳定通过。
- 登录、权限、路由失败和列表异常状态均有自动化覆盖。
- 测试可以在全新依赖安装后重复执行。
- CI/质量门禁中前端测试失败会阻止合并。
## 7. C4:覆盖率缓冲和增量门禁
### 7.1 当前问题
当前全源覆盖率虽然通过,但接近门槛:
- statements 59.84%,门槛 59%。
- branches 51.29%,门槛 50%。
- functions 60.15%,门槛 60%。
- lines 62.60%,门槛 62%。
functions 仅有 0.15 个百分点余量,新增少量未测试函数就可能失败。
### 7.2 实施内容
1. 先通过 C1 和 C3 增加有业务价值的测试,不立即盲目提高门槛。
2. 建立修改文件或新增文件覆盖率门禁,建议初始目标:
- statements/lines 不低于 80%。
- branches 不低于 70%。
- functions 不低于 80%。
3. 全源门槛在覆盖率稳定后分批上调,每次不超过 2~3 个百分点。
4. 覆盖率报告明确使用全源 `collectCoverageFrom` 口径,避免再次混用“已加载源码”和“全部源码”。
### 7.3 出口标准
- 新增 DTO、校验器和安全逻辑具有负向分支测试。
- 修改文件覆盖率门禁能够阻止新增无测试逻辑。
- 文档和质量门禁使用同一统计口径。
## 8. C5:多维登录和验证码保护
### 8.1 实施内容
在现有 Redis 原子计数基础上增加:
- 账号维度失败窗口。
- 来源 IP 维度失败窗口。
- IP+账号组合维度。
- 验证码获取频率限制。
- 单 IP 随机账号扫描保护。
- key 数量、失败次数、锁定次数和 Redis 异常指标。
来源 IP 必须基于受信 Nginx/代理链解析,不能直接信任任意客户端 `X-Forwarded-For`
### 8.2 验证
- 多 API 实例共享相同锁定状态。
- 同账号换 IP、同 IP 换账号均能触发对应保护。
- 正常用户偶发输错不会被过度锁定。
- Redis 不可用继续 fail closed,并产生监控告警。
- 随机账号和验证码请求压测后 Redis key 数量能随 TTL 回落。
## 9. C6API 依赖公告治理
### 9.1 原则
依赖审计公告不直接等于业务可利用漏洞。不得直接执行 `npm audit fix --force`。应先建立矩阵:
| 字段 | 内容 |
|---|---|
| 公告编号 | GHSA/CVE |
| 依赖路径 | 直接依赖到受影响包的完整路径 |
| 实际锁定版本 | 以 `api/package-lock.json` 为准 |
| 环境 | 生产运行时、构建期、开发期、可选工具 |
| 不可信输入 | 是否处理客户输入、文件、URL、模板或命令行参数 |
| 当前缓解 | override、功能未启用、输入边界、兼容适配 |
| 修复方案 | 补丁升级、依赖替换、隔离或书面风险接受 |
| 回归范围 | 构建、Excel 导出、Swagger、Prisma、数据库和部署 |
### 9.2 优先顺序
1. 直接生产运行时且处理不可信输入的路径。
2. Excel 导出、压缩、YAML/Swagger 等数据处理路径。
3. Prisma 的生产客户端路径。
4. 仅 CLI、Studio、构建或可选工具链路径。
### 9.3 出口标准
- 7 项公告均有明确依赖路径和处置结论。
- 可安全升级的依赖完成升级并通过全量回归。
- 暂不能升级的项目有代码级缓解门禁和明确复查日期。
- 根项目和 API 子项目审计口径分别记录,不互相替代。
## 10. C7:真实 lint 和格式门禁
### 10.1 分阶段实施
第一阶段只检查新增和修改文件,启用高价值规则:
- 未使用变量和导入。
- 未处理 Promise。
- 不安全的 `any` 和类型断言。
- React Hooks 依赖和调用规则。
- 无效条件、重复分支和不可达代码。
- Node/NestJS 常见异步错误。
第二阶段再逐目录修复历史问题并扩大到全仓库。
Prettier 首轮只执行 `check`,不得在同一提交中格式化全部历史文件。大规模格式化必须独立提交,避免掩盖业务改动。
### 10.2 脚本语义
- `lint`:真实 ESLint。
- `typecheck`:前端 TypeScript。
- `quality:verify`:结构安全门禁。
- `format:check`Prettier check + `git diff --check`
不要继续用 `lint` 名称包装结构检查和 TypeScript,使开发者误判实际门禁能力。
## 11. C8SendChain 超大服务专项拆分
### 11.1 前置条件
拆分前必须补齐行为锁定测试:
- 事务边界。
- advisory lock 和锁顺序。
- 消息、Submit 和 Outbox 幂等键。
- 主备路由和补发。
- 计费冻结、扣费、退款和释放。
- Inbox claim、租约、超时和恢复。
- Redis Stream 发布及重复消费。
- 回执、上行和下游投递。
### 11.2 建议拆分方向
`send-inbound-entry.service.ts`
- 入站协议数据转换。
- Inbox 持久化和 claim。
- 长短信聚合。
- 企业分组微批。
- 业务校验和消息创建。
`send-gateway-submit.service.ts`
- 路由选择。
- Gateway 命令构造。
- Submit Outbox。
- Submit 结果处理。
- 重试和恢复。
### 11.3 实施规则
- 每次只移动一个职责。
- 先机械提取,再优化逻辑。
- 不同时修改事务、SQL、幂等键或状态语义。
- 每个拆分提交都运行 API 全量、Gateway 全量、真实 PostgreSQL/Redis 契约和队列排空检查。
### 11.4 出口标准
- 原 Service 只保留编排职责。
- 行为测试、全链契约和性能基线不下降。
- 不新增重复查询、跨事务状态漂移或锁顺序变化。
## 12. C9:唯一包管理器和锁文件
当前正式跟踪文件为根目录和 API 的 `package-lock.json`,工作区另有未跟踪 `pnpm-lock.yaml`。该文件属于受保护的既有工作区内容,本方案不授权删除。
需要文件所有者明确选择:
### 方案 A:统一 npm(建议)
- 保留两个正式 `package-lock.json`
- 确认后删除或归档旧 `pnpm-lock.yaml`
- 部署、审计和 CI 全部使用 `npm ci`
- 门禁禁止新增 pnpm/yarn 锁文件。
### 方案 B:统一 pnpm
- 重新生成受控 pnpm 锁文件。
- 重写本地、CI、部署和审计命令。
- 在全新目录验证安装、构建、Prisma、测试和生产部署。
- 完成后再移除 npm 锁文件。
不得长期同时维护两种锁文件,也不得只改锁文件而不改部署流程。
## 13. 不建议在下一轮实施的动作
- 不一次性为全部 API 开启全局严格 `ValidationPipe`
- 不直接执行 `npm audit fix --force`
- 不为了 Vite 的 500 KiB 原始体积警告替换整个 ECharts;当前 gzip 包体仍在预算内。
- 不在安全补丁提交中同时拆分 SendChain。
- 不以大量快照测试提高前端覆盖率。
- 不擅自删除 `=`, `pnpm-lock.yaml` 或其他侧边任务文件。
- 不把测试环境健康检查等同于登录后页面和 CMPP 全链验收。
## 14. 验证矩阵
| 层级 | 必须验证 |
|---|---|
| 静态 | TypeScript、ESLint、Prettier、结构安全门禁、`git diff --check` |
| API | DTO 正负向、租户隔离、密码迁移、登录限流、账务和发送幂等 |
| 前端 | 登录、权限、懒加载、loading/empty/error、确认操作、401/500 |
| 数据库 | Prisma validate、95 项 migration 一致性、真实 PostgreSQL 关键查询 |
| Redis | 验证码 TTL、登录锁定、多实例、Stream pending/lag |
| Gateway | `go test ./... -count=1``go vet ./...`、5 份队列契约 |
| 浏览器 | 登录后页面、控制台、请求、刷新、深层链接和移动端 |
| 发布后 | 部署 commit、服务、端口、健康、日志、队列和临时数据清理 |
## 15. 测试环境发布要求
如下一轮包含部署,必须:
1. 明确目标是测试环境 `100.93.204.60`,显式绕过 Clash/系统代理进行健康探测。
2. 发布前重新建立:
- PostgreSQL custom dump。
- 当前运行目录归档。
- 环境、systemd 和 Nginx 配置。
- Redis RDB 和关键 Stream/队列状态。
- MinIO 数据或业务对象清单。
- 当前部署 commit 和 95 项 migration 状态。
3. 校验 `pg_restore --list`、tar 目录、Redis RDB 和 SHA-256。
4. 发布后核对所有相关服务、API/Gateway health、Redis pending/lag 和 error 日志。
5. 清理临时测试账号、凭据、文件和脚本。
6. 测试环境通过不自动授权预生产发布。
## 16. 建议提交拆分
1. `security: validate remaining client write payloads`
2. `test: cover client dto rejection paths`
3. `security: remove client-controlled tenant headers`
4. `test: add frontend auth and route failure baseline`
5. `test: enforce changed-file coverage`
6. `security: add ip-aware login throttling`
7. `build: document and remediate api audit paths`
8. `build: add eslint and prettier checks`
9. `refactor: extract inbound workflow responsibilities`
10. `refactor: extract gateway submit responsibilities`
11. `chore: enforce the selected package manager`
每个提交只处理一个主题,不混入测试环境部署资产、历史文档改写或其他会话的工作区文件。
## 17. 最终关闭标准
| 项目 | 关闭证据 |
|---|---|
| 客户端 DTO | 所有写接口使用 class DTO 和严格管道;负向测试通过 |
| 租户头清理 | 客户端不再发送/回退 tenantId;双租户矩阵通过 |
| 前端测试 | 核心用例进入质量门禁并稳定运行 |
| 覆盖率 | 全源口径稳定且修改文件满足增量门槛 |
| 登录保护 | 账号/IP/组合/验证码限流和指标通过多实例测试 |
| 依赖公告 | 每项有路径、影响、处理和回归证据 |
| lint/format | ESLint 和 Prettier 真实执行,不再仅使用脚本别名 |
| SendChain | 行为锁定后完成职责拆分,全链语义和性能不下降 |
| 锁文件 | 唯一包管理器、唯一正式锁文件、全新安装可重复 |
| 测试环境 | 恢复资产、真实后端、浏览器和发布后状态证据齐全 |
## 18. 建议执行顺序
```text
C1 客户端 DTO
-> C2 租户头清理
-> C3 前端测试
-> C4 覆盖率缓冲
-> C5 登录保护
-> C6 依赖治理
-> C7 lint/format
-> C8 SendChain 专项拆分
-> C9 锁文件统一
-> 测试环境完整验收
```
其中 C1~C4 可作为下一轮独立交付;C8 必须保持为单独的高风险重构任务。
@@ -0,0 +1,190 @@
# CMPP 平台代码质量复评报告 V2
- 报告日期:2026-08-28
- 复评时间:2026-08-28 12:3812:57Asia/Shanghai
- 复评基线:`3af145abe5eb8cae567bb18e29116b4940889258`
- 对比基线:`171c7d38e8f17de0dd83570603da316d47c0d015`
- 分支状态:`main`,本地 `HEAD``origin/main` 一致
- 变更规模:58 个文件,新增 1,953 行、删除 487 行
- 复评范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis、Go Gateway、自动化测试、依赖与仓库门禁、测试环境只读健康探测
- 明确未执行:提交、推送、部署、服务重启、数据库写入、短信发送、压力测试、MinIO 业务对象写入、预生产或生产环境操作
## 1. 复评结论
本次采用两个相互独立的结论,避免把“代码整改完成”和“环境可发布”混为一谈:
1. **代码整改:有条件通过。** 上一版的两个 P0 已在源码、自动化测试和结构门禁层面关闭;前端分包、覆盖率门槛、Redis 状态存储、数据库配置 fail closed、mock 解耦等整改有效。
2. **发布就绪:待完整验收。** 2026-08-28 12:50 在 Clash 虚拟网卡/代理开启状态下探测测试环境时曾连续返回 HTTP 502;关闭 Clash 后使用 `curl --noproxy '*'` 绕过代理直连,12:56 连续 3 次访问 `/api/health` 均返回 HTTP 200、`status=ok`,TCP 12026 端口可达,连接耗时约 10 ms、总响应约 19~28 ms。此前 502 判定属于本地网络代理干扰,不是测试环境运行故障。
当前代码质量综合参考分:**82/100**,较上一版 **58/100** 提升 24 分。该分数只表示仓库代码治理水平,不等价于功能验收通过率。
测试环境外部 API 健康探测已经恢复为通过;但在重新核对部署版本、内部服务/队列状态并完成真实后端与登录后浏览器复验前,**仍不建议进入预生产发布**。
## 2. 质量评级
| 维度 | 上版 | 本次 | 复评结论 |
|---|---:|---:|---|
| 业务正确性与并发设计 | B | B+ | API/Gateway 回归通过,核心幂等与队列逻辑保持;未重跑真实 CMPP 全链。 |
| 安全性 | D | B+ | 可信租户上下文和强密码哈希已落地;重点写接口校验仍非全覆盖。 |
| 自动化测试 | C | B- | API 47 套 543 项通过并建立覆盖率阈值;前端测试仍为 0。 |
| 前端性能 | C- | B+ | 路由懒加载和 ECharts 按需加载生效,入口 gzip 降至 107.38 KiB。 |
| 可维护性 | C | C+ | mock 解耦和页面辅助逻辑拆分完成;两个 SendChain Service 仍超大,未建立 ESLint/Prettier。 |
| 数据库工程 | B | B+ | Prisma 校验通过,生产/测试缺少 `DATABASE_URL` 时 fail closed;本轮未做真实数据执行计划复测。 |
| 依赖与仓库卫生 | C- | B- | 根正式锁文件审计为 0;API 依赖树仍有 7 项公告,且未跟踪的旧 pnpm 锁文件会造成错误审计口径。 |
| 运行与发布就绪 | 未评 | B- | 绕过本地代理后测试环境外部 `/api/health` 连续 3 次返回 200;本次未独立核对内部服务、队列和登录后业务链。 |
## 3. 上一版问题关闭状态
| 编号 | 原级别 | 本次状态 | 判定依据 |
|---|---:|---|---|
| CQ-SEC-001 客户端租户可伪造 | P0 | **已关闭(代码级)** | 会话中间件从数据库用户写入 `sessionTenantId`;客户端装饰器只读取可信上下文;伪造 header 返回 `CLIENT_TENANT_MISMATCH`;相关租户隔离测试通过。 |
| CQ-SEC-002 无盐 SHA-256 密码 | P0 | **已关闭(代码级)** | 使用随机 salt 的版本化 scrypt、恒定时间比较、参数上限解析;旧 SHA-256 只读兼容并在正确登录后透明升级;写路径和工具门禁已覆盖。 |
| CQ-API-001 缺少运行时输入校验 | P1 | **部分关闭** | 已新增严格 `ValidationPipe` 和重点客户端写 DTO;但未全局注册,部分凭据、Webhook、日志导出等写接口仍使用内联类型和裸 `@Body()`。 |
| CQ-AUTH-001 验证码/失败计数进程内 Map | P1 | **基本关闭** | 已迁移 Redis TTL、`GETDEL` 和 Lua 原子计数,Redis 异常 fail closed;仍建议补 IP+账号双维度限流与容量指标。 |
| CQ-FE-001 主包过大、无路由分包 | P1 | **已关闭** | 全部业务页采用懒加载;ECharts 按需注册;入口 gzip 107.38 KiB,低于 250 KiB 门槛,最大异步图表包 181.64 KiB,低于 190 KiB 门槛。 |
| CQ-TEST-001 测试与验收层级不足 | P1 | **部分关闭** | API 覆盖率阈值已建立且本轮通过;Gateway 测试通过;前端仍无测试,登录后浏览器和真实全链未完成。 |
| CQ-DEP-001 两个 high advisory | P2 | **原问题已关闭** | 正式 `package-lock.json` 使用 React Router 7.18.2、NanoID 3.3.18;按该锁文件重建审计结果为 0。 |
| CQ-MAINT-001 超大文件、无静态风格门禁 | P2 | **部分关闭** | 页面辅助逻辑已拆分,增加结构质量脚本;两个 SendChain Service 仍为 86,253/53,421 字节,`lint` 实际为结构检查加 TypeScript,不是 ESLint/格式门禁。 |
| CQ-CONFIG-001 数据库默认凭据 | P2 | **已关闭** | 非 development/test 环境缺少 `DATABASE_URL` 时启动失败,不再静默使用默认生产连接。 |
| CQ-ARCH-001 生产代码依赖 mock | P2 | **已关闭** | 类型已迁至 `src/api/types`,结构门禁禁止生产页面重新导入 mock。 |
| CQ-REPO-001 仓库产物与审计基线不稳定 | P2 | **部分关闭** | `*.tsbuildinfo` 已停止跟踪并加入忽略;但工作区仍有未跟踪的 `=` 和过期 `pnpm-lock.yaml`。 |
## 4. 关键整改证据
### 4.1 可信租户上下文
- `api/src/auth/session-validation.middleware.ts:70-72`:客户端请求携带的租户头与登录用户租户不一致时返回 403,并将数据库用户租户写入 `request.sessionTenantId`
- `api/src/auth/current-tenant-id.decorator.ts:10-13`:只允许 client portal 读取可信租户上下文,不再回退到请求头。
- 客户端认证、短信配置、账务、发送链、风险复核、用户等控制器已改用 `@CurrentTenantId()`
- 管理端 `@TenantId()` 仍存在于 `admin/files``admin/operation-logs``admin/billing` 等管理路由中,不属于客户端授权依据;结构门禁已禁止客户端控制器回退使用旧装饰器。
判定:原 P0 的根因已消除。测试环境真实双租户结果在整改记录中有描述;本次只复核外部健康接口,没有重跑双租户矩阵,因此仍按代码级关闭记录。
### 4.2 密码存储与迁移
- `api/src/auth/password-hasher.ts:1-82`:版本化 scrypt、随机 salt、`timingSafeEqual`、参数边界和旧 SHA-256 识别均已实现。
- 旧哈希兼容写窗口被限制为最长 2 小时;正常写路径默认生成新格式。
- 登录成功后通过条件更新透明迁移旧哈希,错误密码不迁移。
- 单测覆盖随机盐、错误密码、旧哈希验证/迁移、畸形参数和兼容窗口边界。
判定:满足上一版“强哈希、版本参数、恒定时间比较、透明迁移”的最小关闭标准。测试环境仍保留的历史 SHA-256 数量来自整改记录,本次未直接查询数据库复核。
### 4.3 登录状态与输入校验
- `api/src/auth/session.service.ts:129-163`:验证码使用 Redis TTL,消费使用 `GETDEL`;失败计数使用 Redis Lua 原子逻辑。
- `api/src/common/strict-validation.pipe.ts`:启用 transform、whitelist 和 forbidNonWhitelisted。
- 重点客户端写接口已增加 class-validator DTO 和严格管道。
- `api/src/open-api/client-open-api.controller.ts:15-18` 的凭据/Webhook 写入,以及 `api/src/operations/client-operations.controller.ts:78-84` 的日志导出仍使用内联 body,未套用严格校验管道。
判定:验证码多实例一致性问题已关闭;输入校验只能判为阶段性完成,不能写成全 API 闭环。
### 4.4 前端性能
- `src/routes/AppRoutes.tsx:10-74`:业务页面统一路由级懒加载。
- `src/components/ui/Chart.tsx:3-8`:只注册实际使用的 ECharts 图表、组件和 Canvas renderer。
- 本次生产构建:入口 `index-*.js` 370.54 KiBgzip 107.38 KiB;图表异步包 553.06 KiBgzip 181.64 KiB。
- 包体预算脚本通过:入口不高于 250 KiB gzip,异步包不高于 190 KiB gzip。
判定:原主包 626.18 KiB gzip 的问题已实质关闭。Vite 仍提示图表 chunk 的未压缩体积超过 500 KiB,但其 gzip 体积在当前预算内,作为后续优化项而非阻断项。
### 4.5 数据库与工程配置
- API 正式构建通过。
- Prisma Schema validate 通过;仓库存在 95 个 migration 目录。
- 非开发/测试环境缺少 `DATABASE_URL` 时 fail closed。
- 本轮没有连接真实 PostgreSQL 执行 `EXPLAIN (ANALYZE, BUFFERS)`,也没有独立核对测试库 95/95 migration;相关运行态数据仅见整改记录。
## 5. 本次独立复测结果
| 检查项 | 本次结果 |
|---|---|
| Git 基线 | `HEAD == origin/main == 3af145a...` |
| 工作区 | 仅发现既有未跟踪 `=``pnpm-lock.yaml`;未修改或删除 |
| 前端 TypeScript | 通过 |
| Vite production build | 通过 |
| Bundle budget | 通过;入口 107.38 KiB gzip,最大异步包 181.64 KiB gzip |
| API TypeScript build | 通过 |
| Prisma validate | 通过 |
| API Jest | 47/47 套、543/543 项通过 |
| API 全源覆盖率 | statements 59.84%branches 51.29%functions 60.15%lines 62.60% |
| API 覆盖率门槛 | 通过;门槛分别为 59%、50%、60%、62% |
| Gateway `go test ./... -count=1` | 通过 |
| Gateway `go vet ./...` | 通过 |
| Gateway 队列契约 | 5/5 通过 |
| 代码质量结构检查 | 通过 |
| 依赖缓解/安全部署检查 | 通过 |
| 根正式锁文件生产依赖审计 | 0 个公告 |
| API 正式锁文件生产依赖审计 | 7 个公告:2 moderate、5 high |
| 前端自动化测试 | 0 个测试文件,未通过验收出口标准 |
| 测试环境 `/api/health` | **通过;关闭 Clash 后绕过代理直连,连续 3 次 HTTP 200、`status=ok`** |
| 测试环境 TCP 12026 | **通过;可连接** |
| 测试环境 SSH 只读核验 | 未完成;当前凭据认证失败 |
覆盖率说明:整改结果文档记录的 69.77%/53.47%/71.08%/73.05% 使用 Jest 默认“已加载源码”口径;本报告显式使用 `collectCoverageFrom=src/**/*.ts` 的全源口径,得到 59.84%/51.29%/60.15%/62.60%。两个结果不是同一统计口径,不应直接比较。本次全源结果仍满足已设门槛,并略高于上一版全源基线。
依赖审计说明:工作区未跟踪的 `pnpm-lock.yaml` 固定了旧 React Router 7.18.1 和 NanoID 3.3.16,直接运行 pnpm audit 会错误报出上一版两个 high。按正式、已跟踪的 `package-lock.json` 在临时目录重建锁文件后,根项目审计为 0。该旧锁文件仍应由文件所有者确认后清理或更新,避免 CI/开发者误用。
## 6. 当前阻断项与剩余风险
### 运行探测校正:此前 502 为本地代理干扰,不构成缺陷
**证据链**
- 2026-08-28 12:50,在 Clash 虚拟网卡/代理开启状态下,PowerShell Web 请求曾连续返回 HTTP 502。
- 用户关闭 Clash 后,首次 PowerShell `-NoProxy` 请求出现一次 10 秒超时,随后请求开始恢复。
- 2026-08-28 12:56,使用 `curl --noproxy '*'` 明确绕过代理连续请求 3 次,均返回 HTTP 200 和 `{"status":"ok","service":"cmpp-platform-api"}`
- 三次直连总响应时间分别约 28 ms、23 ms、19 msTCP 12026 检测为可连接。
**校正结论**
此前新增的 `CQ-OPS-001 / P0` 撤销,不计入缺陷和发布阻断。测试环境外部 API 当前可用,原整改记录中的 API 健康结论没有被本次复评推翻。
**仍保留的验证边界**
本次 SSH 凭据认证失败,因此没有独立读取部署标记、11 项服务状态、内部 Gateway health、Redis Stream pending/lag、migration 或服务器日志。这些项目继续引用整改发布记录,预生产前仍应在可用只读凭据下重新核对。后续私网健康探测应显式绕过系统代理,避免 Clash 等本地网络工具造成误判。
### CQ-API-001 / P1:严格输入校验尚未覆盖全部写接口
当前采取“重点接口逐步接入”而非全局管道。凭据创建、Webhook 更新和日志导出等入口仍使用运行时会被擦除的内联 TypeScript 类型。建议先覆盖所有 client POST/PUT/PATCH/DELETE,再评估 admin 高风险写接口;为字段长度、URL、枚举、数组大小和额外字段补负向测试。
### CQ-TEST-001 / P1:前端与真实环境验收未闭环
前端测试文件仍为 0;本轮没有可用登录态,无法验证登录后页面、控制台、请求、加载/空/错误/刷新状态。本次仅确认外部 API health,未重新执行真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 联动复验。
### CQ-DEP-002 / P1API 依赖树仍有 7 项生产审计公告
`api/package-lock.json` 重建审计后为 2 moderate、5 high,涉及 ExcelJS 间接 UUID、Prisma 可选工具链、brace-expansion、Swagger 间接 js-yaml 等。公告并不等于当前业务路径全部可利用,但应建立“依赖路径、运行时是否打包/调用、可升级版本、兼容回归”的矩阵,不能只以根项目审计为 0 宣告供应链闭环。
### CQ-MAINT-001 / P2:超大服务和风格门禁仍未治理
`send-inbound-entry.service.ts` 约 84.2 KiB`send-gateway-submit.service.ts` 约 52.2 KiB;职责仍集中。建议先补行为锁定测试,再按入站持久化、关联/去重、重试路由、下游投递和计费拆分。另应增加 ESLint 与 format check;当前 `npm run lint` 名称实际只执行结构脚本和 TypeScript 检查。
### CQ-REPO-001 / P2:双锁文件口径仍可能误导审计
正式锁文件是 `package-lock.json`,但未跟踪 `pnpm-lock.yaml` 仍存在且版本过期。应先确认其归属,再选择唯一包管理器和唯一锁文件;本报告没有擅自删除或更新用户保留文件。
## 7. 发布建议
当前建议为:**代码可进入下一轮验证,但暂停预生产发布。**
最小充分关闭顺序:
1. 使用显式绕过系统代理的方式复核测试环境,并在可用只读凭据下核对实际部署标记、内部服务和队列状态。
2. 在测试环境重跑双租户隔离和旧密码透明迁移,不发送真实短信。
3. 补齐剩余客户端写接口 DTO/严格校验,并增加负向测试。
4. 建立最小前端测试集:登录、权限、路由懒加载失败、列表加载/空/错误、关键确认操作。
5. 对 API 7 项公告建立升级兼容矩阵;优先处理直接运行时路径。
6. 在单独任务中治理 SendChain 超大服务、ESLint/格式门禁和唯一锁文件。
7. 只有在真实 PostgreSQL、Redis、MinIO、Gateway/CMPP 模拟器和登录后浏览器证据齐全后,再申请预生产发布授权。
## 8. 最终判定
- 原 P0 代码问题:**2/2 已关闭**。
- 原 P1:**2 项关闭或基本关闭,2 项部分关闭**。
- 原 P2:**3 项关闭,2 项部分关闭**。
- 新增运行态阻断:**0 项**;此前 502 已确认是本地 Clash 代理干扰并撤销。
- 自动化门禁:除 API 子树依赖审计和前端测试缺失外,其余本次执行项通过。
- 代码质量:**82/100,有条件通过**。
- 当前发布就绪:**待完整验收;无 502 运行态阻断**。
本报告是对固定提交的独立复评和一次当前测试环境只读探测,不替代完整功能验收、生产安全评估或预生产发布审批。
+16
View File
@@ -4104,3 +4104,19 @@ git diff --check
- 采用两阶段密码安全发布并在每次覆盖前重新建恢复资产:`code-quality-before-d85ff859-20260828T035007Z``code-quality-compatible-d85ff859-20260828T035426Z``code-quality-before-fc4a6a7-20260828T040150Z`。三份均含PostgreSQL custom dump、运行目录、配置、Redis RDB、MinIO数据、状态快照、回退说明和SHA-256;恢复清单、pg_restore、tar和Redis校验通过。 - 采用两阶段密码安全发布并在每次覆盖前重新建恢复资产:`code-quality-before-d85ff859-20260828T035007Z``code-quality-compatible-d85ff859-20260828T035426Z``code-quality-before-fc4a6a7-20260828T040150Z`。三份均含PostgreSQL custom dump、运行目录、配置、Redis RDB、MinIO数据、状态快照、回退说明和SHA-256;恢复清单、pg_restore、tar和Redis校验通过。
- 最终收尾为95/95迁移、11项服务active、入口HTTP 200、API/Gateway健康、Redis PONG、`gateway.submit.results pending=0/lag=0`,发布后7项核心服务error级日志均0。浏览器真实登录页完整且控制台error/warning为0;未自动输入账号密码/验证码,登录后深层页面仍保留为人工浏览器验收边界。 - 最终收尾为95/95迁移、11项服务active、入口HTTP 200、API/Gateway健康、Redis PONG、`gateway.submit.results pending=0/lag=0`,发布后7项核心服务error级日志均0。浏览器真实登录页完整且控制台error/warning为0;未自动输入账号密码/验证码,登录后深层页面仍保留为人工浏览器验收边界。
- 本轮没有发送短信、压测、修改余额/应用/通道或写入MinIO业务对象,也没有执行新的CMPP模拟器全链;两个SendChain超大Service未在安全发布中做高风险重构。完整证据和保留项见`docs/code-quality-remediation-result-20260828.md`。只操作测试环境`100.93.204.60`,预生产未访问或修改。 - 本轮没有发送短信、压测、修改余额/应用/通道或写入MinIO业务对象,也没有执行新的CMPP模拟器全链;两个SendChain超大Service未在安全发布中做高风险重构。完整证据和保留项见`docs/code-quality-remediation-result-20260828.md`。只操作测试环境`100.93.204.60`,预生产未访问或修改。
## 2026-08-28 代码质量 V2 复评与测试环境健康探测校正
- 固定`3af145abe5eb8cae567bb18e29116b4940889258`基线重新复评,原两个P0在代码、单测和结构门禁层面均已关闭;API 47套543项、全源覆盖率59.84/51.29/60.15/62.60、前后端构建、bundle budget、Gateway全包测试/go vet、Prisma及队列契约通过。正式报告为`docs/code-quality-reassessment-20260828-v2.md`
- 首次外部健康探测时本机开启Clash虚拟网卡/代理,`100.93.204.60:12026/api/health`曾连续返回HTTP 502。关闭Clash后使用`curl --noproxy '*'`明确绕过代理,连续3次均返回HTTP 200、`status=ok`,总响应约1928ms,TCP 12026可连接;因此撤销“测试环境502”P0判定,确认为本地代理干扰,不是服务端故障。
- 本轮SSH只读认证未成功,未独立读取部署标记、内部服务、Redis Stream、migration和服务器日志;这些运行态项目继续保留为预生产前复核边界。未部署、未重启服务、未写库、未发送短信、未压测,预生产未访问或修改。
## 2026-08-28 代码质量持续优化执行
- 按`docs/code-quality-continuous-optimization-plan-20260828.md`实施非“暂不建议投入”项:补齐客户端凭据、Webhook、导出、文件、用户等运行时DTO与负向校验;浏览器端不再为客户端请求发送或回退租户ID,服务端继续只从认证会话取得客户端租户。
- 登录保护扩展为账号、来源IP和账号+IP三维失败计数,并增加IP维度验证码频率限制;Redis键只保存规范化值的SHA-256摘要。账号锁定仍为24小时,IP阈值为15分钟窗口30次,组合阈值为24小时5次;Prometheus新增有界事件/范围指标。
- 建立Vitest、Testing Library、jsdom、MSW前端测试基座,覆盖登录错误中文化、异步路由异常、客户端租户头禁止、401/403/500、文件/表单请求、用户列表loading/empty/error及删除确认。33项前端测试通过,纳入覆盖范围的核心模块为statements 88.19%、branches 84.51%、functions 84%、lines 87.94%,门禁为80/70/80/80。
- API增量覆盖门禁覆盖本轮新增DTO、动态JSON校验及登录安全逻辑,28项定向测试通过,覆盖率为86.78/78.29/95.83/89.84。API全量51套563项通过,全源覆盖率为70.44/53.77/71.76/73.61,较V2复评继续形成缓冲。
- 引入ESLint flat config、Prettier和实际增量文件门禁;`packageManager`固定为npm 11.6.2,正式提交仍只维护根目录/API两份`package-lock.json`。历史受保护的未跟踪`pnpm-lock.yaml`和空文件`=`保持原状且不纳入提交。
- 重新审计API依赖并将9项公告降至5项:Swagger、`js-yaml``fast-uri``brace-expansion`安全升级完成;剩余5项属于Prisma CLI同一开发/迁移链,唯一自动方案为破坏性降级Prisma 7至6,未执行`audit fix --force`。逐项路径和运行时判断见`docs/api-dependency-advisory-matrix-20260828.md`
- 本地前后端TypeScript、Vite生产构建、bundle budget、Gateway全包测试与`go vet`、95项Prisma schema、代码质量、安全部署及依赖缓解门禁均通过。按方案明确排除全局ValidationPipe一次性切换、强制audit修复、ECharts替换和SendChain高风险重构;发布与真实环境证据在后续条目补齐。
+26
View File
@@ -0,0 +1,26 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', 'api/vendor/**'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}', 'tools/**/*.mjs'],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['api/src/**/*.ts'],
languageOptions: { globals: { ...globals.node, ...globals.jest } },
rules: { '@typescript-eslint/no-explicit-any': 'warn' },
},
);
+3817 -2
View File
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -2,6 +2,7 @@
"name": "cmpp-platform-frontend", "name": "cmpp-platform-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "npm@11.6.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npm run build && vite preview --host 0.0.0.0", "dev": "npm run build && vite preview --host 0.0.0.0",
@@ -17,9 +18,11 @@
"spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"", "spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs", "spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
"test:api": "npm --prefix api test", "test:api": "npm --prefix api test",
"test:frontend": "vitest run",
"test:frontend:coverage": "vitest run --coverage",
"test:gateway": "npm run spike:gateway", "test:gateway": "npm run spike:gateway",
"lint": "npm run quality:verify && tsc --noEmit", "lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && tsc --noEmit",
"format:check": "git diff --check", "format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format",
"quality:verify": "node tools/quality/verify-code-quality.mjs", "quality:verify": "node tools/quality/verify-code-quality.mjs",
"bundle:verify": "node tools/quality/verify-bundle-budget.mjs", "bundle:verify": "node tools/quality/verify-bundle-budget.mjs",
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build", "verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
@@ -30,7 +33,7 @@
"verify:phase6": "npm run verify:phase5", "verify:phase6": "npm run verify:phase5",
"verify:phase7": "npm run verify:phase6", "verify:phase7": "npm run verify:phase6",
"verify:phase8": "npm run verify:phase7", "verify:phase8": "npm run verify:phase7",
"verify:quality": "npm run lint && npm run format:check && npm run build && npm run bundle:verify && npm --prefix api run test:coverage" "verify:quality": "npm run lint && npm run format:check && npm run test:frontend:coverage && npm run build && npm run bundle:verify && npm --prefix api run test:incremental-coverage && npm --prefix api run test:coverage"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
@@ -46,10 +49,23 @@
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"typescript": "^6.0.3" "@vitest/coverage-v8": "^4.1.11",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^30.0.1",
"msw": "^2.15.0",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.68.0",
"vitest": "^4.1.11"
}, },
"overrides": { "overrides": {
"nanoid": "3.3.18", "nanoid": "3.3.18",
+305 -114
View File
@@ -1,10 +1,45 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { ApplicationCmppParams, CaptchaResponse, ClientApplicationReportField, ClientSignatureWorkspace, ClientSmsApplication, ClientSmsSignatureView, ClientSmsTemplate, DashboardResponse, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, EnterpriseCertification, FileObject, HttpApiConfigResponse, HttpApiCredential, HttpApiRequestLog, HttpWebhookDelivery, HttpWebhookEndpoint, ImportPreviewResponse, ManagedUser, OperationLogResponse, PagedResult, RechargeOrder, SmsBatchTask, SmsDrainageInfo, SmsMessageRecord, SmsUplinkMessage, SystemLogExportResult, UserPayload } from '../types'; import type {
ApplicationCmppParams,
CaptchaResponse,
ClientApplicationReportField,
ClientSignatureWorkspace,
ClientSmsApplication,
ClientSmsSignatureView,
ClientSmsTemplate,
DashboardResponse,
DeleteTargetRequest,
DeletionPreflight,
DeletionResult,
DeletionTargetType,
EnterpriseCertification,
FileObject,
HttpApiConfigResponse,
HttpApiCredential,
HttpApiRequestLog,
HttpWebhookDelivery,
HttpWebhookEndpoint,
ImportPreviewResponse,
ManagedUser,
OperationLogResponse,
PagedResult,
RechargeOrder,
SmsBatchTask,
SmsDrainageInfo,
SmsMessageRecord,
SmsUplinkMessage,
SystemLogExportResult,
} from '../types';
import { assertUploadFileSize } from '@/utils/fileUpload'; import { assertUploadFileSize } from '@/utils/fileUpload';
import type { LoginSession } from '../session'; import type { LoginSession } from '../session';
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session'; import {
clearSession,
dispatchSessionEvent,
hasRecentUserActivity,
readSession,
redirectToPortalLogin,
} from '../session';
import { readErrorBody } from '../core/httpClient'; import { readErrorBody } from '../core/httpClient';
import { DEFAULT_CLIENT_TENANT_ID } from '../types';
// Client methods moved intact during R1; tenant and session behavior still flows // Client methods moved intact during R1; tenant and session behavior still flows
// through the shared HTTP client and the existing upload path below. // through the shared HTTP client and the existing upload path below.
@@ -12,126 +47,277 @@ export const clientApi = {
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'), getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }), request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listUsers: ( listUsers: (query: { displayName?: string; login?: string; status?: string } = {}) =>
query: { displayName?: string; login?: string; status?: string } = {}, request<ManagedUser[]>(withQuery('/client/users', query)),
tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID, createUser: (body: {
) => request<ManagedUser[]>(withQuery('/client/users', query), { tenantId }), username?: string;
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => email?: string;
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }), phone?: string;
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => displayName: string;
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), password: string;
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => status?: string;
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }), }) => request<ManagedUser>('/client/users', { method: 'POST', body: JSON.stringify(body) }),
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => updateUser: (
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }), id: string,
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => body: { username?: string; email?: string; phone?: string; displayName?: string; status?: string },
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }), ) => request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => changeUserStatus: (id: string, status: string) =>
request<DashboardResponse>('/client/operations/dashboard', { tenantId }), request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => deleteUser: (id: string) => request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE' }),
request<EnterpriseCertification[]>('/client/enterprise-certification', { tenantId }), changeUserPassword: (id: string, password: string) =>
submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password }) }),
getDashboard: () => request<DashboardResponse>('/client/operations/dashboard'),
listEnterpriseCertifications: () => request<EnterpriseCertification[]>('/client/enterprise-certification'),
submitEnterpriseCertification: (body: {
companyName: string;
licenseNo?: string;
contactName?: string;
contactPhone?: string;
materials?: Record<string, unknown>;
}) =>
request<EnterpriseCertification>('/client/enterprise-certification', { request<EnterpriseCertification>('/client/enterprise-certification', {
method: 'POST', method: 'POST',
tenantId, body: JSON.stringify(body),
body: JSON.stringify({ ...body, tenantId }),
}), }),
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listSystemLogs: (query: {
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }), keyword?: string;
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) => level?: string;
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), module?: string;
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => range?: string;
request<RechargeOrder[]>('/client/billing/orders', { tenantId }), createdAtFrom?: string;
listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => createdAtTo?: string;
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query), { tenantId }), page?: number;
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => pageSize?: number;
request<ClientSmsApplication[]>('/client/applications', { tenantId }), }) => request<OperationLogResponse>(withQuery('/client/operations/system-logs', query)),
listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => exportSystemLogs: (query: {
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query), { tenantId }), keyword?: string;
listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => level?: string;
request<ClientSmsApplication[]>('/client/application-options', { tenantId }), module?: string;
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => range?: string;
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), createdAtFrom?: string;
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }), createdAtTo?: string;
listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }), }) =>
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), request<SystemLogExportResult>('/client/operations/system-logs/exports', {
revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }), method: 'POST',
listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }), body: JSON.stringify(query),
saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), }),
listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`, { tenantId }), listOrders: () => request<RechargeOrder[]>('/client/billing/orders'),
listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }), listOrdersPage: (query: { page: number; pageSize: number }) =>
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }), request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query)),
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listApplications: () => request<ClientSmsApplication[]>('/client/applications'),
request<ClientApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }), listApplicationsPage: (query: { page: number; pageSize: number }) =>
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query)),
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }), listApplicationOptions: () => request<ClientSmsApplication[]>('/client/application-options'),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => getApplicationCmppParams: (applicationId: string) =>
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }), request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`),
listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => getApplicationHttpApiConfig: (applicationId: string) =>
request<ClientSmsSignatureView[]>('/client/signature-options', { tenantId }), request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`),
getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listHttpApiCredentials: (applicationId: string) =>
request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query), { tenantId }), request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }) =>
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, {
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => method: 'POST',
request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), body: JSON.stringify(body),
submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => }),
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), revokeHttpApiCredential: (applicationId: string, credentialId: string) =>
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), `/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`,
createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => { method: 'POST', body: JSON.stringify({}) },
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), ),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listHttpWebhooks: (applicationId: string) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }), request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => saveHttpWebhook: (
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), applicationId: string,
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => eventType: 'receipt' | 'uplink',
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), body: { url: string; rotateSecret?: boolean; status?: string },
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => ) =>
request<ClientSmsTemplate[]>(withQuery('/client/templates', { request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listHttpApiRequests: (applicationId: string) =>
request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`),
listHttpWebhookDeliveries: (applicationId: string) =>
request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`),
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string) =>
request<{ id: string; status: string }>(
`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`,
{ method: 'POST', body: JSON.stringify({}) },
),
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage') =>
request<ClientApplicationReportField[]>(
withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }),
),
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') =>
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType })),
listSignatures: () => request<ClientSmsSignatureView[]>('/client/signatures'),
listSignatureOptions: () => request<ClientSmsSignatureView[]>('/client/signature-options'),
getSignatureWorkspace: (
query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {},
) => request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query)),
createSignature: (body: {
applicationId?: string;
name: string;
purpose?: string;
drainageInfo?: Record<string, unknown>;
}) => request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', body: JSON.stringify(body) }),
updateSignature: (
id: string,
body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> },
) => request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
submitSignature: (id: string) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
createSignatureMaterial: (
id: string,
body: { fileObjectId?: string; materialType: string; title: string; description?: string },
) =>
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, {
method: 'POST',
body: JSON.stringify(body),
}),
createDrainageInfo: (
signatureId: string,
body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> },
) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateDrainageInfo: (
id: string,
body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> },
) => request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}) =>
request<ClientSmsTemplate[]>(
withQuery('/client/templates', {
status: query.status, status: query.status,
keyword: query.keyword, keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}), { tenantId }), }),
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => ),
request<PagedResult<ClientSmsTemplate>>(withQuery('/client/templates', { listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsTemplate>>(
withQuery('/client/templates', {
keyword: query.keyword, keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
page: query.page, page: query.page,
pageSize: query.pageSize, pageSize: query.pageSize,
}), { tenantId }), }),
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => ),
request<ClientSmsTemplate>('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), createTemplate: (body: {
updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => applicationId: string;
request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), signatureId?: string;
submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => name: string;
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), content: string;
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => category?: string;
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), variables?: Array<{ name: string; example?: string; required?: boolean }>;
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => }) => request<ClientSmsTemplate>('/client/templates', { method: 'POST', body: JSON.stringify(body) }),
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`, { tenantId }), updateTemplate: (
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => id: string,
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }), body: {
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => applicationId?: string;
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }), signatureId?: string | null;
listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => name?: string;
request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query), { tenantId }), content?: string;
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => category?: string;
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), auditStatus?: string;
createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => variables?: Array<{ name: string; example?: string; required?: boolean }>;
request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), },
previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => ) => request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), submitTemplate: (id: string) =>
confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), changeTemplateStatus: (id: string, status: string) =>
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`, { tenantId }), getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string) =>
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`),
request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query), { tenantId }), deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest) =>
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }), listBatchTasks: (query: { status?: string } = {}) =>
listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query)),
request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query), { tenantId }), listBatchTasksPage: (query: {
status?: string;
keyword?: string;
applicationKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query)),
cancelBatchTask: (id: string) =>
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', body: JSON.stringify({}) }),
createBatchTask: (body: {
applicationId?: string;
templateId?: string;
content: string;
category?: string;
phones: string[];
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
variables?: Record<string, unknown>;
}) => request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', body: JSON.stringify(body) }),
previewImport: (body: {
applicationId?: string;
content: string;
fileName?: string;
delimiter?: ',' | '\t';
requiredVariables?: string[];
}) => request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', body: JSON.stringify(body) }),
confirmImport: (body: {
applicationId?: string;
templateId?: string;
content: string;
category?: string;
importContent: string;
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
requiredVariables?: string[];
variables?: Record<string, unknown>;
}) => request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', body: JSON.stringify(body) }),
listBatchTaskMessages: (id: string) => request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`),
listMessages: (
query: {
applicationId?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
contentKeyword?: string;
status?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query)),
listUplinkMessages: (
query: {
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
} = {},
) => request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query)),
listUplinkMessagesPage: (query: {
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page: number;
pageSize: number;
}) => request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query)),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => { uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
assertUploadFileSize(file); assertUploadFileSize(file);
const form = new FormData(); const form = new FormData();
@@ -143,7 +329,12 @@ export const clientApi = {
const headers = new Headers(); const headers = new Headers();
const session = readSession('client'); const session = readSession('client');
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); const response = await fetch('/api/client/files/upload', {
method: 'POST',
headers,
body: form,
credentials: 'same-origin',
});
if (response.status === 401 && session) { if (response.status === 401 && session) {
const error = await readErrorBody(response.clone()); const error = await readErrorBody(response.clone());
if (error.code === 'SESSION_LOCKED') { if (error.code === 'SESSION_LOCKED') {
+221
View File
@@ -0,0 +1,221 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { fileDownloadUrl, readErrorBody, request, requestBlob, requestForm, withQuery } from './httpClient';
import { setReauthenticationHandler, writeSession } from '../session';
let receivedTenantHeader: string | null = null;
const server = setupServer(
http.get('http://localhost/api/client/users', ({ request: incoming }) => {
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return HttpResponse.json([]);
}),
http.get('http://localhost/api/admin/users', ({ request: incoming }) => {
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return HttpResponse.json([]);
}),
http.get('http://localhost/api/client/failure', () =>
HttpResponse.json({ message: '后端业务失败' }, { status: 500 }),
),
);
const nativeFetch = globalThis.fetch;
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
const interceptedFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
interceptedFetch(new URL(String(input), 'http://localhost'), init)) as typeof fetch;
});
afterEach(() => {
receivedTenantHeader = null;
server.resetHandlers();
});
afterAll(() => {
server.close();
globalThis.fetch = nativeFetch;
setReauthenticationHandler();
});
describe('request tenant and error boundaries', () => {
it('never forwards an explicit tenant header to a client route', async () => {
await expect(request('/client/users', { tenantId: 'tenant-attacker' })).resolves.toEqual([]);
expect(receivedTenantHeader).toBeNull();
});
it('keeps explicit tenant selection for non-client administrative routes', async () => {
await expect(request('/admin/users', { tenantId: 'tenant-admin-selected' })).resolves.toEqual([]);
expect(receivedTenantHeader).toBe('tenant-admin-selected');
});
it('surfaces a backend JSON error message instead of a generic status', async () => {
await expect(request('/client/failure')).rejects.toThrow('后端业务失败');
});
it.each([
[HttpResponse.json({ message: ['字段一', '字段二'] }, { status: 422 }), '字段一;字段二'],
[HttpResponse.json({ error: '网关错误' }, { status: 502 }), '网关错误'],
[new HttpResponse(null, { status: 503 }), '请求失败(503'],
])('normalizes additional server error body shapes', async (response, expected) => {
server.use(http.get('http://localhost/api/client/error-shape', () => response));
await expect(request('/client/error-shape')).rejects.toThrow(expected);
});
it('parses empty, JSON and plain-text error bodies safely', async () => {
await expect(readErrorBody(new Response())).resolves.toEqual({});
await expect(readErrorBody(HttpResponse.json({ code: 'BAD', message: ['字段一', '字段二'] }))).resolves.toEqual({
code: 'BAD',
message: ['字段一', '字段二'],
});
await expect(readErrorBody(new Response('代理错误'))).resolves.toEqual({ message: '代理错误' });
});
it('does not redirect a rejected login attempt', async () => {
server.use(
http.post('http://localhost/api/client/auth/login', () =>
HttpResponse.json({ message: 'Invalid login or password' }, { status: 401 }),
),
);
await expect(request('/client/auth/login', { method: 'POST', body: '{}' })).rejects.toThrow(
'Invalid login or password',
);
});
it('can suppress a session redirect for a background probe', async () => {
server.use(
http.get('http://localhost/api/client/probe', () => HttpResponse.json({ message: '未登录' }, { status: 401 })),
);
await expect(request('/client/probe', { suppressSessionRedirect: true })).rejects.toThrow('未登录');
});
it('raises the server lock message for an authenticated session', async () => {
writeSession({
portal: 'client',
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
server.use(
http.get('http://localhost/api/client/locked', () =>
HttpResponse.json({ code: 'SESSION_LOCKED', message: '会话测试锁定' }, { status: 401 }),
),
);
await expect(request('/client/locked')).rejects.toThrow('会话测试锁定');
});
it('reauthenticates once and retries a protected request', async () => {
writeSession({
portal: 'client',
user: { id: 'user-1', username: 'user', displayName: '用户', roles: ['enterprise_admin'] },
idleTimeoutSeconds: 7200,
lockRecoverySeconds: 14400,
absoluteExpiresAt: new Date(Date.now() + 3600000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.post('http://localhost/api/client/protected', () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: HttpResponse.json({ success: true });
}),
);
const reauthenticate = vi.fn().mockResolvedValue(undefined);
setReauthenticationHandler(reauthenticate);
await expect(request('/client/protected', { method: 'POST', body: '{}' })).resolves.toEqual({ success: true });
expect(reauthenticate).toHaveBeenCalledOnce();
});
it('downloads blobs and preserves server text errors', async () => {
server.use(
http.get('http://localhost/api/client/file-ok', () => new HttpResponse('file-data', { status: 200 })),
http.get('http://localhost/api/client/file-fail', () => new HttpResponse('文件不存在', { status: 404 })),
);
await expect((await requestBlob('/client/file-ok')).text()).resolves.toBe('file-data');
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
});
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
writeSession({
portal: 'admin',
user: { id: 'a', username: 'a', displayName: 'A', roles: ['platform_admin'] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.get('http://localhost/api/admin/export', ({ request: incoming }) => {
attempts += 1;
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: new HttpResponse('csv');
}),
);
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
await expect((await requestBlob('/admin/export', { tenantId: 'tenant-1' })).text()).resolves.toBe('csv');
expect(receivedTenantHeader).toBe('tenant-1');
});
it('submits multipart forms without forcing a JSON content type', async () => {
let contentType = '';
server.use(
http.post('http://localhost/api/client/upload', ({ request: incoming }) => {
contentType = incoming.headers.get('content-type') ?? '';
return HttpResponse.json({ id: 'file-1' });
}),
);
const form = new FormData();
form.set('file', new Blob(['data']), 'data.txt');
await expect(requestForm('/client/upload', form)).resolves.toEqual({ id: 'file-1' });
expect(contentType).toContain('multipart/form-data; boundary=');
});
it('retries multipart forms after recent authentication and reports failures', async () => {
writeSession({
portal: 'client',
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.post('http://localhost/api/client/form-protected', () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: HttpResponse.json({ ok: true });
}),
);
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
await expect(requestForm('/client/form-protected', new FormData())).resolves.toEqual({ ok: true });
server.use(
http.post('http://localhost/api/client/form-fail', () =>
HttpResponse.json({ error: '上传失败' }, { status: 400 }),
),
);
await expect(requestForm('/client/form-fail', new FormData())).rejects.toThrow('上传失败');
});
it('builds bounded queries and encoded download URLs', () => {
expect(withQuery('/client/messages', { page: 2, status: 'all', keyword: '', level: 'warn' })).toBe(
'/client/messages?page=2&level=warn',
);
expect(fileDownloadUrl('folder/file 1', 'inline', 'client')).toBe(
'/api/client/files/folder%2Ffile%201/download?disposition=inline',
);
expect(withQuery('/client/messages', { status: undefined })).toBe('/client/messages');
expect(fileDownloadUrl('file-1')).toBe('/api/admin/files/file-1/download?disposition=attachment');
});
});
+17 -13
View File
@@ -2,7 +2,6 @@ import {
clearSession, clearSession,
currentRouteForPortal, currentRouteForPortal,
dispatchSessionEvent, dispatchSessionEvent,
getSessionTenantId,
hasRecentUserActivity, hasRecentUserActivity,
portalFromPath, portalFromPath,
readSession, readSession,
@@ -19,7 +18,6 @@ type RequestOptions = RequestInit & {
suppressSessionRedirect?: boolean; suppressSessionRedirect?: boolean;
}; };
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string }; type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
export async function readErrorBody(response: Response): Promise<ApiErrorBody> { export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
@@ -32,8 +30,14 @@ export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
} }
} }
export type SessionTiming = Pick<
export type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>; LoginSession,
| 'idleTimeoutSeconds'
| 'lockRecoverySeconds'
| 'absoluteExpiresAt'
| 'lastActivityAt'
| 'recentAuthenticationExpiresAt'
>;
// Authentication failures are handled centrally so every domain API keeps the // Authentication failures are handled centrally so every domain API keeps the
// same lock, recovery and redirect behavior as the original adminApi facade. // same lock, recovery and redirect behavior as the original adminApi facade.
@@ -87,9 +91,8 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
const portal = requestPortal(path); const portal = requestPortal(path);
const session = portal ? readSession(portal) : null; const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); if (options.tenantId && !path.startsWith('/client')) {
if (tenantId) { headers.set('x-tenant-id', options.tenantId);
headers.set('x-tenant-id', tenantId);
} }
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login'; const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
@@ -114,9 +117,8 @@ export async function requestBlob(path: string, options: RequestOptions = {}): P
const portal = requestPortal(path); const portal = requestPortal(path);
const session = portal ? readSession(portal) : null; const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); if (options.tenantId && !path.startsWith('/client')) {
if (tenantId) { headers.set('x-tenant-id', options.tenantId);
headers.set('x-tenant-id', tenantId);
} }
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401) { if (response.status === 401) {
@@ -156,7 +158,6 @@ export async function requestForm<T>(path: string, form: FormData, reauthenticat
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export function withQuery(path: string, query: Record<string, string | number | undefined>) { export function withQuery(path: string, query: Record<string, string | number | undefined>) {
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => { Object.entries(query).forEach(([key, value]) => {
@@ -168,7 +169,10 @@ export function withQuery(path: string, query: Record<string, string | number |
return `${path}${suffix}`; return `${path}${suffix}`;
} }
export function fileDownloadUrl(
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') { fileObjectId: string,
disposition: 'attachment' | 'inline' = 'attachment',
portal: Portal = 'admin',
) {
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
} }
+9 -6
View File
@@ -1,14 +1,17 @@
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. // R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
export type DeletionTargetType = 'channel' | 'signature' | 'template'; export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks'; export type DeletionResolutionAction =
'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean }; export type DeletionDependency = {
kind: string;
label: string;
count: number;
items: string[];
detailsVisible: boolean;
};
export type DeletionRequiredSelection = { export type DeletionRequiredSelection = {
action: DeletionResolutionAction; action: DeletionResolutionAction;
+95
View File
@@ -0,0 +1,95 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LoginPage, loginErrorMessage } from './LoginPage';
const { getCaptcha, login } = vi.hoisted(() => ({ getCaptcha: vi.fn(), login: vi.fn() }));
vi.mock('@/api/adminApi', () => ({
adminApi: { getCaptcha, login },
clientApi: { getCaptcha, login },
}));
vi.mock('@/components/ui', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
ClientLoginCanvas: () => <div data-testid="client-canvas" />,
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
<label>
{label}
<input {...props} />
</label>
),
Modal: ({ children, open, title }: { children: React.ReactNode; open: boolean; title: string }) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
describe('LoginPage', () => {
beforeEach(() => {
getCaptcha
.mockReset()
.mockResolvedValue({ captchaId: 'captcha-1', challenge: '12 + 3 = ?', expiresInSeconds: 300 });
login.mockReset();
});
it('renders client-only website return action and loads a captcha', async () => {
render(
<MemoryRouter>
<LoginPage portal="client" />
</MemoryRouter>,
);
expect(screen.getByRole('link', { name: '返回官网' })).toHaveAttribute('href', 'https://www.lisglo.com');
expect(await screen.findByRole('button', { name: '12 + 3 = ?' })).toBeEnabled();
});
it('maps backend login errors to Chinese and refreshes the one-time captcha', async () => {
login.mockRejectedValue(new Error('Invalid login or password'));
render(
<MemoryRouter>
<LoginPage portal="client" />
</MemoryRouter>,
);
await screen.findByRole('button', { name: '12 + 3 = ?' });
fireEvent.change(screen.getByLabelText('用户名/登录账号'), { target: { value: 'bad-user' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'bad-password' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: '15' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(screen.getAllByText('用户名或密码错误')).toHaveLength(2));
expect(getCaptcha).toHaveBeenCalledTimes(2);
});
it('does not render client artwork or the website link for admin login', async () => {
render(
<MemoryRouter>
<LoginPage portal="admin" />
</MemoryRouter>,
);
await screen.findByRole('button', { name: '12 + 3 = ?' });
expect(screen.queryByTestId('client-canvas')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: '返回官网' })).not.toBeInTheDocument();
expect(screen.getByText('运营端登录')).toBeVisible();
});
it.each([
['login and password are required', '请输入用户名和密码'],
['Captcha expired', '验证码已过期,请刷新后重试'],
['Captcha is incorrect', '验证码错误,请重新输入'],
['User is locked', '账号已锁定,请 24 小时后再试'],
['Too many login attempts from this source', '登录尝试过于频繁,请稍后再试'],
['User is disabled or deleted', '账号已停用或已删除'],
['Only platform admins can login to admin portal', '该账号不是运营端管理员'],
['Only enterprise admins linked to a tenant can login to client portal', '该账号不是已绑定企业的客户端管理员'],
['unknown backend error', 'unknown backend error'],
])('maps backend error %s to a stable user message', (backend, expected) => {
expect(loginErrorMessage(new Error(backend))).toBe(expected);
});
it('uses a safe fallback for non-error failures', () => {
expect(loginErrorMessage(null)).toBe('登录失败,请检查账号信息后重试');
});
});
+38 -8
View File
@@ -1,23 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi'; import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session'; import {
consumeSessionRecovery,
markUserActivity,
readSessionRecovery,
writeSession,
type Portal,
} from '@/api/session';
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui'; import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
type LoginPageProps = { type LoginPageProps = {
portal: Portal; portal: Portal;
}; };
function loginErrorMessage(err: unknown) { export function loginErrorMessage(err: unknown) {
const message = err instanceof Error ? err.message : ''; const message = err instanceof Error ? err.message : '';
if (message.includes('Invalid login or password')) return '用户名或密码错误'; if (message.includes('Invalid login or password')) return '用户名或密码错误';
if (message.includes('login and password are required')) return '请输入用户名和密码'; if (message.includes('login and password are required')) return '请输入用户名和密码';
if (message.includes('Captcha expired')) return '验证码已过期,请刷新后重试'; if (message.includes('Captcha expired')) return '验证码已过期,请刷新后重试';
if (message.includes('Captcha is incorrect')) return '验证码错误,请重新输入'; if (message.includes('Captcha is incorrect')) return '验证码错误,请重新输入';
if (message.includes('User is locked')) return '账号已锁定,请 24 小时后再试'; if (message.includes('User is locked')) return '账号已锁定,请 24 小时后再试';
if (message.includes('Too many login attempts')) return '登录尝试过于频繁,请稍后再试';
if (message.includes('User is disabled or deleted')) return '账号已停用或已删除'; if (message.includes('User is disabled or deleted')) return '账号已停用或已删除';
if (message.includes('Only platform admins can login to admin portal')) return '该账号不是运营端管理员'; if (message.includes('Only platform admins can login to admin portal')) return '该账号不是运营端管理员';
if (message.includes('Only enterprise admins linked to a tenant can login to client portal')) return '该账号不是已绑定企业的客户端管理员'; if (message.includes('Only enterprise admins linked to a tenant can login to client portal'))
return '该账号不是已绑定企业的客户端管理员';
return message || '登录失败,请检查账号信息后重试'; return message || '登录失败,请检查账号信息后重试';
} }
@@ -90,17 +98,39 @@ export function LoginPage({ portal }: LoginPageProps) {
{recovery.message ?? '登录会话已失效,请重新登录。'} 访 {recovery.message ?? '登录会话已失效,请重新登录。'} 访
</p> </p>
) : null} ) : null}
<Input label="用户名/登录账号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} /> <Input
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} /> label="用户名/登录账号"
onChange={(event) => setLogin(event.target.value)}
placeholder="请输入用户名、邮箱或手机号"
value={login}
/>
<Input
label="密码"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
<div className="login-captcha-row"> <div className="login-captcha-row">
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} /> <Input
label="图形验证码"
onChange={(event) => setCaptchaText(event.target.value)}
placeholder="请输入计算结果"
value={captchaText}
/>
<button className="login-captcha" onClick={() => void refreshCaptcha()} type="button"> <button className="login-captcha" onClick={() => void refreshCaptcha()} type="button">
{captcha?.challenge ?? '刷新'} {captcha?.challenge ?? '刷新'}
</button> </button>
</div> </div>
{error ? <p className="login-error">{error}</p> : null} {error ? <p className="login-error">{error}</p> : null}
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button> <Button disabled={loading} onClick={submit}>
{!isAdmin ? <a className="login-return-link" href="https://www.lisglo.com"></a> : null} {loading ? '登录中...' : '登录'}
</Button>
{!isAdmin ? (
<a className="login-return-link" href="https://www.lisglo.com">
</a>
) : null}
</div> </div>
</section> </section>
<Modal <Modal
+143
View File
@@ -0,0 +1,143 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientUsersPage } from './ClientUsersPage';
const { clientApi } = vi.hoisted(() => ({
clientApi: {
listUsers: vi.fn(),
deleteUser: vi.fn(),
changeUserStatus: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
changeUserPassword: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
vi.mock('@/components/ui', () => ({
Button: ({
children,
icon: _icon,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => (
<button {...props}>{children}</button>
),
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
<label>
{label}
<input {...props} />
</label>
),
Select: ({
label,
options,
...props
}: React.SelectHTMLAttributes<HTMLSelectElement> & {
label: string;
options: Array<{ label: string; value: string }>;
}) => (
<label>
{label}
<select {...props}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
),
Tag: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Table: ({
columns,
data,
emptyText,
}: {
columns: Array<{ key: string; render?: (record: never) => React.ReactNode }>;
data: never[];
emptyText: string;
}) =>
data.length ? (
<div>
{data.map((row: never, index: number) => (
<div key={index}>
{columns.map((column) => (
<span key={column.key}>{column.render?.(row)}</span>
))}
</div>
))}
</div>
) : (
<p>{emptyText}</p>
),
Modal: ({
children,
footer,
open,
title,
}: {
children: React.ReactNode;
footer: React.ReactNode;
open: boolean;
title: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
{footer}
</div>
) : null,
}));
const user = {
id: 'user-1',
displayName: '截图用户',
username: 'screenshot',
email: 'user@example.com',
phone: '13800000000',
status: 'active',
roles: ['enterprise_admin'],
};
describe('ClientUsersPage states', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
});
it('renders the empty state from a successful real API-shaped response', async () => {
clientApi.listUsers.mockResolvedValue([]);
render(<ClientUsersPage />);
expect(await screen.findByText('暂无用户')).toBeVisible();
});
it('renders a backend loading error', async () => {
clientApi.listUsers.mockRejectedValue(new Error('用户服务暂不可用'));
render(<ClientUsersPage />);
expect(await screen.findByText('用户服务暂不可用')).toBeVisible();
});
it('shows query loading and requires confirmation before deletion', async () => {
let resolveQuery: (value: (typeof user)[]) => void = () => undefined;
clientApi.listUsers
.mockResolvedValueOnce([user])
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveQuery = resolve;
}),
)
.mockResolvedValueOnce([]);
clientApi.deleteUser.mockResolvedValue(user);
render(<ClientUsersPage />);
await screen.findByText('截图用户');
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(screen.getByRole('button', { name: '查询中...' })).toBeDisabled();
resolveQuery([user]);
await screen.findByRole('button', { name: '查询' });
fireEvent.click(screen.getByRole('button', { name: '删除' }));
expect(screen.getByRole('dialog', { name: '删除用户' })).toHaveTextContent('确认删除用户 截图用户');
expect(clientApi.deleteUser).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => expect(clientApi.deleteUser).toHaveBeenCalledWith('user-1'));
});
});
+239 -52
View File
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react'; import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi'; import { clientApi, type ManagedUser } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { readSession } from '@/api/session';
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import './ClientUsersPage.css'; import './ClientUsersPage.css';
@@ -42,19 +41,19 @@ const emptyFilters: UserFilters = {
}; };
function toForm(user?: ManagedUser): UserForm { function toForm(user?: ManagedUser): UserForm {
return user ? { return user
? {
displayName: user.displayName, displayName: user.displayName,
username: user.username, username: user.username,
email: user.email ?? '', email: user.email ?? '',
phone: user.phone ?? '', phone: user.phone ?? '',
status: user.status, status: user.status,
password: '', password: '',
} : emptyForm; }
: emptyForm;
} }
export function ClientUsersPage() { export function ClientUsersPage() {
const session = readSession('client');
const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState<ManagedUser[]>([]); const [users, setUsers] = useState<ManagedUser[]>([]);
const [filters, setFilters] = useState<UserFilters>(emptyFilters); const [filters, setFilters] = useState<UserFilters>(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters); const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
@@ -72,16 +71,15 @@ export function ClientUsersPage() {
const [querying, setQuerying] = useState(false); const [querying, setQuerying] = useState(false);
async function loadUsers(query: UserFilters = appliedFilters) { async function loadUsers(query: UserFilters = appliedFilters) {
if (!tenantId) return; setUsers(await clientApi.listUsers(query));
setUsers(await clientApi.listUsers(query, tenantId));
} }
useEffect(() => { useEffect(() => {
if (!tenantId) return; void clientApi
void clientApi.listUsers({}, tenantId) .listUsers({})
.then(setUsers) .then(setUsers)
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败')); .catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
}, [tenantId]); }, []);
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) { function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value })); setFilters((current) => ({ ...current, [key]: value }));
@@ -122,26 +120,28 @@ export function ClientUsersPage() {
} }
async function saveUser() { async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) { if (
!form.displayName.trim() ||
(!form.email.trim() && !form.phone.trim()) ||
(creating && form.password.length < 6)
) {
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位'); setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return; return;
} }
setSaving(true); setSaving(true);
setFormError(''); setFormError('');
const body: UserPayload = { const body = {
displayName: form.displayName, displayName: form.displayName,
username: form.username || form.email || form.phone, username: form.username || form.email || form.phone,
email: form.email, email: form.email,
phone: form.phone, phone: form.phone,
status: form.status, status: form.status,
roleCode: 'enterprise_admin',
operatorId: session?.user.id,
}; };
try { try {
if (creating) { if (creating) {
await clientApi.createUser({ ...body, password: form.password }, tenantId); await clientApi.createUser({ ...body, password: form.password });
} else if (editingUser) { } else if (editingUser) {
await clientApi.updateUser(editingUser.id, body, tenantId); await clientApi.updateUser(editingUser.id, body);
} }
setCreating(false); setCreating(false);
setEditingUser(null); setEditingUser(null);
@@ -159,9 +159,12 @@ export function ClientUsersPage() {
setConfirmError(''); setConfirmError('');
try { try {
if (confirmAction.type === 'delete') { if (confirmAction.type === 'delete') {
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId); await clientApi.deleteUser(confirmAction.user.id);
} else { } else {
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId); await clientApi.changeUserStatus(
confirmAction.user.id,
confirmAction.user.status === 'active' ? 'disabled' : 'active',
);
} }
} catch (failure) { } catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败'; const detail = failure instanceof Error ? failure.message : '用户操作失败';
@@ -181,55 +184,136 @@ export function ClientUsersPage() {
async function savePassword() { async function savePassword() {
if (!passwordUser) return; if (!passwordUser) return;
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId); await clientApi.changeUserPassword(passwordUser.id, newPassword);
setPasswordUser(null); setPasswordUser(null);
setNewPassword(''); setNewPassword('');
} }
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [ const columns = useMemo<Array<TableColumn<ManagedUser>>>(
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> }, () => [
{
key: 'name',
title: '用户名',
width: '140px',
render: (record) => <strong className="text-strong">{record.displayName}</strong>,
},
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> }, { key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> }, {
key: 'phone',
title: '手机号',
width: '160px',
render: (record) => <span className="muted">{record.phone ?? '-'}</span>,
},
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info"></Tag> }, { key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info"></Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> }, {
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span> }, key: 'status',
title: '状态',
width: '120px',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'neutral'}>
{record.status === 'active' ? '正常' : '禁用'}
</Tag>
),
},
{
key: 'lastLoginAt',
title: '最后登录时间',
width: '190px',
render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span>,
},
{ {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
width: '290px', width: '290px',
render: (record) => ( render: (record) => (
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}> <div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button> <Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button> </Button>
<Button icon={<Trash2 size={15} />} onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger"></Button> <Button
icon={<KeyRound size={15} />}
onClick={() => {
setPasswordUser(record);
setNewPassword('');
}}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => openConfirm({ type: 'status', user: record })}
size="sm"
variant={record.status === 'active' ? 'warning' : 'success'}
>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button
icon={<Trash2 size={15} />}
onClick={() => openConfirm({ type: 'delete', user: record })}
size="sm"
variant="danger"
>
</Button>
</div> </div>
), ),
}, },
], []); ],
[],
);
return ( return (
<section className="page-stack system-page"> <section className="page-stack system-page">
<div className="system-page-toolbar"> <div className="system-page-toolbar">
<div className="sms-send-title"> <div className="sms-send-title">
<span className="sms-send-title__icon"><Users size={22} /></span> <span className="sms-send-title__icon">
<Users size={22} />
</span>
<h1></h1> <h1></h1>
</div> </div>
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm"></Button> <Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">
</Button>
</div> </div>
<div className="surface system-filter-row client-user-filter"> <div className="surface system-filter-row client-user-filter">
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} /> <Input
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} /> label="用户姓名"
onChange={(event) => updateFilter('displayName', event.target.value)}
placeholder="请输入用户姓名"
value={filters.displayName}
/>
<Input
label="登录账号"
onChange={(event) => updateFilter('login', event.target.value)}
placeholder="用户名、邮箱或手机号"
value={filters.login}
/>
<Select <Select
label="状态" label="状态"
onChange={(event) => updateFilter('status', event.target.value)} onChange={(event) => updateFilter('status', event.target.value)}
options={[{ label: '全部状态', value: '' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} options={[
{ label: '全部状态', value: '' },
{ label: '正常', value: 'active' },
{ label: '禁用', value: 'disabled' },
]}
value={filters.status} value={filters.status}
/> />
<div className="client-user-filter__actions"> <div className="client-user-filter__actions">
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button> <Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="secondary"></Button> {querying ? '查询中...' : '查询'}
</Button>
<Button
disabled={querying}
onClick={() => {
setFilters(emptyFilters);
void queryUsers(emptyFilters);
}}
variant="secondary"
>
</Button>
</div> </div>
</div> </div>
{error ? <div className="surface empty-state">{error}</div> : null} {error ? <div className="surface empty-state">{error}</div> : null}
@@ -237,38 +321,141 @@ export function ClientUsersPage() {
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" /> <Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
</div> </div>
{(creating || editingUser) ? ( {creating || editingUser ? (
<Modal <Modal
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary"></Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>} footer={
onClose={() => { setCreating(false); setEditingUser(null); }} <>
<Button
disabled={saving}
onClick={() => {
setCreating(false);
setEditingUser(null);
}}
variant="secondary"
>
</Button>
<Button disabled={saving} onClick={() => void saveUser()}>
{saving ? '保存中...' : '保存'}
</Button>
</>
}
onClose={() => {
setCreating(false);
setEditingUser(null);
}}
open open
size="xl" size="xl"
title={creating ? '添加用户' : '编辑用户'} title={creating ? '添加用户' : '编辑用户'}
> >
<div className="system-user-form"> <div className="system-user-form">
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} /> <Input
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} /> label="用户名 *"
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} /> onChange={(event) => updateField('displayName', event.target.value)}
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} /> placeholder="请输入用户名"
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null} value={form.displayName}
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} /> />
{formError ? <p className="form-error" role="alert">{formError}</p> : null} <Input
label="邮箱 *"
onChange={(event) => updateField('email', event.target.value)}
placeholder="请输入邮箱"
value={form.email}
/>
<Input
label="手机号 *"
onChange={(event) => updateField('phone', event.target.value)}
placeholder="请输入手机号"
value={form.phone}
/>
<Input
hint="可用用户名、邮箱或手机号登录"
label="用户名/登录账号"
onChange={(event) => updateField('username', event.target.value)}
value={form.username}
/>
{creating ? (
<Input
label="初始密码 *"
onChange={(event) => updateField('password', event.target.value)}
type="password"
value={form.password}
/>
) : null}
<Select
label="状态 *"
onChange={(event) => updateField('status', event.target.value)}
options={[
{ label: '正常', value: 'active' },
{ label: '禁用', value: 'disabled' },
]}
value={form.status}
/>
{formError ? (
<p className="form-error" role="alert">
{formError}
</p>
) : null}
</div> </div>
</Modal> </Modal>
) : null} ) : null}
{passwordUser ? ( {passwordUser ? (
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary"></Button><Button onClick={() => void savePassword()}></Button></>} onClose={() => setPasswordUser(null)} open title="修改密码"> <Modal
footer={
<>
<Button onClick={() => setPasswordUser(null)} variant="secondary">
</Button>
<Button onClick={() => void savePassword()}></Button>
</>
}
onClose={() => setPasswordUser(null)}
open
title="修改密码"
>
<div className="system-user-form"> <div className="system-user-form">
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} /> <Input
label="新密码"
onChange={(event) => setNewPassword(event.target.value)}
type="password"
value={newPassword}
/>
</div> </div>
</Modal> </Modal>
) : null} ) : null}
{confirmAction ? ( {confirmAction ? (
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary"></Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}> <Modal
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p> footer={
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null} <>
<Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary">
</Button>
<Button
disabled={confirming}
onClick={() => void runConfirm()}
variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}
>
{confirming ? '处理中...' : '确认'}
</Button>
</>
}
onClose={() => {
if (!confirming) setConfirmAction(null);
}}
open
title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}
>
<p>
{confirmAction.type === 'delete'
? `确认删除用户 ${confirmAction.user.displayName}`
: `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}
</p>
{confirmError ? (
<p className="form-error" role="alert">
{confirmError}
</p>
) : null}
</Modal> </Modal>
) : null} ) : null}
</section> </section>
+29
View File
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RouteLoadBoundary } from './RouteLoadBoundary';
function BrokenRoute(): never {
throw new Error('chunk load failed');
}
describe('RouteLoadBoundary', () => {
it('shows a recoverable Chinese error state when a route chunk throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
render(
<RouteLoadBoundary>
<BrokenRoute />
</RouteLoadBoundary>,
);
expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载失败');
expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled();
});
it('renders healthy route content unchanged', () => {
render(
<RouteLoadBoundary>
<p></p>
</RouteLoadBoundary>,
);
expect(screen.getByText('正常页面')).toBeVisible();
});
});
+9
View File
@@ -0,0 +1,9 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
window.localStorage.clear();
window.sessionStorage.clear();
});
@@ -0,0 +1,31 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..');
const mode = process.argv[2];
if (!['lint', 'format'].includes(mode)) throw new Error('Usage: node run-changed-code-quality.mjs <lint|format>');
const base = process.env.QUALITY_BASE_REF ?? 'HEAD';
const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base, '--'], {
cwd: root,
encoding: 'utf8',
});
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) =>
/^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?)$/.test(file),
);
if (!files.length) {
console.log(`No changed code files require ${mode}.`);
process.exit(0);
}
const executable =
process.platform === 'win32'
? `${mode === 'lint' ? 'eslint' : 'prettier'}.cmd`
: mode === 'lint'
? 'eslint'
: 'prettier';
const args = mode === 'lint' ? files : ['--check', ...files];
const result = spawnSync(resolve(root, 'node_modules/.bin', executable), args, { cwd: root, stdio: 'inherit' });
process.exit(result.status ?? 1);
+56 -11
View File
@@ -5,15 +5,24 @@ import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..'); const root = resolve(import.meta.dirname, '../..');
const violations = []; const violations = [];
const productionFiles = execFileSync('git', ['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'], { cwd: root, encoding: 'utf8' }) const productionFiles = execFileSync(
.split(/\r?\n/).filter(Boolean); 'git',
['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'],
{ cwd: root, encoding: 'utf8' },
)
.split(/\r?\n/)
.filter(Boolean);
for (const file of productionFiles) { for (const file of productionFiles) {
const content = readFileSync(resolve(root, file), 'utf8'); const content = readFileSync(resolve(root, file), 'utf8');
if (/from\s+['"]@\/mock(?:\/|['"])/.test(content)) violations.push(`${file}: production code imports @/mock`); if (/from\s+['"]@\/mock(?:\/|['"])/.test(content)) violations.push(`${file}: production code imports @/mock`);
} }
const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], { cwd: root, encoding: 'utf8' }) const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], {
.split(/\r?\n/).filter(Boolean); cwd: root,
encoding: 'utf8',
})
.split(/\r?\n/)
.filter(Boolean);
for (const file of clientControllers) { for (const file of clientControllers) {
const content = readFileSync(resolve(root, file), 'utf8'); const content = readFileSync(resolve(root, file), 'utf8');
const clientClassOffset = content.indexOf('export class Client'); const clientClassOffset = content.indexOf('export class Client');
@@ -23,8 +32,26 @@ for (const file of clientControllers) {
} }
} }
const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], { cwd: root, encoding: 'utf8' }).trim(); const clientApi = readFileSync(resolve(root, 'src/api/client/client.api.ts'), 'utf8');
if (trackedBuildCaches) violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`); if (/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId|x-tenant-id/.test(clientApi)) {
violations.push(
'src/api/client/client.api.ts: client requests must derive tenant scope from the authenticated server session',
);
}
const httpClient = readFileSync(resolve(root, 'src/api/core/httpClient.ts'), 'utf8');
if (
/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId/.test(httpClient) ||
!httpClient.includes("options.tenantId && !path.startsWith('/client')")
) {
violations.push('src/api/core/httpClient.ts: tenant headers must be reserved for explicit non-client operations');
}
const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], {
cwd: root,
encoding: 'utf8',
}).trim();
if (trackedBuildCaches)
violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`);
const usersService = readFileSync(resolve(root, 'api/src/users/users.service.ts'), 'utf8'); const usersService = readFileSync(resolve(root, 'api/src/users/users.service.ts'), 'utf8');
if (/createHash\(['"]sha256['"]\)/.test(usersService)) { if (/createHash\(['"]sha256['"]\)/.test(usersService)) {
@@ -35,14 +62,20 @@ if (/passwordHash\s*===|===\s*[^\n;]*passwordHash/.test(authService)) {
violations.push('api/src/auth/auth.service.ts: password hashes must not be compared directly'); violations.push('api/src/auth/auth.service.ts: password hashes must not be compared directly');
} }
const ensureAdmin = readFileSync(resolve(root, 'tools/deploy/ensure-production-admin.mjs'), 'utf8'); const ensureAdmin = readFileSync(resolve(root, 'tools/deploy/ensure-production-admin.mjs'), 'utf8');
if (/createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin) if (
|| !ensureAdmin.includes("api/dist/auth/password-hasher.js")) { /createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin) ||
violations.push('tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher'); !ensureAdmin.includes('api/dist/auth/password-hasher.js')
) {
violations.push(
'tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher',
);
} }
for (const relativePath of ['tools/deploy/ensure-production-admin.mjs', 'tools/smoke/real-env-smoke.mjs']) { for (const relativePath of ['tools/deploy/ensure-production-admin.mjs', 'tools/smoke/real-env-smoke.mjs']) {
const content = readFileSync(resolve(root, relativePath), 'utf8'); const content = readFileSync(resolve(root, relativePath), 'utf8');
if (/function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content) if (
|| !content.includes("api/dist/auth/password-hasher.js")) { /function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content) ||
!content.includes('api/dist/auth/password-hasher.js')
) {
violations.push(`${relativePath}: user password writes must use the compiled API password hasher`); violations.push(`${relativePath}: user password writes must use the compiled API password hasher`);
} }
} }
@@ -54,6 +87,18 @@ if (packageJson.dependencies?.['react-router-dom'] !== '7.18.2') {
if (packageJson.overrides?.nanoid !== '3.3.18') { if (packageJson.overrides?.nanoid !== '3.3.18') {
violations.push('package.json: nanoid override must remain on the remediated 3.3.18 baseline'); violations.push('package.json: nanoid override must remain on the remediated 3.3.18 baseline');
} }
const trackedAlternativeLocks = execFileSync('git', ['ls-files', 'pnpm-lock.yaml', 'yarn.lock'], {
cwd: root,
encoding: 'utf8',
}).trim();
if (trackedAlternativeLocks) {
violations.push(
`${trackedAlternativeLocks.replace(/\r?\n/g, ', ')}: npm/package-lock.json is the only supported committed dependency lock`,
);
}
if (packageJson.packageManager !== 'npm@11.6.2') {
violations.push('package.json: packageManager must pin the supported npm baseline');
}
if (violations.length) { if (violations.length) {
console.error(violations.map((item) => `ERROR: ${item}`).join('\n')); console.error(violations.map((item) => `ERROR: ${item}`).join('\n'));
@@ -9,7 +9,10 @@ const apiLock = JSON.parse(readFileSync(join(workspaceRoot, 'api', 'package-lock
assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss'); assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss');
assertVersionAtLeast(rootLock.packages['node_modules/react-router']?.version, [7, 18, 2], 'react-router'); assertVersionAtLeast(rootLock.packages['node_modules/react-router']?.version, [7, 18, 2], 'react-router');
assertVersionAtLeast(rootLock.packages['node_modules/nanoid']?.version, [3, 3, 18], 'nanoid'); assertVersionAtLeast(rootLock.packages['node_modules/nanoid']?.version, [3, 3, 18], 'nanoid');
assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.8', 'brace-expansion-safe'); assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.9', 'brace-expansion-safe');
assertEqual(apiLock.packages['node_modules/fast-uri']?.version, '3.1.5', 'fast-uri');
assertEqual(apiLock.packages['node_modules/js-yaml']?.version, '4.3.1', 'js-yaml');
assertEqual(apiLock.packages['node_modules/@nestjs/swagger']?.version, '11.4.7', '@nestjs/swagger');
assertEqual( assertEqual(
apiLock.packages['node_modules/brace-expansion']?.resolved, apiLock.packages['node_modules/brace-expansion']?.resolved,
'vendor/brace-expansion-compat', 'vendor/brace-expansion-compat',
@@ -35,7 +38,7 @@ for (const filePath of sourceFiles(join(workspaceRoot, 'src'))) {
const apiRequire = createRequire(join(workspaceRoot, 'api', 'package.json')); const apiRequire = createRequire(join(workspaceRoot, 'api', 'package.json'));
const expand = apiRequire('brace-expansion'); const expand = apiRequire('brace-expansion');
if (typeof expand !== 'function' || expand.EXPANSION_MAX_LENGTH !== 4_000_000) { if (typeof expand !== 'function' || expand.EXPANSION_MAX_LENGTH !== 4_000_000) {
throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.8 implementation'); throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.9 implementation');
} }
assertEqual(expand('{a,b}{1,2}').join(','), 'a1,a2,b1,b2', 'brace-expansion legacy API'); assertEqual(expand('{a,b}{1,2}').join(','), 'a1,a2,b1,b2', 'brace-expansion legacy API');
@@ -51,7 +54,9 @@ for (const relativePath of [
} }
} }
console.log('Dependency mitigations verified: PostCSS, React Router and NanoID patched; RSC unused; brace expansion bounded and compatible.'); console.log(
'Dependency mitigations verified: frontend baselines, Swagger YAML/URI parsers and bounded brace expansion are patched.',
);
function sourceFiles(directory) { function sourceFiles(directory) {
return readdirSync(directory).flatMap((name) => { return readdirSync(directory).flatMap((name) => {
@@ -64,7 +69,11 @@ function sourceFiles(directory) {
function assertVersionAtLeast(actual, minimum, label) { function assertVersionAtLeast(actual, minimum, label) {
if (!actual) throw new Error(`${label} is missing from package-lock.json`); if (!actual) throw new Error(`${label} is missing from package-lock.json`);
const parts = actual.split('.').map((part) => Number(part.replace(/\D.*$/u, ''))); const parts = actual.split('.').map((part) => Number(part.replace(/\D.*$/u, '')));
if (minimum.some((value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item))) { if (
minimum.some(
(value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item),
)
) {
throw new Error(`${label} ${actual} is older than ${minimum.join('.')}`); throw new Error(`${label} ${actual} is older than ${minimum.join('.')}`);
} }
} }
+13 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
@@ -34,4 +34,16 @@ export default defineConfig({
}, },
}, },
}, },
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
exclude: ['api/**', 'outputs/**', 'dist/**', 'node_modules/**'],
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary'],
include: ['src/api/core/**/*.ts', 'src/apps/LoginPage.tsx', 'src/routes/RouteLoadBoundary.tsx'],
thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 },
},
},
}); });