Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65082959c0 | ||
|
|
5925cf493b | ||
|
|
aaf96db2d0 | ||
|
|
857171ce9d | ||
|
|
41962e7a6e | ||
|
|
0ec386e674 | ||
|
|
c88d172af6 | ||
|
|
bb435fb0ac | ||
|
|
fb39c8b606 | ||
|
|
48d0363920 | ||
|
|
bc18c7ff12 | ||
|
|
dada0d978b | ||
|
|
fe3c6e5b58 | ||
|
|
7b8a613afa | ||
|
|
4ff6ed0786 | ||
|
|
55915ed826 | ||
|
|
b0016cfd8d | ||
|
|
dc201bf92e | ||
|
|
a50aafb1ec | ||
|
|
19bcca812b | ||
|
|
068b8047dd | ||
|
|
fe5b25a7e4 | ||
|
|
7cb5dd376e | ||
|
|
2dff1be750 | ||
|
|
ad89e8fed7 | ||
|
|
9b8196ecab | ||
|
|
53d3668306 | ||
|
|
cd824999f3 | ||
|
|
9e34757d6f | ||
|
|
1f2dfb5caf | ||
|
|
12668cee87 | ||
|
|
9a15d28da5 | ||
|
|
fd373d9708 | ||
|
|
b0deef5e6e | ||
|
|
119d57772e | ||
|
|
1a5063a5d9 | ||
|
|
67774509d9 | ||
|
|
4d526b83ab | ||
|
|
5328bb09bf | ||
|
|
c68ac7a3db | ||
|
|
9ac41e9308 | ||
|
|
3834d67a30 | ||
|
|
226527f1ce | ||
|
|
ad27acad7e | ||
|
|
3af145abe5 | ||
|
|
fc4a6a7afc | ||
|
|
d85ff85999 | ||
|
|
2744690f9f | ||
|
|
c3bf8af3e6 | ||
|
|
0b84370270 | ||
|
|
171c7d38e8 | ||
|
|
a70e9e2c07 | ||
|
|
4d4c1f39d4 | ||
|
|
5bd347e010 | ||
|
|
088d7aaacb | ||
|
|
33fa3709d9 | ||
|
|
ec721bf95a | ||
|
|
070a951e7c | ||
|
|
01cffa4758 | ||
|
|
d6edb88b76 | ||
|
|
a280b4bb22 | ||
|
|
898471423f | ||
|
|
444c65288c | ||
|
|
5234812990 | ||
|
|
c769b60aaa | ||
|
|
b0bdb608c1 | ||
|
|
b7ae1aa41a | ||
|
|
8170f727a3 | ||
|
|
4a17df78b8 | ||
|
|
2a181777ad | ||
|
|
b1e297245c | ||
|
|
b5005f21d5 | ||
|
|
394949f9f6 | ||
|
|
76e7c8c401 | ||
|
|
d3ceeb1e16 | ||
|
|
9292352be1 | ||
|
|
761c123b65 | ||
|
|
c6f11014d6 | ||
|
|
03f72c87de | ||
|
|
2b5256d4a1 | ||
|
|
90345fba22 | ||
|
|
3c6f1beed1 | ||
|
|
fcf6d3e6b4 | ||
|
|
487b5282a6 | ||
|
|
0176aa6952 | ||
|
|
52028b9bbd | ||
|
|
57192b7586 | ||
|
|
f4560479d3 | ||
|
|
99fb346566 | ||
|
|
633e7a7055 | ||
|
|
229a0b28fd | ||
|
|
6708f1f7c5 | ||
|
|
53073461e9 | ||
|
|
0b63bcd74e | ||
|
|
26ef67fb6a | ||
|
|
14f993c1f8 | ||
|
|
67b760a599 | ||
|
|
485af688d2 | ||
|
|
e9c73333b3 | ||
|
|
0757a699ff | ||
|
|
b9a71fe0b9 | ||
|
|
c4f36fc50d | ||
|
|
482f7ac1ae | ||
|
|
79f5d3f215 | ||
|
|
2216d00d51 | ||
|
|
0cd09441da | ||
|
|
6ccc102830 | ||
|
|
1ef4380422 | ||
|
|
d30d9ea4d0 | ||
|
|
b78faa1aa2 | ||
|
|
96e475d60d | ||
|
|
433b2ee56f | ||
|
|
67fee21616 | ||
|
|
fb02cbcf39 | ||
|
|
4c70978da4 | ||
|
|
16135e5a3e | ||
|
|
4994841709 | ||
|
|
1d8d6701a6 | ||
|
|
f350bf5ef3 | ||
|
|
dc358798e9 | ||
|
|
0cd353450a | ||
|
|
e64b5e23fe | ||
|
|
2ecb24cf8d | ||
|
|
827d8921a8 | ||
|
|
0eb27e4ac0 | ||
|
|
55aa054005 | ||
|
|
232d1c22a3 | ||
|
|
e0c8f82bcf | ||
|
|
35de17a2d4 | ||
|
|
608662a054 | ||
|
|
78b839f468 | ||
|
|
482d332f49 | ||
|
|
7804f64ced | ||
|
|
6add563ee8 | ||
|
|
4724b9db6a | ||
|
|
44352aeb2f |
@@ -10,13 +10,29 @@ REDIS_URL=redis://127.0.0.1:6379
|
|||||||
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
|
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
|
||||||
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
|
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
|
||||||
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
|
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
|
||||||
|
# Keep false in production. Only isolated test environments without TLS may opt in to HTTP.
|
||||||
|
HTTP_API_ALLOW_INSECURE_ORIGIN=false
|
||||||
API_ENABLE_SEND_WORKER=true
|
API_ENABLE_SEND_WORKER=true
|
||||||
API_SEND_WORKER_CONCURRENCY=50
|
API_SEND_WORKER_CONCURRENCY=50
|
||||||
|
API_WORKER_DATABASE_URL=
|
||||||
|
API_WORKER_METRICS_HOST=127.0.0.1
|
||||||
|
API_WORKER_METRICS_PORT=9465
|
||||||
|
CMPP_INBOUND_FAST_PATH_ENABLED=true
|
||||||
|
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED=true
|
||||||
|
API_INBOUND_WORKFLOW_CONCURRENCY=32
|
||||||
|
API_INBOUND_WORKFLOW_BATCH_ENABLED=true
|
||||||
|
API_INBOUND_WORKFLOW_BATCH_SIZE=64
|
||||||
|
API_INBOUND_WORKFLOW_POLL_INTERVAL_MS=100
|
||||||
|
API_INBOUND_WORKFLOW_STALE_SECONDS=300
|
||||||
ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000
|
ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000
|
||||||
CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
|
CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
|
||||||
SESSION_LOCK_RECOVERY_MS=14400000
|
SESSION_LOCK_RECOVERY_MS=14400000
|
||||||
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
||||||
SESSION_RECENT_AUTH_MS=1800000
|
SESSION_RECENT_AUTH_MS=1800000
|
||||||
|
# First-rollout safety only: both values must be set and the deadline must be within two hours.
|
||||||
|
# Omit them during normal operation so all password writes use scrypt.
|
||||||
|
PASSWORD_HASH_LEGACY_TRANSITION=false
|
||||||
|
PASSWORD_HASH_LEGACY_WRITE_UNTIL=
|
||||||
OPERATION_LOG_ARCHIVE_ENABLED=true
|
OPERATION_LOG_ARCHIVE_ENABLED=true
|
||||||
OPERATION_LOG_RETENTION_DAYS=180
|
OPERATION_LOG_RETENTION_DAYS=180
|
||||||
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
|
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
|
||||||
@@ -25,6 +41,9 @@ OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000
|
|||||||
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
||||||
SMS_RECEIPT_TIMEOUT_HOURS=72
|
SMS_RECEIPT_TIMEOUT_HOURS=72
|
||||||
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000
|
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000
|
||||||
|
# System monitoring reads only fixed queries from a loopback Prometheus instance.
|
||||||
|
PROMETHEUS_URL=http://127.0.0.1:9090
|
||||||
|
PROMETHEUS_QUERY_TIMEOUT_MS=5000
|
||||||
# Local HTTP development only. Production must use HTTPS and true.
|
# Local HTTP development only. Production must use HTTPS and true.
|
||||||
SESSION_COOKIE_SECURE=false
|
SESSION_COOKIE_SECURE=false
|
||||||
MINIO_ENDPOINT=localhost:9000
|
MINIO_ENDPOINT=localhost:9000
|
||||||
@@ -39,4 +58,11 @@ GATEWAY_CMPP_VERSION=3.0
|
|||||||
GATEWAY_CMPP_ADDR=127.0.0.1:7890
|
GATEWAY_CMPP_ADDR=127.0.0.1:7890
|
||||||
GATEWAY_CMPP_USER=900001
|
GATEWAY_CMPP_USER=900001
|
||||||
GATEWAY_CMPP_PASSWORD=888888
|
GATEWAY_CMPP_PASSWORD=888888
|
||||||
|
GATEWAY_SUBMIT_WORKER_CONCURRENCY=64
|
||||||
|
GATEWAY_SUBMIT_RESULT_STREAM=gateway.submit.results
|
||||||
|
GATEWAY_SUBMIT_RESULT_GROUP=cmpp-api-callback
|
||||||
|
GATEWAY_SUBMIT_RESULT_CONSUMER=gateway-1
|
||||||
|
GATEWAY_SUBMIT_RESULT_WORKER_CONCURRENCY=8
|
||||||
|
GATEWAY_CALLBACK_BATCH_ENABLED=false
|
||||||
|
GATEWAY_CMPP_INBOUND_MAX_CONCURRENCY=64
|
||||||
CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
|
CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
logs/
|
logs/
|
||||||
|
coverage/
|
||||||
gateway/gateway.exe
|
gateway/gateway.exe
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
*.tsbuildinfo
|
||||||
|
outputs/
|
||||||
|
tmp_*.py
|
||||||
|
tmp_*.ps1
|
||||||
|
tmp_*.sh
|
||||||
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
api/dist
|
||||||
|
api/vendor
|
||||||
|
package-lock.json
|
||||||
|
docs
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -9,4 +9,12 @@ module.exports = {
|
|||||||
testMatch: ['**/*.spec.ts'],
|
testMatch: ['**/*.spec.ts'],
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 59,
|
||||||
|
branches: 50,
|
||||||
|
functions: 60,
|
||||||
|
lines: 62,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+69
-106
@@ -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",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -617,35 +618,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
"@emnapi/wasi-threads": "1.2.3",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"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,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@@ -732,30 +736,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",
|
||||||
@@ -1451,20 +1431,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": "*",
|
||||||
@@ -2345,9 +2325,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2362,9 +2339,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2379,9 +2353,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2396,9 +2367,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2413,9 +2381,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2430,9 +2395,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2447,9 +2409,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2464,9 +2423,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2481,9 +2437,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2498,9 +2451,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2540,6 +2490,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 +3247,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"
|
||||||
@@ -4510,20 +4494,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 +4712,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": [
|
||||||
{
|
{
|
||||||
@@ -6170,9 +6140,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",
|
||||||
@@ -8208,13 +8178,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 +8483,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"
|
||||||
@@ -9523,10 +9486,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -6,9 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"test": "jest --runInBand",
|
"test": "jest --runInBand",
|
||||||
|
"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",
|
||||||
|
"start:report-material-worker": "node dist/report-material-analysis-worker.js",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate:dev": "prisma migrate dev",
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
"prisma:migrate:deploy": "prisma migrate deploy"
|
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||||
@@ -18,7 +21,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",
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -46,6 +50,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { defineConfig } from 'prisma/config';
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
|
||||||
|
if (!databaseUrl && !allowDevelopmentDefault) {
|
||||||
|
throw new Error('DATABASE_URL is required outside development and test environments');
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
schema: 'prisma/schema.prisma',
|
||||||
migrations: {
|
migrations: {
|
||||||
path: 'prisma/migrations',
|
path: 'prisma/migrations',
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url:
|
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "ProtocolInteractionLog"
|
||||||
|
ADD COLUMN "phoneNumber" TEXT;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
ALTER TABLE "SmsChannel"
|
||||||
|
ADD COLUMN "carriers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
UPDATE "SmsChannel"
|
||||||
|
SET "carriers" = CASE
|
||||||
|
WHEN "carrier" = 'mobile' THEN ARRAY['mobile']::TEXT[]
|
||||||
|
WHEN "carrier" = 'unicom' THEN ARRAY['unicom']::TEXT[]
|
||||||
|
WHEN "carrier" = 'telecom' THEN ARRAY['telecom']::TEXT[]
|
||||||
|
WHEN "carrier" = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
-- 旧页面和旧发送链对空/未知carrier一直按移动处理,迁移保持原业务语义且保证至少一项。
|
||||||
|
ELSE ARRAY['mobile']::TEXT[]
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE "SmsChannel"
|
||||||
|
ADD CONSTRAINT "SmsChannel_carriers_supported_check"
|
||||||
|
CHECK (
|
||||||
|
cardinality("carriers") BETWEEN 1 AND 3
|
||||||
|
AND "carriers" <@ ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "ChannelSignatureReportTask"
|
||||||
|
ADD COLUMN "carrier" TEXT,
|
||||||
|
ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "approvalScope" TEXT NOT NULL DEFAULT 'legacy_channel';
|
||||||
|
|
||||||
|
-- The current approved timestamp is reconstructed from the latest transition
|
||||||
|
-- into approved. updatedAt is deliberately not used because unrelated edits
|
||||||
|
-- can change it and would incorrectly restart the grace period.
|
||||||
|
UPDATE "ChannelSignatureReportTask" task
|
||||||
|
SET "approvedAt" = approved_record."approvedAt"
|
||||||
|
FROM (
|
||||||
|
SELECT "taskId", MAX("createdAt") AS "approvedAt"
|
||||||
|
FROM "ChannelSignatureReportRecord"
|
||||||
|
WHERE "statusAfter" = 'approved'
|
||||||
|
GROUP BY "taskId"
|
||||||
|
) approved_record
|
||||||
|
WHERE task.id = approved_record."taskId"
|
||||||
|
AND task.status = 'approved';
|
||||||
|
|
||||||
|
CREATE INDEX "ChannelSignatureReportTask_signatureId_channelId_carrier_idx"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "channelId", "carrier");
|
||||||
|
|
||||||
|
-- 旧索引把运营商排除在唯一维度外,会阻止同一签名/通道建立多运营商事实。
|
||||||
|
-- 拆成三类条件索引,在升级维度的同时继续保护历史任务和引流任务不重复。
|
||||||
|
DROP INDEX IF EXISTS "ChannelSignatureReportTask_target_key";
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_signature_channel_carrier_key"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "channelId", "carrier")
|
||||||
|
WHERE "reportType" = 'signature' AND "carrier" IS NOT NULL AND "drainageItemId" IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_legacy_signature_channel_key"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "channelId")
|
||||||
|
WHERE "reportType" = 'signature' AND "carrier" IS NULL AND "drainageItemId" IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_target_key"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "drainageItemId", "channelId")
|
||||||
|
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ruleType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT,
|
||||||
|
"targetKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"mobileWindowDays" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"mobileThreshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"unicomWindowDays" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"unicomThreshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"telecomWindowDays" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"telecomThreshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"messageTemplate" TEXT,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementRule_ruleType_targetKey_key" ON "SignatureRetirementRule"("ruleType", "targetKey");
|
||||||
|
CREATE INDEX "SignatureRetirementRule_ruleType_enabled_idx" ON "SignatureRetirementRule"("ruleType", "enabled");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementWebhook" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL,
|
||||||
|
"urlEncrypted" TEXT NOT NULL,
|
||||||
|
"urlMasked" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementWebhook_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "SignatureRetirementWebhook_status_createdAt_idx" ON "SignatureRetirementWebhook"("status", "createdAt");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementCycle" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"dimensionType" TEXT NOT NULL,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"channelKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"carrier" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"startedOn" DATE NOT NULL,
|
||||||
|
"lastDetectedOn" DATE NOT NULL,
|
||||||
|
"resolvedOn" DATE,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementCycle_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "SignatureRetirementCycle_dimension_status_idx" ON "SignatureRetirementCycle"("dimensionType", "signatureId", "channelKey", "carrier", "status");
|
||||||
|
CREATE INDEX "SignatureRetirementCycle_status_lastDetectedOn_idx" ON "SignatureRetirementCycle"("status", "lastDetectedOn");
|
||||||
|
-- 同一监控维度只能存在一个开放周期,数据库约束用于兜住并发检测实例。
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementCycle_open_dimension_key"
|
||||||
|
ON "SignatureRetirementCycle"("dimensionType", "signatureId", "channelKey", "carrier")
|
||||||
|
WHERE "status" = 'open';
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementDetection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"detectionDate" DATE NOT NULL,
|
||||||
|
"dimensionType" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"channelKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"carrier" TEXT NOT NULL,
|
||||||
|
"windowDays" INTEGER NOT NULL,
|
||||||
|
"threshold" INTEGER NOT NULL,
|
||||||
|
"submittedAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"acceptedBusinessCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"deliveredBusinessCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"approvedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ruleId" TEXT,
|
||||||
|
"ruleVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"cycleId" TEXT,
|
||||||
|
"suppressed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"notificationTitle" TEXT,
|
||||||
|
"notificationContent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SignatureRetirementDetection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementDetection_dimension_key" ON "SignatureRetirementDetection"("detectionDate", "dimensionType", "signatureId", "channelKey", "carrier");
|
||||||
|
CREATE INDEX "SignatureRetirementDetection_date_type_status_idx" ON "SignatureRetirementDetection"("detectionDate", "dimensionType", "status");
|
||||||
|
CREATE INDEX "SignatureRetirementDetection_signature_carrier_date_idx" ON "SignatureRetirementDetection"("signatureId", "carrier", "detectionDate");
|
||||||
|
CREATE INDEX "SignatureRetirementDetection_channel_carrier_date_idx" ON "SignatureRetirementDetection"("channelId", "carrier", "detectionDate");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"detectionId" TEXT NOT NULL,
|
||||||
|
"cycleId" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"suppressed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"readAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SignatureRetirementMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementMessage_detectionId_key" ON "SignatureRetirementMessage"("detectionId");
|
||||||
|
CREATE INDEX "SignatureRetirementMessage_created_read_suppressed_idx" ON "SignatureRetirementMessage"("createdAt", "isRead", "suppressed");
|
||||||
|
CREATE INDEX "SignatureRetirementMessage_tenant_createdAt_idx" ON "SignatureRetirementMessage"("tenantId", "createdAt");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementSuppression" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"dimensionType" TEXT NOT NULL,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"channelKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"carrier" TEXT NOT NULL,
|
||||||
|
"mode" TEXT NOT NULL,
|
||||||
|
"muteUntil" DATE,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"reason" TEXT,
|
||||||
|
"operatorId" TEXT,
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"cancelledById" TEXT,
|
||||||
|
"cancelReason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementSuppression_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementSuppression_dimension_key" ON "SignatureRetirementSuppression"("dimensionType", "signatureId", "channelKey", "carrier");
|
||||||
|
CREATE INDEX "SignatureRetirementSuppression_active_muteUntil_idx" ON "SignatureRetirementSuppression"("active", "muteUntil");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementWebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"detectionDate" DATE NOT NULL,
|
||||||
|
"webhookId" TEXT NOT NULL,
|
||||||
|
"groupKey" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextRetryAt" TIMESTAMP(3),
|
||||||
|
"lastHttpStatus" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementWebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementWebhookDelivery_key" ON "SignatureRetirementWebhookDelivery"("webhookId", "detectionDate", "groupKey");
|
||||||
|
CREATE INDEX "SignatureRetirementWebhookDelivery_status_retry_idx" ON "SignatureRetirementWebhookDelivery"("status", "nextRetryAt");
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
-- 最终业务口径不再保留历史人工确认:旧通道级状态按通道能力一次性形成运营商级事实。
|
||||||
|
-- 已有运营商任务代表更新的事实,必须保留且不得被旧任务覆盖。
|
||||||
|
WITH legacy_targets AS (
|
||||||
|
SELECT
|
||||||
|
legacy.id AS "legacyId",
|
||||||
|
legacy."tenantId",
|
||||||
|
legacy."signatureId",
|
||||||
|
legacy."channelId",
|
||||||
|
legacy.status,
|
||||||
|
legacy.reason,
|
||||||
|
legacy."createdById",
|
||||||
|
supported.carrier
|
||||||
|
FROM "ChannelSignatureReportTask" legacy
|
||||||
|
JOIN "SmsChannel" channel ON channel.id = legacy."channelId"
|
||||||
|
CROSS JOIN LATERAL unnest(
|
||||||
|
CASE
|
||||||
|
WHEN cardinality(channel.carriers) > 0 THEN channel.carriers
|
||||||
|
WHEN channel.carrier = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
WHEN channel.carrier IN ('mobile', 'unicom', 'telecom') THEN ARRAY[channel.carrier]::TEXT[]
|
||||||
|
ELSE ARRAY['mobile']::TEXT[]
|
||||||
|
END
|
||||||
|
) AS supported(carrier)
|
||||||
|
WHERE legacy."reportType" = 'signature'
|
||||||
|
AND legacy."drainageItemId" IS NULL
|
||||||
|
AND legacy.carrier IS NULL
|
||||||
|
AND legacy."approvalScope" = 'legacy_channel'
|
||||||
|
), inserted_tasks AS (
|
||||||
|
INSERT INTO "ChannelSignatureReportTask" (
|
||||||
|
id,
|
||||||
|
"tenantId",
|
||||||
|
"signatureId",
|
||||||
|
"channelId",
|
||||||
|
carrier,
|
||||||
|
"approvedAt",
|
||||||
|
"approvalScope",
|
||||||
|
"reportType",
|
||||||
|
"drainageItemId",
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
"createdById",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'legacy-auto-' || md5(target."legacyId" || ':' || target.carrier),
|
||||||
|
target."tenantId",
|
||||||
|
target."signatureId",
|
||||||
|
target."channelId",
|
||||||
|
target.carrier,
|
||||||
|
CASE WHEN target.status = 'approved' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||||
|
'carrier_specific',
|
||||||
|
'signature',
|
||||||
|
NULL,
|
||||||
|
target.status,
|
||||||
|
target.reason,
|
||||||
|
target."createdById",
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM legacy_targets target
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id, "channelId", status
|
||||||
|
)
|
||||||
|
INSERT INTO "ChannelSignatureReportRecord" (
|
||||||
|
id,
|
||||||
|
"taskId",
|
||||||
|
"channelId",
|
||||||
|
action,
|
||||||
|
"statusBefore",
|
||||||
|
"statusAfter",
|
||||||
|
reason,
|
||||||
|
"operatorId",
|
||||||
|
"sourceEntry",
|
||||||
|
"createdAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'legacy-auto-record-' || md5(task.id),
|
||||||
|
task.id,
|
||||||
|
task."channelId",
|
||||||
|
'legacy_carrier_auto_split',
|
||||||
|
NULL,
|
||||||
|
task.status,
|
||||||
|
'历史通道级任务按通道运营商能力自动转换',
|
||||||
|
NULL,
|
||||||
|
'migration',
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM inserted_tasks task
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
WITH legacy_targets AS (
|
||||||
|
SELECT
|
||||||
|
legacy.id AS "legacyId",
|
||||||
|
legacy."signatureId",
|
||||||
|
legacy."channelId",
|
||||||
|
supported.carrier
|
||||||
|
FROM "ChannelSignatureReportTask" legacy
|
||||||
|
JOIN "SmsChannel" channel ON channel.id = legacy."channelId"
|
||||||
|
CROSS JOIN LATERAL unnest(
|
||||||
|
CASE
|
||||||
|
WHEN cardinality(channel.carriers) > 0 THEN channel.carriers
|
||||||
|
WHEN channel.carrier = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
WHEN channel.carrier IN ('mobile', 'unicom', 'telecom') THEN ARRAY[channel.carrier]::TEXT[]
|
||||||
|
ELSE ARRAY['mobile']::TEXT[]
|
||||||
|
END
|
||||||
|
) AS supported(carrier)
|
||||||
|
WHERE legacy."reportType" = 'signature'
|
||||||
|
AND legacy."drainageItemId" IS NULL
|
||||||
|
AND legacy.carrier IS NULL
|
||||||
|
AND legacy."approvalScope" = 'legacy_channel'
|
||||||
|
), completed_legacy AS (
|
||||||
|
SELECT target."legacyId"
|
||||||
|
FROM legacy_targets target
|
||||||
|
LEFT JOIN "ChannelSignatureReportTask" exact
|
||||||
|
ON exact."signatureId" = target."signatureId"
|
||||||
|
AND exact."channelId" = target."channelId"
|
||||||
|
AND exact."reportType" = 'signature'
|
||||||
|
AND exact."drainageItemId" IS NULL
|
||||||
|
AND exact.carrier = target.carrier
|
||||||
|
GROUP BY target."legacyId"
|
||||||
|
HAVING COUNT(DISTINCT target.carrier) = COUNT(DISTINCT exact.carrier)
|
||||||
|
), updated_legacy AS (
|
||||||
|
UPDATE "ChannelSignatureReportTask" legacy
|
||||||
|
SET "approvalScope" = 'legacy_split',
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP
|
||||||
|
FROM completed_legacy completed
|
||||||
|
WHERE legacy.id = completed."legacyId"
|
||||||
|
AND legacy."approvalScope" = 'legacy_channel'
|
||||||
|
RETURNING legacy.id, legacy."channelId", legacy.status
|
||||||
|
)
|
||||||
|
INSERT INTO "ChannelSignatureReportRecord" (
|
||||||
|
id,
|
||||||
|
"taskId",
|
||||||
|
"channelId",
|
||||||
|
action,
|
||||||
|
"statusBefore",
|
||||||
|
"statusAfter",
|
||||||
|
reason,
|
||||||
|
"operatorId",
|
||||||
|
"sourceEntry",
|
||||||
|
"createdAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'legacy-split-record-' || md5(legacy.id),
|
||||||
|
legacy.id,
|
||||||
|
legacy."channelId",
|
||||||
|
'legacy_scope_auto_split',
|
||||||
|
legacy.status,
|
||||||
|
legacy.status,
|
||||||
|
'全部适用运营商已自动形成独立报备任务',
|
||||||
|
NULL,
|
||||||
|
'migration',
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM updated_legacy legacy
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TABLE "DownstreamRequeueTask" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"taskNo" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT,
|
||||||
|
"applicationId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
"filterSnapshot" JSONB NOT NULL,
|
||||||
|
"snapshotAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"ratePerSecond" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"consecutiveFailureLimit" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"skippedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"waitingCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"pausedAt" TIMESTAMP(3),
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "DownstreamRequeueTask_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "DownstreamRequeueTaskItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"taskId" TEXT NOT NULL,
|
||||||
|
"deliveryId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
"previousStatus" TEXT NOT NULL,
|
||||||
|
"skipReason" TEXT,
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "DownstreamRequeueTaskItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "DownstreamRequeueTask_taskNo_key" ON "DownstreamRequeueTask"("taskNo");
|
||||||
|
CREATE INDEX "DownstreamRequeueTask_status_createdAt_idx" ON "DownstreamRequeueTask"("status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTask_applicationId_status_createdAt_idx" ON "DownstreamRequeueTask"("applicationId", "status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTask_tenantId_createdAt_idx" ON "DownstreamRequeueTask"("tenantId", "createdAt");
|
||||||
|
CREATE UNIQUE INDEX "DownstreamRequeueTaskItem_taskId_deliveryId_key" ON "DownstreamRequeueTaskItem"("taskId", "deliveryId");
|
||||||
|
CREATE INDEX "DownstreamRequeueTaskItem_taskId_status_createdAt_idx" ON "DownstreamRequeueTaskItem"("taskId", "status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTaskItem_applicationId_status_createdAt_idx" ON "DownstreamRequeueTaskItem"("applicationId", "status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTaskItem_deliveryId_status_idx" ON "DownstreamRequeueTaskItem"("deliveryId", "status");
|
||||||
|
|
||||||
|
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTaskItem" ADD CONSTRAINT "DownstreamRequeueTaskItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "DownstreamRequeueTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTaskItem" ADD CONSTRAINT "DownstreamRequeueTaskItem_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "CmppDownstreamDelivery"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
ALTER TABLE "DownstreamRequeueTask"
|
||||||
|
ADD COLUMN "applicationFailures" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
ADD COLUMN "scanLeaseOwner" TEXT,
|
||||||
|
ADD COLUMN "scanLeaseUntil" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE TABLE "DownstreamRequeueRateWindow" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumed" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "DownstreamRequeueRateWindow_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "DownstreamRequeueRateWindow_applicationId_windowStartedAt_key"
|
||||||
|
ON "DownstreamRequeueRateWindow"("applicationId", "windowStartedAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueRateWindow_windowStartedAt_idx"
|
||||||
|
ON "DownstreamRequeueRateWindow"("windowStartedAt");
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
CREATE TABLE "SecurityDetectionRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"sourceType" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"threshold" INTEGER NOT NULL,
|
||||||
|
"windowSeconds" INTEGER NOT NULL,
|
||||||
|
"cooldownSeconds" INTEGER NOT NULL,
|
||||||
|
"severity" TEXT NOT NULL,
|
||||||
|
"defaultBlockSeconds" INTEGER NOT NULL,
|
||||||
|
"maximumBlockSeconds" INTEGER NOT NULL,
|
||||||
|
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"effectiveVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"applyStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"lastApplyError" TEXT,
|
||||||
|
"pendingConfig" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityDetectionRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityDetectionEvent" (
|
||||||
|
"id" TEXT NOT NULL, "eventKey" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||||
|
"sourceIp" TEXT NOT NULL, "sourcePort" INTEGER, "accountHash" TEXT,
|
||||||
|
"path" TEXT, "protocol" TEXT, "resultCode" TEXT, "evidence" JSONB,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SecurityDetectionEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityAlert" (
|
||||||
|
"id" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||||
|
"sourceIp" TEXT NOT NULL, "severity" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"eventCount" INTEGER NOT NULL DEFAULT 0, "windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"firstOccurredAt" TIMESTAMP(3) NOT NULL, "lastOccurredAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"acknowledgedAt" TIMESTAMP(3), "acknowledgedById" TEXT, "ignoredAt" TIMESTAMP(3),
|
||||||
|
"ignoredById" TEXT, "ignoreReason" TEXT, "blockId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityAlert_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityBlock" (
|
||||||
|
"id" TEXT NOT NULL, "operationKey" TEXT NOT NULL, "alertId" TEXT, "sourceIp" TEXT NOT NULL,
|
||||||
|
"executor" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'requested', "durationSeconds" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL, "requestedById" TEXT NOT NULL, "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"appliedAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "releasedAt" TIMESTAMP(3), "releasedById" TEXT,
|
||||||
|
"executorReference" TEXT, "lastError" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SecurityBlock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityProtectedNetwork" (
|
||||||
|
"id" TEXT NOT NULL, "network" TEXT NOT NULL, "name" TEXT NOT NULL, "reason" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true, "createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityProtectedNetwork_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SecurityDetectionRule_code_key" ON "SecurityDetectionRule"("code");
|
||||||
|
CREATE INDEX "SecurityDetectionRule_enabled_sourceType_idx" ON "SecurityDetectionRule"("enabled", "sourceType");
|
||||||
|
CREATE UNIQUE INDEX "SecurityDetectionEvent_eventKey_key" ON "SecurityDetectionEvent"("eventKey");
|
||||||
|
CREATE INDEX "SecurityDetectionEvent_ruleId_occurredAt_idx" ON "SecurityDetectionEvent"("ruleId", "occurredAt");
|
||||||
|
CREATE INDEX "SecurityDetectionEvent_sourceIp_occurredAt_idx" ON "SecurityDetectionEvent"("sourceIp", "occurredAt");
|
||||||
|
CREATE UNIQUE INDEX "SecurityAlert_fingerprint_key" ON "SecurityAlert"("fingerprint");
|
||||||
|
CREATE INDEX "SecurityAlert_status_severity_lastOccurredAt_idx" ON "SecurityAlert"("status", "severity", "lastOccurredAt");
|
||||||
|
CREATE INDEX "SecurityAlert_sourceIp_status_lastOccurredAt_idx" ON "SecurityAlert"("sourceIp", "status", "lastOccurredAt");
|
||||||
|
CREATE INDEX "SecurityAlert_ruleId_status_lastOccurredAt_idx" ON "SecurityAlert"("ruleId", "status", "lastOccurredAt");
|
||||||
|
CREATE UNIQUE INDEX "SecurityBlock_operationKey_key" ON "SecurityBlock"("operationKey");
|
||||||
|
CREATE INDEX "SecurityBlock_status_expiresAt_idx" ON "SecurityBlock"("status", "expiresAt");
|
||||||
|
CREATE INDEX "SecurityBlock_sourceIp_status_requestedAt_idx" ON "SecurityBlock"("sourceIp", "status", "requestedAt");
|
||||||
|
CREATE INDEX "SecurityBlock_alertId_idx" ON "SecurityBlock"("alertId");
|
||||||
|
CREATE UNIQUE INDEX "SecurityProtectedNetwork_network_key" ON "SecurityProtectedNetwork"("network");
|
||||||
|
CREATE INDEX "SecurityProtectedNetwork_enabled_createdAt_idx" ON "SecurityProtectedNetwork"("enabled", "createdAt");
|
||||||
|
ALTER TABLE "SecurityDetectionEvent" ADD CONSTRAINT "SecurityDetectionEvent_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SecurityAlert" ADD CONSTRAINT "SecurityAlert_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO "SecurityDetectionRule" ("id", "code", "name", "sourceType", "threshold", "windowSeconds", "cooldownSeconds", "severity", "defaultBlockSeconds", "maximumBlockSeconds", "configVersion", "effectiveVersion", "applyStatus", "updatedAt") VALUES
|
||||||
|
('sec_admin_login', 'admin_login_failure', '运营端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_client_login', 'client_login_failure', '客户端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_ssh_auth', 'ssh_auth_failure', 'SSH认证失败', 'fail2ban', 6, 600, 1800, 'high', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_cmpp_auth', 'cmpp_auth_failure', 'CMPP认证失败', 'gateway', 5, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_cmpp_abuse', 'cmpp_protocol_abuse', 'CMPP协议滥用', 'gateway', 20, 60, 900, 'critical', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_key', 'http_invalid_api_key', 'HTTP错误密钥', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_sign', 'http_signature_failure', 'HTTP签名错误', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_replay', 'http_replay_attempt', 'HTTP重放尝试', 'application', 3, 600, 1800, 'critical', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_scan', 'http_malicious_scan', 'HTTP恶意扫描', 'fail2ban', 20, 60, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "InfrastructureAlertSetting" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'global',
|
||||||
|
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"effectiveVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"thresholds" JSONB NOT NULL,
|
||||||
|
"effectiveThresholds" JSONB NOT NULL,
|
||||||
|
"applyStatus" TEXT NOT NULL DEFAULT 'effective',
|
||||||
|
"lastError" TEXT,
|
||||||
|
"updatedById" TEXT,
|
||||||
|
"appliedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "InfrastructureAlertSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "InfrastructureAlertSetting" (
|
||||||
|
"id", "thresholds", "effectiveThresholds", "appliedAt"
|
||||||
|
) VALUES (
|
||||||
|
'global',
|
||||||
|
'{"hostCpu":{"warning":80,"critical":90},"hostMemory":{"warning":85,"critical":95},"hostDisk":{"warning":80,"critical":90},"apiError":{"warning":1,"critical":5},"apiLatency":{"warning":1,"critical":3},"apiEventLoop":{"warning":0.2,"critical":1},"gatewayQueue":{"warning":30,"critical":120},"postgresConnections":{"warning":70,"critical":85},"redisMemory":{"warning":70,"critical":85},"minioCapacity":{"warning":80,"critical":90}}'::jsonb,
|
||||||
|
'{"hostCpu":{"warning":80,"critical":90},"hostMemory":{"warning":85,"critical":95},"hostDisk":{"warning":80,"critical":90},"apiError":{"warning":1,"critical":5},"apiLatency":{"warning":1,"critical":3},"apiEventLoop":{"warning":0.2,"critical":1},"gatewayQueue":{"warning":30,"critical":120},"postgresConnections":{"warning":70,"critical":85},"redisMemory":{"warning":70,"critical":85},"minioCapacity":{"warning":80,"critical":90}}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "InfrastructureAlertRead" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fingerprint" TEXT NOT NULL,
|
||||||
|
"activeAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InfrastructureAlertRead_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "InfrastructureAlertRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "InfrastructureAlertRead_fingerprint_userId_key"
|
||||||
|
ON "InfrastructureAlertRead"("fingerprint", "userId");
|
||||||
|
|
||||||
|
CREATE INDEX "InfrastructureAlertRead_userId_readAt_idx"
|
||||||
|
ON "InfrastructureAlertRead"("userId", "readAt");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "SmsSubmitRecord"
|
||||||
|
ADD COLUMN "resultEventId" TEXT,
|
||||||
|
ADD COLUMN "resultProcessedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "SmsSubmitRecord_resultEventId_key"
|
||||||
|
ON "SmsSubmitRecord"("resultEventId");
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
CREATE TABLE "CmppInboundSubmissionInbox" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"requestKey" TEXT NOT NULL,
|
||||||
|
"payloadHash" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"queuePriority" TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"response" JSONB NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lockedAt" TIMESTAMP(3),
|
||||||
|
"lockedBy" TEXT,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"result" JSONB,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CmppInboundSubmissionInbox_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "CmppInboundSubmissionInbox_requestKey_key"
|
||||||
|
ON "CmppInboundSubmissionInbox"("requestKey");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_status_nextAttemptAt_createdAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("status", "nextAttemptAt", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_status_lockedAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("status", "lockedAt");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_applicationId_createdAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("applicationId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_queuePriority_status_createdAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("queuePriority", "status", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "CmppInboundSubmissionInbox"
|
||||||
|
ADD CONSTRAINT "CmppInboundSubmissionInbox_tenantId_fkey"
|
||||||
|
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "CmppInboundSubmissionInbox"
|
||||||
|
ADD CONSTRAINT "CmppInboundSubmissionInbox_applicationId_fkey"
|
||||||
|
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "SmsApplicationDailyReservation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reservationKey" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"usageDate" DATE NOT NULL,
|
||||||
|
"requestedCount" INTEGER NOT NULL,
|
||||||
|
"dailyLimit" INTEGER NOT NULL,
|
||||||
|
"usedCount" INTEGER,
|
||||||
|
"reserved" BOOLEAN NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SmsApplicationDailyReservation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "SmsApplicationDailyReservation_reservationKey_key"
|
||||||
|
ON "SmsApplicationDailyReservation"("reservationKey");
|
||||||
|
CREATE INDEX "SmsApplicationDailyReservation_applicationId_usageDate_idx"
|
||||||
|
ON "SmsApplicationDailyReservation"("applicationId", "usageDate");
|
||||||
|
CREATE INDEX "SmsApplicationDailyReservation_tenantId_createdAt_idx"
|
||||||
|
ON "SmsApplicationDailyReservation"("tenantId", "createdAt");
|
||||||
|
ALTER TABLE "SmsApplicationDailyReservation" ADD CONSTRAINT "SmsApplicationDailyReservation_tenantId_fkey"
|
||||||
|
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SmsApplicationDailyReservation" ADD CONSTRAINT "SmsApplicationDailyReservation_applicationId_fkey"
|
||||||
|
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "PhoneFrequencyReservation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reservationKey" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"result" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PhoneFrequencyReservation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "PhoneFrequencyReservation_reservationKey_key"
|
||||||
|
ON "PhoneFrequencyReservation"("reservationKey");
|
||||||
|
CREATE INDEX "PhoneFrequencyReservation_applicationId_createdAt_idx"
|
||||||
|
ON "PhoneFrequencyReservation"("applicationId", "createdAt");
|
||||||
|
CREATE INDEX "PhoneFrequencyReservation_tenantId_createdAt_idx"
|
||||||
|
ON "PhoneFrequencyReservation"("tenantId", "createdAt");
|
||||||
|
ALTER TABLE "PhoneFrequencyReservation" ADD CONSTRAINT "PhoneFrequencyReservation_tenantId_fkey"
|
||||||
|
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "PhoneFrequencyReservation" ADD CONSTRAINT "PhoneFrequencyReservation_applicationId_fkey"
|
||||||
|
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "GatewaySubmitOutbox" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"submitId" TEXT NOT NULL,
|
||||||
|
"messageRecordId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"schemaVersion" TEXT NOT NULL DEFAULT 'v1',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"leaseOwner" TEXT,
|
||||||
|
"leaseExpiresAt" TIMESTAMP(3),
|
||||||
|
"streamEntryId" TEXT,
|
||||||
|
"publishedAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "GatewaySubmitOutbox_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "GatewaySubmitOutbox_submitId_key"
|
||||||
|
ON "GatewaySubmitOutbox"("submitId");
|
||||||
|
|
||||||
|
CREATE INDEX "GatewaySubmitOutbox_pending_claim_idx"
|
||||||
|
ON "GatewaySubmitOutbox"("nextAttemptAt", "createdAt")
|
||||||
|
WHERE "status" IN ('pending', 'publishing');
|
||||||
|
|
||||||
|
CREATE INDEX "GatewaySubmitOutbox_messageRecordId_idx"
|
||||||
|
ON "GatewaySubmitOutbox"("messageRecordId");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "SmsUplinkMessage" ADD COLUMN "eventId" TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "SmsUplinkMessage_eventId_key" ON "SmsUplinkMessage"("eventId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "SmsUplinkMessage"
|
||||||
|
ADD COLUMN "gatewayMessageId" TEXT;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "ReportMaterialImportBatch"
|
||||||
|
ADD COLUMN "progress" INTEGER NOT NULL DEFAULT 100,
|
||||||
|
ADD COLUMN "progressStage" TEXT,
|
||||||
|
ADD COLUMN "errorMessage" TEXT,
|
||||||
|
ADD COLUMN "startedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "heartbeatAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX "ReportMaterialImportBatch_status_heartbeatAt_idx"
|
||||||
|
ON "ReportMaterialImportBatch"("status", "heartbeatAt");
|
||||||
+485
-19
@@ -43,6 +43,7 @@ model Tenant {
|
|||||||
smsUplinkMessages SmsUplinkMessage[]
|
smsUplinkMessages SmsUplinkMessage[]
|
||||||
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
|
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||||
cmppDownstreamDeliveries CmppDownstreamDelivery[]
|
cmppDownstreamDeliveries CmppDownstreamDelivery[]
|
||||||
|
downstreamRequeueTasks DownstreamRequeueTask[]
|
||||||
cmppDownstreamConnections CmppDownstreamConnection[]
|
cmppDownstreamConnections CmppDownstreamConnection[]
|
||||||
cmppConnectionStates CmppConnectionState[]
|
cmppConnectionStates CmppConnectionState[]
|
||||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||||
@@ -51,6 +52,9 @@ model Tenant {
|
|||||||
openApiRequests OpenApiRequest[]
|
openApiRequests OpenApiRequest[]
|
||||||
httpWebhookEvents HttpWebhookEvent[]
|
httpWebhookEvents HttpWebhookEvent[]
|
||||||
cmppInboundLongMessages CmppInboundLongMessage[]
|
cmppInboundLongMessages CmppInboundLongMessage[]
|
||||||
|
cmppInboundSubmissionInboxes CmppInboundSubmissionInbox[]
|
||||||
|
smsApplicationDailyReservations SmsApplicationDailyReservation[]
|
||||||
|
phoneFrequencyReservations PhoneFrequencyReservation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model EnterpriseCertification {
|
model EnterpriseCertification {
|
||||||
@@ -98,9 +102,11 @@ model User {
|
|||||||
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||||
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||||
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
||||||
|
createdDownstreamRequeueTasks DownstreamRequeueTask[] @relation("DownstreamRequeueTaskCreator")
|
||||||
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
||||||
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
||||||
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
||||||
|
infrastructureAlertReads InfrastructureAlertRead[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
@@ -203,6 +209,7 @@ model ProtocolInteractionLog {
|
|||||||
traceId String?
|
traceId String?
|
||||||
requestId String?
|
requestId String?
|
||||||
phoneMasked String?
|
phoneMasked String?
|
||||||
|
phoneNumber String?
|
||||||
resultCode String?
|
resultCode String?
|
||||||
durationMs Int?
|
durationMs Int?
|
||||||
payloadBytes Int?
|
payloadBytes Int?
|
||||||
@@ -450,6 +457,7 @@ model SmsApplication {
|
|||||||
uplinkMessages SmsUplinkMessage[]
|
uplinkMessages SmsUplinkMessage[]
|
||||||
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||||
downstreamDeliveries CmppDownstreamDelivery[]
|
downstreamDeliveries CmppDownstreamDelivery[]
|
||||||
|
downstreamRequeueTasks DownstreamRequeueTask[]
|
||||||
downstreamConnections CmppDownstreamConnection[]
|
downstreamConnections CmppDownstreamConnection[]
|
||||||
connectionStates CmppConnectionState[]
|
connectionStates CmppConnectionState[]
|
||||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||||
@@ -462,10 +470,13 @@ model SmsApplication {
|
|||||||
httpWebhookEndpoints HttpWebhookEndpoint[]
|
httpWebhookEndpoints HttpWebhookEndpoint[]
|
||||||
httpWebhookEvents HttpWebhookEvent[]
|
httpWebhookEvents HttpWebhookEvent[]
|
||||||
dailyUsages SmsApplicationDailyUsage[]
|
dailyUsages SmsApplicationDailyUsage[]
|
||||||
|
dailyReservations SmsApplicationDailyReservation[]
|
||||||
inboundLongMessages CmppInboundLongMessage[]
|
inboundLongMessages CmppInboundLongMessage[]
|
||||||
|
inboundSubmissionInboxes CmppInboundSubmissionInbox[]
|
||||||
riskRules RiskRule[]
|
riskRules RiskRule[]
|
||||||
phoneFrequencyStates PhoneFrequencyState[]
|
phoneFrequencyStates PhoneFrequencyState[]
|
||||||
phoneFrequencyHits PhoneFrequencyHit[]
|
phoneFrequencyHits PhoneFrequencyHit[]
|
||||||
|
phoneFrequencyReservations PhoneFrequencyReservation[]
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@ -528,6 +539,25 @@ model SmsApplicationDailyUsage {
|
|||||||
@@index([usageDate])
|
@@index([usageDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SmsApplicationDailyReservation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
reservationKey String @unique
|
||||||
|
tenantId String
|
||||||
|
applicationId String
|
||||||
|
usageDate DateTime @db.Date
|
||||||
|
requestedCount Int
|
||||||
|
dailyLimit Int
|
||||||
|
usedCount Int?
|
||||||
|
reserved Boolean
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([applicationId, usageDate])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model SmsApplicationHttpIpAllowlist {
|
model SmsApplicationHttpIpAllowlist {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
applicationId String
|
applicationId String
|
||||||
@@ -805,6 +835,7 @@ model SmsChannel {
|
|||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
carrier String?
|
carrier String?
|
||||||
|
carriers String[] @default([])
|
||||||
sendRegion String @default("全国")
|
sendRegion String @default("全国")
|
||||||
protocol String @default("CMPP")
|
protocol String @default("CMPP")
|
||||||
gatewayHost String
|
gatewayHost String
|
||||||
@@ -1054,17 +1085,20 @@ model DrainageReportMaterial {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ChannelSignatureReportTask {
|
model ChannelSignatureReportTask {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
signatureId String
|
signatureId String
|
||||||
channelId String
|
channelId String
|
||||||
reportType String @default("signature")
|
carrier String?
|
||||||
|
approvedAt DateTime?
|
||||||
|
approvalScope String @default("legacy_channel")
|
||||||
|
reportType String @default("signature")
|
||||||
drainageItemId String?
|
drainageItemId String?
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
reason String?
|
reason String?
|
||||||
createdById String?
|
createdById String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
||||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||||
@@ -1077,10 +1111,152 @@ model ChannelSignatureReportTask {
|
|||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@index([signatureId, channelId])
|
@@index([signatureId, channelId])
|
||||||
|
@@index([signatureId, channelId, carrier])
|
||||||
@@index([signatureId, drainageItemId, channelId])
|
@@index([signatureId, drainageItemId, channelId])
|
||||||
@@index([reportType, status])
|
@@index([reportType, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ruleType String
|
||||||
|
targetId String?
|
||||||
|
targetKey String @default("")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
mobileWindowDays Int @default(30)
|
||||||
|
mobileThreshold Int @default(1)
|
||||||
|
unicomWindowDays Int @default(30)
|
||||||
|
unicomThreshold Int @default(1)
|
||||||
|
telecomWindowDays Int @default(30)
|
||||||
|
telecomThreshold Int @default(1)
|
||||||
|
messageTemplate String?
|
||||||
|
version Int @default(1)
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ruleType, targetKey])
|
||||||
|
@@index([ruleType, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementWebhook {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
platform String
|
||||||
|
urlEncrypted String
|
||||||
|
urlMasked String
|
||||||
|
status String @default("active")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementCycle {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
dimensionType String
|
||||||
|
signatureId String
|
||||||
|
channelId String?
|
||||||
|
channelKey String @default("")
|
||||||
|
carrier String
|
||||||
|
status String @default("open")
|
||||||
|
startedOn DateTime @db.Date
|
||||||
|
lastDetectedOn DateTime @db.Date
|
||||||
|
resolvedOn DateTime? @db.Date
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([dimensionType, signatureId, channelKey, carrier, status])
|
||||||
|
@@index([status, lastDetectedOn])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementDetection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
detectionDate DateTime @db.Date
|
||||||
|
dimensionType String
|
||||||
|
tenantId String
|
||||||
|
applicationId String?
|
||||||
|
signatureId String
|
||||||
|
channelId String?
|
||||||
|
channelKey String @default("")
|
||||||
|
carrier String
|
||||||
|
windowDays Int
|
||||||
|
threshold Int
|
||||||
|
submittedAttempts Int @default(0)
|
||||||
|
acceptedBusinessCount Int @default(0)
|
||||||
|
deliveredBusinessCount Int @default(0)
|
||||||
|
approvedAt DateTime
|
||||||
|
ruleId String?
|
||||||
|
ruleVersion Int @default(1)
|
||||||
|
status String
|
||||||
|
cycleId String?
|
||||||
|
suppressed Boolean @default(false)
|
||||||
|
notificationTitle String?
|
||||||
|
notificationContent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([detectionDate, dimensionType, signatureId, channelKey, carrier])
|
||||||
|
@@index([detectionDate, dimensionType, status])
|
||||||
|
@@index([signatureId, carrier, detectionDate])
|
||||||
|
@@index([channelId, carrier, detectionDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
detectionId String @unique
|
||||||
|
cycleId String
|
||||||
|
tenantId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
suppressed Boolean @default(false)
|
||||||
|
readAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt, isRead, suppressed])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementSuppression {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
dimensionType String
|
||||||
|
signatureId String
|
||||||
|
channelId String?
|
||||||
|
channelKey String @default("")
|
||||||
|
carrier String
|
||||||
|
mode String
|
||||||
|
muteUntil DateTime? @db.Date
|
||||||
|
active Boolean @default(true)
|
||||||
|
reason String?
|
||||||
|
operatorId String?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
cancelledById String?
|
||||||
|
cancelReason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([dimensionType, signatureId, channelKey, carrier])
|
||||||
|
@@index([active, muteUntil])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementWebhookDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
detectionDate DateTime @db.Date
|
||||||
|
webhookId String
|
||||||
|
groupKey String
|
||||||
|
payload Json
|
||||||
|
status String @default("pending")
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
nextRetryAt DateTime?
|
||||||
|
lastHttpStatus Int?
|
||||||
|
lastError String?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([webhookId, detectionDate, groupKey])
|
||||||
|
@@index([status, nextRetryAt])
|
||||||
|
}
|
||||||
|
|
||||||
model ChannelSignatureReportRecord {
|
model ChannelSignatureReportRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
@@ -1219,6 +1395,11 @@ model ReportMaterialImportBatch {
|
|||||||
fileName String
|
fileName String
|
||||||
reportType String
|
reportType String
|
||||||
status String @default("analyzed")
|
status String @default("analyzed")
|
||||||
|
progress Int @default(100)
|
||||||
|
progressStage String?
|
||||||
|
errorMessage String?
|
||||||
|
startedAt DateTime?
|
||||||
|
heartbeatAt DateTime?
|
||||||
sheetName String
|
sheetName String
|
||||||
headerRowCount Int @default(1)
|
headerRowCount Int @default(1)
|
||||||
dataStartRow Int @default(2)
|
dataStartRow Int @default(2)
|
||||||
@@ -1237,6 +1418,7 @@ model ReportMaterialImportBatch {
|
|||||||
|
|
||||||
@@index([tenantId, createdAt])
|
@@index([tenantId, createdAt])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
|
@@index([status, heartbeatAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ReportMaterialImportItem {
|
model ReportMaterialImportItem {
|
||||||
@@ -1427,6 +1609,21 @@ model PhoneFrequencyHit {
|
|||||||
@@index([releasedById])
|
@@index([releasedById])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PhoneFrequencyReservation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
reservationKey String @unique
|
||||||
|
tenantId String
|
||||||
|
applicationId String
|
||||||
|
result Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model PhoneFrequencyWhitelist {
|
model PhoneFrequencyWhitelist {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
phoneNumber String @unique
|
phoneNumber String @unique
|
||||||
@@ -1609,6 +1806,8 @@ model SmsSubmitRecord {
|
|||||||
sequenceId Int?
|
sequenceId Int?
|
||||||
gatewayMessageId String?
|
gatewayMessageId String?
|
||||||
submitStatus String @default("queued")
|
submitStatus String @default("queued")
|
||||||
|
resultEventId String? @unique
|
||||||
|
resultProcessedAt DateTime?
|
||||||
costUnitPrice BigInt @default(0)
|
costUnitPrice BigInt @default(0)
|
||||||
costAmountCents BigInt @default(0)
|
costAmountCents BigInt @default(0)
|
||||||
errorCode String?
|
errorCode String?
|
||||||
@@ -1635,6 +1834,28 @@ model SmsSubmitRecord {
|
|||||||
@@index([channelGroupId])
|
@@index([channelGroupId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GatewaySubmitOutbox {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
submitId String @unique
|
||||||
|
messageRecordId String
|
||||||
|
channelId String
|
||||||
|
payload Json
|
||||||
|
schemaVersion String @default("v1")
|
||||||
|
status String @default("pending")
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
leaseOwner String?
|
||||||
|
leaseExpiresAt DateTime?
|
||||||
|
streamEntryId String?
|
||||||
|
publishedAt DateTime?
|
||||||
|
lastError String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([submitId, status])
|
||||||
|
@@index([messageRecordId])
|
||||||
|
}
|
||||||
|
|
||||||
model DailyReconciliationReport {
|
model DailyReconciliationReport {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
reportDate DateTime @db.Date
|
reportDate DateTime @db.Date
|
||||||
@@ -1799,6 +2020,35 @@ model CmppInboundLongMessageSegment {
|
|||||||
@@index([sequenceId])
|
@@index([sequenceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CmppInboundSubmissionInbox {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
requestKey String @unique
|
||||||
|
payloadHash String
|
||||||
|
tenantId String
|
||||||
|
applicationId String
|
||||||
|
queuePriority String @default("normal")
|
||||||
|
payload Json
|
||||||
|
response Json
|
||||||
|
status String @default("pending")
|
||||||
|
attempts Int @default(0)
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
lockedAt DateTime?
|
||||||
|
lockedBy String?
|
||||||
|
lastError String?
|
||||||
|
result Json?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||||
|
|
||||||
|
@@index([status, nextAttemptAt, createdAt])
|
||||||
|
@@index([status, lockedAt])
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
@@index([queuePriority, status, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model SmsReceiptRecord {
|
model SmsReceiptRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
@@ -1872,20 +2122,22 @@ model SmsReceiptAnomaly {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsUplinkMessage {
|
model SmsUplinkMessage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
eventId String? @unique
|
||||||
applicationId String?
|
tenantId String?
|
||||||
channelId String
|
applicationId String?
|
||||||
messageRecordId String?
|
channelId String
|
||||||
messageId String?
|
messageRecordId String?
|
||||||
sequenceId Int?
|
messageId String?
|
||||||
phoneNumber String
|
gatewayMessageId String?
|
||||||
destId String
|
sequenceId Int?
|
||||||
content String
|
phoneNumber String
|
||||||
matchStatus String @default("unmatched")
|
destId String
|
||||||
matchReason String?
|
content String
|
||||||
receivedAt DateTime
|
matchStatus String @default("unmatched")
|
||||||
createdAt DateTime @default(now())
|
matchReason String?
|
||||||
|
receivedAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||||
@@ -1960,6 +2212,7 @@ model CmppDownstreamDelivery {
|
|||||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||||
attempts CmppDownstreamDeliveryAttempt[]
|
attempts CmppDownstreamDeliveryAttempt[]
|
||||||
|
requeueItems DownstreamRequeueTaskItem[]
|
||||||
|
|
||||||
@@index([tenantId, status, createdAt])
|
@@index([tenantId, status, createdAt])
|
||||||
@@index([applicationId, status, createdAt])
|
@@index([applicationId, status, createdAt])
|
||||||
@@ -1969,6 +2222,79 @@ model CmppDownstreamDelivery {
|
|||||||
@@index([status, ackDeadlineAt])
|
@@index([status, ackDeadlineAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DownstreamRequeueTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
taskNo String @unique
|
||||||
|
tenantId String?
|
||||||
|
applicationId String?
|
||||||
|
status String @default("queued")
|
||||||
|
filterSnapshot Json
|
||||||
|
snapshotAt DateTime
|
||||||
|
reason String
|
||||||
|
ratePerSecond Int @default(10)
|
||||||
|
consecutiveFailureLimit Int @default(10)
|
||||||
|
totalCount Int @default(0)
|
||||||
|
successCount Int @default(0)
|
||||||
|
failedCount Int @default(0)
|
||||||
|
skippedCount Int @default(0)
|
||||||
|
waitingCount Int @default(0)
|
||||||
|
consecutiveFailures Int @default(0)
|
||||||
|
applicationFailures Json @default("{}")
|
||||||
|
lastError String?
|
||||||
|
scanLeaseOwner String?
|
||||||
|
scanLeaseUntil DateTime?
|
||||||
|
createdById String?
|
||||||
|
startedAt DateTime?
|
||||||
|
pausedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||||
|
createdBy User? @relation("DownstreamRequeueTaskCreator", fields: [createdById], references: [id])
|
||||||
|
items DownstreamRequeueTaskItem[]
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([applicationId, status, createdAt])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownstreamRequeueRateWindow {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
applicationId String
|
||||||
|
windowStartedAt DateTime
|
||||||
|
consumed Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([applicationId, windowStartedAt])
|
||||||
|
@@index([windowStartedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownstreamRequeueTaskItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
taskId String
|
||||||
|
deliveryId String
|
||||||
|
applicationId String
|
||||||
|
status String @default("queued")
|
||||||
|
previousStatus String
|
||||||
|
skipReason String?
|
||||||
|
errorMessage String?
|
||||||
|
claimedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
task DownstreamRequeueTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
delivery CmppDownstreamDelivery @relation(fields: [deliveryId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([taskId, deliveryId])
|
||||||
|
@@index([taskId, status, createdAt])
|
||||||
|
@@index([applicationId, status, createdAt])
|
||||||
|
@@index([deliveryId, status])
|
||||||
|
}
|
||||||
|
|
||||||
model CmppDownstreamDeliveryAttempt {
|
model CmppDownstreamDeliveryAttempt {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
deliveryId String
|
deliveryId String
|
||||||
@@ -2097,3 +2423,143 @@ model GatewayDownstreamRecoveryStatus {
|
|||||||
@@index([state, updatedAt])
|
@@index([state, updatedAt])
|
||||||
@@index([nextRetryAt])
|
@@index([nextRetryAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SecurityDetectionRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
sourceType String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
threshold Int
|
||||||
|
windowSeconds Int
|
||||||
|
cooldownSeconds Int
|
||||||
|
severity String
|
||||||
|
defaultBlockSeconds Int
|
||||||
|
maximumBlockSeconds Int
|
||||||
|
configVersion Int @default(1)
|
||||||
|
effectiveVersion Int @default(0)
|
||||||
|
applyStatus String @default("pending")
|
||||||
|
lastApplyError String?
|
||||||
|
pendingConfig Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
events SecurityDetectionEvent[]
|
||||||
|
alerts SecurityAlert[]
|
||||||
|
|
||||||
|
@@index([enabled, sourceType])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityDetectionEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventKey String @unique
|
||||||
|
ruleId String
|
||||||
|
sourceIp String
|
||||||
|
sourcePort Int?
|
||||||
|
accountHash String?
|
||||||
|
path String?
|
||||||
|
protocol String?
|
||||||
|
resultCode String?
|
||||||
|
evidence Json?
|
||||||
|
occurredAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([ruleId, occurredAt])
|
||||||
|
@@index([sourceIp, occurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityAlert {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fingerprint String @unique
|
||||||
|
ruleId String
|
||||||
|
sourceIp String
|
||||||
|
severity String
|
||||||
|
status String @default("open")
|
||||||
|
eventCount Int @default(0)
|
||||||
|
windowStartedAt DateTime
|
||||||
|
firstOccurredAt DateTime
|
||||||
|
lastOccurredAt DateTime
|
||||||
|
acknowledgedAt DateTime?
|
||||||
|
acknowledgedById String?
|
||||||
|
ignoredAt DateTime?
|
||||||
|
ignoredById String?
|
||||||
|
ignoreReason String?
|
||||||
|
blockId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([status, severity, lastOccurredAt])
|
||||||
|
@@index([sourceIp, status, lastOccurredAt])
|
||||||
|
@@index([ruleId, status, lastOccurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityBlock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
operationKey String @unique
|
||||||
|
alertId String?
|
||||||
|
sourceIp String
|
||||||
|
executor String
|
||||||
|
status String @default("requested")
|
||||||
|
durationSeconds Int
|
||||||
|
reason String
|
||||||
|
requestedById String
|
||||||
|
requestedAt DateTime @default(now())
|
||||||
|
appliedAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
releasedAt DateTime?
|
||||||
|
releasedById String?
|
||||||
|
executorReference String?
|
||||||
|
lastError String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([sourceIp, status, requestedAt])
|
||||||
|
@@index([alertId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityProtectedNetwork {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
network String @unique
|
||||||
|
name String
|
||||||
|
reason String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([enabled, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InfrastructureAlertSetting {
|
||||||
|
id String @id @default("global")
|
||||||
|
configVersion Int @default(1)
|
||||||
|
effectiveVersion Int @default(1)
|
||||||
|
thresholds Json
|
||||||
|
effectiveThresholds Json
|
||||||
|
applyStatus String @default("effective")
|
||||||
|
lastError String?
|
||||||
|
updatedById String?
|
||||||
|
appliedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model InfrastructureAlertRead {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fingerprint String
|
||||||
|
activeAt DateTime
|
||||||
|
userId String
|
||||||
|
readAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fingerprint, userId])
|
||||||
|
@@index([userId, readAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
|
|||||||
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { InfrastructureMonitoringModule } from './infrastructure-monitoring/infrastructure-monitoring.module';
|
||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
import { OpenApiModule } from './open-api/open-api.module';
|
import { OpenApiModule } from './open-api/open-api.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -23,6 +24,9 @@ import { SendChainModule } from './send-chain/send-chain.module';
|
|||||||
import { SmsConfigModule } from './sms-config/sms-config.module';
|
import { SmsConfigModule } from './sms-config/sms-config.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
|
||||||
|
import { SecurityDetectionModule } from './security-detection/security-detection.module';
|
||||||
|
import { MetricsModule } from './metrics/metrics.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -48,7 +52,11 @@ import { UsersModule } from './users/users.module';
|
|||||||
ReportMaterialsModule,
|
ReportMaterialsModule,
|
||||||
SendChainModule,
|
SendChainModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
|
InfrastructureMonitoringModule,
|
||||||
OpenApiModule,
|
OpenApiModule,
|
||||||
|
SignatureRetirementModule,
|
||||||
|
SecurityDetectionModule,
|
||||||
|
MetricsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||||
|
|||||||
+103
-21
@@ -1,12 +1,16 @@
|
|||||||
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UsePipes } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||||
import { AuthService, LoginDto } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { ChangeOwnPasswordDto, LoginDto, PasswordVerificationDto } from './auth.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
||||||
import type { SessionRequest } from './session-validation.middleware';
|
import type { SessionRequest } from './session-validation.middleware';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { requestContext } from '../common/request-context';
|
||||||
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||||
|
|
||||||
type CookieResponse = {
|
type CookieResponse = {
|
||||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||||
@@ -16,26 +20,56 @@ 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) {}
|
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')
|
||||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
|
async adminLogin(
|
||||||
|
@Body() body: LoginDto,
|
||||||
|
@Req() request: SessionRequest,
|
||||||
|
@Res({ passthrough: true }) response: CookieResponse,
|
||||||
|
) {
|
||||||
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
|
try {
|
||||||
|
result = await this.auth.login(body, 'admin', this.sourceIp(request));
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.finishLogin(result, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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')
|
||||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
async clientLogin(
|
||||||
|
@Body() body: LoginDto,
|
||||||
|
@Req() request: SessionRequest,
|
||||||
|
@Res({ passthrough: true }) response: CookieResponse,
|
||||||
|
) {
|
||||||
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
|
try {
|
||||||
|
result = await this.auth.login(body, 'client', this.sourceIp(request));
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.finishLogin(result, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(['admin/auth/session', 'client/auth/session'])
|
@Get(['admin/auth/session', 'client/auth/session'])
|
||||||
@@ -74,22 +108,32 @@ 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'])
|
||||||
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
|
@UsePipes(strictValidationPipe)
|
||||||
|
async unlock(
|
||||||
|
@Req() request: SessionRequest,
|
||||||
|
@Body() body: PasswordVerificationDto,
|
||||||
|
@Res({ passthrough: true }) response: CookieResponse,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
|
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
|
||||||
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
|
@UsePipes(strictValidationPipe)
|
||||||
|
async reauthenticate(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto) {
|
||||||
|
const { password } = body;
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||||
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
||||||
@@ -100,30 +144,68 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(['admin/auth/password', 'client/auth/password'])
|
@Post(['admin/auth/password', 'client/auth/password'])
|
||||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
@UsePipes(strictValidationPipe)
|
||||||
|
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: ChangeOwnPasswordDto) {
|
||||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||||
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,
|
||||||
|
) {
|
||||||
|
return this.security.recordEvent({
|
||||||
|
ruleCode,
|
||||||
|
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
|
||||||
|
account,
|
||||||
|
protocol: 'http',
|
||||||
|
path: ruleCode === 'admin_login_failure' ? '/admin/auth/login' : '/client/auth/login',
|
||||||
|
evidence: { userAgent: request.header('user-agent')?.slice(0, 256) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) login!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(256) password!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(64) captchaId!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(32) captchaText!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PasswordVerificationDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(256) password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangeOwnPasswordDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(256) currentPassword!: string;
|
||||||
|
@IsString() @MinLength(8) @MaxLength(256) password!: string;
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [UsersModule, SecurityDetectionModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { hashPassword } from '../users/users.service';
|
import { legacyHashPassword } from './password-hasher';
|
||||||
|
|
||||||
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
||||||
const user = {
|
const user = {
|
||||||
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
|||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
phone: '13800000000',
|
phone: '13800000000',
|
||||||
displayName: '用户',
|
displayName: '用户',
|
||||||
passwordHash: hashPassword('secret1'),
|
passwordHash: legacyHashPassword('secret1'),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
lockedUntil: null,
|
lockedUntil: null,
|
||||||
@@ -20,56 +20,165 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
findByLogin: jest.fn().mockResolvedValue(user),
|
findByLogin: jest.fn().mockResolvedValue(user),
|
||||||
|
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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionsMock() {
|
function createSessionsMock() {
|
||||||
|
const captchas = new Map<string, string>();
|
||||||
|
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);
|
||||||
|
}),
|
||||||
|
consumeCaptcha: jest.fn(async (id: string) => {
|
||||||
|
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 = 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]
|
||||||
login: 'user@example.com',
|
.split('+')
|
||||||
password,
|
.map((part) => Number(part.trim()))
|
||||||
captchaId: captcha.captchaId,
|
.reduce((sum, value) => sum + value, 0);
|
||||||
captchaText: String(answer),
|
return service.login(
|
||||||
}, portal);
|
{
|
||||||
|
login: 'user@example.com',
|
||||||
|
password,
|
||||||
|
captchaId: captcha.captchaId,
|
||||||
|
captchaText: String(answer),
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
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 { hashPassword, UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
import type { LoginDto } from './auth.dto';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { MetricsService } from '../metrics/metrics.service';
|
||||||
export interface LoginDto {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
captchaId: string;
|
|
||||||
captchaText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginPortal = 'admin' | 'client';
|
type LoginPortal = 'admin' | 'client';
|
||||||
|
|
||||||
type CaptchaRecord = {
|
|
||||||
answer: string;
|
|
||||||
expiresAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const captchaStore = new Map<string, CaptchaRecord>();
|
|
||||||
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
|
|
||||||
|
|
||||||
@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,
|
||||||
|
) {}
|
||||||
|
|
||||||
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();
|
||||||
captchaStore.set(captchaId, {
|
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
|
||||||
answer: String(left + right),
|
|
||||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
captchaId,
|
captchaId,
|
||||||
challenge: `${left} + ${right} = ?`,
|
challenge: `${left} + ${right} = ?`,
|
||||||
@@ -39,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');
|
||||||
}
|
}
|
||||||
this.verifyCaptcha(data.captchaId, data.captchaText);
|
await this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||||
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) {
|
||||||
this.recordAnonymousFailure(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 (user.passwordHash !== hashPassword(data.password)) {
|
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);
|
||||||
anonymousFailures.delete(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 {
|
||||||
@@ -105,30 +103,25 @@ export class AuthService {
|
|||||||
return this.sessions.markReauthenticated(token);
|
return this.sessions.markReauthenticated(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
|
||||||
captchaStore.delete(captchaId ?? '');
|
if (!answer) {
|
||||||
if (!record || record.expiresAt < Date.now()) {
|
|
||||||
throw new BadRequestException('Captcha expired, refresh and try again');
|
throw new BadRequestException('Captcha expired, refresh and try again');
|
||||||
}
|
}
|
||||||
if (record.answer !== captchaText?.trim()) {
|
if (answer !== captchaText?.trim()) {
|
||||||
throw new BadRequestException('Captcha is incorrect');
|
throw new BadRequestException('Captcha is incorrect');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertAnonymousNotLocked(login: string) {
|
private async assertAnonymousNotLocked(login: string, sourceIp: string) {
|
||||||
const current = anonymousFailures.get(login);
|
const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
|
||||||
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
|
if (scope) {
|
||||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordAnonymousFailure(login: string) {
|
|
||||||
const current = anonymousFailures.get(login) ?? { count: 0 };
|
|
||||||
const count = current.count + 1;
|
|
||||||
anonymousFailures.set(login, {
|
|
||||||
count,
|
|
||||||
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ForbiddenException, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tenant bound to the authenticated client session.
|
||||||
|
* Request headers, query parameters and request bodies must never determine this value.
|
||||||
|
*/
|
||||||
|
export const CurrentTenantId = createParamDecorator((_: unknown, context: ExecutionContext) => {
|
||||||
|
const request = context.switchToHttp().getRequest<SessionRequest>();
|
||||||
|
if (request.authSession?.portal !== 'client' || !request.sessionTenantId) {
|
||||||
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '缺少可信企业上下文' });
|
||||||
|
}
|
||||||
|
return request.sessionTenantId;
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
|
||||||
|
|
||||||
|
describe('password hasher', () => {
|
||||||
|
const originalTransition = process.env.PASSWORD_HASH_LEGACY_TRANSITION;
|
||||||
|
const originalUntil = process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalTransition === undefined) delete process.env.PASSWORD_HASH_LEGACY_TRANSITION;
|
||||||
|
else process.env.PASSWORD_HASH_LEGACY_TRANSITION = originalTransition;
|
||||||
|
if (originalUntil === undefined) delete process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL;
|
||||||
|
else process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = originalUntil;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores new passwords using versioned salted scrypt hashes', async () => {
|
||||||
|
const first = await hashPassword('correct horse battery staple');
|
||||||
|
const second = await hashPassword('correct horse battery staple');
|
||||||
|
|
||||||
|
expect(first).toMatch(/^\$scrypt\$v=1\$/);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
await expect(verifyPassword('correct horse battery staple', first)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword('wrong password', first)).resolves.toBe(false);
|
||||||
|
expect(passwordNeedsRehash(first)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes and verifies legacy SHA-256 hashes for transparent migration', async () => {
|
||||||
|
const legacy = legacyHashPassword('legacy-password');
|
||||||
|
|
||||||
|
expect(isLegacySha256(legacy)).toBe(true);
|
||||||
|
expect(passwordNeedsRehash(legacy)).toBe(true);
|
||||||
|
await expect(verifyPassword('legacy-password', legacy)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword('wrong-password', legacy)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed or excessive scrypt parameters', async () => {
|
||||||
|
await expect(verifyPassword('password', '$scrypt$v=1$N=1048576,r=8,p=1$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports only a bounded legacy-write window for the first compatibility rollout', async () => {
|
||||||
|
process.env.PASSWORD_HASH_LEGACY_TRANSITION = 'true';
|
||||||
|
process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 30 * 60 * 1000).toISOString();
|
||||||
|
await expect(hashPassword('transition-password')).resolves.toBe(legacyHashPassword('transition-password'));
|
||||||
|
|
||||||
|
process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
await expect(hashPassword('strong-password')).resolves.toMatch(/^\$scrypt\$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
||||||
|
const VERSION = 1;
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const DEFAULT_N = 32768;
|
||||||
|
const DEFAULT_R = 8;
|
||||||
|
const DEFAULT_P = 1;
|
||||||
|
const MAX_MEMORY = 64 * 1024 * 1024;
|
||||||
|
const MAX_LEGACY_WRITE_WINDOW_MS = 2 * 60 * 60 * 1000;
|
||||||
|
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string) {
|
||||||
|
if (legacyTransitionWriteEnabled()) return legacyHashPassword(password);
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
|
||||||
|
N: DEFAULT_N,
|
||||||
|
r: DEFAULT_R,
|
||||||
|
p: DEFAULT_P,
|
||||||
|
maxmem: MAX_MEMORY,
|
||||||
|
});
|
||||||
|
return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyTransitionWriteEnabled() {
|
||||||
|
if (process.env.PASSWORD_HASH_LEGACY_TRANSITION !== 'true') return false;
|
||||||
|
const until = Date.parse(process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL ?? '');
|
||||||
|
const remaining = until - Date.now();
|
||||||
|
return Number.isFinite(until) && remaining > 0 && remaining <= MAX_LEGACY_WRITE_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, encoded: string) {
|
||||||
|
if (isLegacySha256(encoded)) {
|
||||||
|
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
|
||||||
|
const expected = Buffer.from(encoded, 'hex');
|
||||||
|
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseScryptHash(encoded);
|
||||||
|
if (!parsed) return false;
|
||||||
|
const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, {
|
||||||
|
N: parsed.N,
|
||||||
|
r: parsed.r,
|
||||||
|
p: parsed.p,
|
||||||
|
maxmem: MAX_MEMORY,
|
||||||
|
});
|
||||||
|
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordNeedsRehash(encoded: string) {
|
||||||
|
if (isLegacySha256(encoded)) return true;
|
||||||
|
const parsed = parseScryptHash(encoded);
|
||||||
|
return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacySha256(encoded: string) {
|
||||||
|
return LEGACY_SHA256.test(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyHashPassword(password: string) {
|
||||||
|
return createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScryptHash(encoded: string) {
|
||||||
|
const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const [, version, N, r, p, salt, hash] = match;
|
||||||
|
const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) };
|
||||||
|
if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N
|
||||||
|
|| !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R
|
||||||
|
|| !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined;
|
||||||
|
try {
|
||||||
|
const decodedSalt = Buffer.from(salt, 'base64url');
|
||||||
|
const decodedHash = Buffer.from(hash, 'base64url');
|
||||||
|
if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined;
|
||||||
|
return { ...params, salt: decodedSalt, hash: decodedHash };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
@@ -6,10 +6,10 @@ const record = {
|
|||||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
|
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token', tenantId?: string): SessionRequest {
|
||||||
return {
|
return {
|
||||||
originalUrl: path,
|
originalUrl: path,
|
||||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
|
header: jest.fn((name: string) => name === 'cookie' ? cookie : name === 'x-tenant-id' ? tenantId : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
||||||
|
|
||||||
it('accepts an active Redis session and exposes its user and record', async () => {
|
it('accepts an active Redis session and exposes its user and record', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
const currentRequest = request();
|
const currentRequest = request();
|
||||||
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a session after the user session version changes', async () => {
|
it('rejects a session after the user session version changes', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
|
|
||||||
it('selects only the cookie belonging to the requested portal', async () => {
|
it('selects only the cookie belonging to the requested portal', async () => {
|
||||||
const clientRecord = { ...record, portal: 'client' as const };
|
const clientRecord = { ...record, portal: 'client' as const };
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
|
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
|
||||||
@@ -62,6 +62,33 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
|
|
||||||
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
||||||
expect(currentRequest.sessionToken).toBe('client-token');
|
expect(currentRequest.sessionToken).toBe('client-token');
|
||||||
|
expect(currentRequest.sessionTenantId).toBe('tenant-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a client session whose user is not bound to a tenant', async () => {
|
||||||
|
const clientRecord = { ...record, portal: 'client' as const };
|
||||||
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
|
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token'), {}, jest.fn()))
|
||||||
|
.rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and audits a client tenant header that disagrees with the authenticated user', async () => {
|
||||||
|
const clientRecord = { ...record, portal: 'client' as const };
|
||||||
|
const prisma = {
|
||||||
|
user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) },
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
||||||
|
};
|
||||||
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
|
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token', 'tenant-b'), {}, jest.fn()))
|
||||||
|
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'CLIENT_TENANT_MISMATCH' }) });
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ tenantId: 'tenant-a', userId: 'user-1', action: 'security.client_tenant_mismatch' }),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not accept an admin cookie for a client route', async () => {
|
it('does not accept an admin cookie for a client route', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ export type SessionRequest = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
sessionUserId?: string;
|
sessionUserId?: string;
|
||||||
sessionToken?: string;
|
sessionToken?: string;
|
||||||
|
sessionTenantId?: string;
|
||||||
authSession?: AuthSessionRecord;
|
authSession?: AuthSessionRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: result.record.userId },
|
where: { id: result.record.userId },
|
||||||
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
|
select: { id: true, tenantId: true, status: true, deletedAt: true, sessionVersion: true },
|
||||||
});
|
});
|
||||||
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
||||||
await this.sessions.remove(token);
|
await this.sessions.remove(token);
|
||||||
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
request.sessionUserId = user.id;
|
request.sessionUserId = user.id;
|
||||||
request.sessionToken = token;
|
request.sessionToken = token;
|
||||||
request.authSession = result.record;
|
request.authSession = result.record;
|
||||||
|
if (portal === 'client') {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '当前客户端账号未关联企业' });
|
||||||
|
}
|
||||||
|
const suppliedTenantId = request.header('x-tenant-id')?.trim();
|
||||||
|
if (suppliedTenantId && suppliedTenantId !== user.tenantId) {
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
userId: user.id,
|
||||||
|
action: 'security.client_tenant_mismatch',
|
||||||
|
resource: 'auth_session',
|
||||||
|
detail: { suppliedTenantId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_MISMATCH', message: '请求企业与登录企业不一致' });
|
||||||
|
}
|
||||||
|
request.sessionTenantId = user.tenantId;
|
||||||
|
}
|
||||||
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
||||||
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
||||||
if (result.newlyLocked) {
|
if (result.newlyLocked) {
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('ioredis', () => jest.fn(() => redis));
|
jest.mock('ioredis', () => jest.fn(() => redis));
|
||||||
|
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
describe('SessionService', () => {
|
describe('SessionService', () => {
|
||||||
let now = 1_700_000_000_000;
|
let now = 1_700_000_000_000;
|
||||||
@@ -47,7 +76,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 +86,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 +100,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 +117,81 @@ 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when Redis cannot enforce captcha or login protection', async () => {
|
||||||
|
const service = new SessionService();
|
||||||
|
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||||
|
await expect(service.assertCaptchaRequestAllowed('203.0.113.20')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
redis.mget.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||||
|
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||||
|
await expect(service.recordAnonymousLoginFailure('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export type SessionValidationResult =
|
|||||||
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
||||||
|
|
||||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||||
|
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
|
||||||
|
const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:';
|
||||||
|
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
|
||||||
|
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';
|
||||||
@@ -123,6 +131,108 @@ export class SessionService implements OnModuleDestroy {
|
|||||||
return this.client.del(this.key(token));
|
return this.client.del(this.key(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) {
|
||||||
|
try {
|
||||||
|
await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeCaptcha(captchaId: string) {
|
||||||
|
try {
|
||||||
|
return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCaptchaRequestAllowed(sourceIp: string) {
|
||||||
|
const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`;
|
||||||
|
try {
|
||||||
|
const count = Number(
|
||||||
|
await this.client.eval(
|
||||||
|
`local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||||
|
return count`,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
5 * 60,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return count <= 30;
|
||||||
|
} 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 {
|
||||||
|
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordAnonymousLoginFailure(login: string, sourceIp: string) {
|
||||||
|
const accountDigest = this.loginDigest(login);
|
||||||
|
const ipDigest = this.valueDigest(sourceIp);
|
||||||
|
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
|
||||||
|
try {
|
||||||
|
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 {
|
||||||
|
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
||||||
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
||||||
}
|
}
|
||||||
@@ -138,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) {
|
||||||
@@ -172,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('登录会话服务暂不可用');
|
||||||
}
|
}
|
||||||
@@ -195,6 +308,14 @@ export class SessionService implements OnModuleDestroy {
|
|||||||
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loginDigest(login: string) {
|
||||||
|
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() {
|
||||||
if (!this.redis) {
|
if (!this.redis) {
|
||||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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 { TenantId } from '../common/tenant-id.decorator';
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
|
import { ClientBillingEstimateDto } from '../common/client-write.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
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 {
|
import {
|
||||||
@@ -125,14 +128,15 @@ export class ClientBillingController {
|
|||||||
constructor(private readonly billing: BillingService) {}
|
constructor(private readonly billing: BillingService) {}
|
||||||
|
|
||||||
@Get('orders')
|
@Get('orders')
|
||||||
listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listRechargeOrders(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
return page || pageSize
|
return page || pageSize
|
||||||
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.billing.listRechargeOrders(tenantId);
|
: this.billing.listRechargeOrders(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('estimate')
|
@Post('estimate')
|
||||||
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.billing.estimateSmsCost(body);
|
estimateSmsCost(@CurrentTenantId() tenantId: string, @Body() body: ClientBillingEstimateDto) {
|
||||||
|
return this.billing.estimateSmsCost({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ function createPrismaMock() {
|
|||||||
tenant: {
|
tenant: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
tenantAccount: {
|
tenantAccount: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
|
findUniqueOrThrow: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
updateMany: jest.fn().mockImplementation(({ data }) => {
|
updateMany: jest.fn().mockImplementation(({ data }) => {
|
||||||
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
||||||
|
else if (data.balanceCents?.decrement !== undefined) accountState.balanceCents -= data.balanceCents.decrement;
|
||||||
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
||||||
return Promise.resolve({ count: 1 });
|
return Promise.resolve({ count: 1 });
|
||||||
}),
|
}),
|
||||||
@@ -25,10 +30,12 @@ function createPrismaMock() {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
accountTransaction: {
|
accountTransaction: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
findUniqueOrThrow: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: 'tx-charged', idempotencyKey: where.idempotencyKey })),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||||
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||||
},
|
},
|
||||||
rechargeOrder: {
|
rechargeOrder: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
@@ -48,6 +55,7 @@ function createPrismaMock() {
|
|||||||
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
||||||
},
|
},
|
||||||
$executeRaw: jest.fn(),
|
$executeRaw: jest.fn(),
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
};
|
};
|
||||||
return Object.assign(prisma, {
|
return Object.assign(prisma, {
|
||||||
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
||||||
@@ -91,7 +99,7 @@ describe('BillingService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
it('allows sending only when cash balance plus credit covers the required amount', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new BillingService(prisma as never);
|
const service = new BillingService(prisma as never);
|
||||||
|
|
||||||
@@ -99,7 +107,7 @@ describe('BillingService', () => {
|
|||||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||||
);
|
);
|
||||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
||||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
expect.objectContaining({ availableAmount: 1000, canSend: false }),
|
||||||
);
|
);
|
||||||
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
||||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
||||||
@@ -107,7 +115,7 @@ describe('BillingService', () => {
|
|||||||
);
|
);
|
||||||
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
||||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
||||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }),
|
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: false }),
|
||||||
);
|
);
|
||||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
||||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
@@ -181,9 +189,10 @@ describe('BillingService', () => {
|
|||||||
it('returns the historical balance after each manual recharge', async () => {
|
it('returns the historical balance after each manual recharge', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.rechargeOrder.findMany.mockResolvedValue([
|
prisma.rechargeOrder.findMany.mockResolvedValue([
|
||||||
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 },
|
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000, operatorId: 'admin-1' },
|
||||||
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
||||||
]);
|
]);
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]);
|
||||||
prisma.accountTransaction.findMany.mockResolvedValue([
|
prisma.accountTransaction.findMany.mockResolvedValue([
|
||||||
{ relatedId: 'order-1', balanceAfter: 3000 },
|
{ relatedId: 'order-1', balanceAfter: 3000 },
|
||||||
{ relatedId: 'order-2', balanceAfter: 2700 },
|
{ relatedId: 'order-2', balanceAfter: 2700 },
|
||||||
@@ -191,8 +200,8 @@ describe('BillingService', () => {
|
|||||||
const service = new BillingService(prisma as never);
|
const service = new BillingService(prisma as never);
|
||||||
|
|
||||||
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
||||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
|
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }),
|
||||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
|
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }),
|
||||||
]);
|
]);
|
||||||
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
@@ -201,6 +210,10 @@ describe('BillingService', () => {
|
|||||||
},
|
},
|
||||||
select: { relatedId: true, balanceAfter: true },
|
select: { relatedId: true, balanceAfter: true },
|
||||||
});
|
});
|
||||||
|
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: ['admin-1'] } },
|
||||||
|
select: { id: true, displayName: true, username: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows negative manual recharge amounts for balance correction', async () => {
|
it('allows negative manual recharge amounts for balance correction', async () => {
|
||||||
@@ -304,6 +317,28 @@ describe('BillingService', () => {
|
|||||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('settles a frozen SMS charge with one account lock and an idempotent ledger pair', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const rows = new Map<string, Record<string, unknown>>();
|
||||||
|
prisma.accountTransaction.findMany.mockImplementation(() => Promise.resolve([...rows.values()]));
|
||||||
|
prisma.$queryRaw.mockImplementation(() => {
|
||||||
|
const charged = { id: 'tx-charged', idempotencyKey: 'sms-charge:msg-paid-1', transactionType: 'charged', amountCents: -325 };
|
||||||
|
rows.set(String(charged.idempotencyKey), charged);
|
||||||
|
return Promise.resolve([charged]);
|
||||||
|
});
|
||||||
|
const service = new BillingService(prisma as never);
|
||||||
|
const input = { tenantId: 'tenant-1', amountCents: 325, messageId: 'msg-paid-1', taskId: 'task-paid-1' };
|
||||||
|
|
||||||
|
const first = await service.settleFrozenCharge(input);
|
||||||
|
const replay = await service.settleFrozenCharge(input);
|
||||||
|
|
||||||
|
expect(first).toEqual(expect.objectContaining({ transactionType: 'charged', amountCents: -325 }));
|
||||||
|
expect(replay).toEqual(first);
|
||||||
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.tenantAccount.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
let transactionChain = Promise.resolve<unknown>(undefined);
|
let transactionChain = Promise.resolve<unknown>(undefined);
|
||||||
|
|||||||
@@ -163,18 +163,27 @@ export class BillingService {
|
|||||||
return orders;
|
return orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactions = await this.prisma.accountTransaction.findMany({
|
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||||
where: {
|
const [transactions, operators] = await Promise.all([
|
||||||
relatedType: 'recharge_order',
|
this.prisma.accountTransaction.findMany({
|
||||||
relatedId: { in: orderIds },
|
where: {
|
||||||
},
|
relatedType: 'recharge_order',
|
||||||
select: { relatedId: true, balanceAfter: true },
|
relatedId: { in: orderIds },
|
||||||
});
|
},
|
||||||
|
select: { relatedId: true, balanceAfter: true },
|
||||||
|
}),
|
||||||
|
operatorIds.length ? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: operatorIds } },
|
||||||
|
select: { id: true, displayName: true, username: true },
|
||||||
|
}) : [],
|
||||||
|
]);
|
||||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
||||||
|
const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||||
|
|
||||||
return orders.map((order) => ({
|
return orders.map((order) => ({
|
||||||
...order,
|
...order,
|
||||||
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
||||||
|
operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +204,25 @@ export class BillingService {
|
|||||||
this.prisma.rechargeOrder.count({ where }),
|
this.prisma.rechargeOrder.count({ where }),
|
||||||
]);
|
]);
|
||||||
const orderIds = orders.map((order) => order.id);
|
const orderIds = orders.map((order) => order.id);
|
||||||
const transactions = orderIds.length ? await this.prisma.accountTransaction.findMany({
|
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||||
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
const [transactions, operators] = await Promise.all([
|
||||||
select: { relatedId: true, balanceAfter: true },
|
orderIds.length ? this.prisma.accountTransaction.findMany({
|
||||||
}) : [];
|
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
||||||
|
select: { relatedId: true, balanceAfter: true },
|
||||||
|
}) : [],
|
||||||
|
operatorIds.length ? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: operatorIds } },
|
||||||
|
select: { id: true, displayName: true, username: true },
|
||||||
|
}) : [],
|
||||||
|
]);
|
||||||
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
|
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
|
||||||
|
const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||||
return {
|
return {
|
||||||
items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null })),
|
items: orders.map((order) => ({
|
||||||
|
...order,
|
||||||
|
balanceAfterCents: balances.get(order.id) ?? null,
|
||||||
|
operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null,
|
||||||
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -395,7 +416,7 @@ export class BillingService {
|
|||||||
availableAmount,
|
availableAmount,
|
||||||
balanceCents,
|
balanceCents,
|
||||||
creditCents,
|
creditCents,
|
||||||
canSend: availableAmount > 0,
|
canSend: availableAmount >= requiredAmount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +436,56 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async settleFrozenCharge(data: { tenantId: string; amountCents: number; messageId: string; taskId: string; remark?: string }) {
|
||||||
|
const amountCents = data.amountCents ?? 0;
|
||||||
|
if (amountCents <= 0) return null;
|
||||||
|
const releaseKey = `sms-charge-release:${data.messageId}`;
|
||||||
|
const chargeKey = `sms-charge:${data.messageId}`;
|
||||||
|
const existing = await this.prisma.accountTransaction.findMany({
|
||||||
|
where: { idempotencyKey: { in: [releaseKey, chargeKey] } },
|
||||||
|
});
|
||||||
|
const charge = existing.find((row) => row.idempotencyKey === chargeKey);
|
||||||
|
if (charge) return charge;
|
||||||
|
const release = existing.find((row) => row.idempotencyKey === releaseKey);
|
||||||
|
if (release) {
|
||||||
|
// Recover the legacy two-transaction boundary: a crash may have committed
|
||||||
|
// release before charge, so this path must perform the missing balance debit.
|
||||||
|
return this.applyAccountDelta({
|
||||||
|
tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey,
|
||||||
|
amountCents: -amountCents, relatedType: 'sms_message_record', relatedId: data.messageId,
|
||||||
|
remark: '提交成功扣费(恢复既有已释放冻结)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string; idempotencyKey: string }>>(Prisma.sql`
|
||||||
|
WITH account AS (
|
||||||
|
SELECT "balanceCents" FROM "TenantAccount" WHERE "tenantId" = ${data.tenantId}
|
||||||
|
), inserted AS (
|
||||||
|
INSERT INTO "AccountTransaction" (
|
||||||
|
id, "tenantId", "transactionType", "idempotencyKey", "amountCents",
|
||||||
|
"balanceAfter", "relatedType", "relatedId", remark, "createdAt"
|
||||||
|
)
|
||||||
|
SELECT gen_random_uuid()::text, ${data.tenantId}, ledger."transactionType", ledger."idempotencyKey",
|
||||||
|
ledger."amountCents", account."balanceCents" + ledger."balanceDelta",
|
||||||
|
ledger."relatedType", ledger."relatedId", ledger.remark, (NOW() AT TIME ZONE 'UTC')
|
||||||
|
FROM account
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('released', ${releaseKey}, ${amountCents}::bigint, ${amountCents}::bigint, 'sms_batch_task', ${data.taskId}, ${data.remark ?? null}),
|
||||||
|
('charged', ${chargeKey}, ${-amountCents}::bigint, 0::bigint, 'sms_message_record', ${data.messageId}, '提交成功扣费')
|
||||||
|
) AS ledger("transactionType", "idempotencyKey", "amountCents", "balanceDelta", "relatedType", "relatedId", remark)
|
||||||
|
ON CONFLICT ("idempotencyKey") DO NOTHING
|
||||||
|
RETURNING id, "idempotencyKey"
|
||||||
|
)
|
||||||
|
SELECT id, "idempotencyKey" FROM inserted WHERE "idempotencyKey" = ${chargeKey}
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, "idempotencyKey" FROM "AccountTransaction" WHERE "idempotencyKey" = ${chargeKey}
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
if (rows[0]) return rows[0];
|
||||||
|
// A concurrent identical callback can win ON CONFLICT while remaining
|
||||||
|
// invisible to this statement's snapshot; one read repairs that MVCC edge.
|
||||||
|
return this.prisma.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } });
|
||||||
|
}
|
||||||
|
|
||||||
release(data: BillingActionDto) {
|
release(data: BillingActionDto) {
|
||||||
return this.applyAccountDelta({
|
return this.applyAccountDelta({
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@@ -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 { TenantId } from '../common/tenant-id.decorator';
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
|
import { CertificationService, ReviewCertificationDto } from './certification.service';
|
||||||
|
|
||||||
@ApiTags('client-certification')
|
@ApiTags('client-certification')
|
||||||
@Controller('client/enterprise-certification')
|
@Controller('client/enterprise-certification')
|
||||||
@@ -10,13 +12,14 @@ export class ClientCertificationController {
|
|||||||
constructor(private readonly certifications: CertificationService) {}
|
constructor(private readonly certifications: CertificationService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@TenantId() tenantId?: string) {
|
list(@CurrentTenantId() tenantId: string) {
|
||||||
return this.certifications.list(tenantId);
|
return this.certifications.list(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
submit(@Body() body: SubmitCertificationDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.certifications.submit(body);
|
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
|
||||||
|
return this.certifications.submit({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||||
import { ChannelConnectionService } from './channel-connection.service';
|
import { ChannelConnectionService } from './channel-connection.service';
|
||||||
|
|
||||||
/** R5 channel domain service composed behind ChannelsService. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
@@ -25,19 +25,36 @@ export class ChannelConfigurationService {
|
|||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const where: Prisma.SmsChannelWhereInput = {
|
const where: Prisma.SmsChannelWhereInput = {
|
||||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||||
carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined,
|
carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined,
|
||||||
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||||
};
|
};
|
||||||
const [items, total] = await Promise.all([
|
const candidates = await this.prisma.smsChannel.findMany({ where, select: { id: true, name: true } });
|
||||||
this.prisma.smsChannel.findMany({
|
const total = candidates.length;
|
||||||
where,
|
if (total === 0) return { items: [], total, page, pageSize };
|
||||||
include: { connectionStates: true },
|
const day = currentShanghaiDayRange();
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
const counts = await this.prisma.$queryRaw<Array<{ channelId: string; total: number }>>(Prisma.sql`
|
||||||
skip: (page - 1) * pageSize,
|
SELECT submit."channelId" AS "channelId", COUNT(*)::integer AS total
|
||||||
take: pageSize,
|
FROM "SmsSubmitRecord" submit
|
||||||
}),
|
WHERE submit."channelId" IN (${Prisma.join(candidates.map((channel) => channel.id))})
|
||||||
this.prisma.smsChannel.count({ where }),
|
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||||
]);
|
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||||
|
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||||
|
GROUP BY submit."channelId"
|
||||||
|
`);
|
||||||
|
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
|
||||||
|
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
|
||||||
|
const pageIds = candidates
|
||||||
|
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|
||||||
|
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||||
|
|| left.id.localeCompare(right.id))
|
||||||
|
.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
.map((channel) => channel.id);
|
||||||
|
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } });
|
||||||
|
const itemById = new Map(pageItems.map((item) => [item.id, item]));
|
||||||
|
const items = pageIds.flatMap((id) => {
|
||||||
|
const item = itemById.get(id);
|
||||||
|
return item ? [item] : [];
|
||||||
|
});
|
||||||
return { items, total, page, pageSize };
|
return { items, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +81,13 @@ export class ChannelConfigurationService {
|
|||||||
data.heartbeatMissThreshold,
|
data.heartbeatMissThreshold,
|
||||||
);
|
);
|
||||||
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
|
const carriers = normalizeChannelCarriers(data.carriers, data.carrier);
|
||||||
const channel = await this.prisma.smsChannel.create({
|
const channel = await this.prisma.smsChannel.create({
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
carrier: data.carrier,
|
carrier: legacyCarrierFromCapabilities(carriers),
|
||||||
|
carriers,
|
||||||
sendRegion: data.sendRegion ?? '全国',
|
sendRegion: data.sendRegion ?? '全国',
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: data.gatewayHost,
|
gatewayHost: data.gatewayHost,
|
||||||
@@ -120,6 +139,22 @@ export class ChannelConfigurationService {
|
|||||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
|
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
|
||||||
|
const carriers = data.carriers !== undefined || data.carrier !== undefined
|
||||||
|
? normalizeChannelCarriers(data.carriers, data.carrier)
|
||||||
|
: existingCarriers;
|
||||||
|
if (data.carriers !== undefined || data.carrier !== undefined) {
|
||||||
|
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
|
||||||
|
if (removed.length) {
|
||||||
|
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
|
||||||
|
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
|
||||||
|
include: { group: true },
|
||||||
|
});
|
||||||
|
if (blockingGroups.length) {
|
||||||
|
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||||
@@ -133,7 +168,8 @@ export class ChannelConfigurationService {
|
|||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
carrier: data.carrier,
|
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
|
||||||
|
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
||||||
sendRegion: data.sendRegion,
|
sendRegion: data.sendRegion,
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: data.gatewayHost,
|
gatewayHost: data.gatewayHost,
|
||||||
@@ -159,6 +195,7 @@ export class ChannelConfigurationService {
|
|||||||
code: channel.code,
|
code: channel.code,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
carrier: channel.carrier,
|
carrier: channel.carrier,
|
||||||
|
carriers: channel.carriers,
|
||||||
sendRegion: channel.sendRegion,
|
sendRegion: channel.sendRegion,
|
||||||
gatewayHost: channel.gatewayHost,
|
gatewayHost: channel.gatewayHost,
|
||||||
gatewayPort: channel.gatewayPort,
|
gatewayPort: channel.gatewayPort,
|
||||||
|
|||||||
@@ -367,6 +367,10 @@ export class ChannelConnectionService {
|
|||||||
cmppVersion: channel.cmppVersion,
|
cmppVersion: channel.cmppVersion,
|
||||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||||
|
connectionWarmupSeconds: Number(getConfigValue(channel.config, 'connectionWarmupSeconds') ?? 30),
|
||||||
|
connectionDrainTimeoutSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'connectionDrainTimeoutSeconds'), 60, 'connectionDrainTimeoutSeconds'),
|
||||||
|
submitResponseTimeoutSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'submitResponseTimeoutSeconds'), 60, 'submitResponseTimeoutSeconds'),
|
||||||
|
connectionFailureCooldownSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'connectionFailureCooldownSeconds'), 30, 'connectionFailureCooldownSeconds'),
|
||||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class ChannelCopyService {
|
|||||||
code: nextCode,
|
code: nextCode,
|
||||||
name: nextName,
|
name: nextName,
|
||||||
carrier: source.carrier,
|
carrier: source.carrier,
|
||||||
|
carriers: source.carriers,
|
||||||
protocol: source.protocol,
|
protocol: source.protocol,
|
||||||
gatewayHost: source.gatewayHost,
|
gatewayHost: source.gatewayHost,
|
||||||
gatewayPort: source.gatewayPort,
|
gatewayPort: source.gatewayPort,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class ChannelGroupRoutingService {
|
|||||||
|
|
||||||
listGroups() {
|
listGroups() {
|
||||||
return this.prisma.smsChannelGroup.findMany({
|
return this.prisma.smsChannelGroup.findMany({
|
||||||
|
where: { status: { not: 'deleted' } },
|
||||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -51,7 +52,7 @@ export class ChannelGroupRoutingService {
|
|||||||
if (!channel) {
|
if (!channel) {
|
||||||
throw new NotFoundException('Channel not found');
|
throw new NotFoundException('Channel not found');
|
||||||
}
|
}
|
||||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
|
||||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||||
}
|
}
|
||||||
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
||||||
@@ -158,23 +159,78 @@ export class ChannelGroupRoutingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGroup(groupId: string) {
|
async getGroupDeletionImpact(groupId: string) {
|
||||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
const group = await this.prisma.smsChannelGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
select: { id: true, name: true, items: { select: { id: true } } },
|
||||||
|
});
|
||||||
if (!group) {
|
if (!group) {
|
||||||
throw new NotFoundException('Channel group not found');
|
throw new NotFoundException('Channel group not found');
|
||||||
}
|
}
|
||||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
const routes = await this.prisma.channelRouteRule.findMany({
|
||||||
where: {
|
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
|
||||||
groupId,
|
select: { applicationId: true },
|
||||||
status: 'active',
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
if (boundRoute) {
|
const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))];
|
||||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
const [applications, pendingSupplierSubmitCount] = await Promise.all([
|
||||||
|
this.prisma.smsApplication.findMany({
|
||||||
|
where: { id: { in: applicationIds } },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
}),
|
||||||
|
this.prisma.smsSubmitRecord.count({
|
||||||
|
where: { channelGroupId: groupId, submitStatus: 'queued' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const applicationStatusById = new Map(applications.map((application) => [application.id, application.status]));
|
||||||
|
const deletedApplicationCount = applicationIds.filter((applicationId) => {
|
||||||
|
const status = applicationStatusById.get(applicationId);
|
||||||
|
return status === undefined || status === 'deleted';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
normalApplicationCount: applicationIds.length - deletedApplicationCount,
|
||||||
|
deletedApplicationCount,
|
||||||
|
channelCount: group.items.length,
|
||||||
|
pendingSupplierSubmitCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGroup(groupId: string) {
|
||||||
|
const group = await this.prisma.smsChannelGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundException('Channel group not found');
|
||||||
}
|
}
|
||||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
if (group.status === 'deleted') {
|
||||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
return group;
|
||||||
|
}
|
||||||
|
const impact = await this.getGroupDeletionImpact(groupId);
|
||||||
|
|
||||||
|
// Logical deletion keeps group items and route bindings available for historical
|
||||||
|
// receipts and uplink access-number matching; new submits already require an active group.
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const deleted = await tx.smsChannelGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data: { status: 'deleted' },
|
||||||
|
});
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'sms_channel_group.delete',
|
||||||
|
resource: 'sms_channel_group',
|
||||||
|
resourceId: groupId,
|
||||||
|
detail: {
|
||||||
|
before: channelGroupAuditSnapshot(group),
|
||||||
|
impact,
|
||||||
|
deletionMode: 'soft_delete',
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return deleted;
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
}
|
}
|
||||||
|
|
||||||
listRouteRules() {
|
listRouteRules() {
|
||||||
|
|||||||
@@ -1,13 +1,99 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import IORedis from 'ioredis';
|
import IORedis from 'ioredis';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type {
|
||||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
CreateChannelDto,
|
||||||
|
UpdateChannelDto,
|
||||||
|
CreateChannelGroupDto,
|
||||||
|
CreateChannelGroupItemDto,
|
||||||
|
UpdateChannelGroupDto,
|
||||||
|
CreateRouteRuleDto,
|
||||||
|
CreateReportFieldDto,
|
||||||
|
ReplaceReportFieldsDto,
|
||||||
|
CreateReportMaterialDto,
|
||||||
|
CreateReportTaskDto,
|
||||||
|
ChangeReportTaskStatusesDto,
|
||||||
|
CreateReportExportDto,
|
||||||
|
CreateReceiptImportDto,
|
||||||
|
UpsertConnectionStateDto,
|
||||||
|
ChangeChannelStatusDto,
|
||||||
|
CopyChannelDto,
|
||||||
|
TestChannelDto,
|
||||||
|
} from './channels.contracts';
|
||||||
|
import {
|
||||||
|
GATEWAY_CONNECTION_QUEUE,
|
||||||
|
GATEWAY_SUBMIT_QUEUE,
|
||||||
|
GATEWAY_SUBMIT_STREAM,
|
||||||
|
DEFAULT_GATEWAY_CONTROL_URL,
|
||||||
|
DEFAULT_CHANNEL_CONNECTION_ID,
|
||||||
|
DEFAULT_CONNECTING_TIMEOUT_MS,
|
||||||
|
DEFAULT_CONNECTING_TIMEOUT_SCAN_MS,
|
||||||
|
DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS,
|
||||||
|
DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS,
|
||||||
|
DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS,
|
||||||
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||||
|
HEARTBEAT_AUDIT_INTERVAL_MS,
|
||||||
|
CONNECTING_TIMEOUT_ERROR,
|
||||||
|
DEFAULT_CMPP_VERSION,
|
||||||
|
normalizeTestPhones,
|
||||||
|
normalizeTestContent,
|
||||||
|
calculateBillingUnits,
|
||||||
|
buildChannelTestSubmitCommand,
|
||||||
|
getConfigValue,
|
||||||
|
getStringConfigValue,
|
||||||
|
normalizeConnectionAction,
|
||||||
|
normalizeCmppVersion,
|
||||||
|
normalizeGatewayConnectionStatus,
|
||||||
|
defaultChannelConnectionId,
|
||||||
|
getDesiredConnections,
|
||||||
|
ChannelConnectionSettings,
|
||||||
|
getRuntimeConfigInteger,
|
||||||
|
channelConnectionSettingsChanged,
|
||||||
|
channelGroupAuditSnapshot,
|
||||||
|
normalizeChannelRuntimeConfig,
|
||||||
|
normalizeCmppServiceId,
|
||||||
|
normalizeChannelRateLimit,
|
||||||
|
normalizeExtensionDigits,
|
||||||
|
getPositiveRuntimeInteger,
|
||||||
|
bullmqConnection,
|
||||||
|
getPositiveIntegerEnv,
|
||||||
|
parseReceiptContent,
|
||||||
|
splitReceiptLine,
|
||||||
|
stripReceiptCell,
|
||||||
|
findReceiptStatusIndex,
|
||||||
|
normalizeReceiptStatus,
|
||||||
|
deriveReceiptStatus,
|
||||||
|
ChannelReportDeliveryRow,
|
||||||
|
summarizeChannelReportDelivery,
|
||||||
|
sumReportDelivery,
|
||||||
|
percentage,
|
||||||
|
latestDate,
|
||||||
|
currentShanghaiDayRange,
|
||||||
|
normalizeRetryTimeLimitMinutes,
|
||||||
|
normalizeSpreadsheetSize,
|
||||||
|
normalizeBusinessCarrier,
|
||||||
|
normalizeChannelCarrier,
|
||||||
|
normalizeChannelCarriers,
|
||||||
|
isChannelCarrierCompatible,
|
||||||
|
normalizeRegion,
|
||||||
|
isRegionCompatible,
|
||||||
|
validateGroupItems,
|
||||||
|
normalizeReportType,
|
||||||
|
summarizeReportStatuses,
|
||||||
|
normalizeLinkEvent,
|
||||||
|
} from './channels.helpers';
|
||||||
|
|
||||||
/** R5 channel domain service composed behind ChannelsService. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
export class ChannelReportingService {
|
export class ChannelReportingService {
|
||||||
@@ -94,7 +180,11 @@ export class ChannelReportingService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
return tx.channelReportField.findMany({
|
||||||
|
where: { channelId, reportType },
|
||||||
|
include: { drainageField: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,11 +335,12 @@ export class ChannelReportingService {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
return tasks.map((task) => {
|
return tasks.map((task) => {
|
||||||
const taskRows = rows.filter((row) => (
|
const taskRows = rows.filter(
|
||||||
row.channelId === task.channelId
|
(row) =>
|
||||||
&& row.signatureId === task.signatureId
|
row.channelId === task.channelId &&
|
||||||
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
|
row.signatureId === task.signatureId &&
|
||||||
));
|
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
|
||||||
|
);
|
||||||
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
@@ -261,10 +352,65 @@ export class ChannelReportingService {
|
|||||||
|
|
||||||
async listReportTasksPage(query: {
|
async listReportTasksPage(query: {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
todaySendMin?: number;
|
||||||
|
todaySendMax?: number;
|
||||||
|
sort?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
|
const keyword = query.keyword?.trim();
|
||||||
|
const from = query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined;
|
||||||
|
const to = query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined;
|
||||||
|
const all = await this.listReportTasks(query.tenantId, query.status, query.channelId, query.reportType);
|
||||||
|
const filtered = all.filter((task) => {
|
||||||
|
const createdAt = task.createdAt instanceof Date ? task.createdAt : new Date(task.createdAt);
|
||||||
|
const total = (task as typeof task & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0;
|
||||||
|
if (query.applicationId && task.signature.applicationId !== query.applicationId) return false;
|
||||||
|
if (query.carrier && task.carrier !== query.carrier) return false;
|
||||||
|
if (from && createdAt < from) return false;
|
||||||
|
if (to && createdAt > to) return false;
|
||||||
|
if (Number.isFinite(query.todaySendMin) && total < Number(query.todaySendMin)) return false;
|
||||||
|
if (Number.isFinite(query.todaySendMax) && total > Number(query.todaySendMax)) return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
return [
|
||||||
|
task.id,
|
||||||
|
task.channel.name,
|
||||||
|
task.signature.name,
|
||||||
|
task.signature.tenant.name,
|
||||||
|
task.signature.application?.name,
|
||||||
|
task.drainageInfo?.siteName,
|
||||||
|
task.drainageInfo?.url,
|
||||||
|
].some((value) => String(value ?? '').includes(keyword));
|
||||||
|
});
|
||||||
|
filtered.sort((left, right) =>
|
||||||
|
query.sort === 'todaySendDesc'
|
||||||
|
? ((right as typeof right & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0) -
|
||||||
|
((left as typeof left & { deliveryStats?: { total: number } }).deliveryStats?.total ?? 0) ||
|
||||||
|
right.updatedAt.getTime() - left.updatedAt.getTime()
|
||||||
|
: right.createdAt.getTime() - left.createdAt.getTime(),
|
||||||
|
);
|
||||||
|
return { items: filtered.slice((page - 1) * pageSize, page * pageSize), total: filtered.length, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReportDetailsPage(query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
signatureId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
carrier?: string;
|
||||||
|
status?: string;
|
||||||
|
reportType?: string;
|
||||||
|
keyword?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
createdAtTo?: string;
|
createdAtTo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -272,48 +418,123 @@ export class ChannelReportingService {
|
|||||||
}) {
|
}) {
|
||||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const keyword = query.keyword?.trim();
|
const signatures = await this.prisma.smsSignature.findMany({
|
||||||
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
|
where: {
|
||||||
tenantId: query.tenantId,
|
id: query.signatureId,
|
||||||
status: query.status,
|
tenantId: query.tenantId,
|
||||||
channelId: query.channelId,
|
applicationId: query.applicationId,
|
||||||
reportType: query.reportType,
|
auditStatus: 'approved',
|
||||||
signature: { auditStatus: { not: 'deleted' } },
|
},
|
||||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
include: {
|
||||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
tenant: true,
|
||||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
application: true,
|
||||||
} : undefined,
|
drainageItems: { where: { auditStatus: 'approved' } },
|
||||||
OR: keyword ? [
|
reportTasks: {
|
||||||
{ id: { contains: keyword } },
|
include: {
|
||||||
{ channel: { name: { contains: keyword } } },
|
channel: true,
|
||||||
{ signature: { name: { contains: keyword } } },
|
drainageInfo: true,
|
||||||
{ signature: { tenant: { name: { contains: keyword } } } },
|
exportItems: {
|
||||||
{ signature: { application: { name: { contains: keyword } } } },
|
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||||
{ drainageInfo: { siteName: { contains: keyword } } },
|
orderBy: { id: 'desc' },
|
||||||
{ drainageInfo: { url: { contains: keyword } } },
|
take: 1,
|
||||||
] : undefined,
|
},
|
||||||
};
|
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||||
const [items, total] = await Promise.all([
|
|
||||||
this.prisma.channelSignatureReportTask.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
signature: { include: { tenant: true, application: true } },
|
|
||||||
channel: true,
|
|
||||||
drainageInfo: true,
|
|
||||||
exportItems: {
|
|
||||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
|
||||||
orderBy: { id: 'desc' },
|
|
||||||
take: 1,
|
|
||||||
},
|
},
|
||||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
},
|
||||||
skip: (page - 1) * pageSize,
|
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||||
take: pageSize,
|
});
|
||||||
}),
|
const applicationIds = [
|
||||||
this.prisma.channelSignatureReportTask.count({ where }),
|
...new Set(signatures.map((item) => item.applicationId).filter((id): id is string => Boolean(id))),
|
||||||
]);
|
];
|
||||||
return { items, total, page, pageSize };
|
const routes = applicationIds.length
|
||||||
|
? await this.prisma.channelRouteRule.findMany({
|
||||||
|
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||||
|
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const details = signatures
|
||||||
|
.flatMap((signature) => {
|
||||||
|
const channels = [
|
||||||
|
...new Map(
|
||||||
|
routes
|
||||||
|
.filter((route) => route.applicationId === signature.applicationId && route.group.status === 'active')
|
||||||
|
.flatMap((route) => route.group.items.map((item) => item.channel))
|
||||||
|
.filter((channel) => channel.status === 'active')
|
||||||
|
.map((channel) => [channel.id, channel]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
const signatureDetails = channels.flatMap((channel) =>
|
||||||
|
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
|
||||||
|
const existing = signature.reportTasks.find(
|
||||||
|
(task) =>
|
||||||
|
task.reportType === 'signature' &&
|
||||||
|
task.channelId === channel.id &&
|
||||||
|
(task.carrier === carrier || (!task.carrier && task.approvalScope === 'legacy_channel')),
|
||||||
|
);
|
||||||
|
return existing
|
||||||
|
? { ...existing, signature }
|
||||||
|
: {
|
||||||
|
id: `virtual:${signature.id}:${channel.id}:${carrier}`,
|
||||||
|
tenantId: signature.tenantId,
|
||||||
|
signatureId: signature.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
reportType: 'signature',
|
||||||
|
drainageItemId: null,
|
||||||
|
status: 'pending',
|
||||||
|
reason: null,
|
||||||
|
approvedAt: null,
|
||||||
|
createdAt: signature.reportChangedAt ?? signature.updatedAt,
|
||||||
|
updatedAt: signature.reportChangedAt ?? signature.updatedAt,
|
||||||
|
createdById: null,
|
||||||
|
signature,
|
||||||
|
channel,
|
||||||
|
drainageInfo: null,
|
||||||
|
exportItems: [],
|
||||||
|
records: [],
|
||||||
|
virtual: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
|
||||||
|
channels
|
||||||
|
.map((channel) => {
|
||||||
|
const existing = signature.reportTasks.find(
|
||||||
|
(task) =>
|
||||||
|
task.reportType === 'drainage' &&
|
||||||
|
task.channelId === channel.id &&
|
||||||
|
task.drainageItemId === drainageInfo.id,
|
||||||
|
);
|
||||||
|
return existing ? { ...existing, signature } : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return [...signatureDetails, ...drainageDetails];
|
||||||
|
})
|
||||||
|
.filter((task) => {
|
||||||
|
if (!task) return false;
|
||||||
|
if (query.channelId && task.channelId !== query.channelId) return false;
|
||||||
|
if (query.carrier && task.carrier !== query.carrier) return false;
|
||||||
|
if (query.status && task.status !== query.status) return false;
|
||||||
|
if (query.reportType && task.reportType !== query.reportType) return false;
|
||||||
|
const changedAt = new Date(task.updatedAt);
|
||||||
|
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
|
||||||
|
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false;
|
||||||
|
if (!query.keyword?.trim()) return true;
|
||||||
|
const keyword = query.keyword.trim();
|
||||||
|
return [
|
||||||
|
task.id,
|
||||||
|
task.signature.name,
|
||||||
|
task.signature.tenant.name,
|
||||||
|
task.signature.application?.name,
|
||||||
|
task.channel.name,
|
||||||
|
task.drainageInfo?.siteName,
|
||||||
|
task.drainageInfo?.url,
|
||||||
|
].some((value) => String(value ?? '').includes(keyword));
|
||||||
|
});
|
||||||
|
return { items: details.slice((page - 1) * pageSize, page * pageSize), total: details.length, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReportTask(data: CreateReportTaskDto) {
|
async createReportTask(data: CreateReportTaskDto) {
|
||||||
@@ -321,15 +542,35 @@ export class ChannelReportingService {
|
|||||||
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||||
if (reportType === 'drainage') {
|
if (reportType === 'drainage') {
|
||||||
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
||||||
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
|
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId)
|
||||||
|
throw new NotFoundException('Drainage info not found');
|
||||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||||
}
|
}
|
||||||
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||||
|
if (!channel) throw new NotFoundException('Channel not found');
|
||||||
|
if (!data.carrier) throw new BadRequestException('签名报备任务必须指定运营商');
|
||||||
|
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||||
|
if (!normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
||||||
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.channelSignatureReportTask.findFirst({
|
||||||
|
where: {
|
||||||
|
signatureId: data.signatureId,
|
||||||
|
channelId: data.channelId,
|
||||||
|
carrier,
|
||||||
|
reportType: 'signature',
|
||||||
|
drainageItemId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
|
||||||
const task = await this.prisma.channelSignatureReportTask.create({
|
const task = await this.prisma.channelSignatureReportTask.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
signatureId: data.signatureId,
|
signatureId: data.signatureId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
reportType,
|
reportType,
|
||||||
drainageItemId: undefined,
|
drainageItemId: undefined,
|
||||||
createdById: data.createdById,
|
createdById: data.createdById,
|
||||||
@@ -342,7 +583,15 @@ export class ChannelReportingService {
|
|||||||
|
|
||||||
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
||||||
if (!data.items.length) throw new BadRequestException('items is required');
|
if (!data.items.length) throw new BadRequestException('items is required');
|
||||||
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
const allowed = new Set([
|
||||||
|
'pending',
|
||||||
|
'waiting_material',
|
||||||
|
'reporting',
|
||||||
|
'approved',
|
||||||
|
'failed',
|
||||||
|
'rejected',
|
||||||
|
'abandoned',
|
||||||
|
]);
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
||||||
}
|
}
|
||||||
@@ -351,29 +600,99 @@ export class ChannelReportingService {
|
|||||||
throw new BadRequestException('unsupported report task source entry');
|
throw new BadRequestException('unsupported report task source entry');
|
||||||
}
|
}
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
|
const signatureIds = [
|
||||||
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
|
...new Set(
|
||||||
|
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const drainageResults: Array<{
|
||||||
|
signatureId: string;
|
||||||
|
reportType: 'drainage';
|
||||||
|
drainageItemId: string;
|
||||||
|
channelId: string;
|
||||||
|
status: string;
|
||||||
|
}> = [];
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
const reportType = item.reportType ?? 'signature';
|
const reportType = item.reportType ?? 'signature';
|
||||||
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
if (reportType === 'drainage' && !item.drainageItemId)
|
||||||
|
throw new BadRequestException('drainageItemId is required');
|
||||||
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
||||||
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
||||||
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
||||||
if (reportType === 'drainage') {
|
if (reportType === 'drainage') {
|
||||||
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
||||||
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId)
|
||||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
throw new NotFoundException('Drainage info not found');
|
||||||
|
if (drainageInfo.auditStatus !== 'approved')
|
||||||
|
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
||||||
}
|
}
|
||||||
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
|
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
|
||||||
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
||||||
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
|
}
|
||||||
|
const existing = await tx.channelSignatureReportTask.findFirst({
|
||||||
|
where: {
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
channelId: item.channelId,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
||||||
|
carrier: reportType === 'signature' ? carrier : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (reportType === 'drainage' && !existing)
|
||||||
|
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
||||||
|
if (reportType === 'signature' && !carrier && !existing)
|
||||||
|
throw new BadRequestException('签名报备状态必须指定运营商');
|
||||||
|
const approvedAt =
|
||||||
|
item.status === 'approved'
|
||||||
|
? existing?.status === 'approved'
|
||||||
|
? (existing.approvedAt ?? new Date())
|
||||||
|
: new Date()
|
||||||
|
: null;
|
||||||
const task = existing
|
const task = existing
|
||||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
|
? await tx.channelSignatureReportTask.update({
|
||||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
|
where: { id: existing.id },
|
||||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
|
data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) },
|
||||||
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
})
|
||||||
|
: await tx.channelSignatureReportTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: signature.tenantId,
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
channelId: item.channelId,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
approvedAt,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined,
|
||||||
|
status: item.status,
|
||||||
|
reason: data.reason,
|
||||||
|
createdById: data.operatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
channelId: item.channelId,
|
||||||
|
action: 'manual_status_change',
|
||||||
|
statusBefore: existing?.status,
|
||||||
|
statusAfter: item.status,
|
||||||
|
reason: data.reason,
|
||||||
|
operatorId: data.operatorId,
|
||||||
|
sourceEntry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (reportType === 'drainage')
|
||||||
|
drainageResults.push({
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: item.drainageItemId!,
|
||||||
|
channelId: item.channelId,
|
||||||
|
status: item.status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const summaries = [];
|
const summaries = [];
|
||||||
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
for (const signatureId of signatureIds)
|
||||||
|
summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
||||||
return [...summaries, ...drainageResults];
|
return [...summaries, ...drainageResults];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -381,21 +700,56 @@ export class ChannelReportingService {
|
|||||||
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||||
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||||
if (!signature) throw new NotFoundException('Signature not found');
|
if (!signature) throw new NotFoundException('Signature not found');
|
||||||
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
const routes = signature.applicationId
|
||||||
where: { applicationId: signature.applicationId, status: 'active' },
|
? await tx.channelRouteRule.findMany({
|
||||||
include: { group: { include: { items: { include: { channel: true } } } } },
|
where: { applicationId: signature.applicationId, status: 'active' },
|
||||||
}) : [];
|
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||||
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
})
|
||||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
: [];
|
||||||
|
const configuredChannels = routes
|
||||||
|
.flatMap((route) => route.group.items.map((item) => item.channel))
|
||||||
|
.filter((channel) => channel.status !== 'deleted');
|
||||||
|
const tasks = await tx.channelSignatureReportTask.findMany({
|
||||||
|
where: { signatureId, reportType: 'signature' },
|
||||||
|
include: { channel: true },
|
||||||
|
});
|
||||||
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
||||||
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||||
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
|
const carrierReportSummary = Object.fromEntries(
|
||||||
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||||
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
const targets = uniqueChannels.filter((channel) =>
|
||||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
|
||||||
return [carrier, summarizeReportStatuses(statuses)];
|
);
|
||||||
}));
|
const statuses = targets.map((channel) => {
|
||||||
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
const task =
|
||||||
|
tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) ??
|
||||||
|
tasks.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.channelId === channel.id &&
|
||||||
|
candidate.carrier === null &&
|
||||||
|
candidate.approvalScope === 'legacy_channel',
|
||||||
|
);
|
||||||
|
return task?.status ?? 'pending';
|
||||||
|
});
|
||||||
|
return [carrier, summarizeReportStatuses(statuses)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
|
||||||
|
const targets = uniqueChannels.filter((channel) =>
|
||||||
|
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
|
||||||
|
);
|
||||||
|
return targets.map(
|
||||||
|
(channel) =>
|
||||||
|
tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status ??
|
||||||
|
tasks.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.channelId === channel.id &&
|
||||||
|
candidate.carrier === null &&
|
||||||
|
candidate.approvalScope === 'legacy_channel',
|
||||||
|
)?.status ??
|
||||||
|
'pending',
|
||||||
|
);
|
||||||
|
});
|
||||||
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
||||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||||
return { signatureId, reportStatus, carrierReportSummary };
|
return { signatureId, reportStatus, carrierReportSummary };
|
||||||
@@ -452,6 +806,11 @@ export class ChannelReportingService {
|
|||||||
async listReportRecordsPage(query: {
|
async listReportRecordsPage(query: {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
batchNo?: string;
|
||||||
|
statusAfter?: string;
|
||||||
|
action?: string;
|
||||||
|
sourceEntry?: string;
|
||||||
|
operatorKeyword?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
@@ -462,23 +821,51 @@ export class ChannelReportingService {
|
|||||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const keyword = query.keyword?.trim();
|
const keyword = query.keyword?.trim();
|
||||||
|
const operatorKeyword = query.operatorKeyword?.trim();
|
||||||
|
const operatorIds = operatorKeyword
|
||||||
|
? (
|
||||||
|
await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ username: { contains: operatorKeyword } }, { displayName: { contains: operatorKeyword } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((item) => item.id)
|
||||||
|
: undefined;
|
||||||
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
||||||
taskId: query.taskId,
|
taskId: query.taskId,
|
||||||
channelId: query.channelId,
|
channelId: query.channelId,
|
||||||
task: query.reportType ? { reportType: query.reportType } : undefined,
|
statusAfter: query.statusAfter,
|
||||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
action: query.action,
|
||||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
sourceEntry: query.sourceEntry,
|
||||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
operatorId: operatorIds ? { in: operatorIds } : undefined,
|
||||||
} : undefined,
|
task:
|
||||||
OR: keyword ? [
|
query.reportType || query.batchNo
|
||||||
{ taskId: { contains: keyword } },
|
? {
|
||||||
{ action: { contains: keyword } },
|
reportType: query.reportType,
|
||||||
{ reason: { contains: keyword } },
|
exportItems: query.batchNo
|
||||||
{ channel: { name: { contains: keyword } } },
|
? { some: { batchItem: { batch: { batchNo: { contains: query.batchNo.trim() } } } } }
|
||||||
{ task: { signature: { name: { contains: keyword } } } },
|
: undefined,
|
||||||
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
}
|
||||||
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
: undefined,
|
||||||
] : undefined,
|
createdAt:
|
||||||
|
query.createdAtFrom || query.createdAtTo
|
||||||
|
? {
|
||||||
|
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
||||||
|
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
OR: keyword
|
||||||
|
? [
|
||||||
|
{ taskId: { contains: keyword } },
|
||||||
|
{ action: { contains: keyword } },
|
||||||
|
{ reason: { contains: keyword } },
|
||||||
|
{ channel: { name: { contains: keyword } } },
|
||||||
|
{ task: { signature: { name: { contains: keyword } } } },
|
||||||
|
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
||||||
|
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.channelSignatureReportRecord.findMany({
|
this.prisma.channelSignatureReportRecord.findMany({
|
||||||
@@ -490,11 +877,30 @@ export class ChannelReportingService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.channelSignatureReportRecord.count({ where }),
|
this.prisma.channelSignatureReportRecord.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, pageSize };
|
const userIds = [...new Set(items.map((item) => item.operatorId).filter((id): id is string => Boolean(id)))];
|
||||||
|
const operators = userIds.length
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const operatorMap = new Map(operators.map((item) => [item.id, item]));
|
||||||
|
return {
|
||||||
|
items: items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
operator: item.operatorId ? operatorMap.get(item.operatorId) : undefined,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportTaskOrThrow(taskId: string) {
|
async getReportTaskOrThrow(taskId: string) {
|
||||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
const task = await this.prisma.channelSignatureReportTask.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: { drainageInfo: true },
|
||||||
|
});
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFoundException('Report task not found');
|
throw new NotFoundException('Report task not found');
|
||||||
}
|
}
|
||||||
@@ -514,7 +920,18 @@ export class ChannelReportingService {
|
|||||||
) {
|
) {
|
||||||
await this.prisma.channelSignatureReportTask.update({
|
await this.prisma.channelSignatureReportTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: { status: statusAfter, reason },
|
data: {
|
||||||
|
status: statusAfter,
|
||||||
|
reason,
|
||||||
|
...((
|
||||||
|
await this.prisma.channelSignatureReportTask.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
select: { reportType: true, status: true, approvedAt: true },
|
||||||
|
})
|
||||||
|
)?.reportType === 'signature'
|
||||||
|
? { approvedAt: statusAfter === 'approved' ? (statusBefore === 'approved' ? undefined : new Date()) : null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface CreateChannelDto {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
carrier?: string;
|
carrier?: string;
|
||||||
|
carriers?: string[];
|
||||||
sendRegion?: string;
|
sendRegion?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
gatewayHost: string;
|
gatewayHost: string;
|
||||||
@@ -104,13 +105,14 @@ export interface CreateReportTaskDto {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
signatureId: string;
|
signatureId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
carrier?: string;
|
||||||
reportType?: 'signature' | 'drainage';
|
reportType?: 'signature' | 'drainage';
|
||||||
drainageItemId?: string;
|
drainageItemId?: string;
|
||||||
createdById?: string;
|
createdById?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeReportTaskStatusesDto {
|
export interface ChangeReportTaskStatusesDto {
|
||||||
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
items: Array<{ signatureId: string; channelId: string; carrier?: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||||
|
|||||||
@@ -27,10 +27,19 @@ import { ChannelsService } from './channels.service';
|
|||||||
@ApiTags('channels')
|
@ApiTags('channels')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class ChannelsController {
|
export class ChannelsController {
|
||||||
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {}
|
constructor(
|
||||||
|
private readonly channels: ChannelsService,
|
||||||
|
private readonly deletions: DeletionGovernanceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('channels')
|
@Get('channels')
|
||||||
listChannels(@Query('keyword') keyword?: string, @Query('carrier') carrier?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listChannels(
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
return page || pageSize
|
return page || pageSize
|
||||||
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
|
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.channels.listChannels();
|
: this.channels.listChannels();
|
||||||
@@ -68,7 +77,11 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Delete('channels/:id')
|
@Delete('channels/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
deleteChannel(@Param('id') channelId: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
|
deleteChannel(
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Body() body: DeleteTargetDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
return this.deletions.delete('channel', channelId, { ...body, operatorId });
|
return this.deletions.delete('channel', channelId, { ...body, operatorId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +132,11 @@ export class ChannelsController {
|
|||||||
return this.channels.updateGroup(groupId, body);
|
return this.channels.updateGroup(groupId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('channel-groups/:id/deletion-impact')
|
||||||
|
getGroupDeletionImpact(@Param('id') groupId: string) {
|
||||||
|
return this.channels.getGroupDeletionImpact(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('channel-groups/:id')
|
@Delete('channel-groups/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
deleteGroup(@Param('id') groupId: string) {
|
deleteGroup(@Param('id') groupId: string) {
|
||||||
@@ -154,7 +172,11 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Put('channels/:channelId/report-fields/:reportType')
|
@Put('channels/:channelId/report-fields/:reportType')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
replaceReportFields(@Param('channelId') channelId: string, @Param('reportType') reportType: 'signature' | 'drainage', @Body() body: ReplaceReportFieldsDto) {
|
replaceReportFields(
|
||||||
|
@Param('channelId') channelId: string,
|
||||||
|
@Param('reportType') reportType: 'signature' | 'drainage',
|
||||||
|
@Body() body: ReplaceReportFieldsDto,
|
||||||
|
) {
|
||||||
return this.channels.replaceReportFields(channelId, reportType, body);
|
return this.channels.replaceReportFields(channelId, reportType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,12 +191,82 @@ export class ChannelsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('report-tasks')
|
@Get('report-tasks')
|
||||||
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string, @Query('keyword') keyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listReportTasks(
|
||||||
return page || pageSize || keyword || createdAtFrom || createdAtTo
|
@Query('tenantId') tenantId?: string,
|
||||||
? this.channels.listReportTasksPage({ tenantId, status, channelId, reportType, keyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('reportType') reportType?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
|
@Query('todaySendMin') todaySendMin?: string,
|
||||||
|
@Query('todaySendMax') todaySendMax?: string,
|
||||||
|
@Query('sort') sort?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return page ||
|
||||||
|
pageSize ||
|
||||||
|
keyword ||
|
||||||
|
applicationId ||
|
||||||
|
carrier ||
|
||||||
|
todaySendMin ||
|
||||||
|
todaySendMax ||
|
||||||
|
sort ||
|
||||||
|
createdAtFrom ||
|
||||||
|
createdAtTo
|
||||||
|
? this.channels.listReportTasksPage({
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
status,
|
||||||
|
channelId,
|
||||||
|
reportType,
|
||||||
|
keyword,
|
||||||
|
carrier,
|
||||||
|
todaySendMin: Number(todaySendMin),
|
||||||
|
todaySendMax: Number(todaySendMax),
|
||||||
|
sort,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
})
|
||||||
: this.channels.listReportTasks(tenantId, status, channelId, reportType);
|
: this.channels.listReportTasks(tenantId, status, channelId, reportType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('report-details')
|
||||||
|
listReportDetails(
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('signatureId') signatureId?: string,
|
||||||
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('reportType') reportType?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.channels.listReportDetailsPage({
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
signatureId,
|
||||||
|
channelId,
|
||||||
|
carrier,
|
||||||
|
status,
|
||||||
|
reportType,
|
||||||
|
keyword,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post('report-tasks/generate')
|
@Post('report-tasks/generate')
|
||||||
createReportTask(@Body() body: CreateReportTaskDto) {
|
createReportTask(@Body() body: CreateReportTaskDto) {
|
||||||
return this.channels.createReportTask(body);
|
return this.channels.createReportTask(body);
|
||||||
@@ -197,9 +289,47 @@ export class ChannelsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('report-records')
|
@Get('report-records')
|
||||||
listReportRecords(@Query('taskId') taskId?: string, @Query('channelId') channelId?: string, @Query('keyword') keyword?: string, @Query('reportType') reportType?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listReportRecords(
|
||||||
return page || pageSize || keyword || reportType || createdAtFrom || createdAtTo
|
@Query('taskId') taskId?: string,
|
||||||
? this.channels.listReportRecordsPage({ taskId, channelId, keyword, reportType, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('batchNo') batchNo?: string,
|
||||||
|
@Query('statusAfter') statusAfter?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
@Query('sourceEntry') sourceEntry?: string,
|
||||||
|
@Query('operatorKeyword') operatorKeyword?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('reportType') reportType?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return page ||
|
||||||
|
pageSize ||
|
||||||
|
keyword ||
|
||||||
|
batchNo ||
|
||||||
|
statusAfter ||
|
||||||
|
action ||
|
||||||
|
sourceEntry ||
|
||||||
|
operatorKeyword ||
|
||||||
|
reportType ||
|
||||||
|
createdAtFrom ||
|
||||||
|
createdAtTo
|
||||||
|
? this.channels.listReportRecordsPage({
|
||||||
|
taskId,
|
||||||
|
channelId,
|
||||||
|
batchNo,
|
||||||
|
statusAfter,
|
||||||
|
action,
|
||||||
|
sourceEntry,
|
||||||
|
operatorKeyword,
|
||||||
|
keyword,
|
||||||
|
reportType,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
})
|
||||||
: this.channels.listReportRecords(taskId, channelId);
|
: this.channels.listReportRecords(taskId, channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { normalizeChannelRuntimeConfig } from './channels.helpers';
|
||||||
|
|
||||||
|
describe('Gateway channel capacity validation', () => {
|
||||||
|
it.each([1, 2, 4, 8])('accepts %i supplier connections', (desiredConnections) => {
|
||||||
|
expect(normalizeChannelRuntimeConfig(undefined, undefined, desiredConnections, 16)).toEqual(expect.objectContaining({ desiredConnections, windowSize: 16 }));
|
||||||
|
});
|
||||||
|
it.each([1, 16, 32, 64])('accepts supplier window %i', (windowSize) => {
|
||||||
|
expect(normalizeChannelRuntimeConfig(undefined, undefined, 1, windowSize)).toEqual(expect.objectContaining({ desiredConnections: 1, windowSize }));
|
||||||
|
});
|
||||||
|
it.each([[0, 16], [9, 16], [1, 0], [1, 65]])('rejects capacity outside 1..8 connections and 1..64 window', (connections, window) => {
|
||||||
|
expect(() => normalizeChannelRuntimeConfig(undefined, undefined, connections, window)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
isChannelCarrierCompatible,
|
||||||
|
legacyCarrierFromCapabilities,
|
||||||
|
normalizeChannelCarriers,
|
||||||
|
} from './channels.helpers';
|
||||||
|
|
||||||
|
describe('channel carrier capabilities', () => {
|
||||||
|
it('preserves the legacy default when an old caller omits carrier fields', () => {
|
||||||
|
expect(normalizeChannelCarriers()).toEqual(['mobile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a historical three-network channel without inventing data for partial capabilities', () => {
|
||||||
|
expect(normalizeChannelCarriers(undefined, 'all')).toEqual(['mobile', 'unicom', 'telecom']);
|
||||||
|
expect(normalizeChannelCarriers(['telecom', 'mobile'], 'all')).toEqual(['mobile', 'telecom']);
|
||||||
|
expect(legacyCarrierFromCapabilities(['mobile', 'telecom'])).toBe('multi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks a group carrier against the new multi-select capability list', () => {
|
||||||
|
expect(isChannelCarrierCompatible('multi', 'mobile', ['mobile', 'telecom'])).toBe(true);
|
||||||
|
expect(isChannelCarrierCompatible('multi', 'unicom', ['mobile', 'telecom'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||||
|
|
||||||
|
export function summarizeReportStatuses(statuses: string[]) {
|
||||||
|
return summarizeCommonReportStatuses(statuses);
|
||||||
|
}
|
||||||
|
|
||||||
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
||||||
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||||
|
|
||||||
@@ -223,7 +228,7 @@ export function defaultChannelConnectionId(channelId: string) {
|
|||||||
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||||
const value = Number(config.desiredConnections);
|
const value = Number(config.desiredConnections);
|
||||||
if (Number.isInteger(value) && value > 0) {
|
if (Number.isInteger(value) && value >= 1 && value <= 8) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,8 +327,12 @@ export function normalizeChannelRuntimeConfig(
|
|||||||
? incomingConfig
|
? incomingConfig
|
||||||
: {};
|
: {};
|
||||||
const base = { ...existing, ...incoming };
|
const base = { ...existing, ...incoming };
|
||||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections');
|
||||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
|
||||||
|
base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds');
|
||||||
|
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds');
|
||||||
|
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds');
|
||||||
|
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds');
|
||||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
@@ -340,6 +349,14 @@ export function normalizeChannelRuntimeConfig(
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundedRuntimeInteger(value: unknown, minimum: number, maximum: number, fallback: number, field: string) {
|
||||||
|
const normalized = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||||||
|
if (!Number.isInteger(normalized) || normalized < minimum || normalized > maximum) {
|
||||||
|
throw new BadRequestException(`${field} must be an integer between ${minimum} and ${maximum}`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLongMessageReceiptMode(value: unknown) {
|
export function normalizeLongMessageReceiptMode(value: unknown) {
|
||||||
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
||||||
if (!['per_segment', 'message_level'].includes(normalized)) {
|
if (!['per_segment', 'message_level'].includes(normalized)) {
|
||||||
@@ -593,9 +610,29 @@ export function normalizeChannelCarrier(carrier?: string | null) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
|
export const SUPPORTED_CHANNEL_CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
const normalized = normalizeChannelCarrier(channelCarrier);
|
|
||||||
return normalized === 'all' || normalized === groupCarrier;
|
export function normalizeChannelCarriers(carriers?: string[] | null, legacyCarrier?: string | null): string[] {
|
||||||
|
const source = carriers?.length
|
||||||
|
? carriers
|
||||||
|
: normalizeChannelCarrier(legacyCarrier ?? 'mobile') === 'all'
|
||||||
|
? [...SUPPORTED_CHANNEL_CARRIERS]
|
||||||
|
: [normalizeChannelCarrier(legacyCarrier ?? 'mobile')];
|
||||||
|
const normalized = [...new Set(source.map((carrier) => normalizeBusinessCarrier(carrier)))];
|
||||||
|
if (normalized.length === 0) throw new BadRequestException('至少选择一个运营商');
|
||||||
|
return SUPPORTED_CHANNEL_CARRIERS.filter((carrier) => normalized.includes(carrier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyCarrierFromCapabilities(carriers: string[]) {
|
||||||
|
if (carriers.length === 1) return carriers[0];
|
||||||
|
if (carriers.length === SUPPORTED_CHANNEL_CARRIERS.length) return 'all';
|
||||||
|
// Old readers must fail closed for a two-carrier channel instead of treating
|
||||||
|
// it as three-network capable and accidentally routing unsupported traffic.
|
||||||
|
return 'multi';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
|
||||||
|
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRegion(region?: string | null) {
|
export function normalizeRegion(region?: string | null) {
|
||||||
@@ -609,7 +646,7 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
|
|||||||
export function validateGroupItems(
|
export function validateGroupItems(
|
||||||
groupCarrier: string,
|
groupCarrier: string,
|
||||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||||
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
|
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
|
||||||
) {
|
) {
|
||||||
const channelIds = new Set<string>();
|
const channelIds = new Set<string>();
|
||||||
const provinces = new Set<string>();
|
const provinces = new Set<string>();
|
||||||
@@ -627,7 +664,7 @@ export function validateGroupItems(
|
|||||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||||
}
|
}
|
||||||
channelIds.add(item.channelId);
|
channelIds.add(item.channelId);
|
||||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
|
||||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||||
}
|
}
|
||||||
if (item.province) {
|
if (item.province) {
|
||||||
@@ -654,17 +691,6 @@ export function normalizeReportType(value?: string) {
|
|||||||
throw new BadRequestException('reportType must be signature, drainage or both');
|
throw new BadRequestException('reportType must be signature, drainage or both');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeReportStatuses(statuses: string[]) {
|
|
||||||
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
|
||||||
const approved = statuses.filter((status) => status === 'approved').length;
|
|
||||||
let status = 'pending';
|
|
||||||
if (approved === statuses.length) status = 'approved';
|
|
||||||
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
|
|
||||||
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
|
|
||||||
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
|
|
||||||
return { status, approved, total: statuses.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeLinkEvent(action: string) {
|
export function normalizeLinkEvent(action: string) {
|
||||||
if (action.includes('connect_requested')) {
|
if (action.includes('connect_requested')) {
|
||||||
return '连接请求';
|
return '连接请求';
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
|
|||||||
})));
|
})));
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', reportType: 'signature', status: 'pending' };
|
||||||
const channel = {
|
const channel = {
|
||||||
id: 'channel-1',
|
id: 'channel-1',
|
||||||
code: 'CMPP-A',
|
code: 'CMPP-A',
|
||||||
name: '主通道',
|
name: '主通道',
|
||||||
carrier: 'mobile',
|
carrier: 'mobile',
|
||||||
|
carriers: ['mobile'],
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: '127.0.0.1',
|
gatewayHost: '127.0.0.1',
|
||||||
gatewayPort: 17890,
|
gatewayPort: 17890,
|
||||||
@@ -81,13 +82,14 @@ function createPrismaMock() {
|
|||||||
channelHealthMetric: { findMany: jest.fn() },
|
channelHealthMetric: { findMany: jest.fn() },
|
||||||
smsChannelGroup: {
|
smsChannelGroup: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||||
},
|
},
|
||||||
smsChannelGroupItem: {
|
smsChannelGroupItem: {
|
||||||
deleteMany: jest.fn(),
|
deleteMany: jest.fn(),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findFirst: jest.fn().mockResolvedValue(null),
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
||||||
},
|
},
|
||||||
@@ -115,6 +117,7 @@ function createPrismaMock() {
|
|||||||
channelSignatureReportTask: {
|
channelSignatureReportTask: {
|
||||||
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
|
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
|
||||||
create: jest.fn().mockResolvedValue(reportTask),
|
create: jest.fn().mockResolvedValue(reportTask),
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
findUnique: jest.fn().mockResolvedValue(reportTask),
|
findUnique: jest.fn().mockResolvedValue(reportTask),
|
||||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
||||||
},
|
},
|
||||||
@@ -137,6 +140,7 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
tenant: {
|
tenant: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
|
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
|
||||||
@@ -153,6 +157,7 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
smsSubmitRecord: {
|
smsSubmitRecord: {
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
cmppConnectionState: {
|
cmppConnectionState: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
@@ -169,6 +174,51 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ChannelsService', () => {
|
describe('ChannelsService', () => {
|
||||||
|
it('sorts all filtered channels by today submit count before pagination', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const candidates = [
|
||||||
|
{ id: 'channel-low', name: '乙通道' },
|
||||||
|
{ id: 'channel-high', name: '甲通道' },
|
||||||
|
{ id: 'channel-zero', name: '丙通道' },
|
||||||
|
];
|
||||||
|
const fullChannels = candidates.map((channel) => ({ ...channel, connectionStates: [] }));
|
||||||
|
prisma.smsChannel.findMany
|
||||||
|
.mockResolvedValueOnce(candidates)
|
||||||
|
.mockResolvedValueOnce([fullChannels[0], fullChannels[1]]);
|
||||||
|
prisma.$queryRaw.mockResolvedValue([
|
||||||
|
{ channelId: 'channel-low', total: 3 },
|
||||||
|
{ channelId: 'channel-high', total: 12 },
|
||||||
|
]);
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
const result = await service.listChannelsPage({ page: 1, pageSize: 2 });
|
||||||
|
|
||||||
|
expect(result.items.map((channel) => channel.id)).toEqual(['channel-high', 'channel-low']);
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
expect(prisma.smsChannel.findMany).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: { in: ['channel-high', 'channel-low'] } },
|
||||||
|
include: { connectionStates: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses channel name and id as a stable tie breaker for zero-submit channels', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const candidates = [
|
||||||
|
{ id: 'channel-b', name: 'A通道' },
|
||||||
|
{ id: 'channel-a', name: 'A通道' },
|
||||||
|
{ id: 'channel-c', name: 'B通道' },
|
||||||
|
];
|
||||||
|
prisma.smsChannel.findMany
|
||||||
|
.mockResolvedValueOnce(candidates)
|
||||||
|
.mockResolvedValueOnce(candidates);
|
||||||
|
prisma.$queryRaw.mockResolvedValue([]);
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
const result = await service.listChannelsPage({ page: 1, pageSize: 10 });
|
||||||
|
|
||||||
|
expect(result.items.map((channel) => channel.id)).toEqual(['channel-a', 'channel-b', 'channel-c']);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates channel report requirements only from the report field library', async () => {
|
it('creates channel report requirements only from the report field library', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
@@ -302,32 +352,79 @@ describe('ChannelsService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('changes channel report status and recomputes the signature summary atomically', async () => {
|
it.each(['enterprise_signature', 'report_task'] as const)(
|
||||||
|
'changes channel report status from %s and recomputes the signature summary atomically',
|
||||||
|
async (sourceEntry) => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const tx = {
|
||||||
|
smsSignature: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||||
|
},
|
||||||
|
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }) },
|
||||||
|
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
|
||||||
|
channelSignatureReportTask: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }),
|
||||||
|
create: jest.fn(),
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' } }]),
|
||||||
|
},
|
||||||
|
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
||||||
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry })).resolves.toEqual([
|
||||||
|
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
||||||
|
]);
|
||||||
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry }) });
|
||||||
|
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'task-1' },
|
||||||
|
data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }),
|
||||||
|
});
|
||||||
|
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||||
},
|
},
|
||||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
|
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }) },
|
||||||
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
|
smsDrainageInfo: { findUnique: jest.fn() },
|
||||||
channelSignatureReportTask: {
|
channelSignatureReportTask: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
|
update: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })),
|
||||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]),
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' } },
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
||||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
|
await service.changeReportTaskStatuses({
|
||||||
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }],
|
||||||
]);
|
sourceEntry: 'enterprise_signature',
|
||||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
|
});
|
||||||
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
|
||||||
|
expect(tx.channelSignatureReportTask.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
signatureId: 'sig-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
carrier: 'mobile',
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
status: 'approved',
|
||||||
|
approvedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
||||||
@@ -353,7 +450,7 @@ describe('ChannelsService', () => {
|
|||||||
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
|
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
|
||||||
reason: '引流信息已报备',
|
reason: '引流信息已报备',
|
||||||
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
|
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
|
||||||
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } });
|
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', carrier: null } });
|
||||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
|
||||||
expect(tx.smsSignature.update).not.toHaveBeenCalled();
|
expect(tx.smsSignature.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -866,16 +963,60 @@ describe('ChannelsService', () => {
|
|||||||
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes channel groups only when no active route rule is bound', async () => {
|
it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] });
|
||||||
|
prisma.channelRouteRule.findMany.mockResolvedValueOnce([
|
||||||
|
{ applicationId: 'app-active' },
|
||||||
|
{ applicationId: 'app-active' },
|
||||||
|
{ applicationId: 'app-deleted' },
|
||||||
|
{ applicationId: 'app-missing' },
|
||||||
|
]);
|
||||||
|
prisma.smsApplication.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: 'app-active', status: 'active' },
|
||||||
|
{ id: 'app-deleted', status: 'deleted' },
|
||||||
|
]);
|
||||||
|
prisma.smsSubmitRecord.count.mockResolvedValueOnce(2);
|
||||||
|
|
||||||
await service.deleteGroup('group-1');
|
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
|
||||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
|
groupId: 'group-1',
|
||||||
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
groupName: '移动主通道组',
|
||||||
|
normalApplicationCount: 1,
|
||||||
|
deletedApplicationCount: 2,
|
||||||
|
channelCount: 2,
|
||||||
|
pendingSupplierSubmitCount: 2,
|
||||||
|
});
|
||||||
|
expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({
|
||||||
|
where: { channelGroupId: 'group-1', submitStatus: 'queued' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
|
it('logically deletes channel groups without removing application bindings or group items', async () => {
|
||||||
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
|
const prisma = createPrismaMock();
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' });
|
||||||
|
const operationLogCreate = jest.fn();
|
||||||
|
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]);
|
||||||
|
prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]);
|
||||||
|
prisma.$transaction.mockImplementationOnce((callback) => callback({
|
||||||
|
smsChannelGroup: { update: groupUpdate },
|
||||||
|
operationLog: { create: operationLogCreate },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' });
|
||||||
|
expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } });
|
||||||
|
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled();
|
||||||
|
expect(operationLogCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'sms_channel_group.delete',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
deletionMode: 'soft_delete',
|
||||||
|
impact: expect.objectContaining({ normalApplicationCount: 1 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upserts signature report material per channel field', async () => {
|
it('upserts signature report material per channel field', async () => {
|
||||||
@@ -901,7 +1042,7 @@ describe('ChannelsService', () => {
|
|||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', createdById: 'user-1' });
|
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', createdById: 'user-1' });
|
||||||
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
|
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
|
||||||
await service.importReportReceipt('report-task-1', {
|
await service.importReportReceipt('report-task-1', {
|
||||||
fileName: 'receipt.csv',
|
fileName: 'receipt.csv',
|
||||||
@@ -916,11 +1057,11 @@ describe('ChannelsService', () => {
|
|||||||
});
|
});
|
||||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'report-task-1' },
|
where: { id: 'report-task-1' },
|
||||||
data: { status: 'exporting', reason: undefined },
|
data: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }),
|
||||||
});
|
});
|
||||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'report-task-1' },
|
where: { id: 'report-task-1' },
|
||||||
data: { status: 'partial', reason: 'one rejected' },
|
data: expect.objectContaining({ status: 'partial', reason: 'one rejected', approvedAt: null }),
|
||||||
});
|
});
|
||||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'sig-1' },
|
where: { id: 'sig-1' },
|
||||||
@@ -951,7 +1092,7 @@ describe('ChannelsService', () => {
|
|||||||
});
|
});
|
||||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'report-task-1' },
|
where: { id: 'report-task-1' },
|
||||||
data: { status: 'partial', reason: 'carrier receipt' },
|
data: expect.objectContaining({ status: 'partial', reason: 'carrier receipt', approvedAt: null }),
|
||||||
});
|
});
|
||||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'sig-1' },
|
where: { id: 'sig-1' },
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type {
|
||||||
|
CreateChannelDto,
|
||||||
|
UpdateChannelDto,
|
||||||
|
CreateChannelGroupDto,
|
||||||
|
CreateChannelGroupItemDto,
|
||||||
|
UpdateChannelGroupDto,
|
||||||
|
CreateRouteRuleDto,
|
||||||
|
CreateReportFieldDto,
|
||||||
|
ReplaceReportFieldsDto,
|
||||||
|
CreateReportMaterialDto,
|
||||||
|
CreateReportTaskDto,
|
||||||
|
ChangeReportTaskStatusesDto,
|
||||||
|
CreateReportExportDto,
|
||||||
|
CreateReceiptImportDto,
|
||||||
|
UpsertConnectionStateDto,
|
||||||
|
ChangeChannelStatusDto,
|
||||||
|
CopyChannelDto,
|
||||||
|
TestChannelDto,
|
||||||
|
} from './channels.contracts';
|
||||||
import { ChannelConfigurationService } from './channel-configuration.service';
|
import { ChannelConfigurationService } from './channel-configuration.service';
|
||||||
import { ChannelConnectionService } from './channel-connection.service';
|
import { ChannelConnectionService } from './channel-connection.service';
|
||||||
import { ChannelCopyService } from './channel-copy.service';
|
import { ChannelCopyService } from './channel-copy.service';
|
||||||
@@ -42,7 +60,13 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.configuration.listChannels();
|
return this.configuration.listChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
|
async listChannelsPage(query: {
|
||||||
|
keyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
return this.configuration.listChannelsPage(query);
|
return this.configuration.listChannelsPage(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +138,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.groups.deleteGroup(groupId);
|
return this.groups.deleteGroup(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGroupDeletionImpact(groupId: string) {
|
||||||
|
return this.groups.getGroupDeletionImpact(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
listRouteRules() {
|
listRouteRules() {
|
||||||
return this.groups.listRouteRules();
|
return this.groups.listRouteRules();
|
||||||
}
|
}
|
||||||
@@ -148,16 +176,38 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async listReportTasksPage(query: {
|
async listReportTasksPage(query: {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
todaySendMin?: number;
|
||||||
|
todaySendMax?: number;
|
||||||
|
sort?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
|
return this.reporting.listReportTasksPage(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReportDetailsPage(query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
signatureId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
carrier?: string;
|
||||||
|
status?: string;
|
||||||
|
reportType?: string;
|
||||||
|
keyword?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
createdAtTo?: string;
|
createdAtTo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}) {
|
}) {
|
||||||
return this.reporting.listReportTasksPage(query);
|
return this.reporting.listReportDetailsPage(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReportTask(data: CreateReportTaskDto) {
|
async createReportTask(data: CreateReportTaskDto) {
|
||||||
@@ -183,6 +233,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async listReportRecordsPage(query: {
|
async listReportRecordsPage(query: {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
batchNo?: string;
|
||||||
|
statusAfter?: string;
|
||||||
|
action?: string;
|
||||||
|
sourceEntry?: string;
|
||||||
|
operatorKeyword?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientDrainageInfoDto, ClientImportConfirmDto } from './client-write.dto';
|
||||||
|
import { strictValidationPipe } from './strict-validation.pipe';
|
||||||
|
|
||||||
|
function validate<T>(metatype: new () => T, value: unknown) {
|
||||||
|
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('strict client write DTOs', () => {
|
||||||
|
it('accepts an import confirmation without a client-supplied phones array', async () => {
|
||||||
|
await expect(
|
||||||
|
validate(ClientImportConfirmDto, {
|
||||||
|
content: '【测试】验证码 ${code}',
|
||||||
|
importContent: 'phone,code\n13800000001,1234',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a direct batch task without validated phone numbers', async () => {
|
||||||
|
await expect(validate(ClientBatchTaskDto, { content: '【测试】通知' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a client-supplied operator identity', async () => {
|
||||||
|
await expect(
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://example.com/path',
|
||||||
|
'example.com/path?source=sms',
|
||||||
|
'www.example.com',
|
||||||
|
'192.0.2.10:8080/landing',
|
||||||
|
'13800138000',
|
||||||
|
'+86 138-0013-8000',
|
||||||
|
'0755-12345678',
|
||||||
|
'(010) 12345678-123',
|
||||||
|
])('accepts a drainage URL or phone number without a separate name: %s', async (url) => {
|
||||||
|
await expect(validate(ClientDrainageInfoDto, { url })).resolves.toEqual(expect.objectContaining({ url }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects arbitrary drainage text that is neither a URL nor a phone number', async () => {
|
||||||
|
await expect(validate(ClientDrainageInfoDto, { url: '品牌官网' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
IsDateString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { IsBoundedJsonObject } from './bounded-json-object.validator';
|
||||||
|
import { DRAINAGE_TARGET_ERROR, DRAINAGE_TARGET_PATTERN } from './drainage-target';
|
||||||
|
|
||||||
|
export class ClientCertificationSubmissionDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
|
||||||
|
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) materials?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientTaskBaseDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||||
|
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
|
||||||
|
@IsOptional() @IsDateString({ strict: true }) scheduledAt?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) variables?: Record<string, unknown>;
|
||||||
|
@IsOptional() @IsDateString({ strict: true }) requestedAt?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientBatchTaskDto extends ClientTaskBaseDto {
|
||||||
|
@IsArray() @ArrayMaxSize(100000) @Matches(/^1\d{10}$/, { each: true }) phones!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientImportPreviewDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
|
||||||
|
@IsOptional() @IsIn(['utf8', 'gbk']) encoding?: 'utf8' | 'gbk';
|
||||||
|
@IsOptional() @IsIn([',', '\t']) delimiter?: ',' | '\t';
|
||||||
|
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientImportConfirmDto extends ClientTaskBaseDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5_000_000) importContent!: string;
|
||||||
|
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientBillingEstimateDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||||
|
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
|
||||||
|
@IsOptional() @Type(() => Number) @Min(0) unitPrice?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) taskId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsApplicationDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) scene?: string;
|
||||||
|
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) cmppAccount?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) cmppEnterpriseCode?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) cmppApplicationExtension?: string;
|
||||||
|
@IsOptional() @IsBoolean() cmppAccessNumberFillEnabled?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) cmppAccessNumberFillPrefix?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(4096) passwordCipher?: string;
|
||||||
|
@IsOptional() @IsBoolean() interfaceEnabled?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) interfaceType?: string;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) cmppMaxConnections?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1000) cmppWindowSize?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(0) dailyLimit?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @Min(0) customerUnitPrice?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) queuePriority?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) templateMismatchMode?: string;
|
||||||
|
@IsOptional() @IsBoolean() downstreamReceiptRetryEnabled?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() downstreamUplinkRetryEnabled?: boolean;
|
||||||
|
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) ipAllowlist?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsSignatureDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) drainageInfo?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientDrainageInfoDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) siteName?: string;
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2048)
|
||||||
|
@Matches(DRAINAGE_TARGET_PATTERN, {
|
||||||
|
message: DRAINAGE_TARGET_ERROR,
|
||||||
|
})
|
||||||
|
url!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
|
||||||
|
|
||||||
|
export class ClientSignatureMaterialDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) fileObjectId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(64) materialType!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) title!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TemplateVariableDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(64) name!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) example?: string;
|
||||||
|
@IsOptional() @IsBoolean() required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsTemplateDto {
|
||||||
|
@IsString() @MaxLength(64) applicationId!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TemplateVariableDto)
|
||||||
|
variables?: TemplateVariableDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientStatusChangeDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) 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;
|
||||||
|
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
|
||||||
|
|
||||||
|
export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码';
|
||||||
|
|
||||||
|
export function normalizeDrainageTarget(value?: string) {
|
||||||
|
const target = value?.trim() ?? '';
|
||||||
|
return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { summarizeReportStatuses } from './report-status';
|
||||||
|
|
||||||
|
describe('summarizeReportStatuses', () => {
|
||||||
|
it.each([
|
||||||
|
[[], { status: 'not_applicable', approved: 0, total: 0 }],
|
||||||
|
[['approved', 'approved'], { status: 'approved', approved: 2, total: 2 }],
|
||||||
|
[['abandoned', 'abandoned'], { status: 'abandoned', approved: 0, total: 2 }],
|
||||||
|
[['failed', 'rejected'], { status: 'failed', approved: 0, total: 2 }],
|
||||||
|
[['approved', 'failed'], { status: 'partial_success', approved: 1, total: 2 }],
|
||||||
|
[['failed', 'pending'], { status: 'reporting', approved: 0, total: 2 }],
|
||||||
|
[['waiting_material', 'pending'], { status: 'waiting_material', approved: 0, total: 2 }],
|
||||||
|
[['abandoned', 'reporting'], { status: 'reporting', approved: 0, total: 2 }],
|
||||||
|
])('summarizes %j without allowing one failure to override other targets', (statuses, expected) => {
|
||||||
|
expect(summarizeReportStatuses(statuses)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type ReportStatusSummary = {
|
||||||
|
status: string;
|
||||||
|
approved: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAILED_REPORT_STATUSES = new Set(['failed', 'rejected']);
|
||||||
|
|
||||||
|
export function summarizeReportStatuses(statuses: string[]): ReportStatusSummary {
|
||||||
|
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
||||||
|
|
||||||
|
const approved = statuses.filter((status) => status === 'approved').length;
|
||||||
|
const failed = statuses.filter((status) => FAILED_REPORT_STATUSES.has(status)).length;
|
||||||
|
const abandoned = statuses.filter((status) => status === 'abandoned').length;
|
||||||
|
|
||||||
|
if (approved === statuses.length) return { status: 'approved', approved, total: statuses.length };
|
||||||
|
if (abandoned === statuses.length) return { status: 'abandoned', approved, total: statuses.length };
|
||||||
|
|
||||||
|
// Overall failure means every current target failed. A single failed channel must not
|
||||||
|
// erase successful channels or targets that can still finish reporting.
|
||||||
|
if (failed === statuses.length) return { status: 'failed', approved, total: statuses.length };
|
||||||
|
if (approved > 0) return { status: 'partial_success', approved, total: statuses.length };
|
||||||
|
if (failed > 0) return { status: 'reporting', approved, total: statuses.length };
|
||||||
|
if (statuses.some((status) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(status))) {
|
||||||
|
return { status: 'reporting', approved, total: statuses.length };
|
||||||
|
}
|
||||||
|
if (statuses.some((status) => status === 'waiting_material')) {
|
||||||
|
return { status: 'waiting_material', approved, total: statuses.length };
|
||||||
|
}
|
||||||
|
return { status: 'pending', approved, total: statuses.length };
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ export class RequestContextMiddleware implements NestMiddleware {
|
|||||||
use(request: RequestLike, _response: unknown, next: () => void) {
|
use(request: RequestLike, _response: unknown, next: () => void) {
|
||||||
const forwarded = request.headers['x-forwarded-for'];
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
||||||
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
|
const remoteAddress = request.socket?.remoteAddress?.trim().replace(/^::ffff:/, '');
|
||||||
|
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||||
|
// 仅可信反向代理可以声明客户端地址,防止攻击者伪造 X-Forwarded-For 绕过保护名单或嫁祸他人。
|
||||||
|
const ipAddress = (remoteAddress && trustedProxies.has(remoteAddress) ? firstForwarded : remoteAddress)?.trim().replace(/^::ffff:/, '');
|
||||||
requestContext.run({ ipAddress }, next);
|
requestContext.run({ ipAddress }, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const strictValidationPipe = new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
stopAtFirstError: false,
|
||||||
|
transformOptions: { enableImplicitConversion: false },
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post } 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 { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
|
||||||
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
||||||
|
|
||||||
@ApiTags('deletion-governance')
|
@ApiTags('deletion-governance')
|
||||||
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
|
|||||||
constructor(private readonly deletions: DeletionGovernanceService) {}
|
constructor(private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get(':type/:id/preflight')
|
@Get(':type/:id/preflight')
|
||||||
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
|
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @CurrentTenantId() tenantId: string) {
|
||||||
return this.deletions.preflight(type, id, tenantId);
|
return this.deletions.preflight(type, id, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':type/:id')
|
@Post(':type/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||||
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
|
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ describe('DeletionGovernanceService', () => {
|
|||||||
function setup() {
|
function setup() {
|
||||||
const tx = {
|
const tx = {
|
||||||
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
||||||
smsChannel: { updateMany: jest.fn() },
|
smsChannel: { findUnique: jest.fn(), updateMany: jest.fn() },
|
||||||
smsSignature: { updateMany: jest.fn() },
|
smsSignature: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||||
smsTemplate: { updateMany: jest.fn() },
|
smsTemplate: { findFirst: jest.fn(), updateMany: jest.fn() },
|
||||||
|
smsDrainageInfo: { updateMany: jest.fn() },
|
||||||
|
channelSignatureReportTask: { findMany: jest.fn(), update: jest.fn() },
|
||||||
|
channelSignatureReportRecord: { create: jest.fn() },
|
||||||
|
channelRouteRule: { findMany: jest.fn() },
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
operationLog: { findFirst: jest.fn() },
|
operationLog: { findFirst: jest.fn() },
|
||||||
@@ -35,12 +39,47 @@ describe('DeletionGovernanceService', () => {
|
|||||||
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']);
|
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows channel deletion with unfinished report tasks only after the cascade selection is confirmed', async () => {
|
||||||
|
const { service, prisma, tx } = setup();
|
||||||
|
const channel = {
|
||||||
|
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
|
||||||
|
groupItems: [], routeRules: [], connectionStates: [],
|
||||||
|
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||||
|
};
|
||||||
|
prisma.smsChannel.findUnique.mockResolvedValue(channel);
|
||||||
|
|
||||||
|
const preflight = await service.preflight('channel', 'channel-1');
|
||||||
|
|
||||||
|
expect(preflight.allowedActions).toEqual(['delete']);
|
||||||
|
expect(preflight.requiredSelections).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ action: 'abandon_associated_report_tasks', count: 1 }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.smsChannel.findUnique.mockResolvedValue(channel);
|
||||||
|
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||||
|
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||||
|
tx.smsChannel.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.smsSignature.findUnique.mockResolvedValue({ id: 'signature-1', applicationId: null, auditStatus: 'approved' });
|
||||||
|
tx.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: 'channel-1', status: 'abandoned', channel: { id: 'channel-1', status: 'deleted' } }]);
|
||||||
|
tx.smsSignature.update.mockResolvedValue({ id: 'signature-1' });
|
||||||
|
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||||
|
|
||||||
|
await expect(service.delete('channel', 'channel-1', {
|
||||||
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-channel-1', abandonAssociatedReportTasks: true,
|
||||||
|
})).resolves.toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||||
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }),
|
||||||
|
}));
|
||||||
|
expect(tx.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ data: { reportStatus: 'not_applicable' } }));
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an allowed template preflight scoped to the client tenant', async () => {
|
it('returns an allowed template preflight scoped to the client tenant', async () => {
|
||||||
const { service, prisma } = setup();
|
const { service, prisma } = setup();
|
||||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
|
||||||
sendTasks: [], batchTasks: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.preflight('template', 'template-1', 'tenant-1');
|
const result = await service.preflight('template', 'template-1', 'tenant-1');
|
||||||
@@ -48,30 +87,79 @@ describe('DeletionGovernanceService', () => {
|
|||||||
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
|
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
|
||||||
expect(result.allowedActions).toEqual(['delete']);
|
expect(result.allowedActions).toEqual(['delete']);
|
||||||
expect(result.identity.tenant).toBe('示例企业');
|
expect(result.identity.tenant).toBe('示例企业');
|
||||||
|
expect(result.requiredSelections).toEqual([]);
|
||||||
|
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
include: expect.not.objectContaining({ sendTasks: expect.anything(), batchTasks: expect.anything() }),
|
||||||
|
}));
|
||||||
|
expect(result.impacts).toContain('已创建任务继续使用保存的内容快照');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks signature deletion and exposes the referencing template and drainage items', async () => {
|
it('turns signature dependencies into mandatory cascade selections', async () => {
|
||||||
const { service, prisma } = setup();
|
const { service, prisma } = setup();
|
||||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
templates: [{ id: 'template-1', name: '验证码模板' }],
|
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||||
|
|
||||||
expect(result.allowedActions).toEqual([]);
|
expect(result.allowedActions).toEqual(['delete']);
|
||||||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }),
|
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }),
|
||||||
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }),
|
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }),
|
||||||
]));
|
]));
|
||||||
|
expect(result.requiredSelections.map((item) => item.action)).toEqual([
|
||||||
|
'delete_associated_templates', 'delete_associated_drainage',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires version, idempotency key and a meaningful reason', async () => {
|
it('does not expose report task ids or statuses to the client but still requires confirmation', async () => {
|
||||||
|
const { service, prisma } = setup();
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [], drainageItems: [], reportTasks: [{ id: 'internal-task-1', status: 'reporting' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: 'report_tasks', count: 1, items: [], detailsVisible: false }),
|
||||||
|
]));
|
||||||
|
expect(JSON.stringify(result)).not.toContain('internal-task-1');
|
||||||
|
expect(result.requiredSelections).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ action: 'abandon_associated_report_tasks' }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not classify approved or abandoned report history as unfinished', async () => {
|
||||||
|
const { service, prisma } = setup();
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [], drainageItems: [], reportTasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
include: expect.objectContaining({
|
||||||
|
reportTasks: expect.objectContaining({
|
||||||
|
where: { status: { notIn: expect.arrayContaining(['approved', 'abandoned']) } },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: 'report_tasks', count: 0 }),
|
||||||
|
]));
|
||||||
|
expect(result.allowedActions).toEqual(['delete']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires version and idempotency key but allows an omitted reason', async () => {
|
||||||
const { service } = setup();
|
const { service } = setup();
|
||||||
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('soft deletes once and writes an auditable operation number', async () => {
|
it('soft deletes once and writes an auditable operation number', async () => {
|
||||||
@@ -80,19 +168,71 @@ describe('DeletionGovernanceService', () => {
|
|||||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||||||
sendTasks: [], batchTasks: [],
|
|
||||||
});
|
});
|
||||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.smsTemplate.findFirst.mockResolvedValue({
|
||||||
|
id: 'template-1', tenantId: 'tenant-1',
|
||||||
|
});
|
||||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||||
|
|
||||||
const result = await service.delete('template', 'template-1', {
|
const result = await service.delete('template', 'template-1', {
|
||||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1',
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', operatorId: 'user-1',
|
||||||
|
}, 'tenant-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||||
|
expect(tx.smsTemplate.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'template-1', tenantId: 'tenant-1' },
|
||||||
|
select: { id: true, tenantId: true },
|
||||||
|
});
|
||||||
|
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades all selected signature dependencies in one transaction with task history', async () => {
|
||||||
|
const { service, prisma, tx } = setup();
|
||||||
|
const preflightItem = {
|
||||||
|
id: 'signature-1', tenantId: 'tenant-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||||
|
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }],
|
||||||
|
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||||
|
};
|
||||||
|
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||||
|
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||||
|
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.smsDrainageInfo.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||||
|
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||||
|
tx.smsSignature.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||||
|
|
||||||
|
const result = await service.delete('signature', 'signature-1', {
|
||||||
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-signature-1', operatorId: 'user-1',
|
||||||
|
deleteAssociatedTemplates: true, deleteAssociatedDrainage: true, abandonAssociatedReportTasks: true,
|
||||||
}, 'tenant-1');
|
}, 'tenant-1');
|
||||||
|
|
||||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||||
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||||||
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
|
expect(tx.smsDrainageInfo.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted', pendingReport: false } }));
|
||||||
|
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'abandoned' }) }));
|
||||||
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ statusBefore: 'reporting', statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deletion until every discovered cascade selection is confirmed', async () => {
|
||||||
|
const { service, prisma } = setup();
|
||||||
|
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }], drainageItems: [], reportTasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.delete('signature', 'signature-1', {
|
||||||
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'missing-selection',
|
||||||
|
}, 'tenant-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a stale optimistic-lock version', async () => {
|
it('rejects a stale optimistic-lock version', async () => {
|
||||||
@@ -101,7 +241,6 @@ describe('DeletionGovernanceService', () => {
|
|||||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||||||
sendTasks: [], batchTasks: [],
|
|
||||||
});
|
});
|
||||||
await expect(service.delete('template', 'template-1', {
|
await expect(service.delete('template', 'template-1', {
|
||||||
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
|
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
|
||||||
|
|||||||
@@ -1,17 +1,54 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { summarizeReportStatuses } from '../common/report-status';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||||
|
|
||||||
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 DeleteTargetDto = {
|
export type DeleteTargetDto = {
|
||||||
expectedUpdatedAt?: string;
|
expectedUpdatedAt?: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
|
deleteAssociatedTemplates?: boolean;
|
||||||
|
deleteAssociatedDrainage?: boolean;
|
||||||
|
abandonAssociatedReportTasks?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Dependency = { kind: string; label: string; count: number; items: string[] };
|
type Dependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
|
||||||
|
type RequiredSelection = {
|
||||||
|
action: DeletionResolutionAction;
|
||||||
|
dependencyKind: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 报备任务、人工审核任务和批量发送任务使用不同的状态词汇。这里分别维护终态,
|
||||||
|
// 是为了避免把已完成历史误判成活动依赖,也避免删除正在发送的数据配置。
|
||||||
|
const TERMINAL_REPORT_TASK_STATUSES = ['approved', 'completed', 'failed', 'cancelled', 'rejected', 'abandoned', 'partial', 'partial_success'];
|
||||||
|
const TERMINAL_SEND_TASK_STATUSES = ['approved', 'rejected'];
|
||||||
|
const TERMINAL_BATCH_TASK_STATUSES = ['finished', 'canceled', 'rejected', 'failed', 'completed', 'cancelled'];
|
||||||
|
|
||||||
|
const RESOLUTION_COPY: Record<DeletionResolutionAction, Omit<RequiredSelection, 'dependencyKind' | 'count'>> = {
|
||||||
|
delete_associated_templates: {
|
||||||
|
action: 'delete_associated_templates',
|
||||||
|
label: '同时删除关联的模板',
|
||||||
|
description: '发现关联的短信模板。勾选后将一并逻辑删除这些模板,历史发送和审核记录继续保留。',
|
||||||
|
},
|
||||||
|
delete_associated_drainage: {
|
||||||
|
action: 'delete_associated_drainage',
|
||||||
|
label: '同时删除引流信息',
|
||||||
|
description: '发现关联的引流信息。勾选后将一并逻辑删除这些引流信息,历史发送、审核和报备记录继续保留。',
|
||||||
|
},
|
||||||
|
abandon_associated_report_tasks: {
|
||||||
|
action: 'abandon_associated_report_tasks',
|
||||||
|
label: '同时结束关联的报备任务',
|
||||||
|
description: '发现关联的未结束报备任务。勾选后将全部置为“放弃报备”,历史任务和报备记录继续保留。',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type DeletionPreflight = {
|
export type DeletionPreflight = {
|
||||||
type: DeletionTargetType;
|
type: DeletionTargetType;
|
||||||
@@ -19,6 +56,7 @@ export type DeletionPreflight = {
|
|||||||
expectedUpdatedAt: string;
|
expectedUpdatedAt: string;
|
||||||
identity: Record<string, string>;
|
identity: Record<string, string>;
|
||||||
dependencies: Dependency[];
|
dependencies: Dependency[];
|
||||||
|
requiredSelections: RequiredSelection[];
|
||||||
impacts: string[];
|
impacts: string[];
|
||||||
blockedReasons: string[];
|
blockedReasons: string[];
|
||||||
allowedActions: Array<'delete'>;
|
allowedActions: Array<'delete'>;
|
||||||
@@ -40,9 +78,8 @@ export class DeletionGovernanceService {
|
|||||||
this.assertType(type);
|
this.assertType(type);
|
||||||
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
|
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
|
||||||
const idempotencyKey = body.idempotencyKey?.trim();
|
const idempotencyKey = body.idempotencyKey?.trim();
|
||||||
const reason = body.reason?.trim();
|
const reason = body.reason?.trim() || undefined;
|
||||||
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
|
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
|
||||||
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
|
|
||||||
|
|
||||||
const replay = await this.prisma.operationLog.findFirst({
|
const replay = await this.prisma.operationLog.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -58,6 +95,7 @@ export class DeletionGovernanceService {
|
|||||||
if (!preflight.allowedActions.includes('delete')) {
|
if (!preflight.allowedActions.includes('delete')) {
|
||||||
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
|
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
|
||||||
}
|
}
|
||||||
|
this.assertSelections(preflight.requiredSelections, body);
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.operationLog.findFirst({
|
const existing = await tx.operationLog.findFirst({
|
||||||
@@ -68,6 +106,12 @@ export class DeletionGovernanceService {
|
|||||||
});
|
});
|
||||||
if (existing) return { operationId: existing.id, status: 'deleted', replayed: true };
|
if (existing) return { operationId: existing.id, status: 'deleted', replayed: true };
|
||||||
|
|
||||||
|
const cascade = type === 'channel'
|
||||||
|
? await this.prepareChannelDeletion(tx, id, body, reason)
|
||||||
|
: type === 'signature'
|
||||||
|
? await this.prepareSignatureDeletion(tx, id, tenantId, body, reason)
|
||||||
|
: await this.prepareTemplateDeletion(tx, id, tenantId);
|
||||||
|
|
||||||
const updated = type === 'channel'
|
const updated = type === 'channel'
|
||||||
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
|
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
|
||||||
: type === 'signature'
|
: type === 'signature'
|
||||||
@@ -75,10 +119,22 @@ export class DeletionGovernanceService {
|
|||||||
: await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
|
: await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
|
||||||
if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检');
|
if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检');
|
||||||
|
|
||||||
|
if (type === 'channel') {
|
||||||
|
for (const signatureId of cascade.affectedSignatureIds) await this.recomputeSignatureReportSummary(tx, signatureId);
|
||||||
|
}
|
||||||
|
|
||||||
const log = await tx.operationLog.create({
|
const log = await tx.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
tenantId: cascade.tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
||||||
detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts },
|
detail: {
|
||||||
|
idempotencyKey,
|
||||||
|
reason: reason ?? null,
|
||||||
|
expectedUpdatedAt,
|
||||||
|
dependencies: preflight.dependencies,
|
||||||
|
impacts: preflight.impacts,
|
||||||
|
selections: preflight.requiredSelections.map((selection) => selection.action),
|
||||||
|
cascade: cascade.detail,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { operationId: log.id, status: 'deleted', replayed: false };
|
return { operationId: log.id, status: 'deleted', replayed: false };
|
||||||
@@ -93,7 +149,7 @@ export class DeletionGovernanceService {
|
|||||||
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
|
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
|
||||||
routeRules: { where: { status: 'active' } },
|
routeRules: { where: { status: 'active' } },
|
||||||
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
|
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
|
||||||
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } } },
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException('通道不存在');
|
if (!item) throw new NotFoundException('通道不存在');
|
||||||
@@ -101,10 +157,11 @@ export class DeletionGovernanceService {
|
|||||||
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)),
|
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)),
|
||||||
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
|
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
|
||||||
dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)),
|
dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)),
|
||||||
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => row.id)),
|
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)),
|
||||||
];
|
];
|
||||||
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
|
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
|
||||||
['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']);
|
['删除后不再参与新消息路由', '所选未结束报备任务将置为“放弃报备”', '历史发送、回执和审计记录继续保留'],
|
||||||
|
{ report_tasks: 'abandon_associated_report_tasks' });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
||||||
@@ -112,20 +169,34 @@ export class DeletionGovernanceService {
|
|||||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
include: {
|
include: {
|
||||||
tenant: { select: { name: true } }, application: { select: { name: true } },
|
tenant: { select: { name: true } }, application: { select: { name: true } },
|
||||||
templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } },
|
templates: {
|
||||||
|
where: { auditStatus: { not: 'deleted' } },
|
||||||
|
select: {
|
||||||
|
id: true, name: true,
|
||||||
|
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
|
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
|
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
|
||||||
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException('签名不存在或无权访问');
|
if (!item) throw new NotFoundException('签名不存在或无权访问');
|
||||||
|
const activeTemplateTasks = item.templates.flatMap((template) => [
|
||||||
|
...template.sendTasks.map((task) => `${template.name}:发送任务 ${task.id}(${task.status})`),
|
||||||
|
...template.batchTasks.map((task) => `${template.name}:批量任务 ${task.id}(${task.status})`),
|
||||||
|
]);
|
||||||
const dependencies: Dependency[] = [
|
const dependencies: Dependency[] = [
|
||||||
dep('templates', '仍在使用该签名的模板', item.templates.map((row) => `${row.name}(${row.id})`)),
|
dep('templates', '关联短信模板', item.templates.map((row) => `${row.name}(${row.id})`)),
|
||||||
|
dep('template_active_tasks', '关联模板仍有未结束发送任务', activeTemplateTasks),
|
||||||
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}(${row.id})`)),
|
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}(${row.id})`)),
|
||||||
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)),
|
dep('report_tasks', '未结束报备任务', tenantId ? [] : item.reportTasks.map((row) => `${row.id}(${row.status})`), item.reportTasks.length, !tenantId),
|
||||||
];
|
];
|
||||||
return buildPreflight('signature', item.id, item.updatedAt, {
|
return buildPreflight('signature', item.id, item.updatedAt, {
|
||||||
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
|
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
|
||||||
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']);
|
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '勾选的关联配置将同步逻辑删除或结束', '历史消息、审核与报备记录继续保留'], {
|
||||||
|
templates: 'delete_associated_templates', drainage: 'delete_associated_drainage', report_tasks: 'abandon_associated_report_tasks',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async templatePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
private async templatePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
||||||
@@ -133,19 +204,153 @@ export class DeletionGovernanceService {
|
|||||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
include: {
|
include: {
|
||||||
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
|
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
|
||||||
sendTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
|
||||||
batchTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
||||||
const dependencies: Dependency[] = [
|
|
||||||
dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}(${row.status})`)),
|
|
||||||
dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}(${row.status})`)),
|
|
||||||
];
|
|
||||||
return buildPreflight('template', item.id, item.updatedAt, {
|
return buildPreflight('template', item.id, item.updatedAt, {
|
||||||
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name,
|
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name,
|
||||||
signature: item.signature?.name ?? '未绑定',
|
signature: item.signature?.name ?? '未绑定',
|
||||||
}, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']);
|
}, item.auditStatus, [], ['删除后不能用于新发送任务', '已创建任务继续使用保存的内容快照', '历史消息、计费和审核记录继续保留']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareChannelDeletion(tx: Prisma.TransactionClient, id: string, body: DeleteTargetDto, reason?: string) {
|
||||||
|
const item = await tx.smsChannel.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
groupItems: { where: { group: { status: { not: 'deleted' } } }, select: { id: true } },
|
||||||
|
routeRules: { where: { status: 'active' }, select: { id: true } },
|
||||||
|
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } }, select: { id: true } },
|
||||||
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('通道不存在');
|
||||||
|
const blockers = [
|
||||||
|
item.groupItems.length ? `引用该通道的通道组共 ${item.groupItems.length} 项,请先解除或完成` : '',
|
||||||
|
item.routeRules.length ? `直接路由规则共 ${item.routeRules.length} 项,请先解除或完成` : '',
|
||||||
|
item.connectionStates.length ? `活动网关连接共 ${item.connectionStates.length} 项,请先解除或完成` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (blockers.length) throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: blockers });
|
||||||
|
this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label);
|
||||||
|
const abandonReason = reason ?? '删除通道时同步放弃关联报备任务';
|
||||||
|
await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, abandonReason);
|
||||||
|
return {
|
||||||
|
tenantId: undefined,
|
||||||
|
affectedSignatureIds: [...new Set(item.reportTasks.filter((task) => task.reportType === 'signature').map((task) => task.signatureId))],
|
||||||
|
detail: { abandonedReportTaskIds: item.reportTasks.map((task) => task.id) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSignatureDeletion(tx: Prisma.TransactionClient, id: string, tenantId: string | undefined, body: DeleteTargetDto, reason?: string) {
|
||||||
|
const item = await tx.smsSignature.findFirst({
|
||||||
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
|
include: {
|
||||||
|
templates: {
|
||||||
|
where: { auditStatus: { not: 'deleted' } },
|
||||||
|
select: {
|
||||||
|
id: true, name: true,
|
||||||
|
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true } },
|
||||||
|
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true } },
|
||||||
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('签名不存在或无权访问');
|
||||||
|
const activeTemplateTaskCount = item.templates.reduce((sum, template) => sum + template.sendTasks.length + template.batchTasks.length, 0);
|
||||||
|
if (activeTemplateTaskCount) {
|
||||||
|
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: [`关联模板仍有未结束发送任务共 ${activeTemplateTaskCount} 项,请先解除或完成`] });
|
||||||
|
}
|
||||||
|
this.assertRuntimeSelection(item.templates.length, body.deleteAssociatedTemplates, RESOLUTION_COPY.delete_associated_templates.label);
|
||||||
|
this.assertRuntimeSelection(item.drainageItems.length, body.deleteAssociatedDrainage, RESOLUTION_COPY.delete_associated_drainage.label);
|
||||||
|
this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label);
|
||||||
|
|
||||||
|
const templateIds = item.templates.map((template) => template.id);
|
||||||
|
const drainageIds = item.drainageItems.map((drainage) => drainage.id);
|
||||||
|
if (templateIds.length) {
|
||||||
|
await tx.smsTemplate.updateMany({ where: { id: { in: templateIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
|
||||||
|
for (const template of item.templates) {
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'template', resourceId: template.id,
|
||||||
|
detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除关联模板' } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (drainageIds.length) {
|
||||||
|
await tx.smsDrainageInfo.updateMany({ where: { id: { in: drainageIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } });
|
||||||
|
for (const drainageId of drainageIds) {
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'drainage', resourceId: drainageId,
|
||||||
|
detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除引流信息' } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, reason ?? '删除签名时同步放弃关联报备任务');
|
||||||
|
return {
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
affectedSignatureIds: [] as string[],
|
||||||
|
detail: { deletedTemplateIds: templateIds, deletedDrainageIds: drainageIds, abandonedReportTaskIds: item.reportTasks.map((task) => task.id) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareTemplateDeletion(tx: Prisma.TransactionClient, id: string, tenantId?: string) {
|
||||||
|
const item = await tx.smsTemplate.findFirst({
|
||||||
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
|
select: { id: true, tenantId: true },
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
||||||
|
return { tenantId: item.tenantId, affectedSignatureIds: [] as string[], detail: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async abandonReportTasks(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
tasks: Array<{ id: string; channelId: string; status: string }>,
|
||||||
|
operatorId: string | undefined,
|
||||||
|
reason: string,
|
||||||
|
) {
|
||||||
|
for (const task of tasks) {
|
||||||
|
// 每条任务分别留存状态前后值,便于解释一次级联删除为何结束了哪些报备任务。
|
||||||
|
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason } });
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id, channelId: task.channelId, action: 'delete_cascade_abandon', statusBefore: task.status,
|
||||||
|
statusAfter: 'abandoned', reason, operatorId, sourceEntry: 'deletion_governance',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||||
|
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||||
|
if (!signature || signature.auditStatus === 'deleted') return;
|
||||||
|
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
||||||
|
where: { applicationId: signature.applicationId, status: 'active' },
|
||||||
|
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||||
|
}) : [];
|
||||||
|
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
||||||
|
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||||
|
const fallbackChannels = tasks.map((task) => task.channel).filter((channel) => channel.status !== 'deleted');
|
||||||
|
const uniqueChannels = [...new Map((configuredChannels.length ? configuredChannels : fallbackChannels).map((channel) => [channel.id, channel])).values()];
|
||||||
|
const statuses = uniqueChannels.flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => (
|
||||||
|
tasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
|
||||||
|
?? tasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
|
||||||
|
?? 'pending'
|
||||||
|
)));
|
||||||
|
const reportStatus = summarizeReportStatuses(statuses).status;
|
||||||
|
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSelections(requiredSelections: RequiredSelection[], body: DeleteTargetDto) {
|
||||||
|
const missing = requiredSelections.filter((selection) => !selectionSelected(selection.action, body));
|
||||||
|
if (missing.length) throw new BadRequestException(`请先确认:${missing.map((selection) => selection.label).join('、')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRuntimeSelection(count: number, selected: boolean | undefined, label: string) {
|
||||||
|
if (count > 0 && selected !== true) throw new ConflictException(`关联数据已变化,请重新预检并勾选“${label}”`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertType(type: string): asserts type is DeletionTargetType {
|
private assertType(type: string): asserts type is DeletionTargetType {
|
||||||
@@ -153,16 +358,38 @@ export class DeletionGovernanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dep(kind: string, label: string, items: string[]): Dependency {
|
function dep(kind: string, label: string, items: string[], count = items.length, detailsVisible = true): Dependency {
|
||||||
return { kind, label, count: items.length, items: items.slice(0, 8) };
|
return { kind, label, count, items: detailsVisible ? items.slice(0, 8) : [], detailsVisible };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreflight(type: DeletionTargetType, id: string, updatedAt: Date, identity: Record<string, string>, status: string, dependencies: Dependency[], impacts: string[]): DeletionPreflight {
|
function buildPreflight(
|
||||||
const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}共 ${item.count} 项,请先解除或完成`);
|
type: DeletionTargetType,
|
||||||
|
id: string,
|
||||||
|
updatedAt: Date,
|
||||||
|
identity: Record<string, string>,
|
||||||
|
status: string,
|
||||||
|
dependencies: Dependency[],
|
||||||
|
impacts: string[],
|
||||||
|
resolutions: Partial<Record<string, DeletionResolutionAction>> = {},
|
||||||
|
): DeletionPreflight {
|
||||||
|
const requiredSelections = dependencies.flatMap((dependency) => {
|
||||||
|
const action = resolutions[dependency.kind];
|
||||||
|
if (!action || dependency.count === 0) return [];
|
||||||
|
return [{ ...RESOLUTION_COPY[action], dependencyKind: dependency.kind, count: dependency.count }];
|
||||||
|
});
|
||||||
|
const blockedReasons = dependencies
|
||||||
|
.filter((dependency) => dependency.count > 0 && !resolutions[dependency.kind])
|
||||||
|
.map((dependency) => `${dependency.label}共 ${dependency.count} 项,请先解除或完成`);
|
||||||
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
|
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
|
||||||
return {
|
return {
|
||||||
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons,
|
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, requiredSelections, impacts, blockedReasons,
|
||||||
allowedActions: blockedReasons.length ? [] : ['delete'],
|
allowedActions: blockedReasons.length ? [] : ['delete'],
|
||||||
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
|
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectionSelected(action: DeletionResolutionAction, body: DeleteTargetDto) {
|
||||||
|
if (action === 'delete_associated_templates') return body.deleteAssociatedTemplates === true;
|
||||||
|
if (action === 'delete_associated_drainage') return body.deleteAssociatedDrainage === true;
|
||||||
|
return body.abandonAssociatedReportTasks === true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
CreateSensitiveWordDto,
|
CreateSensitiveWordDto,
|
||||||
DictionariesService,
|
DictionariesService,
|
||||||
DictionaryStatusDto,
|
DictionaryStatusDto,
|
||||||
|
ReorderCommonReportFieldsDto,
|
||||||
|
UpdateDrainageFieldDto,
|
||||||
} from './dictionaries.service';
|
} from './dictionaries.service';
|
||||||
|
|
||||||
@ApiTags('dictionaries')
|
@ApiTags('dictionaries')
|
||||||
@@ -19,6 +21,11 @@ import {
|
|||||||
export class DictionariesController {
|
export class DictionariesController {
|
||||||
constructor(private readonly dictionaries: DictionariesService) {}
|
constructor(private readonly dictionaries: DictionariesService) {}
|
||||||
|
|
||||||
|
@Get('administrative-regions')
|
||||||
|
listAdministrativeRegions() {
|
||||||
|
return this.dictionaries.listAdministrativeRegions();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('phone-segments')
|
@Get('phone-segments')
|
||||||
listPhoneSegments(
|
listPhoneSegments(
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@@ -123,6 +130,15 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createDrainageField(body);
|
return this.dictionaries.createDrainageField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('drainage-fields/:id')
|
||||||
|
updateDrainageField(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateDrainageFieldDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.dictionaries.updateDrainageField(id, body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('drainage-fields/:id')
|
@Delete('drainage-fields/:id')
|
||||||
deleteDrainageField(@Param('id') id: string) {
|
deleteDrainageField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteDrainageField(id);
|
return this.dictionaries.deleteDrainageField(id);
|
||||||
@@ -163,8 +179,21 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createCommonReportField(body);
|
return this.dictionaries.createCommonReportField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('common-report-fields/order')
|
||||||
|
reorderCommonReportFields(
|
||||||
|
@Body() body: ReorderCommonReportFieldsDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.dictionaries.reorderCommonReportFields(body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('common-report-fields/:id')
|
@Delete('common-report-fields/:id')
|
||||||
deleteCommonReportField(@Param('id') id: string) {
|
deleteCommonReportField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteCommonReportField(id);
|
return this.dictionaries.deleteCommonReportField(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('common-report-fields/:id')
|
||||||
|
updateCommonReportField(@Param('id') id: string, @Body() body: CreateCommonReportFieldDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.dictionaries.updateCommonReportField(id, body, operatorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||||
},
|
},
|
||||||
channelReportField: {
|
channelReportField: {
|
||||||
@@ -46,6 +47,7 @@ function createPrismaMock() {
|
|||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||||
@@ -58,6 +60,82 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DictionariesService', () => {
|
describe('DictionariesService', () => {
|
||||||
|
it('edits common configuration in place with an audit trail and rejects duplicate or inactive fields', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const existing = { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false };
|
||||||
|
prisma.commonReportField.findUnique.mockImplementation(({ where }: { where: { id?: string } }) => Promise.resolve(where.id ? existing : null) as never);
|
||||||
|
prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'active' } as never);
|
||||||
|
const tx = { commonReportField: { update: jest.fn().mockResolvedValue({ ...existing, required: true }) }, operationLog: { create: jest.fn() } };
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
const body = { drainageFieldId: 'field-2', reportType: 'drainage' as const, required: true };
|
||||||
|
await service.updateCommonReportField('common-1', body, 'admin-1');
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'common-1' }, data: { ...body, sortOrder: undefined } }));
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'common_report_field.update', userId: 'admin-1' }) }));
|
||||||
|
prisma.commonReportField.findUnique.mockResolvedValue({ id: 'other' } as never);
|
||||||
|
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已配置');
|
||||||
|
prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'inactive' } as never);
|
||||||
|
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
||||||
|
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders every common field in one report type transaction and writes an audit trail', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const tx = {
|
||||||
|
commonReportField: {
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]),
|
||||||
|
},
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'),
|
||||||
|
).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]);
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { id: 'common-2' },
|
||||||
|
data: { sortOrder: 10 },
|
||||||
|
});
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: 'common-1' },
|
||||||
|
data: { sortOrder: 20 },
|
||||||
|
});
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }),
|
||||||
|
});
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
|
||||||
|
tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]);
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }),
|
||||||
|
).resolves.toEqual([{ id: 'common-1' }]);
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }),
|
||||||
|
).rejects.toThrow('排序范围已变化');
|
||||||
|
});
|
||||||
|
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
|
{ province: '山东', city: '青岛' },
|
||||||
|
{ province: '山东', city: '济南' },
|
||||||
|
{ province: '山东', city: '济南' },
|
||||||
|
{ province: '江苏', city: '苏州' },
|
||||||
|
{ province: ' ', city: '无效' },
|
||||||
|
]);
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.listAdministrativeRegions()).resolves.toEqual([
|
||||||
|
{ province: '江苏', cities: ['苏州'] },
|
||||||
|
{ province: '山东', cities: ['济南', '青岛'] },
|
||||||
|
]);
|
||||||
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { province: { not: null } },
|
||||||
|
select: { province: true, city: true },
|
||||||
|
distinct: ['province', 'city'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes a phone segment from the real dictionary table', async () => {
|
it('deletes a phone segment from the real dictionary table', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new DictionariesService(prisma as never);
|
const service = new DictionariesService(prisma as never);
|
||||||
@@ -90,6 +168,32 @@ describe('DictionariesService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('edits an unreferenced field atomically and protects mapping keys once referenced', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null };
|
||||||
|
prisma.drainageField.findUnique.mockResolvedValue(existing as never);
|
||||||
|
const tx = {
|
||||||
|
drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) },
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.updateDrainageField('field-1', {
|
||||||
|
code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ',
|
||||||
|
}, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' }));
|
||||||
|
expect(tx.drainageField.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'field-1' },
|
||||||
|
data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' },
|
||||||
|
});
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) });
|
||||||
|
|
||||||
|
prisma.channelReportField.count.mockResolvedValue(1);
|
||||||
|
await expect(service.updateDrainageField('field-1', {
|
||||||
|
code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '',
|
||||||
|
})).rejects.toThrow('不能修改字段代码或类型');
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
|
||||||
|
|
||||||
export interface UpsertDrainageDetectionRuleDto {
|
export interface UpsertDrainageDetectionRuleDto {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReorderCommonReportFieldsDto {
|
||||||
|
reportType: 'signature' | 'drainage';
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DictionaryStatusDto {
|
export interface DictionaryStatusDto {
|
||||||
status?: string;
|
status?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
@@ -111,6 +118,27 @@ export class DictionariesService {
|
|||||||
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
|
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async listAdministrativeRegions() {
|
||||||
|
const rows = await this.prisma.phoneSegment.findMany({
|
||||||
|
where: { province: { not: null } },
|
||||||
|
select: { province: true, city: true },
|
||||||
|
distinct: ['province', 'city'],
|
||||||
|
});
|
||||||
|
const citiesByProvince = new Map<string, Set<string>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const province = row.province?.trim();
|
||||||
|
if (!province) continue;
|
||||||
|
const cities = citiesByProvince.get(province) ?? new Set<string>();
|
||||||
|
const city = row.city?.trim();
|
||||||
|
if (city) cities.add(city);
|
||||||
|
citiesByProvince.set(province, cities);
|
||||||
|
}
|
||||||
|
return Array.from(citiesByProvince, ([province, cities]) => ({
|
||||||
|
province,
|
||||||
|
cities: Array.from(cities).sort((left, right) => left.localeCompare(right, 'zh-CN')),
|
||||||
|
})).sort((left, right) => left.province.localeCompare(right.province, 'zh-CN'));
|
||||||
|
}
|
||||||
|
|
||||||
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
||||||
const page = Math.max(1, Number(query.page ?? 1));
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
||||||
@@ -378,6 +406,58 @@ export class DictionariesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) {
|
||||||
|
const code = data.code?.trim();
|
||||||
|
const name = data.name?.trim();
|
||||||
|
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
|
||||||
|
throw new BadRequestException('code must contain only Arabic numerals and English letters');
|
||||||
|
}
|
||||||
|
if (!name) throw new BadRequestException('name is required');
|
||||||
|
if (!['string', 'image', 'file'].includes(data.fieldType)) {
|
||||||
|
throw new BadRequestException('fieldType must be string, image or file');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.drainageField.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('报备字段不存在');
|
||||||
|
const [usageCount, commonUsageCount] = await Promise.all([
|
||||||
|
this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } } }),
|
||||||
|
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
|
||||||
|
]);
|
||||||
|
if ((usageCount > 0 || commonUsageCount > 0) && (code !== existing.code || data.fieldType !== existing.fieldType)) {
|
||||||
|
throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.drainageField.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
fieldType: data.fieldType,
|
||||||
|
description: data.description?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: operatorId,
|
||||||
|
action: 'drainage_field.update',
|
||||||
|
resource: 'drainage_field',
|
||||||
|
resourceId: id,
|
||||||
|
detail: {
|
||||||
|
before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description },
|
||||||
|
after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description },
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
throw new ConflictException('字段代码已存在');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteDrainageField(id: string) {
|
async deleteDrainageField(id: string) {
|
||||||
const [usageCount, commonUsageCount] = await Promise.all([
|
const [usageCount, commonUsageCount] = await Promise.all([
|
||||||
this.prisma.channelReportField.count({
|
this.prisma.channelReportField.count({
|
||||||
@@ -520,10 +600,80 @@ export class DictionariesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) {
|
||||||
|
if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) {
|
||||||
|
throw new BadRequestException('通用字段排序参数无效');
|
||||||
|
}
|
||||||
|
if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项');
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.commonReportField.findMany({
|
||||||
|
where: { reportType: data.reportType, status: 'active' },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
const existingIds = existing.map((field) => field.id);
|
||||||
|
if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) {
|
||||||
|
throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试');
|
||||||
|
}
|
||||||
|
for (const [index, id] of data.ids.entries()) {
|
||||||
|
await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } });
|
||||||
|
}
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: operatorId,
|
||||||
|
action: 'common_report_field.reorder',
|
||||||
|
resource: 'common_report_field',
|
||||||
|
detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return tx.commonReportField.findMany({
|
||||||
|
where: { reportType: data.reportType, status: 'active' },
|
||||||
|
include: { drainageField: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
|
}
|
||||||
|
|
||||||
deleteCommonReportField(id: string) {
|
deleteCommonReportField(id: string) {
|
||||||
return this.prisma.commonReportField.delete({ where: { id } });
|
return this.prisma.commonReportField.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCommonReportField(id: string, data: CreateCommonReportFieldDto, operatorId?: string) {
|
||||||
|
if (!['signature', 'drainage'].includes(data.reportType) || typeof data.required !== 'boolean') {
|
||||||
|
throw new BadRequestException('资料用途或是否必填无效');
|
||||||
|
}
|
||||||
|
if (data.sortOrder !== undefined && !Number.isInteger(data.sortOrder)) {
|
||||||
|
throw new BadRequestException('排序值必须为整数');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.commonReportField.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('通用字段配置不存在');
|
||||||
|
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||||
|
if (!field || field.status !== 'active') throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||||
|
const duplicate = await this.prisma.commonReportField.findUnique({
|
||||||
|
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
|
||||||
|
});
|
||||||
|
if (duplicate && duplicate.id !== id) throw new ConflictException('该字段已配置为对应类型的通用字段');
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.commonReportField.update({
|
||||||
|
where: { id },
|
||||||
|
data: { drainageFieldId: field.id, reportType: data.reportType, required: data.required, sortOrder: data.sortOrder },
|
||||||
|
include: { drainageField: true },
|
||||||
|
});
|
||||||
|
await tx.operationLog.create({ data: {
|
||||||
|
userId: operatorId, action: 'common_report_field.update', resource: 'common_report_field', resourceId: id,
|
||||||
|
detail: { before: { drainageFieldId: existing.drainageFieldId, reportType: existing.reportType, required: existing.required }, after: { drainageFieldId: field.id, reportType: data.reportType, required: data.required } },
|
||||||
|
} });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
throw new ConflictException('该字段已配置为对应类型的通用字段');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||||
return this.prisma.operationLog.create({
|
return this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -22,18 +22,25 @@ export class PhoneRoutingLookupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async identifyProvince(phoneNumber: string) {
|
async identifyProvince(phoneNumber: string) {
|
||||||
const prefixes = phonePrefixes(phoneNumber);
|
return (await this.identifyProvinces([phoneNumber])).get(phoneNumber) ?? null;
|
||||||
if (prefixes.length === 0) return null;
|
}
|
||||||
|
|
||||||
|
async identifyProvinces(phoneNumbers: string[]) {
|
||||||
|
const uniquePhones = [...new Set(phoneNumbers)];
|
||||||
|
const prefixesByPhone = new Map(uniquePhones.map((phone) => [phone, phonePrefixes(phone)]));
|
||||||
|
const prefixes = [...new Set([...prefixesByPhone.values()].flat())];
|
||||||
|
if (prefixes.length === 0) return new Map(uniquePhones.map((phone) => [phone, null]));
|
||||||
const segments = await this.prisma.phoneSegment.findMany({
|
const segments = await this.prisma.phoneSegment.findMany({
|
||||||
where: { prefix: { in: prefixes } },
|
where: { prefix: { in: prefixes } },
|
||||||
select: { prefix: true, province: true },
|
select: { prefix: true, province: true },
|
||||||
});
|
});
|
||||||
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
||||||
for (const prefix of prefixes) {
|
return new Map(uniquePhones.map((phone) => {
|
||||||
const province = provinceByPrefix.get(prefix);
|
const province = (prefixesByPhone.get(phone) ?? [])
|
||||||
if (province) return province;
|
.map((prefix) => provinceByPrefix.get(prefix))
|
||||||
}
|
.find((value): value is string => Boolean(value)) ?? null;
|
||||||
return null;
|
return [phone, province];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateCarrierRules() {
|
invalidateCarrierRules() {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { assertUploadSize } from './files.service';
|
||||||
|
|
||||||
|
describe('FilesService report-material upload limits', () => {
|
||||||
|
const workbook = {
|
||||||
|
originalname: '行业报备.xlsx',
|
||||||
|
mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('allows report-material imports up to 100MB without raising the generic file limit', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertUploadSize('report_material_import', { ...workbook, size: 40 * 1024 * 1024 }),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => assertUploadSize('other', { ...workbook, size: 40 * 1024 * 1024 })).toThrow('10MB');
|
||||||
|
expect(() =>
|
||||||
|
assertUploadSize('report_material_import', { ...workbook, size: 101 * 1024 * 1024 }),
|
||||||
|
).toThrow('100MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,10 +40,12 @@ export class FilesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
list(tenantId?: string) {
|
list(tenantId?: string) {
|
||||||
return this.prisma.fileObject.findMany({
|
return this.prisma.fileObject
|
||||||
where: tenantId ? { tenantId } : undefined,
|
.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
where: tenantId ? { tenantId } : undefined,
|
||||||
}).then((items) => items.map(serializeFileObject));
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
.then((items) => items.map(serializeFileObject));
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data: CreateFileObjectDto) {
|
create(data: CreateFileObjectDto) {
|
||||||
@@ -71,7 +73,7 @@ export class FilesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
||||||
assertUploadSize(file);
|
assertUploadSize(data.purpose, file);
|
||||||
const fileName = normalizeMultipartFileName(file.originalname);
|
const fileName = normalizeMultipartFileName(file.originalname);
|
||||||
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
||||||
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
||||||
@@ -87,7 +89,11 @@ export class FilesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadForClient(userId: string | undefined, data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
async uploadForClient(
|
||||||
|
userId: string | undefined,
|
||||||
|
data: UploadFileDto,
|
||||||
|
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||||
|
) {
|
||||||
const tenantId = await this.resolveClientTenantId(userId);
|
const tenantId = await this.resolveClientTenantId(userId);
|
||||||
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
|
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
|
||||||
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
|
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
|
||||||
@@ -135,18 +141,33 @@ export class FilesService {
|
|||||||
|
|
||||||
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
const REPORT_MATERIAL_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i;
|
const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i;
|
||||||
|
|
||||||
function assertUploadSize(file: { originalname: string; mimetype: string; size: number }) {
|
export function assertUploadSize(purpose: string, file: { originalname: string; mimetype: string; size: number }) {
|
||||||
const image = file.mimetype.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.originalname);
|
const image = file.mimetype.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.originalname);
|
||||||
const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES;
|
const reportMaterialImport = purpose === 'report_material_import';
|
||||||
|
const limit = reportMaterialImport
|
||||||
|
? REPORT_MATERIAL_IMPORT_MAX_BYTES
|
||||||
|
: image
|
||||||
|
? IMAGE_UPLOAD_MAX_BYTES
|
||||||
|
: FILE_UPLOAD_MAX_BYTES;
|
||||||
if (file.size > limit) {
|
if (file.size > limit) {
|
||||||
throw new BadRequestException(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB');
|
throw new BadRequestException(
|
||||||
|
reportMaterialImport
|
||||||
|
? '报备资料文件大小不能超过 100MB'
|
||||||
|
: image
|
||||||
|
? '图片大小不能超过 2MB'
|
||||||
|
: '文件大小不能超过 10MB',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMultipartFileName(value: string) {
|
function normalizeMultipartFileName(value: string) {
|
||||||
if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) {
|
if (
|
||||||
|
![...value].some((character) => character.charCodeAt(0) > 0x7f) ||
|
||||||
|
[...value].some((character) => character.charCodeAt(0) > 0xff)
|
||||||
|
) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { BillingService } from './billing/billing.service';
|
||||||
|
import { PhoneRoutingLookupService } from './dictionaries/phone-routing-lookup.service';
|
||||||
|
import { MetricsModule } from './metrics/metrics.module';
|
||||||
|
import { OpenApiService } from './open-api/open-api.service';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module';
|
||||||
|
import { PhoneFrequencyService } from './risk-review/phone-frequency.service';
|
||||||
|
import { RiskReviewService } from './risk-review/risk-review.service';
|
||||||
|
import { GatewayCallbackController } from './send-chain/gateway-callback.controller';
|
||||||
|
import { SendChainService } from './send-chain/send-chain.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }),
|
||||||
|
PrismaModule,
|
||||||
|
MetricsModule,
|
||||||
|
ProtocolLogsModule,
|
||||||
|
],
|
||||||
|
controllers: [GatewayCallbackController],
|
||||||
|
providers: [
|
||||||
|
BillingService,
|
||||||
|
RiskReviewService,
|
||||||
|
PhoneFrequencyService,
|
||||||
|
PhoneRoutingLookupService,
|
||||||
|
SendChainService,
|
||||||
|
OpenApiService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class GatewayCallbackModule {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { GatewayCallbackModule } from './gateway-callback.module';
|
||||||
|
import { configureHttpBodyParsers } from './http-body-limits';
|
||||||
|
import { MetricsService } from './metrics/metrics.service';
|
||||||
|
|
||||||
|
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: bigint) {
|
||||||
|
const result = Number(this);
|
||||||
|
if (!Number.isSafeInteger(result)) throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (process.env.CMPP_PROCESS_ROLE !== 'callback') {
|
||||||
|
throw new Error('gateway-callback requires CMPP_PROCESS_ROLE=callback');
|
||||||
|
}
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(GatewayCallbackModule, { rawBody: true, bodyParser: false });
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
configureHttpBodyParsers(app);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
const host = process.env.API_CALLBACK_HOST?.trim() || '127.0.0.1';
|
||||||
|
const port = Number(process.env.API_CALLBACK_PORT ?? 3001);
|
||||||
|
await app.listen(port, host);
|
||||||
|
|
||||||
|
const metrics = app.get(MetricsService);
|
||||||
|
const metricsHost = process.env.API_CALLBACK_METRICS_HOST?.trim() || '127.0.0.1';
|
||||||
|
const metricsPort = Number(process.env.API_CALLBACK_METRICS_PORT ?? 9468);
|
||||||
|
const metricsServer = createServer((request, response) => {
|
||||||
|
if (request.method !== 'GET' || request.url !== '/metrics') return void response.writeHead(404).end();
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
response.end(metrics.render());
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
metricsServer.once('error', reject);
|
||||||
|
metricsServer.listen(metricsPort, metricsHost, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits';
|
||||||
|
|
||||||
|
const express = require('express') as () => {
|
||||||
|
use(...args: unknown[]): void;
|
||||||
|
post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void;
|
||||||
|
listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null };
|
||||||
|
};
|
||||||
|
const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown };
|
||||||
|
const http = require('node:http') as typeof import('node:http');
|
||||||
|
|
||||||
|
describe('configureHttpBodyParsers', () => {
|
||||||
|
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
|
||||||
|
const use = jest.fn();
|
||||||
|
const useBodyParser = jest.fn();
|
||||||
|
|
||||||
|
configureHttpBodyParsers({ use, useBodyParser } as never);
|
||||||
|
|
||||||
|
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
|
||||||
|
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
|
||||||
|
expect(use).toHaveBeenCalledTimes(1);
|
||||||
|
expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function));
|
||||||
|
expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' });
|
||||||
|
expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a 3 MiB import JSON body but rejects the same ordinary JSON body', async () => {
|
||||||
|
const serverApp = express();
|
||||||
|
configureHttpBodyParsers({
|
||||||
|
use: serverApp.use.bind(serverApp),
|
||||||
|
useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) {
|
||||||
|
serverApp.use(type === 'json'
|
||||||
|
? expressModule.json({ limit: options.limit })
|
||||||
|
: expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true }));
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 }));
|
||||||
|
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
|
||||||
|
|
||||||
|
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
|
||||||
|
const listening = serverApp.listen(0, '127.0.0.1', () => resolve(listening));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') throw new Error('test server did not expose a TCP port');
|
||||||
|
const body = JSON.stringify({ content: 'x'.repeat(3 * 1024 * 1024) });
|
||||||
|
const importResponse = await postJSON(address.port, '/api/client/send/imports/preview', body);
|
||||||
|
expect(importResponse.status).toBe(200);
|
||||||
|
expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) });
|
||||||
|
await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 });
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function postJSON(port: number, path: string, body: string) {
|
||||||
|
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||||
|
const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
||||||
|
});
|
||||||
|
request.once('error', reject);
|
||||||
|
request.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
|
const express = require('express') as {
|
||||||
|
json(options: {
|
||||||
|
limit: string;
|
||||||
|
verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void;
|
||||||
|
}): (...args: unknown[]) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_JSON_BODY_LIMIT = '2mb';
|
||||||
|
export const IMPORT_JSON_BODY_LIMIT = '25mb';
|
||||||
|
|
||||||
|
export function configureHttpBodyParsers(app: NestExpressApplication) {
|
||||||
|
// Import preview/confirmation temporarily carries the source CSV/TSV in
|
||||||
|
// JSON. Give only these endpoints the larger boundary; keeping ordinary
|
||||||
|
// JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint.
|
||||||
|
app.use('/api/client/send/imports', express.json({
|
||||||
|
limit: IMPORT_JSON_BODY_LIMIT,
|
||||||
|
verify(request, _response, buffer) {
|
||||||
|
request.rawBody = buffer;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
|
||||||
|
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { configureApiHttpServerTimeouts } from './http-server-timeouts';
|
||||||
|
|
||||||
|
describe('configureApiHttpServerTimeouts', () => {
|
||||||
|
it('keeps the API connection alive longer than the Gateway idle pool', () => {
|
||||||
|
const server = { keepAliveTimeout: 0, headersTimeout: 0 };
|
||||||
|
expect(configureApiHttpServerTimeouts(server, {})).toEqual({
|
||||||
|
keepAliveTimeoutMs: 120_000,
|
||||||
|
headersTimeoutMs: 125_000,
|
||||||
|
});
|
||||||
|
expect(server).toEqual({ keepAliveTimeout: 120_000, headersTimeout: 125_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps headers timeout above a configured keep-alive timeout', () => {
|
||||||
|
const server = { keepAliveTimeout: 0, headersTimeout: 0 };
|
||||||
|
expect(configureApiHttpServerTimeouts(server, {
|
||||||
|
API_HTTP_KEEP_ALIVE_TIMEOUT_MS: '90000',
|
||||||
|
API_HTTP_HEADERS_TIMEOUT_MS: '1000',
|
||||||
|
})).toEqual({ keepAliveTimeoutMs: 90_000, headersTimeoutMs: 91_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Server } from 'node:http';
|
||||||
|
|
||||||
|
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 120_000;
|
||||||
|
const DEFAULT_HEADERS_TIMEOUT_MS = 125_000;
|
||||||
|
|
||||||
|
export function configureApiHttpServerTimeouts(
|
||||||
|
server: Pick<Server, 'keepAliveTimeout' | 'headersTimeout'>,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
) {
|
||||||
|
const keepAliveTimeoutMs = positiveInteger(env.API_HTTP_KEEP_ALIVE_TIMEOUT_MS, DEFAULT_KEEP_ALIVE_TIMEOUT_MS);
|
||||||
|
const configuredHeadersTimeoutMs = positiveInteger(env.API_HTTP_HEADERS_TIMEOUT_MS, DEFAULT_HEADERS_TIMEOUT_MS);
|
||||||
|
const headersTimeoutMs = Math.max(configuredHeadersTimeoutMs, keepAliveTimeoutMs + 1_000);
|
||||||
|
server.keepAliveTimeout = keepAliveTimeoutMs;
|
||||||
|
server.headersTimeout = headersTimeoutMs;
|
||||||
|
return { keepAliveTimeoutMs, headersTimeoutMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: string | undefined, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// A bind mount is another path to the same filesystem, not another disk.
|
||||||
|
export const FILESYSTEM_LABELS = 'instance, device, fstype';
|
||||||
|
export const FILESYSTEM_SELECTOR = '{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}';
|
||||||
|
export const FILESYSTEM_USAGE_PERCENT = `max by (${FILESYSTEM_LABELS}) ((1 - node_filesystem_avail_bytes${FILESYSTEM_SELECTOR} / node_filesystem_size_bytes${FILESYSTEM_SELECTOR}) * 100)`;
|
||||||
|
export const FILESYSTEM_INODE_USAGE_PERCENT = `max by (${FILESYSTEM_LABELS}) ((1 - node_filesystem_files_free${FILESYSTEM_SELECTOR} / node_filesystem_files${FILESYSTEM_SELECTOR}) * 100)`;
|
||||||
|
|
||||||
|
export function filesystemIdentity(metric: Record<string, string>) {
|
||||||
|
return JSON.stringify([metric.instance ?? '', metric.device ?? '', metric.fstype ?? '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the filesystem's shallowest visible path; tie-breaking is deterministic.
|
||||||
|
export function compareMountpoints(left: string, right: string) {
|
||||||
|
return left.split('/').filter(Boolean).length - right.split('/').filter(Boolean).length
|
||||||
|
|| left.length - right.length || left.localeCompare(right);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DEFAULT_ALERT_THRESHOLDS, InfrastructureAlertSettingsService } from './infrastructure-alert-settings.service';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { FILESYSTEM_INODE_USAGE_PERCENT, FILESYSTEM_USAGE_PERCENT } from './filesystem-metrics';
|
||||||
|
|
||||||
|
describe('InfrastructureAlertSettingsService', () => {
|
||||||
|
const service = new InfrastructureAlertSettingsService({} as never, { get: () => undefined } as never);
|
||||||
|
|
||||||
|
it('accepts the fixed threshold whitelist and renders managed rules', () => {
|
||||||
|
const validated = (service as unknown as { validate(value: unknown): unknown }).validate(DEFAULT_ALERT_THRESHOLDS);
|
||||||
|
const rules = (service as unknown as { renderRules(value: unknown): string }).renderRules(validated);
|
||||||
|
expect(rules).toContain('HostCpuUsageWarning');
|
||||||
|
expect(rules).toContain('CmppGatewayQueueDelayedCritical');
|
||||||
|
expect(rules).toContain('threshold: "120秒"');
|
||||||
|
expect(rules).toContain('redis_memory_max_bytes > 0');
|
||||||
|
expect(rules).toContain('sum(increase(cmpp_api_http_requests_total');
|
||||||
|
expect(rules).not.toContain('mountpoint="/"');
|
||||||
|
expect(rules).toContain('device=~"/dev/.+"');
|
||||||
|
expect(rules).not.toContain('{{ $labels.mountpoint }}');
|
||||||
|
expect(rules).toContain('{{ $labels.device }}');
|
||||||
|
expect(rules).toContain('{{ $labels.fstype }}');
|
||||||
|
expect(rules).toContain(FILESYSTEM_USAGE_PERCENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps static capacity and inode rules aligned with filesystem-level deduplication', () => {
|
||||||
|
for (const file of ['cmpp-alerts.yml', 'cmpp-managed-alerts.yml']) {
|
||||||
|
const content = readFileSync(resolve(__dirname, '../../../tools/monitoring', file), 'utf8');
|
||||||
|
const blocks = [...content.matchAll(/ - alert: (HostRoot(?:Disk|Inode)Usage(?:Warning|Critical))\r?\n([\s\S]*?)(?=\r?\n - alert:|$)/g)];
|
||||||
|
expect(blocks).toHaveLength(file === 'cmpp-alerts.yml' ? 4 : 2);
|
||||||
|
for (const [, name, body] of blocks) {
|
||||||
|
const expression = body.match(/^\s+expr: (.+)$/m)![1].trim();
|
||||||
|
const base = name.includes('Inode') ? FILESYSTEM_INODE_USAGE_PERCENT : FILESYSTEM_USAGE_PERCENT;
|
||||||
|
expect(expression).toBe(name.endsWith('Warning') ? `(${base} > 80) and (${base} <= 90)` : `${base} > 90`);
|
||||||
|
expect(body).not.toContain('$labels.mountpoint');
|
||||||
|
expect(body).toContain('$labels.device');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown keys and warning thresholds that are not below critical', () => {
|
||||||
|
expect(() => (service as unknown as { validate(value: unknown): unknown }).validate({ ...DEFAULT_ALERT_THRESHOLDS, promql: { warning: 1, critical: 2 } })).toThrow(BadRequestException);
|
||||||
|
expect(() => (service as unknown as { validate(value: unknown): unknown }).validate({ ...DEFAULT_ALERT_THRESHOLDS, hostCpu: { warning: 90, critical: 90 } })).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { FILESYSTEM_USAGE_PERCENT } from './filesystem-metrics';
|
||||||
|
import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from './infrastructure-monitoring.contracts';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export const ALERT_THRESHOLD_DEFINITIONS = [
|
||||||
|
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'hostDisk', label: '磁盘(独立文件系统)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: FILESYSTEM_USAGE_PERCENT, names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] },
|
||||||
|
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] },
|
||||||
|
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] },
|
||||||
|
{ key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
|
||||||
|
ALERT_THRESHOLD_DEFINITIONS.map((item) => [item.key, { warning: item.warning, critical: item.critical }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InfrastructureAlertSettingsService {
|
||||||
|
private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
|
||||||
|
private readonly rulesPath: string;
|
||||||
|
private readonly promtoolPath: string;
|
||||||
|
private readonly reloadUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService, config: ConfigService) {
|
||||||
|
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml');
|
||||||
|
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool');
|
||||||
|
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(): Promise<InfrastructureAlertSettings> {
|
||||||
|
const row = await this.prisma.infrastructureAlertSetting.findUnique({ where: { id: 'global' } });
|
||||||
|
const thresholds = this.asThresholds(row?.thresholds) ?? DEFAULT_ALERT_THRESHOLDS;
|
||||||
|
const effective = this.asThresholds(row?.effectiveThresholds) ?? thresholds;
|
||||||
|
return {
|
||||||
|
configVersion: row?.configVersion ?? 1,
|
||||||
|
effectiveVersion: row?.effectiveVersion ?? 1,
|
||||||
|
applyStatus: (row?.applyStatus as InfrastructureAlertSettings['applyStatus']) ?? 'effective',
|
||||||
|
lastError: row?.lastError ?? null,
|
||||||
|
appliedAt: row?.appliedAt?.toISOString() ?? null,
|
||||||
|
thresholds,
|
||||||
|
effectiveThresholds: effective,
|
||||||
|
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(body: { configVersion?: number; thresholds?: unknown }, operatorId?: string) {
|
||||||
|
const expectedVersion = Number(body.configVersion);
|
||||||
|
if (!Number.isInteger(expectedVersion) || expectedVersion < 1) throw new BadRequestException('配置版本无效');
|
||||||
|
const thresholds = this.validate(body.thresholds);
|
||||||
|
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
|
||||||
|
where: { id: 'global', configVersion: expectedVersion },
|
||||||
|
data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId },
|
||||||
|
});
|
||||||
|
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
|
||||||
|
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
|
||||||
|
const nextVersion = expectedVersion + 1;
|
||||||
|
try {
|
||||||
|
await this.applyRules(thresholds);
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }),
|
||||||
|
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
|
||||||
|
await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } });
|
||||||
|
this.logger.error(`Prometheus managed rules apply failed: ${message}`);
|
||||||
|
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
|
||||||
|
}
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate(value: unknown): InfrastructureAlertThresholds {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) throw new BadRequestException('存在不允许配置的告警指标');
|
||||||
|
const result: InfrastructureAlertThresholds = {};
|
||||||
|
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
|
||||||
|
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
|
||||||
|
const warning = Number(pair?.warning);
|
||||||
|
const critical = Number(pair?.critical);
|
||||||
|
if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) {
|
||||||
|
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
|
||||||
|
}
|
||||||
|
result[definition.key] = { warning, critical };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private asThresholds(value: unknown) {
|
||||||
|
try { return this.validate(value); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRules(thresholds: InfrastructureAlertThresholds) {
|
||||||
|
const lines = ['groups:', ' - name: cmpp-managed-thresholds', ' rules:'];
|
||||||
|
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
|
||||||
|
const pair = thresholds[definition.key];
|
||||||
|
const values = [pair.warning, pair.critical];
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
const isWarning = index === 0;
|
||||||
|
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
|
||||||
|
const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
|
||||||
|
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`;
|
||||||
|
const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : '';
|
||||||
|
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyRules(thresholds: InfrastructureAlertThresholds) {
|
||||||
|
const directory = dirname(this.rulesPath);
|
||||||
|
const temporary = `${this.rulesPath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
const previous = await readFile(this.rulesPath).catch(() => null);
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
|
||||||
|
await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 });
|
||||||
|
await rename(temporary, this.rulesPath);
|
||||||
|
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
|
||||||
|
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { force: true });
|
||||||
|
// 规则替换和 reload 不是一个事务,失败时必须恢复旧文件并再次 reload,避免数据库状态与实际告警漂移。
|
||||||
|
if (previous) {
|
||||||
|
await writeFile(temporary, previous, { mode: 0o640 });
|
||||||
|
await rename(temporary, this.rulesPath);
|
||||||
|
await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
export type InfrastructureMonitoringRange = '1h' | '24h' | '7d';
|
||||||
|
|
||||||
|
export type InfrastructureMetricPoint = {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureServiceStatus = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
status: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureServiceMetricGroup = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
metrics: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
unit: 'percent' | 'seconds' | 'count' | 'per_second' | 'bytes';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureAlert = {
|
||||||
|
fingerprint: string;
|
||||||
|
name: string;
|
||||||
|
severity: 'info' | 'warning' | 'critical';
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
currentValue?: string;
|
||||||
|
threshold?: string;
|
||||||
|
service?: string;
|
||||||
|
instance?: string;
|
||||||
|
acknowledged: boolean;
|
||||||
|
acknowledgedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureMonitoringOverview = {
|
||||||
|
disks: Array<{
|
||||||
|
id: string;
|
||||||
|
instance: string;
|
||||||
|
device: string;
|
||||||
|
mountpoint: string;
|
||||||
|
mountpoints: string[];
|
||||||
|
filesystem: string;
|
||||||
|
usagePercent: number | null;
|
||||||
|
totalBytes: number | null;
|
||||||
|
availableBytes: number | null;
|
||||||
|
trend: InfrastructureMetricPoint[];
|
||||||
|
}>;
|
||||||
|
available: boolean;
|
||||||
|
range: InfrastructureMonitoringRange;
|
||||||
|
collectedAt: string;
|
||||||
|
lastSampleAt: string | null;
|
||||||
|
error?: string;
|
||||||
|
summary: {
|
||||||
|
overallStatus: 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||||
|
serviceTotal: number;
|
||||||
|
serviceHealthy: number;
|
||||||
|
warningAlerts: number;
|
||||||
|
criticalAlerts: number;
|
||||||
|
activeAlerts: number;
|
||||||
|
};
|
||||||
|
metrics: {
|
||||||
|
cpuUsagePercent: number | null;
|
||||||
|
memoryUsagePercent: number | null;
|
||||||
|
memoryTotalBytes: number | null;
|
||||||
|
memoryAvailableBytes: number | null;
|
||||||
|
diskUsagePercent: number | null;
|
||||||
|
diskTotalBytes: number | null;
|
||||||
|
diskAvailableBytes: number | null;
|
||||||
|
networkReceiveBytesPerSecond: number | null;
|
||||||
|
networkTransmitBytesPerSecond: number | null;
|
||||||
|
load1: number | null;
|
||||||
|
uptimeSeconds: number | null;
|
||||||
|
};
|
||||||
|
trends: {
|
||||||
|
cpuUsagePercent: InfrastructureMetricPoint[];
|
||||||
|
memoryUsagePercent: InfrastructureMetricPoint[];
|
||||||
|
diskUsagePercent: InfrastructureMetricPoint[];
|
||||||
|
networkReceiveBytesPerSecond: InfrastructureMetricPoint[];
|
||||||
|
networkTransmitBytesPerSecond: InfrastructureMetricPoint[];
|
||||||
|
};
|
||||||
|
services: InfrastructureServiceStatus[];
|
||||||
|
serviceMetrics: InfrastructureServiceMetricGroup[];
|
||||||
|
alerts: InfrastructureAlert[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureAlertThresholds = Record<string, { warning: number; critical: number }>;
|
||||||
|
|
||||||
|
export type InfrastructureAlertSettings = {
|
||||||
|
configVersion: number;
|
||||||
|
effectiveVersion: number;
|
||||||
|
applyStatus: 'effective' | 'applying' | 'failed';
|
||||||
|
lastError: string | null;
|
||||||
|
appliedAt: string | null;
|
||||||
|
thresholds: InfrastructureAlertThresholds;
|
||||||
|
effectiveThresholds: InfrastructureAlertThresholds;
|
||||||
|
definitions: Array<{ key: string; label: string; unit: string; min: number; max: number; step: number }>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
|
import { InfrastructureAlertSettingsService } from './infrastructure-alert-settings.service';
|
||||||
|
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||||
|
|
||||||
|
@ApiTags('infrastructure-monitoring')
|
||||||
|
@Controller('admin/infrastructure-monitoring')
|
||||||
|
export class InfrastructureMonitoringController {
|
||||||
|
constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {}
|
||||||
|
|
||||||
|
@Get('overview')
|
||||||
|
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
|
||||||
|
return this.monitoring.overview(range, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('notification-summary')
|
||||||
|
notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); }
|
||||||
|
|
||||||
|
@Post('alerts/:fingerprint/read')
|
||||||
|
markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) {
|
||||||
|
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('alert-thresholds')
|
||||||
|
alertThresholds() { return this.settings.get(); }
|
||||||
|
|
||||||
|
@Put('alert-thresholds')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.settings.update(body, operatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InfrastructureMonitoringController } from './infrastructure-monitoring.controller';
|
||||||
|
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||||
|
import { InfrastructureAlertSettingsService } from './infrastructure-alert-settings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [InfrastructureMonitoringController],
|
||||||
|
providers: [InfrastructureMonitoringService, InfrastructureAlertSettingsService],
|
||||||
|
})
|
||||||
|
export class InfrastructureMonitoringModule {}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||||
|
import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
|
||||||
|
|
||||||
|
function success(data: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ status: 'success', data }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InfrastructureMonitoringService', () => {
|
||||||
|
const prisma = {
|
||||||
|
infrastructureAlertRead: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), findUniqueOrThrow: jest.fn() },
|
||||||
|
operationLog: { create: jest.fn() },
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
prisma.infrastructureAlertRead.findMany.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ranges outside the fixed whitelist before querying Prometheus', async () => {
|
||||||
|
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
await expect(service.overview('30d')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => {
|
||||||
|
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }), prisma as never)).toThrow('must not contain credentials');
|
||||||
|
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }), prisma as never)).toThrow('must use HTTPS');
|
||||||
|
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }), prisma as never)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads real Prometheus vectors, ranges, services and active alerts', async () => {
|
||||||
|
const requestedUrls: URL[] = [];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
requestedUrls.push(url);
|
||||||
|
if (url.pathname.endsWith('/alerts')) {
|
||||||
|
return success({ alerts: [{
|
||||||
|
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
|
||||||
|
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
|
||||||
|
state: 'firing',
|
||||||
|
activeAt: '2026-08-14T03:00:00.000Z',
|
||||||
|
value: '88.2',
|
||||||
|
}] });
|
||||||
|
}
|
||||||
|
const query = url.searchParams.get('query') ?? '';
|
||||||
|
if (url.pathname.endsWith('/query_range')) {
|
||||||
|
return success({ result: [{ metric: {}, values: [[1_765_000_000, '12.5'], [1_765_000_060, '14.5']] }] });
|
||||||
|
}
|
||||||
|
if (query.includes('node_systemd_unit_state')) {
|
||||||
|
return success({ result: [
|
||||||
|
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (query.includes('cmpp:service_.*')) {
|
||||||
|
return success({ result: [
|
||||||
|
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
|
||||||
|
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
|
||||||
|
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (query.includes('timestamp(node_uname_info)')) {
|
||||||
|
return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] });
|
||||||
|
}
|
||||||
|
return success({ result: [{ metric: {}, value: [1_765_000_060, '25'] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
const result = await service.overview('1h');
|
||||||
|
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.metrics.cpuUsagePercent).toBe(25);
|
||||||
|
expect(result.trends.cpuUsagePercent).toHaveLength(2);
|
||||||
|
expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 });
|
||||||
|
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({ unit: 'redis-server.service', status: 'healthy' });
|
||||||
|
expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true });
|
||||||
|
expect(result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')?.value).toBe(3);
|
||||||
|
expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' });
|
||||||
|
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5);
|
||||||
|
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range')).every((url) => url.searchParams.get('step') === '60')).toBe(true);
|
||||||
|
expect(requestedUrls.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))?.searchParams.get('query'))
|
||||||
|
.toContain('cmpp-api\\\\.service');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
const result = await service.overview('24h');
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
expect(result.summary.overallStatus).toBe('unknown');
|
||||||
|
expect(result.metrics.cpuUsagePercent).toBeNull();
|
||||||
|
expect(result.trends.cpuUsagePercent).toEqual([]);
|
||||||
|
expect(result.serviceMetrics.every((item) => item.available === false)).toBe(true);
|
||||||
|
expect(result.error).not.toContain('ECONNREFUSED');
|
||||||
|
expect(result.disks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps system, data and additional disks distinct regardless of Prometheus series order', async () => {
|
||||||
|
const metrics = [
|
||||||
|
{ instance: 'host:9100', device: '/dev/sdb1', mountpoint: '/data', fstype: 'ext4' },
|
||||||
|
{ instance: 'host:9100', device: '/dev/sda2', mountpoint: '/', fstype: 'ext4' },
|
||||||
|
{ instance: 'host:9100', device: '/dev/nvme1n1p1', mountpoint: '/archive', fstype: 'xfs' },
|
||||||
|
];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const query = url.searchParams.get('query') ?? '';
|
||||||
|
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
|
||||||
|
if (!query.includes('node_filesystem_')) return success({ result: [] });
|
||||||
|
expect(query).not.toContain('mountpoint="/"');
|
||||||
|
if (url.pathname.endsWith('/query_range')) return success({ result: [...metrics].reverse().map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })) });
|
||||||
|
return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, query === FILESYSTEM_USAGE_PERCENT ? (metric.mountpoint === '/' ? '91' : '12') : query.includes('avail') ? '9' : '100'] })) });
|
||||||
|
});
|
||||||
|
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
|
||||||
|
expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']);
|
||||||
|
expect(result.disks[0]).toMatchObject({ usagePercent: 91, totalBytes: 100, availableBytes: 9, trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }] });
|
||||||
|
expect(result.disks[2].trend[0].value).toBe(12);
|
||||||
|
expect(result.metrics.diskUsagePercent).toBe(91);
|
||||||
|
expect(result.trends.diskUsagePercent[0].value).toBe(91);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges bind mounts without summing capacity, keeps a stable identity and uses one aggregated trend', async () => {
|
||||||
|
const data = { instance: 'host:9100', device: '/dev/sdb1', fstype: 'ext4' };
|
||||||
|
const root = { instance: 'host:9100', device: '/dev/sda2', fstype: 'ext4' };
|
||||||
|
let mounts = ['/var/lib/redis', '/var/lib/pgsql', '/data', '/var/lib/minio'];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const query = url.searchParams.get('query') ?? '';
|
||||||
|
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
|
||||||
|
if (!query.includes('node_filesystem_')) return success({ result: [] });
|
||||||
|
if (url.pathname.endsWith('/query_range')) {
|
||||||
|
expect(query).toBe(FILESYSTEM_USAGE_PERCENT);
|
||||||
|
return success({ result: [
|
||||||
|
{ metric: data, values: [[1765000000, '82'], [1765000060, 'NaN'], [1765000120, '83.5']] },
|
||||||
|
{ metric: root, values: [[1765000000, '91']] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (query.startsWith('node_filesystem_size_bytes')) return success({ result: [
|
||||||
|
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
|
||||||
|
...['/var/root-bind', '/'].map((mountpoint) => ({ metric: { ...root, mountpoint }, value: [1765000120, '200'] })),
|
||||||
|
] });
|
||||||
|
if (query === FILESYSTEM_USAGE_PERCENT) return success({ result: [
|
||||||
|
{ metric: data, value: [1765000120, '83.5'] }, { metric: root, value: [1765000120, '91'] },
|
||||||
|
] });
|
||||||
|
expect(query).toContain('min by (instance, device, fstype)');
|
||||||
|
return success({ result: [{ metric: data, value: [1765000120, '16.5'] }, { metric: root, value: [1765000120, '18'] }] });
|
||||||
|
});
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
const result = await service.overview('1h');
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.disks).toHaveLength(2);
|
||||||
|
expect(result.disks[0]).toMatchObject({ mountpoint: '/', mountpoints: ['/', '/var/root-bind'], totalBytes: 200 });
|
||||||
|
expect(result.metrics.diskUsagePercent).toBe(91);
|
||||||
|
expect(result.trends.diskUsagePercent[0].value).toBe(91);
|
||||||
|
const disk = result.disks[1];
|
||||||
|
expect(disk).toMatchObject({ id: filesystemIdentity(data), mountpoint: '/data', totalBytes: 100, availableBytes: 16.5, usagePercent: 83.5 });
|
||||||
|
expect(disk.mountpoints).toEqual(['/data', '/var/lib/minio', '/var/lib/pgsql', '/var/lib/redis']);
|
||||||
|
expect(disk.trend.map((point) => point.value)).toEqual([82, 83.5]);
|
||||||
|
mounts.reverse();
|
||||||
|
expect((await service.overview('1h')).disks).toEqual(result.disks);
|
||||||
|
mounts = ['/var/lib/redis'];
|
||||||
|
const aliasOnly = (await service.overview('1h')).disks.find((item) => item.id === disk.id)!;
|
||||||
|
expect(aliasOnly.mountpoint).toBe('/var/lib/redis');
|
||||||
|
expect(aliasOnly.trend).toEqual(disk.trend);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not merge different hosts, devices or filesystem types with identical capacity', async () => {
|
||||||
|
const metrics = [
|
||||||
|
{ instance: 'a:9100', device: '/dev/sdb1', fstype: 'ext4', mountpoint: '/data' },
|
||||||
|
{ instance: 'b:9100', device: '/dev/sdb1', fstype: 'ext4', mountpoint: '/data' },
|
||||||
|
{ instance: 'a:9100', device: '/dev/sdc1', fstype: 'ext4', mountpoint: '/archive' },
|
||||||
|
{ instance: 'a:9100', device: '/dev/sdb1', fstype: 'xfs', mountpoint: '/other' },
|
||||||
|
];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
|
||||||
|
if (url.searchParams.get('query')?.startsWith('node_filesystem_size_bytes')) {
|
||||||
|
return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, '100'] })) });
|
||||||
|
}
|
||||||
|
return success({ result: [] });
|
||||||
|
});
|
||||||
|
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
|
||||||
|
expect(result.disks).toHaveLength(4);
|
||||||
|
expect(new Set(result.disks.map((disk) => disk.id)).size).toBe(4);
|
||||||
|
expect(result.disks.every((disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0)).toBe(true);
|
||||||
|
expect(result.metrics.diskUsagePercent).toBeNull();
|
||||||
|
expect(result.trends.diskUsagePercent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
|
||||||
|
const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' };
|
||||||
|
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24);
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' }] }));
|
||||||
|
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') }]);
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 });
|
||||||
|
|
||||||
|
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') }]);
|
||||||
|
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts an idempotent per-user read record only for a currently active occurrence', async () => {
|
||||||
|
const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' };
|
||||||
|
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24);
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' }] }));
|
||||||
|
prisma.$transaction.mockResolvedValue([{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') }, {}]);
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({ fingerprint, acknowledged: true });
|
||||||
|
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) }));
|
||||||
|
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow('已结束或已重新触发');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { compareMountpoints, FILESYSTEM_LABELS, FILESYSTEM_SELECTOR, FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
|
||||||
|
import type {
|
||||||
|
InfrastructureAlert,
|
||||||
|
InfrastructureMetricPoint,
|
||||||
|
InfrastructureMonitoringOverview,
|
||||||
|
InfrastructureMonitoringRange,
|
||||||
|
InfrastructureServiceStatus,
|
||||||
|
InfrastructureServiceMetricGroup,
|
||||||
|
} from './infrastructure-monitoring.contracts';
|
||||||
|
|
||||||
|
type PrometheusSample = [number, string];
|
||||||
|
type PrometheusSeries = {
|
||||||
|
metric: Record<string, string>;
|
||||||
|
value?: PrometheusSample;
|
||||||
|
values?: PrometheusSample[];
|
||||||
|
};
|
||||||
|
type PrometheusQueryResponse = {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: { result?: PrometheusSeries[] };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
type PrometheusAlertResponse = {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: {
|
||||||
|
alerts?: Array<{
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
|
state?: string;
|
||||||
|
activeAt?: string;
|
||||||
|
value?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANGE_CONFIG: Record<InfrastructureMonitoringRange, { seconds: number; step: number }> = {
|
||||||
|
'1h': { seconds: 60 * 60, step: 60 },
|
||||||
|
'24h': { seconds: 24 * 60 * 60, step: 300 },
|
||||||
|
'7d': { seconds: 7 * 24 * 60 * 60, step: 1800 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUERIES = {
|
||||||
|
cpuUsagePercent: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
|
||||||
|
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
|
||||||
|
memoryTotalBytes: 'node_memory_MemTotal_bytes',
|
||||||
|
memoryAvailableBytes: 'node_memory_MemAvailable_bytes',
|
||||||
|
diskUsagePercent: FILESYSTEM_USAGE_PERCENT,
|
||||||
|
diskTotalBytes: `node_filesystem_size_bytes${FILESYSTEM_SELECTOR}`,
|
||||||
|
diskAvailableBytes: `min by (${FILESYSTEM_LABELS}) (node_filesystem_avail_bytes${FILESYSTEM_SELECTOR})`,
|
||||||
|
networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))',
|
||||||
|
networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))',
|
||||||
|
load1: 'node_load1',
|
||||||
|
uptimeSeconds: 'time() - node_boot_time_seconds',
|
||||||
|
lastSampleAt: 'max(timestamp(node_uname_info))',
|
||||||
|
// PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。
|
||||||
|
services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SERVICE_DEFINITIONS = [
|
||||||
|
{ key: 'api', name: 'API服务', units: ['cmpp-api.service'] },
|
||||||
|
{ key: 'gateway', name: 'Gateway服务', units: ['cmpp-gateway.service'] },
|
||||||
|
{ key: 'postgresql', name: 'PostgreSQL', units: ['postgresql.service'] },
|
||||||
|
{ key: 'redis', name: 'Redis', units: ['redis.service', 'redis-server.service'] },
|
||||||
|
{ key: 'minio', name: 'MinIO', units: ['cmpp-minio.service'] },
|
||||||
|
{ key: 'nginx', name: 'Nginx', units: ['nginx.service'] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SERVICE_METRIC_DEFINITIONS = [
|
||||||
|
{ key: 'api', name: 'API服务', metrics: [
|
||||||
|
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
|
||||||
|
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
|
||||||
|
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
|
||||||
|
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
|
||||||
|
] },
|
||||||
|
{ key: 'gateway', name: 'Gateway服务', metrics: [
|
||||||
|
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
|
||||||
|
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
|
||||||
|
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
|
||||||
|
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
|
||||||
|
] },
|
||||||
|
{ key: 'postgresql', name: 'PostgreSQL', metrics: [
|
||||||
|
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
|
||||||
|
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
|
||||||
|
] },
|
||||||
|
{ key: 'redis', name: 'Redis', metrics: [
|
||||||
|
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
|
||||||
|
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
|
||||||
|
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
|
||||||
|
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
|
||||||
|
] },
|
||||||
|
{ key: 'minio', name: 'MinIO', metrics: [
|
||||||
|
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
|
||||||
|
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
|
||||||
|
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
|
||||||
|
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
|
||||||
|
] },
|
||||||
|
{ key: 'nginx', name: 'Nginx', metrics: [
|
||||||
|
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
|
||||||
|
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
|
||||||
|
] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}';
|
||||||
|
|
||||||
|
function finiteNumber(value: string | number | undefined): number | null {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrometheusUrl(rawValue: unknown) {
|
||||||
|
const url = new URL(String(rawValue ?? 'http://127.0.0.1:9090'));
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('PROMETHEUS_URL must use HTTP or HTTPS');
|
||||||
|
if (url.username || url.password) throw new Error('PROMETHEUS_URL must not contain credentials');
|
||||||
|
const privateIpv4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(url.hostname);
|
||||||
|
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
||||||
|
// Plain HTTP is only safe on loopback or an explicit RFC1918 address; named remote endpoints must use HTTPS.
|
||||||
|
if (url.protocol === 'http:' && !loopback && !privateIpv4) throw new Error('Remote PROMETHEUS_URL must use HTTPS');
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function vectorValue(response: PrometheusQueryResponse): number | null {
|
||||||
|
return finiteNumber(response.data?.result?.[0]?.value?.[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrixValues(response: PrometheusQueryResponse): InfrastructureMetricPoint[] {
|
||||||
|
return (response.data?.result?.[0]?.values ?? []).flatMap(([timestamp, value]) => {
|
||||||
|
const parsed = finiteNumber(value);
|
||||||
|
return parsed === null ? [] : [{ timestamp: new Date(timestamp * 1000).toISOString(), value: parsed }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyMetrics(): InfrastructureMonitoringOverview['metrics'] {
|
||||||
|
return {
|
||||||
|
cpuUsagePercent: null,
|
||||||
|
memoryUsagePercent: null,
|
||||||
|
memoryTotalBytes: null,
|
||||||
|
memoryAvailableBytes: null,
|
||||||
|
diskUsagePercent: null,
|
||||||
|
diskTotalBytes: null,
|
||||||
|
diskAvailableBytes: null,
|
||||||
|
networkReceiveBytesPerSecond: null,
|
||||||
|
networkTransmitBytesPerSecond: null,
|
||||||
|
load1: null,
|
||||||
|
uptimeSeconds: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTrends(): InfrastructureMonitoringOverview['trends'] {
|
||||||
|
return {
|
||||||
|
cpuUsagePercent: [],
|
||||||
|
memoryUsagePercent: [],
|
||||||
|
diskUsagePercent: [],
|
||||||
|
networkReceiveBytesPerSecond: [],
|
||||||
|
networkTransmitBytesPerSecond: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InfrastructureMonitoringService {
|
||||||
|
private readonly logger = new Logger(InfrastructureMonitoringService.name);
|
||||||
|
private readonly prometheusUrl: string;
|
||||||
|
private readonly queryTimeoutMs: number;
|
||||||
|
|
||||||
|
constructor(config: ConfigService, private readonly prisma: PrismaService) {
|
||||||
|
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
|
||||||
|
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async overview(rawRange?: string, userId?: string): Promise<InfrastructureMonitoringOverview> {
|
||||||
|
const range = this.parseRange(rawRange);
|
||||||
|
const collectedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const [instant, trends, serviceResponse, serviceMetricResponse, alertResponse] = await Promise.all([
|
||||||
|
this.loadInstantMetrics(),
|
||||||
|
this.loadTrends(range),
|
||||||
|
this.query(QUERIES.services),
|
||||||
|
this.query(SERVICE_METRICS_QUERY),
|
||||||
|
this.getJson<PrometheusAlertResponse>('/api/v1/alerts'),
|
||||||
|
]);
|
||||||
|
const services = this.parseServices(serviceResponse);
|
||||||
|
const serviceMetrics = this.parseServiceMetrics(serviceMetricResponse);
|
||||||
|
const alerts = await this.attachReadState(this.parseAlerts(alertResponse), userId);
|
||||||
|
const warningAlerts = alerts.filter((item) => item.severity === 'warning').length;
|
||||||
|
const criticalAlerts = alerts.filter((item) => item.severity === 'critical').length;
|
||||||
|
const overallStatus = criticalAlerts > 0 ? 'critical' : warningAlerts > 0 ? 'warning' : 'healthy';
|
||||||
|
const rootDisk = instant.disks.find((disk) => disk.mountpoints.includes('/'));
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
range,
|
||||||
|
collectedAt,
|
||||||
|
lastSampleAt: instant.lastSampleAt === null ? null : new Date(instant.lastSampleAt * 1000).toISOString(),
|
||||||
|
summary: {
|
||||||
|
overallStatus,
|
||||||
|
serviceTotal: services.length,
|
||||||
|
serviceHealthy: services.filter((item) => item.status === 'healthy').length,
|
||||||
|
warningAlerts,
|
||||||
|
criticalAlerts,
|
||||||
|
activeAlerts: alerts.length,
|
||||||
|
},
|
||||||
|
metrics: instant.metrics,
|
||||||
|
trends: { ...trends.metrics, diskUsagePercent: rootDisk ? trends.disks.get(rootDisk.id) ?? [] : [] },
|
||||||
|
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
|
||||||
|
services,
|
||||||
|
serviceMetrics,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断。
|
||||||
|
this.logger.warn(`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return this.unavailable(range, collectedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async notificationSummary(userId?: string) {
|
||||||
|
try {
|
||||||
|
const alerts = await this.attachReadState(this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')), userId);
|
||||||
|
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
|
||||||
|
return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAlertRead(fingerprint: string, rawActiveAt: unknown, userId: string) {
|
||||||
|
if (!/^[a-f0-9]{24}$/.test(fingerprint)) throw new BadRequestException('告警指纹无效');
|
||||||
|
const activeAt = new Date(String(rawActiveAt ?? ''));
|
||||||
|
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
|
||||||
|
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts'));
|
||||||
|
const current = activeAlerts.find((item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime());
|
||||||
|
if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试');
|
||||||
|
const readAt = new Date();
|
||||||
|
const log = () => this.prisma.operationLog.create({
|
||||||
|
data: { userId, action: 'monitoring.alert_marked_read', resource: 'infrastructure_alert', resourceId: fingerprint, detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity } },
|
||||||
|
});
|
||||||
|
let read;
|
||||||
|
try {
|
||||||
|
[read] = await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertRead.create({ data: { fingerprint, activeAt, userId, readAt } }),
|
||||||
|
log(),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
|
||||||
|
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } });
|
||||||
|
// 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。
|
||||||
|
if (existing.activeAt.getTime() === activeAt.getTime()) read = existing;
|
||||||
|
else [read] = await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertRead.update({ where: { fingerprint_userId: { fingerprint, userId } }, data: { activeAt, readAt } }),
|
||||||
|
log(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRange(value?: string): InfrastructureMonitoringRange {
|
||||||
|
const range = value || '24h';
|
||||||
|
if (!(range in RANGE_CONFIG)) throw new BadRequestException('监控时间范围只支持1h、24h或7d');
|
||||||
|
return range as InfrastructureMonitoringRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInstantMetrics() {
|
||||||
|
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
|
||||||
|
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
|
||||||
|
const metrics = emptyMetrics();
|
||||||
|
keys.forEach((key, index) => { if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]); });
|
||||||
|
const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? [];
|
||||||
|
const usage = new Map(diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]));
|
||||||
|
const available = new Map(diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]));
|
||||||
|
const groups = new Map<string, PrometheusSeries[]>();
|
||||||
|
for (const item of diskSamples('diskTotalBytes')) {
|
||||||
|
if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue;
|
||||||
|
const id = filesystemIdentity(item.metric);
|
||||||
|
const group = groups.get(id) ?? [];
|
||||||
|
group.push(item);
|
||||||
|
groups.set(id, group);
|
||||||
|
}
|
||||||
|
const disks = [...groups].map(([id, items]) => {
|
||||||
|
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
|
||||||
|
const metric = items[0].metric;
|
||||||
|
return {
|
||||||
|
id, instance: metric.instance ?? '', device: metric.device, filesystem: metric.fstype ?? '',
|
||||||
|
mountpoint: mountpoints[0], mountpoints,
|
||||||
|
// Never sum aliases. Max/min also tolerate slight sampling differences.
|
||||||
|
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
|
||||||
|
availableBytes: available.get(id) ?? null, usagePercent: usage.get(id) ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint)));
|
||||||
|
const rootDisk = disks.find((disk) => disk.mountpoints.includes('/'));
|
||||||
|
metrics.diskUsagePercent = rootDisk?.usagePercent ?? null;
|
||||||
|
metrics.diskTotalBytes = rootDisk?.totalBytes ?? null;
|
||||||
|
metrics.diskAvailableBytes = rootDisk?.availableBytes ?? null;
|
||||||
|
return { metrics, disks, lastSampleAt: vectorValue(responses[responses.length - 1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTrends(range: InfrastructureMonitoringRange) {
|
||||||
|
const config = RANGE_CONFIG[range];
|
||||||
|
const end = Math.floor(Date.now() / 1000);
|
||||||
|
const start = end - config.seconds;
|
||||||
|
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
|
||||||
|
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
|
||||||
|
return {
|
||||||
|
metrics: Object.fromEntries(keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'],
|
||||||
|
disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
|
||||||
|
filesystemIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }),
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
||||||
|
const values = new Map<string, number>();
|
||||||
|
for (const item of response.data?.result ?? []) {
|
||||||
|
if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
|
||||||
|
}
|
||||||
|
return SERVICE_DEFINITIONS.map((definition) => {
|
||||||
|
const present = definition.units.filter((unit) => values.has(unit));
|
||||||
|
const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy';
|
||||||
|
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAlerts(response: PrometheusAlertResponse): InfrastructureAlert[] {
|
||||||
|
return (response.data?.alerts ?? [])
|
||||||
|
.filter((item) => item.state === 'firing' || item.state === 'pending')
|
||||||
|
.map<InfrastructureAlert>((item) => {
|
||||||
|
const labels = item.labels ?? {};
|
||||||
|
const annotations = item.annotations ?? {};
|
||||||
|
const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
|
||||||
|
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
|
||||||
|
return {
|
||||||
|
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
|
||||||
|
name: labels.alertname || '未命名告警',
|
||||||
|
severity,
|
||||||
|
status: item.state || 'unknown',
|
||||||
|
startedAt: item.activeAt || new Date().toISOString(),
|
||||||
|
summary: annotations.summary || annotations.description || labels.alertname || '监控告警',
|
||||||
|
description: annotations.description,
|
||||||
|
currentValue: annotations.currentValue || item.value,
|
||||||
|
threshold: annotations.threshold,
|
||||||
|
service: labels.service,
|
||||||
|
instance: labels.instance,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
|
||||||
|
return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attachReadState(alerts: InfrastructureAlert[], userId?: string) {
|
||||||
|
if (!userId || alerts.length === 0) return alerts;
|
||||||
|
const reads = await this.prisma.infrastructureAlertRead.findMany({
|
||||||
|
where: { userId, fingerprint: { in: alerts.map((item) => item.fingerprint) } },
|
||||||
|
select: { fingerprint: true, activeAt: true, readAt: true },
|
||||||
|
});
|
||||||
|
const byFingerprint = new Map(reads.map((item) => [item.fingerprint, item]));
|
||||||
|
return alerts.map((alert) => {
|
||||||
|
const read = byFingerprint.get(alert.fingerprint);
|
||||||
|
const acknowledged = Boolean(read && read.activeAt.getTime() === Date.parse(alert.startedAt));
|
||||||
|
return { ...alert, acknowledged, acknowledgedAt: acknowledged ? read?.readAt.toISOString() : undefined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseServiceMetrics(response: PrometheusQueryResponse): InfrastructureServiceMetricGroup[] {
|
||||||
|
const values = new Map<string, number>();
|
||||||
|
for (const item of response.data?.result ?? []) {
|
||||||
|
const metricName = item.metric.__name__;
|
||||||
|
const value = vectorValue({ status: 'success', data: { result: [item] } });
|
||||||
|
if (metricName && value !== null) values.set(metricName, value);
|
||||||
|
}
|
||||||
|
return SERVICE_METRIC_DEFINITIONS.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
name: group.name,
|
||||||
|
available: group.metrics.some((metric) => values.has(metric[2])),
|
||||||
|
metrics: group.metrics.map(([key, label, metricName, unit]) => ({ key, label, value: values.get(metricName) ?? null, unit })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
|
||||||
|
const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const }));
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
range,
|
||||||
|
collectedAt,
|
||||||
|
lastSampleAt: null,
|
||||||
|
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
|
||||||
|
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
|
||||||
|
metrics: emptyMetrics(),
|
||||||
|
disks: [],
|
||||||
|
trends: emptyTrends(),
|
||||||
|
services,
|
||||||
|
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })),
|
||||||
|
alerts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private query(query: string) {
|
||||||
|
return this.getJson<PrometheusQueryResponse>('/api/v1/query', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
private queryRange(query: string, start: number, end: number, step: number) {
|
||||||
|
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||||
|
const url = new URL(`${this.prometheusUrl}${path}`);
|
||||||
|
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||||
|
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) });
|
||||||
|
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
|
||||||
|
const result = await response.json() as T;
|
||||||
|
if (result.status !== 'success') throw new Error('Prometheus query failed');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-2
@@ -1,8 +1,13 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { MetricsService } from './metrics/metrics.service';
|
||||||
import { OpenApiModule } from './open-api/open-api.module';
|
import { OpenApiModule } from './open-api/open-api.module';
|
||||||
|
import { configureHttpBodyParsers } from './http-body-limits';
|
||||||
|
import { configureApiHttpServerTimeouts } from './http-server-timeouts';
|
||||||
|
|
||||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -16,8 +21,9 @@ Object.defineProperty(BigInt.prototype, 'toJSON', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, bodyParser: false });
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
configureHttpBodyParsers(app);
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('CMPP Platform API')
|
.setTitle('CMPP Platform API')
|
||||||
@@ -36,7 +42,27 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
||||||
|
|
||||||
const port = Number(process.env.API_PORT ?? 3000);
|
const port = Number(process.env.API_PORT ?? 3000);
|
||||||
await app.listen(port);
|
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
|
||||||
|
const host = process.env.API_HOST?.trim() || '127.0.0.1';
|
||||||
|
const apiServer = await app.listen(port, host);
|
||||||
|
configureApiHttpServerTimeouts(apiServer, process.env);
|
||||||
|
|
||||||
|
const metrics = app.get(MetricsService);
|
||||||
|
const metricsHost = process.env.API_METRICS_HOST?.trim() || '127.0.0.1';
|
||||||
|
const metricsPort = Number(process.env.API_METRICS_PORT ?? 9464);
|
||||||
|
const metricsServer = createServer((request, response) => {
|
||||||
|
if (request.method !== 'GET' || request.url !== '/metrics') {
|
||||||
|
response.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
response.end(metrics.render());
|
||||||
|
});
|
||||||
|
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
metricsServer.once('error', reject);
|
||||||
|
metricsServer.listen(metricsPort, metricsHost, resolve);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import type { Observable } from 'rxjs';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
type RequestLike = { method?: string; baseUrl?: string; route?: { path?: string } };
|
||||||
|
type ResponseLike = { statusCode?: number };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly metrics: MetricsService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
if (context.getType() !== 'http') return next.handle();
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const request = http.getRequest<RequestLike>();
|
||||||
|
const response = http.getResponse<ResponseLike>();
|
||||||
|
const startedAt = this.metrics.beginRequest();
|
||||||
|
return next.handle().pipe(finalize(() => {
|
||||||
|
const route = `${request.baseUrl ?? ''}${request.route?.path ?? '/unmatched'}`;
|
||||||
|
this.metrics.finishRequest(startedAt, request.method ?? 'UNKNOWN', route, response.statusCode ?? 500);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { MetricsInterceptor } from './metrics.interceptor';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [MetricsService, { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }],
|
||||||
|
exports: [MetricsService],
|
||||||
|
})
|
||||||
|
export class MetricsModule {}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
describe('MetricsService', () => {
|
||||||
|
it('exports bounded API process and HTTP metrics without raw identifiers', () => {
|
||||||
|
const service = new MetricsService();
|
||||||
|
const startedAt = service.beginRequest();
|
||||||
|
service.finishRequest(startedAt, 'GET', '/api/admin/tenants/:id', 200);
|
||||||
|
const inboundStartedAt = service.beginCmppInboundStage();
|
||||||
|
service.finishCmppInboundStage(inboundStartedAt, 'application_lookup', 'success');
|
||||||
|
const sendStartedAt = service.beginSendWorkerStage();
|
||||||
|
service.finishSendWorkerStage(sendStartedAt, 'route_lookup', 'success');
|
||||||
|
service.setSendWorkerSlots(20, 3);
|
||||||
|
service.setSendWorkerQueueJobs('waiting', 12);
|
||||||
|
service.recordSendWorkerResult('completed');
|
||||||
|
service.setSendWorkerDatabasePool('max', 8);
|
||||||
|
service.setSendWorkerDatabasePool('waiting', 2);
|
||||||
|
const output = service.render();
|
||||||
|
|
||||||
|
expect(output).toContain('cmpp_api_process_resident_memory_bytes');
|
||||||
|
expect(output).toContain('cmpp_api_http_requests_total{method="GET",route="/api/admin/tenants/:id",status="200"} 1');
|
||||||
|
expect(output).toContain('cmpp_api_http_request_duration_seconds_bucket');
|
||||||
|
expect(output).toContain('cmpp_api_cmpp_inbound_stage_duration_seconds_count{stage="application_lookup",result="success"} 1');
|
||||||
|
expect(output).toContain('cmpp_worker_send_stage_duration_seconds_count{stage="route_lookup",result="success"} 1');
|
||||||
|
expect(output).toContain('cmpp_worker_send_slots{state="configured"} 20');
|
||||||
|
expect(output).toContain('cmpp_worker_send_slots{state="in_flight"} 3');
|
||||||
|
expect(output).toContain('cmpp_worker_send_queue_jobs{state="waiting"} 12');
|
||||||
|
expect(output).toContain('cmpp_worker_send_jobs_total{result="completed"} 1');
|
||||||
|
expect(output).toContain('cmpp_worker_database_pool_connections{state="max"} 8');
|
||||||
|
expect(output).toContain('cmpp_worker_database_pool_connections{state="waiting"} 2');
|
||||||
|
expect(output).not.toContain('phone_number');
|
||||||
|
expect(output).not.toContain('tenant_id');
|
||||||
|
expect(output).not.toContain('channel_id');
|
||||||
|
service.onModuleDestroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
||||||
|
|
||||||
|
const HTTP_DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
|
||||||
|
const CMPP_INBOUND_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
|
||||||
|
const SEND_WORKER_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
|
||||||
|
|
||||||
|
export type CmppInboundStage =
|
||||||
|
| 'application_lookup'
|
||||||
|
| 'inbox_persist'
|
||||||
|
| 'worker_claim'
|
||||||
|
| 'reference_preload'
|
||||||
|
| 'daily_quota'
|
||||||
|
| 'long_message_fragment'
|
||||||
|
| 'submission_precheck'
|
||||||
|
| 'template_match'
|
||||||
|
| 'task_persist'
|
||||||
|
| 'api_request_persist'
|
||||||
|
| 'content_detection'
|
||||||
|
| 'message_persist'
|
||||||
|
| 'risk_frequency'
|
||||||
|
| 'billing'
|
||||||
|
| 'queue_publish'
|
||||||
|
| 'complete_submit'
|
||||||
|
| 'total';
|
||||||
|
|
||||||
|
export type CmppInboundStageResult = 'success' | 'error';
|
||||||
|
|
||||||
|
export type SendWorkerStage =
|
||||||
|
| 'message_load'
|
||||||
|
| 'phone_routing'
|
||||||
|
| 'route_lookup'
|
||||||
|
| 'signature_candidates'
|
||||||
|
| 'signature_final_check'
|
||||||
|
| 'rate_limit'
|
||||||
|
| 'submit_transaction'
|
||||||
|
| 'gateway_bullmq_publish'
|
||||||
|
| 'gateway_stream_publish'
|
||||||
|
| 'task_progress'
|
||||||
|
| 'total';
|
||||||
|
|
||||||
|
export type SendWorkerStageResult = 'success' | 'error' | 'skipped';
|
||||||
|
export type SendWorkerQueueState = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'prioritized';
|
||||||
|
|
||||||
|
type HttpMetric = {
|
||||||
|
count: number;
|
||||||
|
durationSum: number;
|
||||||
|
buckets: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeLabel(value: string) {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricLine(name: string, value: number, labels?: Record<string, string>) {
|
||||||
|
const suffix = labels
|
||||||
|
? `{${Object.entries(labels)
|
||||||
|
.map(([key, item]) => `${key}="${escapeLabel(item)}"`)
|
||||||
|
.join(',')}}`
|
||||||
|
: '';
|
||||||
|
return `${name}${suffix} ${Number.isFinite(value) ? value : 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsService implements OnModuleDestroy {
|
||||||
|
private readonly startedAt = process.hrtime.bigint();
|
||||||
|
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||||
|
private readonly http = new Map<string, HttpMetric>();
|
||||||
|
private readonly cmppInbound = new Map<string, HttpMetric>();
|
||||||
|
private readonly sendWorkerStages = new Map<string, HttpMetric>();
|
||||||
|
private readonly sendWorkerQueueJobs = new Map<SendWorkerQueueState, number>();
|
||||||
|
private readonly sendWorkerResults = new Map<string, number>();
|
||||||
|
private sendWorkerConfiguredSlots = 0;
|
||||||
|
private sendWorkerInFlightSlots = 0;
|
||||||
|
private readonly sendWorkerDatabasePool = new Map<'max' | 'total' | 'idle' | 'waiting', number>();
|
||||||
|
private inFlight = 0;
|
||||||
|
private inboundWorkflowPending = 0;
|
||||||
|
private inboundWorkflowProcessing = 0;
|
||||||
|
private inboundWorkflowOldestPendingAgeSeconds = 0;
|
||||||
|
private inboundWorkflowConfiguredSlots = 0;
|
||||||
|
private inboundWorkflowInFlightSlots = 0;
|
||||||
|
private readonly inboundWorkflowResults = new Map<string, number>();
|
||||||
|
private readonly authProtectionResults = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.eventLoopDelay.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginRequest() {
|
||||||
|
this.inFlight += 1;
|
||||||
|
return process.hrtime.bigint();
|
||||||
|
}
|
||||||
|
|
||||||
|
finishRequest(startedAt: bigint, method: string, route: string, statusCode: number) {
|
||||||
|
this.inFlight = Math.max(0, this.inFlight - 1);
|
||||||
|
// Only route templates enter labels. Raw URLs, IDs, phone numbers and query strings would create unbounded time series.
|
||||||
|
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
||||||
|
const labels = [method.toUpperCase(), normalizedRoute, String(statusCode)];
|
||||||
|
const key = labels.join('\u0000');
|
||||||
|
const metric = this.http.get(key) ?? { count: 0, durationSum: 0, buckets: HTTP_DURATION_BUCKETS.map(() => 0) };
|
||||||
|
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
|
||||||
|
metric.count += 1;
|
||||||
|
metric.durationSum += durationSeconds;
|
||||||
|
HTTP_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||||
|
if (durationSeconds <= bucket) metric.buckets[index] += 1;
|
||||||
|
});
|
||||||
|
this.http.set(key, metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
beginCmppInboundStage() {
|
||||||
|
return process.hrtime.bigint();
|
||||||
|
}
|
||||||
|
|
||||||
|
finishCmppInboundStage(startedAt: bigint, stage: CmppInboundStage, result: CmppInboundStageResult) {
|
||||||
|
const key = `${stage}\u0000${result}`;
|
||||||
|
const metric = this.cmppInbound.get(key) ?? {
|
||||||
|
count: 0,
|
||||||
|
durationSum: 0,
|
||||||
|
buckets: CMPP_INBOUND_DURATION_BUCKETS.map(() => 0),
|
||||||
|
};
|
||||||
|
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
|
||||||
|
metric.count += 1;
|
||||||
|
metric.durationSum += durationSeconds;
|
||||||
|
CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||||
|
if (durationSeconds <= bucket) metric.buckets[index] += 1;
|
||||||
|
});
|
||||||
|
this.cmppInbound.set(key, metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
beginSendWorkerStage() {
|
||||||
|
return process.hrtime.bigint();
|
||||||
|
}
|
||||||
|
|
||||||
|
finishSendWorkerStage(startedAt: bigint, stage: SendWorkerStage, result: SendWorkerStageResult) {
|
||||||
|
const key = `${stage}\u0000${result}`;
|
||||||
|
const metric = this.sendWorkerStages.get(key) ?? {
|
||||||
|
count: 0,
|
||||||
|
durationSum: 0,
|
||||||
|
buckets: SEND_WORKER_DURATION_BUCKETS.map(() => 0),
|
||||||
|
};
|
||||||
|
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
|
||||||
|
metric.count += 1;
|
||||||
|
metric.durationSum += durationSeconds;
|
||||||
|
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||||
|
if (durationSeconds <= bucket) metric.buckets[index] += 1;
|
||||||
|
});
|
||||||
|
this.sendWorkerStages.set(key, metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSendWorkerSlots(configured: number, inFlight: number) {
|
||||||
|
this.sendWorkerConfiguredSlots = Math.max(0, configured);
|
||||||
|
this.sendWorkerInFlightSlots = Math.max(0, inFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSendWorkerQueueJobs(state: SendWorkerQueueState, count: number) {
|
||||||
|
this.sendWorkerQueueJobs.set(state, Math.max(0, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSendWorkerResult(result: 'completed' | 'failed' | 'skipped') {
|
||||||
|
this.sendWorkerResults.set(result, (this.sendWorkerResults.get(result) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSendWorkerDatabasePool(state: 'max' | 'total' | 'idle' | 'waiting', count: number) {
|
||||||
|
this.sendWorkerDatabasePool.set(state, Math.max(0, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInboundWorkflowState(pending: number, processing: number, oldestPendingAgeSeconds: number) {
|
||||||
|
this.inboundWorkflowPending = Math.max(0, pending);
|
||||||
|
this.inboundWorkflowProcessing = Math.max(0, processing);
|
||||||
|
this.inboundWorkflowOldestPendingAgeSeconds = Math.max(0, oldestPendingAgeSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInboundWorkflowSlots(configured: number, inFlight: number) {
|
||||||
|
this.inboundWorkflowConfiguredSlots = Math.max(0, configured);
|
||||||
|
this.inboundWorkflowInFlightSlots = Math.max(0, inFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordInboundWorkflowResult(result: 'completed' | 'retry') {
|
||||||
|
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() {
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
const uptime = Number(process.hrtime.bigint() - this.startedAt) / 1_000_000_000;
|
||||||
|
const lines = [
|
||||||
|
'# HELP cmpp_api_process_uptime_seconds API process uptime.',
|
||||||
|
'# TYPE cmpp_api_process_uptime_seconds gauge',
|
||||||
|
metricLine('cmpp_api_process_uptime_seconds', uptime),
|
||||||
|
'# HELP cmpp_api_process_resident_memory_bytes API resident memory.',
|
||||||
|
'# TYPE cmpp_api_process_resident_memory_bytes gauge',
|
||||||
|
metricLine('cmpp_api_process_resident_memory_bytes', memory.rss),
|
||||||
|
'# HELP cmpp_api_nodejs_heap_used_bytes Node.js heap currently used.',
|
||||||
|
'# TYPE cmpp_api_nodejs_heap_used_bytes gauge',
|
||||||
|
metricLine('cmpp_api_nodejs_heap_used_bytes', memory.heapUsed),
|
||||||
|
'# HELP cmpp_api_nodejs_heap_total_bytes Node.js allocated heap.',
|
||||||
|
'# TYPE cmpp_api_nodejs_heap_total_bytes gauge',
|
||||||
|
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.',
|
||||||
|
'# 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,
|
||||||
|
),
|
||||||
|
'# HELP cmpp_api_http_requests_in_flight Current API requests in flight.',
|
||||||
|
'# TYPE cmpp_api_http_requests_in_flight gauge',
|
||||||
|
metricLine('cmpp_api_http_requests_in_flight', this.inFlight),
|
||||||
|
'# HELP cmpp_api_http_requests_total API requests grouped by bounded route templates.',
|
||||||
|
'# TYPE cmpp_api_http_requests_total counter',
|
||||||
|
'# HELP cmpp_api_http_request_duration_seconds API request duration.',
|
||||||
|
'# TYPE cmpp_api_http_request_duration_seconds histogram',
|
||||||
|
'# HELP cmpp_api_cmpp_inbound_stage_duration_seconds CMPP inbound processing duration by bounded stage and result.',
|
||||||
|
'# TYPE cmpp_api_cmpp_inbound_stage_duration_seconds histogram',
|
||||||
|
'# HELP cmpp_worker_send_stage_duration_seconds Send worker processing duration by bounded stage and result.',
|
||||||
|
'# TYPE cmpp_worker_send_stage_duration_seconds histogram',
|
||||||
|
'# HELP cmpp_worker_send_queue_jobs BullMQ send jobs by queue state.',
|
||||||
|
'# TYPE cmpp_worker_send_queue_jobs gauge',
|
||||||
|
'# HELP cmpp_worker_send_slots Send worker concurrency slots by state.',
|
||||||
|
'# TYPE cmpp_worker_send_slots gauge',
|
||||||
|
metricLine('cmpp_worker_send_slots', this.sendWorkerConfiguredSlots, { state: 'configured' }),
|
||||||
|
metricLine('cmpp_worker_send_slots', this.sendWorkerInFlightSlots, { state: 'in_flight' }),
|
||||||
|
'# HELP cmpp_worker_send_jobs_total Send worker processing outcomes.',
|
||||||
|
'# TYPE cmpp_worker_send_jobs_total counter',
|
||||||
|
'# HELP cmpp_worker_database_pool_connections Worker PostgreSQL client pool slots by state.',
|
||||||
|
'# TYPE cmpp_worker_database_pool_connections gauge',
|
||||||
|
'# HELP cmpp_worker_inbound_workflow_items Current durable CMPP inbound workflow items by state.',
|
||||||
|
'# TYPE cmpp_worker_inbound_workflow_items gauge',
|
||||||
|
metricLine('cmpp_worker_inbound_workflow_items', this.inboundWorkflowPending, { state: 'pending' }),
|
||||||
|
metricLine('cmpp_worker_inbound_workflow_items', this.inboundWorkflowProcessing, { state: 'processing' }),
|
||||||
|
'# HELP cmpp_worker_inbound_workflow_slots Durable workflow worker slots by state.',
|
||||||
|
'# TYPE cmpp_worker_inbound_workflow_slots gauge',
|
||||||
|
metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowConfiguredSlots, { state: 'configured' }),
|
||||||
|
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.',
|
||||||
|
'# TYPE cmpp_worker_inbound_workflow_oldest_pending_age_seconds gauge',
|
||||||
|
metricLine(
|
||||||
|
'cmpp_worker_inbound_workflow_oldest_pending_age_seconds',
|
||||||
|
this.inboundWorkflowOldestPendingAgeSeconds,
|
||||||
|
),
|
||||||
|
'# HELP cmpp_worker_inbound_workflow_results_total Durable workflow processing outcomes.',
|
||||||
|
'# 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) {
|
||||||
|
const [method, route, status] = key.split('\u0000');
|
||||||
|
const labels = { method, route, status };
|
||||||
|
lines.push(metricLine('cmpp_api_http_requests_total', metric.count, labels));
|
||||||
|
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.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_count', metric.count, labels));
|
||||||
|
}
|
||||||
|
for (const [key, metric] of this.cmppInbound) {
|
||||||
|
const [stage, result] = key.split('\u0000');
|
||||||
|
const labels = { stage, result };
|
||||||
|
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.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_count', metric.count, labels));
|
||||||
|
}
|
||||||
|
for (const [key, metric] of this.sendWorkerStages) {
|
||||||
|
const [stage, result] = key.split('\u0000');
|
||||||
|
const labels = { stage, result };
|
||||||
|
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.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_count', metric.count, labels));
|
||||||
|
}
|
||||||
|
for (const [state, count] of this.sendWorkerQueueJobs) {
|
||||||
|
lines.push(metricLine('cmpp_worker_send_queue_jobs', count, { state }));
|
||||||
|
}
|
||||||
|
for (const [result, count] of this.sendWorkerResults) {
|
||||||
|
lines.push(metricLine('cmpp_worker_send_jobs_total', count, { result }));
|
||||||
|
}
|
||||||
|
for (const [state, count] of this.sendWorkerDatabasePool) {
|
||||||
|
lines.push(metricLine('cmpp_worker_database_pool_connections', count, { state }));
|
||||||
|
}
|
||||||
|
for (const [result, count] of this.inboundWorkflowResults) {
|
||||||
|
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();
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
this.eventLoopDelay.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +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 { TenantId } from '../common/tenant-id.decorator';
|
import { CurrentTenantId } from '../auth/current-tenant-id.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, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); }
|
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
|
||||||
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() 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 }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); }
|
}
|
||||||
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
|
@Get('credentials') listCredentials(
|
||||||
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @TenantId() 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 }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
|
@CurrentTenantId() tenantId: string,
|
||||||
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, tenantId); }
|
) {
|
||||||
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @TenantId() 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, @TenantId() 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import IORedis from 'ioredis';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { decryptSecret } from './open-api.crypto';
|
import { decryptSecret } from './open-api.crypto';
|
||||||
import type { OpenApiRequestLike } from './open-api.types';
|
import type { OpenApiRequestLike } from './open-api.types';
|
||||||
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||||
private redis?: IORedis;
|
private redis?: IORedis;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
||||||
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
const nonce = header(request, 'x-nonce');
|
const nonce = header(request, 'x-nonce');
|
||||||
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
||||||
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, undefined, 'AUTH_HEADERS_MISSING');
|
||||||
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
||||||
}
|
}
|
||||||
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, accessKey, 'NONCE_INVALID');
|
||||||
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
||||||
}
|
}
|
||||||
const credential = await this.prisma.httpApiCredential.findUnique({
|
const credential = await this.prisma.httpApiCredential.findUnique({
|
||||||
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
||||||
});
|
});
|
||||||
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
||||||
|
await this.recordFailure('http_invalid_api_key', request, accessKey, 'CREDENTIAL_INVALID');
|
||||||
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
||||||
}
|
}
|
||||||
const config = credential.application.httpConfig;
|
const config = credential.application.httpConfig;
|
||||||
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const timestamp = Number(timestampText);
|
const timestamp = Number(timestampText);
|
||||||
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
|
||||||
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
||||||
}
|
}
|
||||||
const sourceIp = requestIp(request);
|
const sourceIp = requestIp(request);
|
||||||
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||||
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
||||||
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
|
||||||
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
||||||
}
|
}
|
||||||
const redis = this.getRedis();
|
const redis = this.getRedis();
|
||||||
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
||||||
if (nonceAccepted !== 'OK') {
|
if (nonceAccepted !== 'OK') {
|
||||||
|
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
|
||||||
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
||||||
}
|
}
|
||||||
const second = Math.floor(Date.now() / 1000);
|
const second = Math.floor(Date.now() / 1000);
|
||||||
@@ -81,6 +88,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
||||||
return this.redis;
|
return this.redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
|
||||||
|
const sourceIp = requestIp(request);
|
||||||
|
if (!sourceIp) return;
|
||||||
|
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
|
||||||
|
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function header(request: OpenApiRequestLike, name: string) {
|
function header(request: OpenApiRequestLike, name: string) {
|
||||||
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
|
|||||||
|
|
||||||
function requestIp(request: OpenApiRequestLike) {
|
function requestIp(request: OpenApiRequestLike) {
|
||||||
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
||||||
return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, '');
|
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
|
||||||
|
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||||
|
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipMatches(ip: string, rule: string) {
|
function ipMatches(ip: string, rule: string) {
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
|
|||||||
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
||||||
import { OpenApiController } from './open-api.controller';
|
import { OpenApiController } from './open-api.controller';
|
||||||
import { OpenApiService } from './open-api.service';
|
import { OpenApiService } from './open-api.service';
|
||||||
|
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
|
||||||
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
||||||
providers: [OpenApiService, OpenApiAuthGuard],
|
providers: [OpenApiService, OpenApiAuthGuard],
|
||||||
exports: [OpenApiService],
|
exports: [OpenApiService],
|
||||||
|
|||||||
@@ -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,38 +17,104 @@ 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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only permits a plain HTTP public origin when an isolated test environment explicitly opts in', async () => {
|
||||||
|
const previousOrigin = process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||||
|
const previousAllowInsecure = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
|
||||||
|
process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/';
|
||||||
|
delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
|
||||||
|
const prisma = {
|
||||||
|
smsApplication: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const service = new OpenApiService(prisma as never, {} as never);
|
||||||
|
await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN');
|
||||||
|
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' }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||||
|
else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin;
|
||||||
|
if (previousAllowInsecure === undefined) delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
|
||||||
|
else process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = previousAllowInsecure;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -58,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(
|
||||||
where: { eventId: 'evt_receipt_record-1' },
|
expect.objectContaining({
|
||||||
}));
|
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(
|
||||||
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
|
expect.objectContaining({
|
||||||
}));
|
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)),
|
||||||
@@ -107,19 +235,21 @@ 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(
|
||||||
create: expect.objectContaining({
|
expect.objectContaining({
|
||||||
enabled: true,
|
create: expect.objectContaining({
|
||||||
sendEnabled: true,
|
enabled: true,
|
||||||
messageQueryEnabled: true,
|
sendEnabled: true,
|
||||||
receiptWebhookEnabled: true,
|
messageQueryEnabled: true,
|
||||||
uplinkWebhookEnabled: true,
|
receiptWebhookEnabled: true,
|
||||||
uplinkQueryEnabled: true,
|
uplinkWebhookEnabled: true,
|
||||||
credentialSelfServiceEnabled: true,
|
uplinkQueryEnabled: true,
|
||||||
receiptDeliveryMode: 'both',
|
credentialSelfServiceEnabled: true,
|
||||||
uplinkDeliveryMode: 'both',
|
receiptDeliveryMode: '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 () => {
|
||||||
@@ -139,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() {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -55,7 +68,14 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
|||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
const connection = bullmqConnection();
|
const connection = bullmqConnection();
|
||||||
this.queue = new Queue(WEBHOOK_QUEUE, { connection });
|
this.queue = new Queue(WEBHOOK_QUEUE, { connection });
|
||||||
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 });
|
// The isolated Gateway callback process only enqueues customer callbacks.
|
||||||
|
// Delivery remains owned by the main API process so callback DB/HTTP capacity
|
||||||
|
// cannot be consumed by slow customer webhook endpoints.
|
||||||
|
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
|
||||||
|
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
|
||||||
|
connection,
|
||||||
|
concurrency: 10,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
@@ -85,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 };
|
||||||
}
|
}
|
||||||
@@ -94,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() },
|
||||||
@@ -137,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 {
|
||||||
@@ -158,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;
|
||||||
@@ -217,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',
|
||||||
@@ -241,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',
|
||||||
@@ -264,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({
|
||||||
@@ -289,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,
|
||||||
});
|
});
|
||||||
@@ -302,29 +496,55 @@ 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 =
|
||||||
? `evt_receipt_${data.messageRecordId}`
|
data.eventType === 'receipt' && data.messageRecordId
|
||||||
: data.eventType === 'uplink' && data.uplinkMessageId
|
? `evt_receipt_${data.messageRecordId}`
|
||||||
? `evt_uplink_${data.uplinkMessageId}`
|
: data.eventType === 'uplink' && data.uplinkMessageId
|
||||||
: `evt_${randomUUID()}`;
|
? `evt_uplink_${data.uplinkMessageId}`
|
||||||
|
: `evt_${randomUUID()}`;
|
||||||
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 } },
|
||||||
@@ -332,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(
|
||||||
'content-type': 'application/json',
|
delivery.endpoint.url,
|
||||||
'x-event-id': delivery.event.eventId,
|
body,
|
||||||
'x-event-type': delivery.event.eventType,
|
{
|
||||||
'x-timestamp': timestamp,
|
'content-type': 'application/json',
|
||||||
'x-signature': `sha256=${signature}`,
|
'x-event-id': delivery.event.eventId,
|
||||||
}, config.webhookTimeoutSeconds * 1000, config.requireHttps);
|
'x-event-type': delivery.event.eventType,
|
||||||
|
'x-timestamp': timestamp,
|
||||||
|
'x-signature': `sha256=${signature}`,
|
||||||
|
},
|
||||||
|
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',
|
||||||
@@ -399,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;
|
||||||
}
|
}
|
||||||
@@ -424,10 +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);
|
||||||
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
const insecureHttpExplicitlyAllowed =
|
||||||
|
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 when deployment config is wrong.
|
// publishing an insecure or path-dependent endpoint unless an isolated test environment
|
||||||
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址');
|
// has explicitly opted into plain HTTP.
|
||||||
|
throw new Error(
|
||||||
|
'HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return url.origin;
|
return url.origin;
|
||||||
}
|
}
|
||||||
@@ -435,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(
|
||||||
@@ -452,15 +809,17 @@ 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,
|
? {
|
||||||
messageQueryEnabled: true,
|
sendEnabled: true,
|
||||||
receiptWebhookEnabled: true,
|
messageQueryEnabled: true,
|
||||||
uplinkWebhookEnabled: true,
|
receiptWebhookEnabled: true,
|
||||||
uplinkQueryEnabled: true,
|
uplinkWebhookEnabled: true,
|
||||||
credentialSelfServiceEnabled: true,
|
uplinkQueryEnabled: true,
|
||||||
...input,
|
credentialSelfServiceEnabled: true,
|
||||||
} : 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 {
|
||||||
@@ -490,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 [
|
||||||
const [ip, prefix] = item.split('/');
|
...new Set(
|
||||||
const version = isIP(ip);
|
(values ?? [])
|
||||||
if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
|
.map((item) => item.trim())
|
||||||
if (prefix !== undefined) {
|
.filter(Boolean)
|
||||||
const bits = Number(prefix);
|
.map((item) => {
|
||||||
const max = version === 4 ? 32 : 128;
|
const [ip, prefix] = item.split('/');
|
||||||
if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException(`CIDR格式非法:${item}`);
|
const version = isIP(ip);
|
||||||
}
|
if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
|
||||||
return item;
|
if (prefix !== undefined) {
|
||||||
}))];
|
const bits = Number(prefix);
|
||||||
|
const max = version === 4 ? 32 : 128;
|
||||||
|
if (!Number.isInteger(bits) || bits < 0 || bits > max)
|
||||||
|
throw new BadRequestException(`CIDR格式非法:${item}`);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateWebhookUrl(value: string, requireHttps: boolean) {
|
async function validateWebhookUrl(value: string, requireHttps: boolean) {
|
||||||
@@ -514,37 +882,54 @@ 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(
|
||||||
method: 'POST',
|
target.url,
|
||||||
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
|
{
|
||||||
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
|
method: 'POST',
|
||||||
}, (response) => {
|
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
|
||||||
const chunks: Buffer[] = [];
|
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
|
||||||
let size = 0;
|
},
|
||||||
response.on('data', (chunk: Buffer) => {
|
(response) => {
|
||||||
if (size < 1000) {
|
const chunks: Buffer[] = [];
|
||||||
const buffer = Buffer.from(chunk);
|
let size = 0;
|
||||||
chunks.push(buffer.subarray(0, 1000 - size));
|
response.on('data', (chunk: Buffer) => {
|
||||||
size += buffer.length;
|
if (size < 1000) {
|
||||||
}
|
const buffer = Buffer.from(chunk);
|
||||||
});
|
chunks.push(buffer.subarray(0, 1000 - size));
|
||||||
response.on('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
size += buffer.length;
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
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);
|
||||||
@@ -553,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 {
|
||||||
@@ -567,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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export class AdminOperationsController {
|
|||||||
response.send(`\uFEFF${exported.content}`);
|
response.send(`\uFEFF${exported.content}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('messages/:id')
|
||||||
|
getMessage(@Param('id') id: string) {
|
||||||
|
return this.operations.getMessage(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('message-segment-audits')
|
@Get('message-segment-audits')
|
||||||
messageSegmentAudits(
|
messageSegmentAudits(
|
||||||
@Query('messageId') messageId?: string,
|
@Query('messageId') messageId?: string,
|
||||||
@@ -225,6 +230,14 @@ export class AdminOperationsController {
|
|||||||
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
|
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gateway-submit-dead-letters/:id/resolve')
|
||||||
|
resolveGatewaySubmitDeadLetter(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('receipt-anomalies')
|
@Get('receipt-anomalies')
|
||||||
receiptAnomalies(
|
receiptAnomalies(
|
||||||
@Query('tenantId') tenantId?: string,
|
@Query('tenantId') tenantId?: string,
|
||||||
@@ -354,6 +367,57 @@ export class AdminOperationsController {
|
|||||||
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
|
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
|
||||||
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
|
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('downstream-requeue-tasks/preview')
|
||||||
|
previewDownstreamRequeueTask(
|
||||||
|
@Body() body: { filter?: Record<string, string | undefined> },
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.previewDownstreamRequeueTask(body.filter ?? {}, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('downstream-requeue-tasks')
|
||||||
|
createDownstreamRequeueTask(
|
||||||
|
@Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.createDownstreamRequeueTask({
|
||||||
|
previewToken: body.previewToken ?? '',
|
||||||
|
reason: body.reason ?? '',
|
||||||
|
ratePerSecond: body.ratePerSecond,
|
||||||
|
consecutiveFailureLimit: body.consecutiveFailureLimit,
|
||||||
|
}, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('downstream-requeue-tasks')
|
||||||
|
listDownstreamRequeueTasks(@Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
|
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('downstream-requeue-tasks/:id')
|
||||||
|
getDownstreamRequeueTask(@Param('id') id: string) {
|
||||||
|
return this.sendChain.getDownstreamRequeueTask(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('downstream-requeue-tasks/:id/items')
|
||||||
|
listDownstreamRequeueTaskItems(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.listDownstreamRequeueTaskItems(id, { status, keyword, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('downstream-requeue-tasks/:id/:action')
|
||||||
|
changeDownstreamRequeueTaskStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('action') action: 'pause' | 'resume' | 'terminate',
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.changeDownstreamRequeueTaskStatus(id, action, operatorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('admin-system-logs')
|
@ApiTags('admin-system-logs')
|
||||||
@@ -372,10 +436,12 @@ export class AdminSystemLogsController {
|
|||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('range') range?: string,
|
@Query('range') range?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, page: Number(page), pageSize: Number(pageSize) });
|
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -386,14 +452,16 @@ export class AdminSystemLogsController {
|
|||||||
@Query('level') level?: string,
|
@Query('level') level?: string,
|
||||||
@Query('module') module?: string,
|
@Query('module') module?: string,
|
||||||
@Query('range') range?: string,
|
@Query('range') range?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('exports')
|
@Post('exports')
|
||||||
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string }) {
|
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) {
|
||||||
return this.operations.exportSystemLogs(body);
|
return this.operations.exportSystemLogs(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user