138 Commits
Author SHA1 Message Date
hectorzhao 458ddd97df refactor: 完成全局CSS模块化治理
CSS quality / css-quality (push) Has been cancelled
2026-09-05 00:45:05 +08:00
hectorzhao 1a7a7245a6 fix: 修复报备导入映射与审核跳转 2026-09-05 00:20:33 +08:00
hectorzhao 65082959c0 docs: 记录WPS异步解析测试发布 2026-09-04 23:25:57 +08:00
hectorzhao 5925cf493b feat: 异步解析大文件WPS报备资料 2026-09-04 22:51:07 +08:00
hectorzhao aaf96db2d0 docs: finalize import review delivery status 2026-09-04 20:40:39 +08:00
hectorzhao 857171ce9d docs: record import review flow validation 2026-09-04 20:40:03 +08:00
hectorzhao 41962e7a6e fix: approve imported signature identity fields 2026-09-04 20:24:08 +08:00
hectorzhao 0ec386e674 fix: keep report field pool cards fixed height 2026-09-04 18:31:36 +08:00
hectorzhao c88d172af6 docs: record WPS test deployment evidence 2026-09-04 18:15:25 +08:00
hectorzhao bb435fb0ac docs: record WPS test deployment status 2026-09-04 17:15:54 +08:00
hectorzhao fb39c8b606 feat: support WPS report material workbooks 2026-09-04 17:10:04 +08:00
hectorzhao 48d0363920 feat: enhance operations dashboard and reporting controls 2026-09-04 16:26:22 +08:00
hectorzhao bc18c7ff12 docs: record preproduction report workbench release 2026-09-03 20:59:21 +08:00
hectorzhao dada0d978b docs: record report status page deployment 2026-09-03 17:15:57 +08:00
hectorzhao fe3c6e5b58 fix: compact report status record table 2026-09-03 16:48:56 +08:00
hectorzhao 7b8a613afa docs: record report material test deployment 2026-09-03 13:15:42 +08:00
hectorzhao 4ff6ed0786 feat: refine report material workflow states 2026-09-03 12:50:33 +08:00
hectorzhao 55915ed826 fix: refresh pending report detail summary 2026-09-03 12:33:57 +08:00
hectorzhao b0016cfd8d docs: record report batch test deployment 2026-09-03 11:21:34 +08:00
hectorzhao dc201bf92e feat: redesign report batch workflow 2026-09-03 11:04:32 +08:00
hectorzhao a50aafb1ec fix: align report workbench page actions 2026-09-03 10:09:12 +08:00
hectorzhao 19bcca812b docs: record report field batch verification 2026-09-03 09:57:49 +08:00
hectorzhao 068b8047dd fix: keep absent brief SMS content empty 2026-09-03 09:37:24 +08:00
hectorzhao fe5b25a7e4 docs: record report brief test deployment 2026-09-02 19:08:55 +08:00
hectorzhao 7cb5dd376e feat: add channel report batch briefs 2026-09-02 18:29:05 +08:00
hectorzhao 2dff1be750 docs: record high-frequency test deployment 2026-09-02 17:10:48 +08:00
hectorzhao ad89e8fed7 feat: optimize signature workflows and high-frequency queries 2026-09-02 15:45:48 +08:00
hectorzhao 9b8196ecab feat: build reporting workbench workflow 2026-09-02 14:30:15 +08:00
hectorzhao 53d3668306 docs: record test enterprise signature deployment 2026-09-02 12:10:08 +08:00
hectorzhao cd824999f3 fix: restore carrier reporting status summaries 2026-09-02 10:27:29 +08:00
hectorzhao 9e34757d6f feat: densify enterprise signature management 2026-09-02 10:04:58 +08:00
hectorzhao 1f2dfb5caf fix: deduplicate filesystem mounts in monitoring 2026-08-31 23:39:24 +08:00
hectorzhao 12668cee87 docs: record preproduction release after data migration 2026-08-31 23:15:50 +08:00
hectorzhao 9a15d28da5 fix: preserve migrated storage guards during deployment 2026-08-31 23:01:35 +08:00
hectorzhao fd373d9708 docs: record six fixes test deployment verification 2026-08-31 12:31:05 +08:00
hectorzhao b0deef5e6e fix: align reporting fields queries and disk monitoring 2026-08-31 12:19:39 +08:00
hectorzhao 119d57772e docs: record preproduction split architecture upgrade 2026-08-30 22:02:42 +08:00
hectorzhao 1a5063a5d9 fix: ack protocol logs after complete flush 2026-08-30 21:54:33 +08:00
hectorzhao 67774509d9 docs: record preproduction drainage release 2026-08-30 21:19:55 +08:00
hectorzhao 4d526b83ab docs: record drainage UI test deployment 2026-08-28 18:04:19 +08:00
hectorzhao 5328bb09bf fix: accept scheme-less drainage URLs 2026-08-28 17:58:45 +08:00
hectorzhao c68ac7a3db feat: unify drainage targets and carrier status UI 2026-08-28 17:24:01 +08:00
hectorzhao 9ac41e9308 test: close remaining quality verification gaps 2026-08-28 16:27:00 +08:00
hectorzhao 3834d67a30 docs: record quality release evidence 2026-08-28 15:00:01 +08:00
hectorzhao 226527f1ce fix: align npm locks with test runtime 2026-08-28 14:41:51 +08:00
hectorzhao ad27acad7e refactor: strengthen client boundaries and quality gates 2026-08-28 14:26:58 +08:00
hectorzhao 3af145abe5 docs: record code quality remediation rollout 2026-08-28 12:23:17 +08:00
hectorzhao fc4a6a7afc fix: enforce strong hashes in maintenance tools 2026-08-28 12:00:55 +08:00
hectorzhao d85ff85999 chore: add rollback-safe password rollout window 2026-08-28 11:47:08 +08:00
hectorzhao 2744690f9f fix: harden tenant auth and quality gates 2026-08-28 11:44:16 +08:00
hectorzhao c3bf8af3e6 docs: record dashboard direct-text deployment 2026-08-28 10:35:13 +08:00
hectorzhao 0b84370270 fix: remove dashboard amount wrappers 2026-08-28 10:27:31 +08:00
hectorzhao 171c7d38e8 docs: record dashboard metrics deployment 2026-08-28 10:20:38 +08:00
hectorzhao a70e9e2c07 fix: align client dashboard metrics 2026-08-28 10:14:31 +08:00
hectorzhao 4d4c1f39d4 docs: record client billing test deployment 2026-08-28 09:44:57 +08:00
hectorzhao 5bd347e010 fix: normalize client billing presentation 2026-08-28 09:37:59 +08:00
hectorzhao 088d7aaacb docs: record amount and log-filter deployment 2026-08-27 20:11:09 +08:00
hectorzhao 33fa3709d9 fix: clarify client amounts and log date filters 2026-08-27 19:50:12 +08:00
hectorzhao ec721bf95a docs: record test deployment verification 2026-08-27 18:48:04 +08:00
hectorzhao 070a951e7c fix: allow explicit HTTP origin in test environments 2026-08-27 18:32:21 +08:00
hectorzhao 01cffa4758 feat: refine client status and review views 2026-08-27 17:32:05 +08:00
hectorzhao d6edb88b76 fix: recover gateway callbacks and client send flows 2026-08-27 14:30:02 +08:00
hectorzhao a280b4bb22 feat: densify sms records and improve uplink matching 2026-08-27 10:15:08 +08:00
hectorzhao 898471423f fix: hide deleted signatures and fit uplink table 2026-08-26 16:10:46 +08:00
hectorzhao 444c65288c docs: record preproduction monitoring repair 2026-08-26 14:44:25 +08:00
hectorzhao 5234812990 fix: escape node exporter unit filter 2026-08-26 14:41:52 +08:00
hectorzhao c769b60aaa fix: allow verified offline monitoring releases 2026-08-26 14:33:44 +08:00
hectorzhao b0bdb608c1 fix: support preproduction monitoring on arm64 2026-08-26 14:25:31 +08:00
hectorzhao b7ae1aa41a fix: restore channels after gateway restart 2026-08-26 14:04:44 +08:00
hectorzhao 8170f727a3 fix: default gateway callback batching off 2026-08-26 13:59:21 +08:00
hectorzhao 4a17df78b8 fix: audit submissions disabled after bind 2026-08-25 19:54:41 +08:00
hectorzhao 2a181777ad docs: record test environment send guard regressions 2026-08-25 19:38:17 +08:00
hectorzhao b1e297245c docs: record tenant batching capacity results 2026-08-25 18:10:37 +08:00
hectorzhao b5005f21d5 fix: make downstream connection events idempotent 2026-08-25 17:53:05 +08:00
hectorzhao 394949f9f6 fix: align api keepalive with gateway pool 2026-08-25 17:35:18 +08:00
hectorzhao 76e7c8c401 perf: batch inbound workflows by tenant 2026-08-25 17:11:55 +08:00
hectorzhao d3ceeb1e16 docs: diagnose paid single-tenant throughput ceiling 2026-08-25 16:38:59 +08:00
hectorzhao 9292352be1 perf: expand gateway capacity and prevent receipt replay 2026-08-25 16:08:30 +08:00
hectorzhao 761c123b65 perf: batch gateway submits and isolate callbacks 2026-08-25 11:39:59 +08:00
hectorzhao c6f11014d6 test: verify CMPP send guards and channel retry 2026-08-21 12:05:47 +08:00
hectorzhao 03f72c87de docs: record CMPP main flow regression 2026-08-21 11:40:26 +08:00
hectorzhao 2b5256d4a1 docs: record phase four paid stress results 2026-08-21 11:18:19 +08:00
hectorzhao 90345fba22 perf: coalesce batch progress refreshes 2026-08-21 11:07:20 +08:00
hectorzhao 3c6f1beed1 perf: collapse short submit result callbacks 2026-08-21 10:46:27 +08:00
hectorzhao fcf6d3e6b4 perf: remove paid result account lock contention 2026-08-21 10:40:28 +08:00
hectorzhao 487b5282a6 perf: batch paid CMPP accounting and weighted routes 2026-08-21 10:25:40 +08:00
hectorzhao 0176aa6952 docs: record CMPP phase three pressure results 2026-08-21 09:56:31 +08:00
hectorzhao 52028b9bbd perf: batch CMPP inbound workflow processing 2026-08-21 09:44:51 +08:00
hectorzhao 57192b7586 docs: record CMPP phase two pressure results 2026-08-20 19:13:46 +08:00
hectorzhao f4560479d3 fix: type CMPP inbox JSON parameters 2026-08-20 19:04:12 +08:00
hectorzhao 99fb346566 perf: merge CMPP inbox validation and persistence 2026-08-20 18:55:09 +08:00
hectorzhao 633e7a7055 docs(cmpp): record phase one capacity ceiling 2026-08-20 18:39:29 +08:00
hectorzhao 229a0b28fd perf(cmpp): reserve transport for inbound submit 2026-08-20 18:23:01 +08:00
hectorzhao 6708f1f7c5 perf(cmpp): isolate inbound transport capacity 2026-08-20 18:09:29 +08:00
hectorzhao 53073461e9 fix(cmpp): honor UTC inbox retry leases 2026-08-20 17:50:00 +08:00
hectorzhao 0b63bcd74e perf(cmpp): add durable inbound fast path 2026-08-20 17:34:45 +08:00
hectorzhao 26ef67fb6a docs(test): record v5 pressure and priority results 2026-08-20 16:26:44 +08:00
hectorzhao 14f993c1f8 perf(cmpp): reduce inbound database round trips 2026-08-20 15:29:45 +08:00
hectorzhao 67b760a599 perf(cmpp): decouple supplier result callbacks 2026-08-20 14:19:42 +08:00
hectorzhao 485af688d2 docs(cmpp): record V3 test pressure results 2026-08-20 12:34:08 +08:00
hectorzhao e9c73333b3 fix(cmpp): preserve inbound window after message registration 2026-08-20 12:08:49 +08:00
hectorzhao 0757a699ff perf(cmpp): process inbound submits within client window 2026-08-20 11:45:16 +08:00
hectorzhao b9a71fe0b9 perf(cmpp): instrument inbound flow and unbatch submit worker 2026-08-20 11:27:35 +08:00
hectorzhao c4f36fc50d docs: record monitoring read deployment 2026-08-16 15:11:52 +08:00
hectorzhao 482f7ac1ae feat: add monitoring alert read state 2026-08-16 15:04:26 +08:00
hectorzhao 79f5d3f215 docs: record monitoring test deployment 2026-08-14 18:05:19 +08:00
hectorzhao 2216d00d51 fix: preserve Prometheus rule group access 2026-08-14 18:02:42 +08:00
hectorzhao 0cd09441da fix: guard configurable monitoring ratios 2026-08-14 17:59:22 +08:00
hectorzhao 6ccc102830 feat: add configurable infrastructure alerts 2026-08-14 17:51:42 +08:00
hectorzhao 1ef4380422 feat: 完善服务监控与下游重投 2026-08-14 17:21:29 +08:00
hectorzhao d30d9ea4d0 feat: add Fail2ban security detection console 2026-08-14 10:58:18 +08:00
hectorzhao b78faa1aa2 feat: add Prometheus system monitoring 2026-08-14 10:10:28 +08:00
hectorzhao 96e475d60d docs: record downstream requeue release 2026-08-13 12:13:41 +08:00
hectorzhao 433b2ee56f feat: harden downstream requeue tasks 2026-08-13 12:05:57 +08:00
hectorzhao 67fee21616 feat: refine operations UI and transport limits 2026-08-13 10:42:59 +08:00
hectorzhao fb02cbcf39 docs: 记录签名质量热修复发布 2026-08-12 17:56:26 +08:00
hectorzhao 4c70978da4 fix: 修复未报备签名聚合查询 2026-08-12 17:53:30 +08:00
hectorzhao 16135e5a3e style: 更新运营商标签配色 2026-08-12 17:22:43 +08:00
hectorzhao 4994841709 feat: 增强下游重投与签名质量检测 2026-08-12 17:05:53 +08:00
hectorzhao 1d8d6701a6 docs: 记录通道界面优化部署 2026-08-12 13:25:26 +08:00
hectorzhao f350bf5ef3 feat: 优化通道运营商与金额展示 2026-08-12 13:16:25 +08:00
hectorzhao dc358798e9 docs: 记录签名热力图预发布结果 2026-08-12 11:57:25 +08:00
hectorzhao 0cd353450a feat: 优化签名热力图与金额展示 2026-08-12 11:41:47 +08:00
hectorzhao e64b5e23fe docs: record legacy signature migration deployment 2026-08-10 22:59:16 +08:00
hectorzhao 2ecb24cf8d feat: remove legacy signature confirmation 2026-08-10 22:40:13 +08:00
hectorzhao 827d8921a8 docs: record signature retirement deployment 2026-08-10 21:06:09 +08:00
hectorzhao 0eb27e4ac0 fix: wait for services during deployment 2026-08-10 21:00:18 +08:00
hectorzhao 55aa054005 feat: add carrier-aware signature retirement alerts 2026-08-10 20:54:05 +08:00
hectorzhao 232d1c22a3 revert: roll back signature quality period trends 2026-08-09 23:24:50 +08:00
hectorzhao e0c8f82bcf docs: record 35de17a2 preproduction deployment 2026-08-09 23:03:02 +08:00
hectorzhao 35de17a2d4 feat: add signature quality period trends 2026-08-09 22:54:32 +08:00
hectorzhao 608662a054 docs: record 78b839f4 preproduction deployment 2026-08-09 21:04:25 +08:00
hectorzhao 78b839f468 feat: refine template deletion and channel group filters 2026-08-09 20:54:52 +08:00
hectorzhao 482d332f49 docs: record 7804f64c preproduction deployment 2026-08-09 19:17:04 +08:00
hectorzhao 7804f64ced feat: support governed cascade deletion 2026-08-09 19:08:38 +08:00
hectorzhao 6add563ee8 docs: record preproduction deployment for 4724b9db 2026-08-09 15:11:15 +08:00
hectorzhao 4724b9db6a feat: improve operations diagnostics and channel management 2026-08-09 14:27:19 +08:00
hectorzhao 44352aeb2f docs: record RealeseV2.3 preproduction deployment 2026-08-06 11:32:32 +08:00
494 changed files with 67440 additions and 14666 deletions
+26
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
name: CSS quality
on:
pull_request:
push:
branches: [main]
jobs:
css-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- name: Verify changed formatting and CSS ownership
env:
QUALITY_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || format('{0}^', github.sha) }}
run: npm run format:check && npm run style:check && npm run css:verify
+7
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
api/dist
api/vendor
package-lock.json
docs
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}
+55
View File
@@ -0,0 +1,55 @@
{
"extends": ["stylelint-config-standard"],
"ignoreFiles": ["dist/**/*.css"],
"rules": {
"alpha-value-notation": null,
"color-function-notation": null,
"declaration-block-single-line-max-declarations": null,
"media-feature-range-notation": null,
"no-descending-specificity": null,
"selector-class-pattern": null
},
"overrides": [
{
"files": [
"src/styles/*.css",
"src/styles/domains/*.css",
"src/apps/admin/channels/AdminChannelsPage.css",
"src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
"src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
"src/apps/admin/sms-records/AdminSmsRecordsPage.css",
"src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
"src/apps/client/ClientUsersPage.css"
],
"rules": {
"at-rule-empty-line-before": null,
"block-no-empty": null,
"color-function-alias-notation": null,
"color-hex-length": null,
"comment-empty-line-before": null,
"custom-property-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"declaration-empty-line-before": null,
"declaration-property-value-keyword-no-deprecated": null,
"font-family-name-quotes": null,
"function-url-quotes": null,
"import-notation": null,
"keyframes-name-pattern": null,
"length-zero-no-unit": null,
"no-duplicate-selectors": null,
"number-max-precision": null,
"property-no-vendor-prefix": null,
"rule-empty-line-before": null,
"selector-attribute-quotes": null,
"selector-id-pattern": null,
"selector-not-notation": null,
"selector-pseudo-class-no-unknown": null,
"selector-type-no-unknown": null,
"shorthand-property-no-redundant-values": null,
"value-keyword-case": null,
"value-no-vendor-prefix": null
}
}
]
}
+44
View File
@@ -0,0 +1,44 @@
# CMPP 平台仓库开发约束
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
## 开始工作前
- 先检查当前分支、最近提交、远端差异以及 staged、unstaged、untracked 文件。
- 保留其他会话和用户已有修改;禁止未经授权执行 reset、清理、覆盖、切分支、推送或部署。
- 功能事实必须以当前代码、真实 API、PostgreSQL、Redis、MinIO、Gateway 和目标环境为准,交接记录只作为线索。
- 不得发送、补发、重投或重新入队短信;不得擅自修改余额、通道或客户配置。
## CSS 任务强制阅读
凡新增、修改、迁移或审查 CSS,必须先完整阅读:
1. `docs/css-development-guidelines.md`
2. 涉及存量拆分时,再阅读 `docs/global-css-modularization-plan-20260904.md`
## CSS 所有权
- 设计变量归 `src/styles/tokens.css`
- 浏览器重置和标签基础规则归 `src/styles/reset.css`
- AppShell、侧栏、顶栏、导航和页面骨架归 `src/styles/shell.css`
- 跨业务复用且语义一致的公共组件样式归 `src/styles/components/`;迁移完成前的既有公共规则可继续位于明确登记的共享样式文件。
- 同一业务域多个页面共享的样式归业务域目录,并由业务域根类名限定。
- 只服务一个页面、弹窗或局部组件的样式必须放在其 TSX 同目录或对应业务目录,由所有者直接 import。
- `src/styles/global.css` 是存量兼容文件:只允许迁出、删除和保持视觉等价所必需的修正,不得新增页面或业务选择器。例外必须在变更说明中写明原因、影响范围和清理条件。
## CSS 禁止事项
- 禁止把新页面、新弹窗或新业务组件的选择器写入 `global.css`
- 禁止使用无业务根节点限定的标签选择器或短通用类名影响其他页面。
- 禁止通过新增 `!important`、提高 specificity 或依赖偶然加载顺序掩盖归属和级联问题;第三方不可控样式等例外必须记录原因、影响范围和移除条件。
- 禁止把公共组件样式建立在业务页面样式之上。
- 禁止对 `global.css` 执行整文件格式化,或在迁移提交中夹带视觉改版和无关重排。
- 禁止仅凭类名前缀批量移动选择器;必须同时核对 TSX 引用、其他 CSS 定义、媒体查询和真实 DOM。
## CSS 变更验收
- 提交前检查 staged diff,确保只包含本轮目标文件或精确 hunk。
- 运行与范围匹配的定向测试、前端全量测试、TypeScript、生产构建、仓库当前提供的格式和样式门禁以及 `git diff --check`;自动门禁尚未覆盖的项目按日常规范人工检查并记录。
- 页面级变更至少检查 1600×1000、1366×768 和 390×844;覆盖首次进入、刷新、跨路由切换及相关交互状态。
- 使用真实 API 和测试环境完成最终功能验收;构建成功、组件测试或隔离截图不能代替真实页面验收。
- 纯 CSS 拆分不得改变计算样式。发现差异时先恢复原级联关系,不得顺手调整设计。
+8
View File
@@ -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,
},
},
}; };
+17
View File
@@ -0,0 +1,17 @@
const base = require('./jest.config.cjs');
module.exports = {
...base,
collectCoverageFrom: [
'src/auth/auth.service.ts',
'src/auth/session.service.ts',
'src/common/bounded-json-object.validator.ts',
'src/files/client-files.dto.ts',
'src/open-api/client-open-api.dto.ts',
'src/operations/client-operations.dto.ts',
'src/users/client-user.dto.ts',
],
coverageThreshold: {
global: { statements: 80, branches: 70, functions: 80, lines: 80 },
},
};
+69 -106
View File
@@ -12,7 +12,7 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28", "@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28", "@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0", "@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0", "@prisma/client": "^7.9.0",
"brace-expansion": "file:vendor/brace-expansion-compat", "brace-expansion": "file:vendor/brace-expansion-compat",
@@ -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
View File
@@ -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"
} }
} }
+7 -3
View File
@@ -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");
@@ -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
View File
@@ -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])
}
+8
View File
@@ -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
View File
@@ -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,
},
}); });
} }
+17
View File
@@ -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;
}
+2 -1
View File
@@ -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,
+126 -17
View File
@@ -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);
});
}); });
+40 -47
View File
@@ -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;
});
+46
View File
@@ -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\$/);
});
});
+87
View File
@@ -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 () => {
+22 -2
View File
@@ -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) {
+122 -6
View File
@@ -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,
);
});
}); });
+123 -2
View File
@@ -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', {
+8 -4
View File
@@ -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 });
} }
} }
+43 -8
View File
@@ -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);
+84 -13
View File
@@ -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,
+1
View File
@@ -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() {
+511 -94
View File
@@ -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);
} }
+3 -1
View File
@@ -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';
+140 -10
View File
@@ -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);
});
});
+22
View File
@@ -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);
});
});
+45 -19
View File
@@ -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 '连接请求';
+167 -26
View File
@@ -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' },
+58 -3
View File
@@ -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);
}
+65
View File
@@ -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);
});
});
+190
View File
@@ -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;
}
+8
View File
@@ -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;
}
+16
View File
@@ -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);
});
});
+31
View File
@@ -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 };
}
+4 -1
View File
@@ -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);
} }
} }
+9
View File
@@ -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 = {
+151 -1
View File
@@ -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() {
+18 -4
View File
@@ -1,8 +1,22 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientFileUploadDto } from './client-files.dto';
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer }; type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void }; type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@@ -14,14 +28,14 @@ export class ClientFilesController {
@Post('upload') @Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
@UsePipes(strictValidationPipe)
upload( upload(
@CurrentSessionUserId() userId: string | undefined, @CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile, @UploadedFile() file: UploadedMultipartFile,
@Body('purpose') purpose: string, @Body() body: ClientFileUploadDto,
@Body('prefix') prefix?: string,
) { ) {
if (!file) throw new BadRequestException('Upload file is required'); if (!file) throw new BadRequestException('Upload file is required');
return this.files.uploadForClient(userId, { purpose, prefix }, file); return this.files.uploadForClient(userId, body, file);
} }
@Get(':id/download') @Get(':id/download')
+28
View File
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientFileUploadDto } from './client-files.dto';
describe('ClientFileUploadDto', () => {
it('accepts bounded client material paths and rejects traversal before storage', async () => {
await expect(
strictValidationPipe.transform(
{ purpose: 'drainage_report_material', prefix: 'drainage-materials/item-1' },
{
type: 'body',
metatype: ClientFileUploadDto,
data: undefined,
},
),
).resolves.toEqual(expect.objectContaining({ purpose: 'drainage_report_material' }));
await expect(
strictValidationPipe.transform(
{ purpose: 'enterprise_certification', prefix: '../admin' },
{
type: 'body',
metatype: ClientFileUploadDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class ClientFileUploadDto {
@IsString()
@IsIn(['enterprise_certification', 'signature_report_material', 'drainage_report_material'])
purpose!: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/)
prefix?: string;
}
+18
View File
@@ -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');
});
});
+31 -10
View File
@@ -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;
} }
+31
View File
@@ -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 {}
+44
View File
@@ -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();
+66
View File
@@ -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);
});
}
+25
View File
@@ -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 });
}
+20
View File
@@ -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 });
});
});
+21
View File
@@ -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
View File
@@ -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();
+24
View File
@@ -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);
}));
}
}
+11
View File
@@ -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 {}
+35
View File
@@ -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();
});
});
+323
View File
@@ -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();
}
}
+56 -11
View File
@@ -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);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
export enum ClientWebhookEventType {
Receipt = 'receipt',
Uplink = 'uplink',
}
export class ClientHttpCredentialDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsDateString({ strict: true })
expiresAt?: string;
}
export class ClientWebhookDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(2048)
@ValidateIf(({ url }) => url !== '')
@IsUrl({ require_protocol: true, require_tld: false, protocols: ['http', 'https'] })
url!: string;
@IsOptional()
@IsBoolean()
rotateSecret?: boolean;
@IsOptional()
@IsIn(['active', 'inactive'])
status?: 'active' | 'inactive';
}
+18 -2
View File
@@ -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) {
+2 -1
View File
@@ -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],

Some files were not shown because too many files have changed in this diff Show More