123 Commits
Author SHA1 Message Date
hectorzhao 3cacb6e8e7 fix: 修复模板唯一性及六项运营页面问题
CSS quality / css-quality (push) Canceled after 0s
2026-09-21 19:28:50 +08:00
hectorzhao 28951b4fc4 fix: 修复未报备签名统计的中文编码兼容性 2026-09-21 15:39:45 +08:00
hectorzhao 001d5f2cbd fix: 修复 CMPP 协议字段容量与版本兼容性
CSS quality / css-quality (push) Has been cancelled
2026-09-20 15:49:37 +08:00
hectorzhao b24cd7c08d feat: 增加模板通道拒收策略并修复运营页面 2026-09-20 15:09:57 +08:00
hectorzhao c20c2246b2 fix: 固化长短信回执终态并隔离发送尝试归属 2026-09-18 13:20:03 +08:00
hectorzhao 1676cfe622 fix: 限制有效签名名称唯一并保留批量导入补资料 2026-09-17 16:49:58 +08:00
hectorzhao 5e4d644788 feat: 重构首页回执营业统计并增加企业返还金额
CSS quality / css-quality (push) Has been cancelled
2026-09-17 13:11:35 +08:00
hectorzhao 627fa7ec97 fix: 固定跨午夜日报刷新基准并补充验收记录 2026-09-17 11:14:21 +08:00
hectorzhao 572290308c fix: 修复上行归属并实现签名质量日报优化 2026-09-17 11:12:58 +08:00
hectorzhao 4eb7b16d12 docs: record test deployment and long-message completion acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-16 19:22:16 +08:00
hectorzhao 010ba32168 fix: register supplier response waiter before reader can consume reply
CSS quality / css-quality (push) Has been cancelled
2026-09-16 19:08:42 +08:00
hectorzhao a350aca883 fix: coordinate SMS completion and improve operations diagnostics
CSS quality / css-quality (push) Has been cancelled
2026-09-16 18:27:29 +08:00
hectorzhao a0209f93bc chore: rename product to 聆界短信平台 2026-09-16 11:31:45 +08:00
hectorzhao 86cb9aea36 docs: record HTTP integration acceptance and test deployment
CSS quality / css-quality (push) Has been cancelled
2026-09-15 17:42:28 +08:00
hectorzhao cbc4a03325 fix: exclude prose colons from drainage URL boundaries
CSS quality / css-quality (push) Has been cancelled
2026-09-15 16:52:30 +08:00
hectorzhao c781313de5 feat: add HTTP signature tools and fix independent HTTP send validation
CSS quality / css-quality (push) Has been cancelled
2026-09-15 15:58:29 +08:00
hectorzhao 18ecf8045f feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled
2026-09-15 14:58:31 +08:00
hectorzhao bcb278be29 docs: record client fixes test deployment and acceptance boundaries
CSS quality / css-quality (push) Has been cancelled
2026-09-14 23:29:37 +08:00
hectorzhao 4665079ca3 fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled
2026-09-14 22:53:34 +08:00
hectorzhao 7f9abe3da0 fix: enforce drainage uniqueness and carrier-specific reporting 2026-09-14 18:12:38 +08:00
hectorzhao ac6449028c docs: close HTTP API real simulator acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:53:22 +08:00
hectorzhao 97d1334423 fix: preserve fractional timestamp input compatibility
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:20:30 +08:00
hectorzhao a420d61b23 fix: validate HTTP dates IPv6 URLs and parser errors
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:15:58 +08:00
hectorzhao 92b112cc6e fix: honor Node all-address DNS lookup for HTTP webhooks
CSS quality / css-quality (push) Has been cancelled
2026-09-14 14:03:00 +08:00
hectorzhao 40c8e279f0 docs: close HTTP API test deployment with verification evidence
CSS quality / css-quality (push) Has been cancelled
2026-09-14 13:20:37 +08:00
hectorzhao 04e9f467ab docs: record HTTP remediation validation and test release handoff
CSS quality / css-quality (push) Has been cancelled
2026-09-14 13:02:16 +08:00
hectorzhao f0e843436c feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled
2026-09-14 12:48:10 +08:00
hectorzhao d13ca0713a docs: record channel sensitive words test deployment
CSS quality / css-quality (push) Has been cancelled
2026-09-10 16:11:13 +08:00
hectorzhao 8e4bc5a20e feat: filter SMS routes by channel sensitive words 2026-09-10 15:50:56 +08:00
hectorzhao 86947827cc docs: record drainage test deployment and recovery evidence 2026-09-10 14:06:54 +08:00
hectorzhao 0c3f820cc9 feat: enforce signature-scoped drainage authorization before SMS submission 2026-09-10 13:29:04 +08:00
hectorzhao 5bcdbb2a03 fix: correct operational statistics and form interactions 2026-09-09 23:15:42 +08:00
hectorzhao 6d63eb5452 docs: record test and preproduction release acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-08 23:17:11 +08:00
hectorzhao 809175b544 fix: enforce production React release builds
CSS quality / css-quality (push) Has been cancelled
2026-09-08 22:39:32 +08:00
hectorzhao 6816f56178 fix: harden notification polling and simplify pagination controls 2026-09-08 21:44:08 +08:00
hectorzhao ebb185b22b fix: prevent daily report refresh timeouts
CSS quality / css-quality (push) Has been cancelled
2026-09-08 17:16:43 +08:00
hectorzhao 2a9d03be2e fix: unify analytics and report pagination controls 2026-09-08 14:52:12 +08:00
hectorzhao 6d3c78330d docs: record test release and real operations acceptance 2026-09-08 13:41:49 +08:00
hectorzhao 2c228a94e1 fix: bound formatter memory and improve operations workflows 2026-09-08 12:46:15 +08:00
hectorzhao 50ae37242b docs: 记录夜间累计审核双环境发布与验收
CSS quality / css-quality (push) Has been cancelled
2026-09-07 23:26:27 +08:00
hectorzhao 633ba59775 feat: 按企业应用累计夜间短信并复用聚合审核
CSS quality / css-quality (push) Has been cancelled
2026-09-07 22:55:29 +08:00
hectorzhao e281ff853b fix: 修复WPS图片跨节点误配并定位损坏单元格 2026-09-07 15:17:09 +08:00
hectorzhao f885f0b907 docs: 记录规则管理双环境发布与验收结果
CSS quality / css-quality (push) Has been cancelled
2026-09-06 22:44:24 +08:00
hectorzhao e4f93f7193 feat: 优化整体兜底个性化规则管理交互
CSS quality / css-quality (push) Has been cancelled
2026-09-06 22:16:09 +08:00
hectorzhao 0e3424c4a7 docs: 补记验收文档推送认证重试结果
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:36:08 +08:00
hectorzhao c51255d407 docs: 记录监控配置修复发布与告警演示验收
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:34:37 +08:00
hectorzhao 4c210723cd fix: 修复监控纳管保存并移除运行概况最近记录
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:22:48 +08:00
hectorzhao f059674852 docs: 记录发送监控与报备通知测试发布验收
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:00:21 +08:00
hectorzhao 247fee6d6b fix: 完善监控页签与通知弹窗异步交互
CSS quality / css-quality (push) Has been cancelled
2026-09-06 19:46:08 +08:00
hectorzhao 457319e627 feat: 实现发送质量监控与报备状态消息通知
CSS quality / css-quality (push) Has been cancelled
2026-09-06 19:22:49 +08:00
hectorzhao 69e3d7368d feat: 简化通道组顺序调整并过滤不可选通道 2026-09-06 15:54:27 +08:00
hectorzhao bd920f76b0 feat: 新增报备任务提醒并调整预警分组 2026-09-06 15:36:14 +08:00
hectorzhao 442dda711d docs: 记录通道组双环境发布结果
CSS quality / css-quality (push) Has been cancelled
2026-09-05 23:34:18 +08:00
hectorzhao 1e05a643e5 feat: 优化通道组编辑交互 2026-09-05 22:55:09 +08:00
hectorzhao 839dba8d9b fix: 补齐签名导入待审通知并展示通道组成本 2026-09-05 21:22:18 +08:00
hectorzhao 15a1f9d8ed docs: 记录CSS门禁修复测试环境验收 2026-09-05 09:43:50 +08:00
hectorzhao ca1fc2847f fix: 收紧CSS所有权与历史兼容门禁 2026-09-05 09:33:34 +08:00
hectorzhao 64bfb9ad3d docs: 记录CSS治理测试环境发布结果
CSS quality / css-quality (push) Has been cancelled
2026-09-05 01:05:56 +08:00
hectorzhao 798e85c930 fix: 支持归档环境执行CSS门禁
CSS quality / css-quality (push) Has been cancelled
2026-09-05 00:48:59 +08:00
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
637 changed files with 98004 additions and 22777 deletions
+6
View File
@@ -10,6 +10,8 @@ REDIS_URL=redis://127.0.0.1:6379
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.
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_SEND_WORKER_CONCURRENCY=50
API_WORKER_DATABASE_URL=
@@ -27,6 +29,10 @@ CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
SESSION_LOCK_RECOVERY_MS=14400000
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
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_RETENTION_DAYS=180
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
+36
View File
@@ -0,0 +1,36 @@
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: Resolve the complete change range
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
if [ "$EVENT_NAME" = pull_request ]; then
base=$(git merge-base "$PR_BASE" HEAD)
elif [ "$PUSH_BEFORE" = 0000000000000000000000000000000000000000 ]; then
base=$(git hash-object -t tree /dev/null)
else
git cat-file -e "$PUSH_BEFORE^{commit}"
base=$PUSH_BEFORE
fi
echo "QUALITY_BASE_REF=$base" >> "$GITHUB_ENV"
- name: Verify changed formatting and CSS ownership
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*
pnpm-debug.log*
logs/
coverage/
gateway/gateway.exe
dump.rdb
*.tsbuildinfo
outputs/
tmp_*.py
tmp_*.ps1
tmp_*.sh
.DS_Store
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"
}
+74
View File
@@ -0,0 +1,74 @@
{
"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/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",
"src/styles/admin.css",
"src/styles/client.css",
"src/styles/components.css",
"src/styles/domains/01-operations-dashboard.css",
"src/styles/domains/02-client-sending.css",
"src/styles/domains/03-client-records.css",
"src/styles/domains/04-signatures.css",
"src/styles/domains/05-templates.css",
"src/styles/domains/06-auth-enterprise.css",
"src/styles/domains/07-admin-operations.css",
"src/styles/domains/08-reporting.css",
"src/styles/domains/09-channels.css",
"src/styles/domains/10-signature-quality.css",
"src/styles/domains/11-deliveries-reporting.css",
"src/styles/domains/12-admin-configuration.css",
"src/styles/domains/13-client-signatures.css",
"src/styles/domains/14-responsive-requeue.css",
"src/styles/domains/index.css",
"src/styles/reset.css",
"src/styles/shell.css",
"src/styles/tokens.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 @@
# 聆界短信平台仓库开发约束
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `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'],
moduleFileExtensions: ['ts', 'js', 'json'],
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 },
},
};
+178 -107
View File
@@ -12,7 +12,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^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/client": "^7.9.0",
"brace-expansion": "file:vendor/brace-expansion-compat",
@@ -21,12 +21,15 @@
"class-validator": "^0.14.3",
"exceljs": "^4.4.0",
"ioredis": "^5.11.1",
"jszip": "^3.10.1",
"minio": "^8.0.7",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"tldts": "^7.4.12"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^25.9.3",
"jest": "^30.4.2",
@@ -617,35 +620,38 @@
}
},
"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==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"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==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -732,30 +738,6 @@
"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": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -1451,20 +1433,20 @@
}
},
"node_modules/@nestjs/swagger": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.3.0",
"js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.32.8"
"swagger-ui-dist": "5.32.13"
},
"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/core": "^11.0.1",
"class-transformer": "*",
@@ -2031,6 +2013,27 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
@@ -2120,6 +2123,31 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
"integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -2127,6 +2155,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2192,6 +2227,20 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2202,6 +2251,27 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -2345,9 +2415,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2362,9 +2429,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2379,9 +2443,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2396,9 +2457,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2413,9 +2471,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2430,9 +2485,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2447,9 +2499,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2464,9 +2513,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2481,9 +2527,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2498,9 +2541,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2540,6 +2580,40 @@
"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": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
@@ -3263,9 +3337,9 @@
},
"node_modules/brace-expansion-safe": {
"name": "brace-expansion",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -4510,20 +4584,6 @@
"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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4742,9 +4802,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"devOptional": true,
"funding": [
{
@@ -6170,9 +6230,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -8208,13 +8268,6 @@
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -8520,9 +8573,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"version": "5.32.13",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -8626,6 +8679,24 @@
"readable-stream": "3"
}
},
"node_modules/tldts": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.12"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -9523,10 +9594,10 @@
"node_modules/zip-stream/node_modules/minimatch/vendor/brace-expansion-compat": {},
"vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8-compat.1",
"version": "5.0.9-compat.1",
"license": "MIT",
"dependencies": {
"brace-expansion-safe": "npm:brace-expansion@5.0.8"
"brace-expansion-safe": "npm:brace-expansion@5.0.9"
}
}
}
+11 -3
View File
@@ -6,9 +6,12 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"test:incremental-coverage": "jest --runInBand --coverage --config jest.incremental.config.cjs",
"test:watch": "jest --watch",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
"start:report-material-worker": "node dist/report-material-analysis-worker.js",
"prisma:generate": "prisma generate",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy"
@@ -18,7 +21,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^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/client": "^7.9.0",
"bullmq": "^5.79.2",
@@ -27,12 +30,15 @@
"brace-expansion": "file:vendor/brace-expansion-compat",
"exceljs": "^4.4.0",
"ioredis": "^5.11.1",
"jszip": "^3.10.1",
"minio": "^8.0.7",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"tldts": "^7.4.12"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^25.9.3",
"jest": "^30.4.2",
@@ -46,6 +52,8 @@
"exceljs": {
"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';
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({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url:
process.env.DATABASE_URL ??
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
},
});
@@ -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");
@@ -0,0 +1,110 @@
CREATE TABLE "ReportReadinessState" (
"objectKey" TEXT PRIMARY KEY, "mask" INTEGER NOT NULL, "armed" BOOLEAN NOT NULL,
"cycle" INTEGER NOT NULL DEFAULT 0, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
CREATE TABLE "ReportNotificationHour" (
"id" TEXT PRIMARY KEY, "tenantId" TEXT NOT NULL, "tenantName" TEXT NOT NULL,
"hour" TIMESTAMP(3) NOT NULL, "revision" INTEGER NOT NULL DEFAULT 1,
"signatureCount" INTEGER NOT NULL DEFAULT 0, "drainageCount" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE ("tenantId", "hour")
);
CREATE INDEX "ReportNotificationHour_hour_idx" ON "ReportNotificationHour" ("hour" DESC);
CREATE TABLE "ReportReadinessEvent" (
"id" TEXT PRIMARY KEY, "hourId" TEXT NOT NULL REFERENCES "ReportNotificationHour"("id"),
"objectKey" TEXT NOT NULL, "cycle" INTEGER NOT NULL, "tenantId" TEXT NOT NULL,
"reportType" TEXT NOT NULL, "signatureId" TEXT NOT NULL, "drainageItemId" TEXT,
"applicationId" TEXT, "applicationName" TEXT, "signatureName" TEXT NOT NULL,
"targetName" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE ("objectKey", "cycle")
);
CREATE INDEX "ReportReadinessEvent_hour_idx" ON "ReportReadinessEvent" ("hourId", "createdAt", "id");
CREATE TABLE "ReportNotificationRead" (
"userId" TEXT NOT NULL, "hourId" TEXT NOT NULL REFERENCES "ReportNotificationHour"("id"),
"revision" INTEGER NOT NULL, PRIMARY KEY ("userId", "hourId")
);
CREATE FUNCTION cmpp_report_ready_mask(kind TEXT, signature_id TEXT, drainage_id TEXT) RETURNS INTEGER
LANGUAGE sql STABLE AS $$
SELECT COALESCE(sum(bit),0)::integer FROM (VALUES ('mobile',1),('unicom',2),('telecom',4)) AS carriers(name,bit)
WHERE EXISTS (
SELECT 1 FROM "SmsChannel" c
WHERE c.status='active' AND c."sendRegion"='全国'
AND (CASE WHEN cardinality(c.carriers)>0 THEN carriers.name=ANY(c.carriers)
ELSE c.carrier IN (carriers.name,'all') END)
AND (SELECT t.status FROM "ChannelSignatureReportTask" t
WHERE t."channelId"=c.id AND t."signatureId"=signature_id AND t."reportType"=kind
AND (kind='signature' OR t."drainageItemId"=drainage_id)
AND (t.carrier=carriers.name OR (t.carrier IS NULL AND t."approvalScope"='legacy_channel'))
ORDER BY (t.carrier=carriers.name) DESC NULLS LAST, t."updatedAt" DESC, t.id DESC LIMIT 1)='approved'
);
$$;
-- Seed only state. Existing successes must never become historical unread notices.
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed)
SELECT 'signature:'||id, mask, mask=0 FROM "SmsSignature" s
CROSS JOIN LATERAL (SELECT cmpp_report_ready_mask('signature',s.id,NULL) AS mask) m;
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed)
SELECT 'drainage:'||id, mask, mask=0 FROM "SmsDrainageInfo" d
CROSS JOIN LATERAL (SELECT cmpp_report_ready_mask('drainage',d."signatureId",d.id) AS mask) m;
CREATE FUNCTION cmpp_report_readiness_before() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r "ChannelSignatureReportTask"; k TEXT; m INTEGER;
BEGIN
r := CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
IF TG_OP='UPDATE' AND (OLD."signatureId",OLD."drainageItemId",OLD."reportType") IS DISTINCT FROM
(NEW."signatureId",NEW."drainageItemId",NEW."reportType") THEN
RAISE EXCEPTION 'Reporting task identity is immutable';
END IF;
k := r."reportType"||':'||CASE WHEN r."reportType"='drainage' THEN r."drainageItemId" ELSE r."signatureId" END;
IF k IS NULL THEN RETURN r; END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(k, 20260906));
m := cmpp_report_ready_mask(r."reportType",r."signatureId",r."drainageItemId");
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed) VALUES(k,m,m=0) ON CONFLICT DO NOTHING;
-- A configuration change can remove eligibility without changing a reporting task.
UPDATE "ReportReadinessState" SET mask=m, armed=armed OR m=0 WHERE "objectKey"=k;
RETURN r;
END;
$$;
CREATE FUNCTION cmpp_report_readiness_after() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r "ChannelSignatureReportTask"; k TEXT; m INTEGER; st "ReportReadinessState";
sig "SmsSignature"; tenant_name TEXT; app_name TEXT; target_name TEXT;
app_id TEXT; hour_value TIMESTAMP(3); hour_id TEXT; next_cycle INTEGER;
BEGIN
r := CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
k := r."reportType"||':'||CASE WHEN r."reportType"='drainage' THEN r."drainageItemId" ELSE r."signatureId" END;
IF k IS NULL THEN RETURN r; END IF;
SELECT * INTO st FROM "ReportReadinessState" WHERE "objectKey"=k FOR UPDATE;
m := cmpp_report_ready_mask(r."reportType",r."signatureId",r."drainageItemId");
IF m=7 AND st.armed THEN
SELECT * INTO sig FROM "SmsSignature" WHERE id=r."signatureId";
IF sig.id IS NOT NULL THEN
SELECT name INTO tenant_name FROM "Tenant" WHERE id=sig."tenantId";
app_id:=sig."applicationId"; target_name:=sig.name;
IF r."reportType"='drainage' THEN
SELECT COALESCE(d."applicationId",sig."applicationId"), d.url INTO app_id,target_name
FROM "SmsDrainageInfo" d WHERE id=r."drainageItemId";
END IF;
SELECT name INTO app_name FROM "SmsApplication" WHERE id=app_id;
hour_value:=date_trunc('hour',timezone('UTC',statement_timestamp()));
hour_id:=md5(sig."tenantId"||':'||hour_value::text);
next_cycle:=st.cycle+1;
INSERT INTO "ReportNotificationHour" (id,"tenantId","tenantName",hour,"signatureCount","drainageCount")
VALUES(hour_id,sig."tenantId",tenant_name,hour_value,(r."reportType"='signature')::integer,(r."reportType"='drainage')::integer)
ON CONFLICT ("tenantId",hour) DO UPDATE SET revision="ReportNotificationHour".revision+1,
"signatureCount"="ReportNotificationHour"."signatureCount"+EXCLUDED."signatureCount",
"drainageCount"="ReportNotificationHour"."drainageCount"+EXCLUDED."drainageCount",
"updatedAt"=timezone('UTC',statement_timestamp()) RETURNING id INTO hour_id;
INSERT INTO "ReportReadinessEvent" (id,"hourId","objectKey",cycle,"tenantId","reportType","signatureId","drainageItemId","applicationId","applicationName","signatureName","targetName")
VALUES(md5(k||':'||next_cycle),hour_id,k,next_cycle,sig."tenantId",r."reportType",sig.id,r."drainageItemId",app_id,app_name,sig.name,target_name);
UPDATE "ReportReadinessState" SET cycle=next_cycle,armed=false WHERE "objectKey"=k;
END IF;
END IF;
UPDATE "ReportReadinessState" SET mask=m,armed=armed OR m=0,"updatedAt"=timezone('UTC',statement_timestamp()) WHERE "objectKey"=k;
RETURN r;
END;
$$;
CREATE TRIGGER report_readiness_before BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION cmpp_report_readiness_before();
CREATE TRIGGER report_readiness_after AFTER INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION cmpp_report_readiness_after();
@@ -0,0 +1,73 @@
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "firstWireSubmitAt" TIMESTAMP(3), ADD COLUMN "wireTimeSource" TEXT;
ALTER TABLE "SmsMessageSegmentAudit" ADD COLUMN "firstWireSubmitAt" TIMESTAMP(3), ADD COLUMN "wireTimeSource" TEXT;
ALTER TABLE "UpstreamReceiptInbox" ADD COLUMN "gatewayReceivedAt" TIMESTAMP(3);
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "receiptRequested" BOOLEAN;
ALTER TABLE "SmsMessageSegmentAudit" ADD COLUMN "receiptRequested" BOOLEAN;
CREATE INDEX "SmsSubmitRecord_monitor_updated_idx" ON "SmsSubmitRecord" ("updatedAt",id);
CREATE INDEX "SmsSubmitRecord_monitor_created_idx" ON "SmsSubmitRecord" ("createdAt",id);
CREATE INDEX "UpstreamReceiptInbox_monitor_updated_idx" ON "UpstreamReceiptInbox" ("updatedAt",id);
CREATE INDEX "UpstreamReceiptInbox_monitor_message_idx" ON "UpstreamReceiptInbox" ("matchedMessageRecordId","gatewayMessageId");
CREATE TABLE "SendingMonitorTarget" (
"channelId" TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL, version INTEGER NOT NULL,
"effectiveFrom" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
"updatedBy" TEXT NOT NULL
);
CREATE TABLE "SendingMonitorTargetVersion" (
"channelId" TEXT NOT NULL, version INTEGER NOT NULL, enabled BOOLEAN NOT NULL,
"effectiveFrom" TIMESTAMP(3) NOT NULL, "updatedBy" TEXT NOT NULL,
PRIMARY KEY("channelId",version)
);
CREATE TABLE "SendingMonitorRule" (
id TEXT PRIMARY KEY, type TEXT NOT NULL, "scopeKey" TEXT NOT NULL, scope JSONB NOT NULL,
config JSONB NOT NULL, version INTEGER NOT NULL, "effectiveAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedBy" TEXT NOT NULL,
UNIQUE(type,"scopeKey")
);
CREATE TABLE "SendingMonitorRuleVersion" (
"ruleId" TEXT NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, scope JSONB NOT NULL,
config JSONB NOT NULL, "effectiveAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
"createdBy" TEXT NOT NULL, PRIMARY KEY("ruleId",version)
);
CREATE INDEX "SendingMonitorRuleVersion_effective_idx" ON "SendingMonitorRuleVersion" (type,"effectiveAt");
CREATE TABLE "SendingMonitorFact" (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, "sourceId" TEXT NOT NULL, "dimensionKey" TEXT NOT NULL,
"messageRecordId" TEXT REFERENCES "SmsMessageRecord"(id) ON DELETE SET NULL ON UPDATE CASCADE,
dimensions JSONB NOT NULL, "submittedAt" TIMESTAMP(3) NOT NULL, "successAt" TIMESTAMP(3),
verification BOOLEAN NOT NULL, reason TEXT, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE(kind,"sourceId")
);
CREATE INDEX "SendingMonitorFact_window_idx" ON "SendingMonitorFact" (kind,"submittedAt","dimensionKey");
CREATE INDEX "SendingMonitorFact_message_idx" ON "SendingMonitorFact" ("messageRecordId");
CREATE TABLE "SendingMonitorMinute" (
kind TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, minute TIMESTAMP(3) NOT NULL,
verification BOOLEAN NOT NULL, dimensions JSONB NOT NULL, metrics JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
PRIMARY KEY(kind,"dimensionKey",minute,verification)
);
CREATE INDEX "SendingMonitorMinute_window_idx" ON "SendingMonitorMinute" (kind,minute);
CREATE TABLE "SendingMonitorSnapshot" (
id TEXT PRIMARY KEY, type TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, dimensions JSONB NOT NULL,
"evaluationAt" TIMESTAMP(3) NOT NULL, "windowFrom" TIMESTAMP(3) NOT NULL,
"observedUntil" TIMESTAMP(3) NOT NULL, stage TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
metrics JSONB NOT NULL, rule JSONB, status TEXT NOT NULL, completeness JSONB NOT NULL,
"computedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE(type,"dimensionKey","evaluationAt")
);
CREATE INDEX "SendingMonitorSnapshot_latest_idx" ON "SendingMonitorSnapshot" (type,"evaluationAt" DESC,status);
CREATE INDEX "SendingMonitorSnapshot_history_idx" ON "SendingMonitorSnapshot" (type,"dimensionKey","evaluationAt");
CREATE TABLE "SendingMonitorAlert" (
id TEXT PRIMARY KEY, type TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, dimensions JSONB NOT NULL,
state TEXT NOT NULL, "openedAt" TIMESTAMP(3) NOT NULL, "lastEvaluatedAt" TIMESTAMP(3) NOT NULL,
"closedAt" TIMESTAMP(3), "closeReason" TEXT, "ruleKey" TEXT NOT NULL,
latest JSONB NOT NULL, worst JSONB NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
CREATE UNIQUE INDEX "SendingMonitorAlert_one_active_idx" ON "SendingMonitorAlert" (type,"dimensionKey") WHERE state='active';
CREATE INDEX "SendingMonitorAlert_state_idx" ON "SendingMonitorAlert" (state,"openedAt" DESC);
CREATE TABLE "SendingMonitorAlertRead" (
"alertId" TEXT NOT NULL, "userId" TEXT NOT NULL, "readAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
PRIMARY KEY("alertId","userId")
);
CREATE TABLE "SendingMonitorCheckpoint" (
id TEXT PRIMARY KEY, data JSONB NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
@@ -0,0 +1,38 @@
CREATE TABLE "NightSendingWindow" (
"id" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"windowEndsAt" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"baselineCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE UNIQUE INDEX "NightSendingWindow_applicationId_windowStartedAt_key" ON "NightSendingWindow"("applicationId", "windowStartedAt");
CREATE INDEX "NightSendingWindow_applicationId_windowEndsAt_idx" ON "NightSendingWindow"("applicationId", "windowEndsAt");
CREATE TABLE "NightSendingReservation" (
"messageRecordId" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"thresholdValue" INTEGER NOT NULL,
"reviewTaskId" TEXT,
"continuedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "NightSendingReservation_applicationId_windowId_idx" ON "NightSendingReservation"("applicationId", "windowId");
CREATE INDEX "NightSendingReservation_reviewTaskId_idx" ON "NightSendingReservation"("reviewTaskId");
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseOwner" TEXT;
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseExpiresAt" TIMESTAMP(3);
-- Preserve rule IDs and thresholds for application overrides and historical hits.
UPDATE "RiskRule" SET "name" = '夜间累计发送量审核',
"description" = '同一企业应用在夜间累计业务短信超过阈值后进入人工审核,所有入口与内容合并计数。',
"metric" = 'nightSendingCount', "action" = 'manual_review',
"config" = COALESCE("config", '{}'::jsonb) || '{"timeZone":"Asia/Shanghai"}'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK';
-- The newly approved policy applies by default to every application.
UPDATE "RiskRule" SET "status" = 'active', "updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK' AND "applicationId" IS NULL AND "status" <> 'deleted';
@@ -0,0 +1,7 @@
-- Nullable additive fields preserve historical messages and old readers.
ALTER TABLE "SignatureRetirementMessage"
ADD COLUMN "dailyGroupKey" TEXT,
ADD COLUMN "notificationDate" DATE,
ADD COLUMN "applicationId" TEXT,
ADD COLUMN "detectionIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE UNIQUE INDEX "SignatureRetirementMessage_dailyGroupKey_key" ON "SignatureRetirementMessage"("dailyGroupKey");
@@ -0,0 +1,30 @@
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageReceiptPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_drainage_receipt_pending" ON "SmsMessageRecord" ("updatedAt") WHERE "drainageReceiptPending" = true;
CREATE TABLE "SmsDrainageDecision" (
"id" TEXT PRIMARY KEY,
"messageRecordId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshot" JSONB NOT NULL
);
CREATE INDEX "SmsDrainageDecision_messageRecordId_decidedAt_idx" ON "SmsDrainageDecision" ("messageRecordId", "decidedAt");
CREATE OR REPLACE FUNCTION drainage_authorization_lock() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."signatureId" IS DISTINCT FROM NEW."signatureId" THEN
PERFORM pg_advisory_xact_lock(hashtextextended(value, 910))
FROM unnest(ARRAY[OLD."signatureId", NEW."signatureId"]) AS ids(value) ORDER BY value;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
PERFORM pg_advisory_xact_lock(hashtextextended(OLD."signatureId", 910));
RETURN OLD;
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(NEW."signatureId", 910));
RETURN NEW;
END $$;
CREATE TRIGGER drainage_material_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "SmsDrainageInfo"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
CREATE TRIGGER drainage_report_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
@@ -0,0 +1,20 @@
CREATE TABLE "ChannelSensitiveWord" (
"id" TEXT PRIMARY KEY, "channelId" TEXT NOT NULL, "word" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active', "remark" TEXT NOT NULL DEFAULT '',
"version" INTEGER NOT NULL DEFAULT 1, "createdBy" TEXT NOT NULL, "updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChannelSensitiveWord_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChannelSensitiveWord_status_check" CHECK ("status" IN ('active','inactive','deleted')),
CONSTRAINT "ChannelSensitiveWord_word_check" CHECK (char_length("word") BETWEEN 1 AND 200)
);
CREATE UNIQUE INDEX "ChannelSensitiveWord_channelId_word_key" ON "ChannelSensitiveWord"("channelId","word");
CREATE INDEX "ChannelSensitiveWord_channelId_status_idx" ON "ChannelSensitiveWord"("channelId","status");
CREATE TABLE "SmsChannelSensitiveDecision" (
"id" TEXT PRIMARY KEY, "messageRecordId" TEXT NOT NULL, "routeAttemptId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "snapshot" JSONB NOT NULL,
CONSTRAINT "SmsChannelSensitiveDecision_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "SmsChannelSensitiveDecision_routeAttemptId_key" ON "SmsChannelSensitiveDecision"("routeAttemptId");
CREATE INDEX "SmsChannelSensitiveDecision_messageRecordId_decidedAt_idx" ON "SmsChannelSensitiveDecision"("messageRecordId","decidedAt");
ALTER TABLE "SmsMessageRecord" ADD COLUMN "channelWordFinalizationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_channelWordFinalizationPending_idx" ON "SmsMessageRecord"("updatedAt") WHERE "channelWordFinalizationPending" = true;
@@ -0,0 +1,11 @@
ALTER TABLE "HttpWebhookDelivery" ADD COLUMN "recoveryVersion" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "leaseToken" TEXT, ADD COLUMN "leaseUntil" TIMESTAMP(3);
CREATE TABLE "OpenApiDispatchOutbox" (
"id" TEXT NOT NULL, "requestId" TEXT NOT NULL, "batchTaskId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending', "leaseToken" TEXT, "leaseUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenApiDispatchOutbox_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_requestId_key" ON "OpenApiDispatchOutbox"("requestId");
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_batchTaskId_key" ON "OpenApiDispatchOutbox"("batchTaskId");
CREATE INDEX "OpenApiDispatchOutbox_status_leaseUntil_idx" ON "OpenApiDispatchOutbox"("status", "leaseUntil");
@@ -0,0 +1,10 @@
-- Preserve all legacy rows; independent carrier states require distinct business keys.
BEGIN;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_carrier_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId", "carrier")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NOT NULL;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_legacy_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NULL;
DROP INDEX "ChannelSignatureReportTask_drainage_target_key";
COMMIT;
@@ -0,0 +1,17 @@
CREATE TABLE "InfrastructureAlertCollection" (
"id" TEXT NOT NULL PRIMARY KEY,
"observedAt" TIMESTAMP(3) NOT NULL
);
CREATE TABLE "InfrastructureAlertEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"fingerprint" TEXT NOT NULL,
"activeAt" TIMESTAMP(3) NOT NULL,
"payload" JSONB NOT NULL,
"lastObservedAt" TIMESTAMP(3) NOT NULL,
"recoveredAt" TIMESTAMP(3),
"clearedAt" TIMESTAMP(3),
"clearedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "InfrastructureAlertEvent_fingerprint_activeAt_key" ON "InfrastructureAlertEvent"("fingerprint", "activeAt");
CREATE INDEX "InfrastructureAlertEvent_clearedAt_activeAt_idx" ON "InfrastructureAlertEvent"("clearedAt", "activeAt");
@@ -0,0 +1,15 @@
CREATE TABLE "SmsAttemptCompletionWork" (
"id" TEXT PRIMARY KEY, "workKey" TEXT NOT NULL, "tenantId" TEXT, "messageRecordId" TEXT NOT NULL, "sourceSubmitRecordId" TEXT,
"revision" INTEGER NOT NULL DEFAULT 0, "processedRevision" INTEGER NOT NULL DEFAULT 0, "state" TEXT NOT NULL DEFAULT 'pending',
"leaseOwner" TEXT, "leaseUntil" TIMESTAMP(3), "fenceVersion" INTEGER NOT NULL DEFAULT 0, "attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "decision" TEXT, "retrySubmitRecordId" TEXT, "lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'));
CREATE UNIQUE INDEX "SmsAttemptCompletionWork_workKey_key" ON "SmsAttemptCompletionWork"("workKey");
CREATE UNIQUE INDEX "SmsAttemptCompletionWork_sourceSubmitRecordId_key" ON "SmsAttemptCompletionWork"("sourceSubmitRecordId");
CREATE INDEX "SmsAttemptCompletionWork_state_nextAttemptAt_idx" ON "SmsAttemptCompletionWork"("state", "nextAttemptAt");
CREATE INDEX "SmsAttemptCompletionWork_state_leaseUntil_idx" ON "SmsAttemptCompletionWork"("state", "leaseUntil");
CREATE INDEX "SmsAttemptCompletionWork_messageRecordId_idx" ON "SmsAttemptCompletionWork"("messageRecordId");
CREATE TABLE "SmsCompletionEvent" ("id" TEXT PRIMARY KEY, "eventKey" TEXT NOT NULL, "workId" TEXT NOT NULL REFERENCES "SmsAttemptCompletionWork"("id") ON DELETE RESTRICT,
"kind" TEXT NOT NULL, "payload" JSONB NOT NULL, "processedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'));
CREATE UNIQUE INDEX "SmsCompletionEvent_eventKey_key" ON "SmsCompletionEvent"("eventKey");
CREATE INDEX "SmsCompletionEvent_workId_processedAt_createdAt_idx" ON "SmsCompletionEvent"("workId", "processedAt", "createdAt");
@@ -0,0 +1,130 @@
-- CreateTable
CREATE TABLE "SignatureAnalyticsGeneration" (
"id" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"sourceAsOf" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsGeneration_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsDay" (
"businessDate" DATE NOT NULL,
"publishedGenerationId" TEXT,
"generatedAt" TIMESTAMP(3),
"sourceAsOf" TIMESTAMP(3),
"refreshFor" DATE,
"state" TEXT NOT NULL DEFAULT 'missing',
"error" TEXT,
"provenance" TEXT NOT NULL DEFAULT 'daily',
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"rowCounts" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsDay_pkey" PRIMARY KEY ("businessDate")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsRun" (
"id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"refreshFor" DATE NOT NULL,
"generationId" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'pending',
"owner" TEXT,
"fence" INTEGER NOT NULL DEFAULT 0,
"leaseUntil" TIMESTAMP(3),
"attempt" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkpoint" JSONB,
"error" TEXT,
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
CONSTRAINT "SignatureAnalyticsRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureQualityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"signatureId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationNames" TEXT NOT NULL,
"total" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
CONSTRAINT "SignatureQualityDaily_pkey" PRIMARY KEY ("generationId","signatureId")
);
-- CreateTable
CREATE TABLE "SignatureActivityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"dimensionType" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"channelKey" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"channelName" TEXT NOT NULL,
"approvedAt" TIMESTAMP(3),
"submittedAttempts" INTEGER NOT NULL,
"acceptedBusinessCount" INTEGER NOT NULL,
"deliveredBusinessCount" INTEGER NOT NULL,
"applicability" TEXT NOT NULL,
CONSTRAINT "SignatureActivityDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateTable
CREATE TABLE "UnreportedSignatureDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"messageCount" INTEGER NOT NULL,
CONSTRAINT "UnreportedSignatureDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsGeneration_id_businessDate_key" ON "SignatureAnalyticsGeneration"("id", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureAnalyticsRun_state_nextAttemptAt_idx" ON "SignatureAnalyticsRun"("state", "nextAttemptAt");
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsRun_scope_businessDate_key" ON "SignatureAnalyticsRun"("scope", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureQualityDaily_businessDate_generationId_total_idx" ON "SignatureQualityDaily"("businessDate", "generationId", "total");
-- CreateIndex
CREATE INDEX "SignatureActivityDaily_businessDate_generationId_dimensionT_idx" ON "SignatureActivityDaily"("businessDate", "generationId", "dimensionType", "acceptedBusinessCount");
-- CreateIndex
CREATE INDEX "UnreportedSignatureDaily_businessDate_generationId_messageC_idx" ON "UnreportedSignatureDaily"("businessDate", "generationId", "messageCount");
-- AddForeignKey
ALTER TABLE "SignatureAnalyticsDay" ADD CONSTRAINT "SignatureAnalyticsDay_publishedGenerationId_businessDate_fkey" FOREIGN KEY ("publishedGenerationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureQualityDaily" ADD CONSTRAINT "SignatureQualityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureActivityDaily" ADD CONSTRAINT "SignatureActivityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UnreportedSignatureDaily" ADD CONSTRAINT "UnreportedSignatureDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- Expression index matches the actual report/retirement time predicate, including legacy NULL submittedAt.
-- Deliberately outside a transaction: online construction must not block SMS writes.
CREATE INDEX CONCURRENTLY "SmsSubmitRecord_effective_at_idx"
ON "SmsSubmitRecord" ((COALESCE("submittedAt", "createdAt")));
@@ -0,0 +1,24 @@
CREATE TABLE "HomeProjectionState" (id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0, "seededDay" TEXT, initialized BOOLEAN NOT NULL DEFAULT false, "lastError" TEXT, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE "HomeProjectionDirty" ("messageRecordId" TEXT PRIMARY KEY,"enqueuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE "HomeMessageFact" ("messageRecordId" TEXT NOT NULL,"fromVersion" INTEGER NOT NULL,"toVersion" INTEGER,"queuedDay" TEXT NOT NULL,payload JSONB NOT NULL,PRIMARY KEY("messageRecordId","fromVersion"));
CREATE INDEX "HomeMessageFact_queuedDay_toVersion_idx" ON "HomeMessageFact"("queuedDay","toVersion");
CREATE TABLE "HomeSnapshot" (id TEXT PRIMARY KEY,"userId" TEXT NOT NULL,"businessDate" TEXT NOT NULL,version INTEGER NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"expiresAt" TIMESTAMP(3) NOT NULL,summary JSONB NOT NULL);
CREATE INDEX "HomeSnapshot_expiresAt_idx" ON "HomeSnapshot"("expiresAt");
INSERT INTO "HomeProjectionState"(id) VALUES ('home');
-- A durable, transactional invalidation only: no business state or external effects.
CREATE FUNCTION home_mark_dirty() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mid TEXT;
BEGIN
IF TG_TABLE_NAME = 'SmsMessageRecord' THEN mid := COALESCE(NEW.id,OLD.id);
ELSIF TG_TABLE_NAME = 'UpstreamReceiptInbox' THEN mid := COALESCE(NEW."matchedMessageRecordId",OLD."matchedMessageRecordId");
ELSE mid := COALESCE(NEW."messageRecordId",OLD."messageRecordId"); END IF;
IF mid IS NOT NULL THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(mid) ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER home_message_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsMessageRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_submit_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsSubmitRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_segment_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsMessageSegmentAudit" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_inbox_dirty AFTER INSERT OR UPDATE OR DELETE ON "UpstreamReceiptInbox" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_receipt_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsReceiptRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
@@ -0,0 +1,24 @@
-- Re-association invalidates both owners; source writes and the durable invalidation are atomic.
CREATE OR REPLACE FUNCTION home_mark_dirty() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mid TEXT; previous_mid TEXT;
BEGIN
IF TG_TABLE_NAME = 'SmsMessageRecord' THEN
mid := COALESCE(NEW.id,OLD.id); previous_mid := OLD.id;
ELSIF TG_TABLE_NAME = 'UpstreamReceiptInbox' THEN
mid := COALESCE(NEW."matchedMessageRecordId",OLD."matchedMessageRecordId"); previous_mid := OLD."matchedMessageRecordId";
ELSE
mid := COALESCE(NEW."messageRecordId",OLD."messageRecordId"); previous_mid := OLD."messageRecordId";
END IF;
IF mid IS NOT NULL THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(mid)
ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
IF previous_mid IS NOT NULL AND previous_mid IS DISTINCT FROM mid THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(previous_mid)
ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
RETURN NULL;
END $$;
CREATE INDEX "HomeProjectionDirty_enqueuedAt_idx" ON "HomeProjectionDirty"("enqueuedAt");
CREATE INDEX "HomeMessageFact_toVersion_idx" ON "HomeMessageFact"("toVersion");
CREATE UNIQUE INDEX "HomeMessageFact_current_key" ON "HomeMessageFact"("messageRecordId") WHERE "toVersion" IS NULL;
@@ -0,0 +1,24 @@
BEGIN;
-- Preserve every historical record. A conflicting installation must be reviewed before release.
LOCK TABLE "SmsSignature" IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "SmsSignature"
WHERE "auditStatus" NOT IN ('deleted', 'disabled')
GROUP BY "tenantId", "applicationId", "name" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce signature uniqueness: duplicate active signatures exist; review tenantId/applicationId/name groups without deleting or merging automatically';
END IF;
END $$;
CREATE UNIQUE INDEX "SmsSignature_active_application_name_key"
ON "SmsSignature" ("tenantId", "applicationId", "name")
WHERE "applicationId" IS NOT NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
CREATE UNIQUE INDEX "SmsSignature_active_unbound_name_key"
ON "SmsSignature" ("tenantId", "name")
WHERE "applicationId" IS NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
COMMIT;
@@ -0,0 +1,4 @@
ALTER TABLE "SmsTemplate" ADD COLUMN "optOutRules" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "SmsTemplate" ADD CONSTRAINT "SmsTemplate_optOutRules_array" CHECK (jsonb_typeof("optOutRules") = 'array');
ALTER TABLE "SmsMessageRecord" ADD COLUMN "originalContent" TEXT;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "sentContent" TEXT, ADD COLUMN "contentPolicy" JSONB;
@@ -0,0 +1,19 @@
-- Widen only; historical invalid values abort the entire transaction. Never narrow on rollback.
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '5min';
ALTER TABLE "UpstreamReceiptInbox" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "UpstreamReceiptInbox_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsReceiptRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsReceiptRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsUplinkMessage" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsUplinkMessage_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsSubmitRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsSubmitRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsMessageSegmentAudit" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsMessageSegmentAudit_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDelivery" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDelivery_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDeliveryAttempt" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDeliveryAttempt_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
COMMIT;
@@ -0,0 +1,4 @@
-- Deleted records remain available for audit but do not reserve an application template name.
-- Fail on conflicting legacy rows; never rename or delete business data during migration.
CREATE UNIQUE INDEX "SmsTemplate_application_name_active_key"
ON "SmsTemplate" ("applicationId", btrim(name)) WHERE "auditStatus" <> 'deleted';
@@ -0,0 +1,21 @@
-- Serialize member writes against channel capability changes; validate whole carrier strings.
CREATE FUNCTION cmpp_check_group_channel_carrier() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
capabilities text[];
legacy text;
target_carrier text;
BEGIN
SELECT carriers, carrier INTO capabilities, legacy FROM "SmsChannel" WHERE id=NEW."channelId" FOR SHARE;
SELECT carrier INTO target_carrier FROM "SmsChannelGroup" WHERE id=NEW."groupId";
IF cardinality(capabilities) = 0 THEN
capabilities := CASE WHEN legacy='all' THEN ARRAY['mobile','unicom','telecom'] ELSE ARRAY[legacy] END;
END IF;
IF target_carrier IS NOT NULL AND NOT (target_carrier=ANY(capabilities)) THEN
RAISE EXCEPTION 'Channel carrier is not compatible with the channel group carrier' USING ERRCODE='23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER "SmsChannelGroupItem_carrier_guard"
BEFORE INSERT OR UPDATE OF "groupId", "channelId" ON "SmsChannelGroupItem"
FOR EACH ROW EXECUTE FUNCTION cmpp_check_group_channel_carrier();
+502 -21
View File
@@ -266,6 +266,32 @@ model PhoneCarrierRule {
@@index([status, priority])
}
model ChannelSensitiveWord {
id String @id @default(cuid())
channelId String
channel SmsChannel @relation(fields: [channelId], references: [id])
word String
status String @default("active")
remark String @default("")
version Int @default(1)
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channelId, word])
@@index([channelId, status])
}
model SmsChannelSensitiveDecision {
id String @id @default(cuid())
messageRecordId String
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id])
routeAttemptId String @unique
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SensitiveWord {
id String @id @default(cuid())
word String @unique
@@ -660,6 +686,9 @@ model HttpWebhookEvent {
}
model HttpWebhookDelivery {
recoveryVersion Int @default(0)
leaseToken String?
leaseUntil DateTime?
id String @id @default(cuid())
eventId String
endpointId String
@@ -696,7 +725,59 @@ model HttpWebhookAttempt {
@@unique([deliveryId, attemptNo])
}
model ReportReadinessState {
objectKey String @id
mask Int
armed Boolean
cycle Int @default(0)
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
}
model ReportNotificationHour {
id String @id
tenantId String
tenantName String
hour DateTime
revision Int @default(1)
signatureCount Int @default(0)
drainageCount Int @default(0)
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
events ReportReadinessEvent[]
reads ReportNotificationRead[]
@@unique([tenantId, hour])
@@index([hour(sort: Desc)], map: "ReportNotificationHour_hour_idx")
}
model ReportReadinessEvent {
id String @id
hourId String
objectKey String
cycle Int
tenantId String
reportType String
signatureId String
drainageItemId String?
applicationId String?
applicationName String?
signatureName String
targetName String
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
hour ReportNotificationHour @relation(fields: [hourId], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@unique([objectKey, cycle])
@@index([hourId, createdAt, id], map: "ReportReadinessEvent_hour_idx")
}
model ReportNotificationRead {
userId String
hourId String
revision Int
hour ReportNotificationHour @relation(fields: [hourId], references: [id], onDelete: NoAction, onUpdate: NoAction)
@@id([userId, hourId])
}
model SmsSignature {
// Active name uniqueness (including null applicationId) is enforced by two partial SQL indexes.
// Owned by migration 20260917120000_signature_active_name_unique; do not replace with @@unique.
id String @id @default(cuid())
tenantId String
applicationId String?
@@ -772,6 +853,7 @@ model SignatureMaterial {
}
model SmsTemplate {
optOutRules Json @default("[]")
id String @id @default(cuid())
tenantId String
applicationId String
@@ -831,6 +913,7 @@ model AuditRecord {
}
model SmsChannel {
sensitiveWords ChannelSensitiveWord[]
id String @id @default(cuid())
code String @unique
name String
@@ -1201,6 +1284,10 @@ model SignatureRetirementDetection {
}
model SignatureRetirementMessage {
dailyGroupKey String? @unique
notificationDate DateTime? @db.Date
applicationId String?
detectionIds String[] @default([])
id String @id @default(cuid())
detectionId String @unique
cycleId String
@@ -1395,6 +1482,11 @@ model ReportMaterialImportBatch {
fileName String
reportType String
status String @default("analyzed")
progress Int @default(100)
progressStage String?
errorMessage String?
startedAt DateTime?
heartbeatAt DateTime?
sheetName String
headerRowCount Int @default(1)
dataStartRow Int @default(2)
@@ -1413,6 +1505,7 @@ model ReportMaterialImportBatch {
@@index([tenantId, createdAt])
@@index([status, createdAt])
@@index([status, heartbeatAt])
}
model ReportMaterialImportItem {
@@ -1455,6 +1548,36 @@ model ReportReceiptImport {
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
}
model NightSendingWindow {
id String @id
tenantId String
applicationId String
windowStartedAt DateTime
windowEndsAt DateTime
count Int @default(0)
baselineCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([applicationId, windowStartedAt])
@@index([applicationId, windowEndsAt])
}
model NightSendingReservation {
messageRecordId String @id
tenantId String
applicationId String
windowId String
sequence Int
thresholdValue Int
reviewTaskId String?
continuedAt DateTime?
createdAt DateTime @default(now())
@@index([applicationId, windowId])
@@index([reviewTaskId])
}
model RiskRule {
id String @id @default(cuid())
tenantId String?
@@ -1507,6 +1630,8 @@ model SmsSendTask {
createdById String?
reviewedById String?
reviewedAt DateTime?
continuationLeaseOwner String?
continuationLeaseExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1698,7 +1823,19 @@ model SmsApiRequest {
@@index([batchTaskId])
}
model SmsDrainageDecision {
id String @id @default(cuid())
messageRecordId String
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SmsMessageRecord {
originalContent String?
channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[]
id String @id @default(cuid())
tenantId String?
batchTaskId String?
@@ -1715,6 +1852,8 @@ model SmsMessageRecord {
content String
hasDrainageContent Boolean?
drainageDetection Json?
drainageGate Json?
drainageReceiptPending Boolean @default(false)
drainageDetectionVersion String?
drainageEvaluatedAt DateTime?
billingUnits Int @default(1)
@@ -1787,6 +1926,9 @@ model CmppSubmitSession {
}
model SmsSubmitRecord {
sentContent String?
contentPolicy Json?
drainageGate Json?
id String @id @default(cuid())
tenantId String?
batchTaskId String?
@@ -1797,7 +1939,7 @@ model SmsSubmitRecord {
sessionId String?
retryOfSubmitRecordId String? @unique
submitId String @unique
sequenceId Int?
sequenceId BigInt?
gatewayMessageId String?
submitStatus String @default("queued")
resultEventId String? @unique
@@ -1807,6 +1949,9 @@ model SmsSubmitRecord {
errorCode String?
errorMessage String?
submittedAt DateTime?
firstWireSubmitAt DateTime?
wireTimeSource String?
receiptRequested Boolean?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1826,6 +1971,8 @@ model SmsSubmitRecord {
@@index([gatewayMessageId])
@@index([channelId, gatewayMessageId])
@@index([channelGroupId])
@@index([updatedAt,id], map: "SmsSubmitRecord_monitor_updated_idx")
@@index([createdAt,id], map: "SmsSubmitRecord_monitor_created_idx")
}
model GatewaySubmitOutbox {
@@ -1943,7 +2090,7 @@ model SmsMessageSegmentAudit {
attempt Int @default(0)
segmentTotal Int @default(1)
segmentIndex Int @default(1)
sequenceId Int?
sequenceId BigInt?
gatewayMessageId String?
submitStatus String @default("queued")
receiptStatus String?
@@ -1952,6 +2099,9 @@ model SmsMessageSegmentAudit {
errorCode String?
errorMessage String?
submittedAt DateTime?
firstWireSubmitAt DateTime?
wireTimeSource String?
receiptRequested Boolean?
deliveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2053,7 +2203,7 @@ model SmsReceiptRecord {
messageId String
gatewayMessageId String
phoneNumber String?
sequenceId Int?
sequenceId BigInt?
receiptStatus String
rawStatus String
errorCode String?
@@ -2116,21 +2266,22 @@ model SmsReceiptAnomaly {
}
model SmsUplinkMessage {
id String @id @default(cuid())
eventId String? @unique
tenantId String?
applicationId String?
channelId String
messageRecordId String?
messageId String?
sequenceId Int?
phoneNumber String
destId String
content String
matchStatus String @default("unmatched")
matchReason String?
receivedAt DateTime
createdAt DateTime @default(now())
id String @id @default(cuid())
eventId String? @unique
tenantId String?
applicationId String?
channelId String
messageRecordId String?
messageId String?
gatewayMessageId String?
sequenceId BigInt?
phoneNumber String
destId String
content String
matchStatus String @default("unmatched")
matchReason String?
receivedAt DateTime
createdAt DateTime @default(now())
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id])
@@ -2192,7 +2343,7 @@ model CmppDownstreamDelivery {
sentAt DateTime?
acknowledgedAt DateTime?
ackDeadlineAt DateTime?
ackResult Int?
ackResult BigInt?
ackSequenceId String?
ackMessageId String?
connectionId String?
@@ -2300,7 +2451,7 @@ model CmppDownstreamDeliveryAttempt {
sentAt DateTime?
ackDeadlineAt DateTime?
acknowledgedAt DateTime?
ackResult Int?
ackResult BigInt?
failureType String?
errorMessage String?
createdAt DateTime @default(now())
@@ -2324,7 +2475,7 @@ model UpstreamReceiptInbox {
protocol String
protocolVersion String
provisionalMessageId String?
sequenceId Int?
sequenceId BigInt?
gatewayMessageId String
phoneNumber String?
receiptStatus String
@@ -2332,6 +2483,7 @@ model UpstreamReceiptInbox {
errorCode String?
errorMessage String?
deliveredAt DateTime
gatewayReceivedAt DateTime?
receivedAt DateTime @default(now())
status String @default("pending")
matchedMessageRecordId String?
@@ -2348,6 +2500,8 @@ model UpstreamReceiptInbox {
@@index([gatewayMessageId, phoneNumber])
@@index([incomingChannelId, receivedAt])
@@index([matchedMessageRecordId])
@@index([updatedAt,id], map: "UpstreamReceiptInbox_monitor_updated_idx")
@@index([matchedMessageRecordId,gatewayMessageId], map: "UpstreamReceiptInbox_monitor_message_idx")
}
model GatewaySubmitDeadLetter {
@@ -2556,3 +2710,330 @@ model InfrastructureAlertRead {
@@unique([fingerprint, userId])
@@index([userId, readAt])
}
model SendingMonitorTarget {
channelId String @id
enabled Boolean
version Int
effectiveFrom DateTime
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
updatedBy String
}
model SendingMonitorRule {
id String @id
type String
scopeKey String
scope Json
config Json
version Int
effectiveAt DateTime
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
updatedBy String
@@unique([type,scopeKey])
}
model SendingMonitorRuleVersion {
ruleId String
version Int
type String
scope Json
config Json
effectiveAt DateTime
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
createdBy String
@@id([ruleId,version])
@@index([type,effectiveAt], map: "SendingMonitorRuleVersion_effective_idx")
}
model SendingMonitorFact {
id String @id
kind String
sourceId String
messageRecordId String?
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id], onDelete: SetNull)
dimensionKey String
dimensions Json
submittedAt DateTime
successAt DateTime?
verification Boolean
reason String?
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
@@unique([kind,sourceId])
@@index([messageRecordId], map: "SendingMonitorFact_message_idx")
@@index([kind,submittedAt,dimensionKey], map: "SendingMonitorFact_window_idx")
}
model SendingMonitorMinute {
kind String
dimensionKey String
minute DateTime
verification Boolean
dimensions Json
metrics Json
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
@@id([kind,dimensionKey,minute,verification])
@@index([kind,minute], map: "SendingMonitorMinute_window_idx")
}
model SendingMonitorSnapshot {
id String @id
type String
dimensionKey String
dimensions Json
evaluationAt DateTime
windowFrom DateTime
observedUntil DateTime
stage String
revision Int @default(1)
metrics Json
rule Json?
status String
completeness Json
computedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
@@unique([type,dimensionKey,evaluationAt])
@@index([type,evaluationAt(sort: Desc),status], map: "SendingMonitorSnapshot_latest_idx")
@@index([type,dimensionKey,evaluationAt], map: "SendingMonitorSnapshot_history_idx")
}
model SendingMonitorAlert {
id String @id
type String
dimensionKey String
dimensions Json
state String
openedAt DateTime
lastEvaluatedAt DateTime
closedAt DateTime?
closeReason String?
ruleKey String
latest Json
worst Json
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
@@index([state,openedAt(sort: Desc)], map: "SendingMonitorAlert_state_idx")
}
model SendingMonitorAlertRead {
alertId String
userId String
readAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
@@id([alertId,userId])
}
model SendingMonitorCheckpoint {
id String @id
data Json
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
}
model SendingMonitorTargetVersion {
channelId String
version Int
enabled Boolean
effectiveFrom DateTime @db.Timestamp(3)
updatedBy String
@@id([channelId,version])
}
model OpenApiDispatchOutbox {
id String @id @default(cuid())
requestId String @unique
batchTaskId String @unique
status String @default("pending")
leaseToken String?
leaseUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, leaseUntil])
}
model InfrastructureAlertCollection {
id String @id
observedAt DateTime
}
model InfrastructureAlertEvent {
id String @id @default(cuid())
fingerprint String
activeAt DateTime
payload Json
lastObservedAt DateTime
recoveredAt DateTime?
clearedAt DateTime?
clearedBy String?
createdAt DateTime @default(now())
@@unique([fingerprint, activeAt])
@@index([clearedAt, activeAt])
}
model SmsAttemptCompletionWork {
id String @id @default(cuid())
workKey String @unique
tenantId String?
messageRecordId String
sourceSubmitRecordId String? @unique
revision Int @default(0)
processedRevision Int @default(0)
state String @default("pending")
leaseOwner String?
leaseUntil DateTime?
fenceVersion Int @default(0)
attempts Int @default(0)
nextAttemptAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
decision String?
retrySubmitRecordId String?
lastError String?
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
updatedAt DateTime @updatedAt
events SmsCompletionEvent[]
@@index([state, nextAttemptAt])
@@index([state, leaseUntil])
@@index([messageRecordId])
}
model SmsCompletionEvent {
id String @id @default(cuid())
eventKey String @unique
workId String
kind String
payload Json
processedAt DateTime?
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
work SmsAttemptCompletionWork @relation(fields: [workId], references: [id], onDelete: Restrict)
@@index([workId, processedAt, createdAt])
}
model SignatureAnalyticsGeneration {
id String @id
businessDate DateTime @db.Date
sourceAsOf DateTime
days SignatureAnalyticsDay[]
quality SignatureQualityDaily[]
activity SignatureActivityDaily[]
unreported UnreportedSignatureDaily[]
@@unique([id, businessDate])
}
model SignatureAnalyticsDay {
businessDate DateTime @id @db.Date
publishedGenerationId String?
publishedGeneration SignatureAnalyticsGeneration? @relation(fields: [publishedGenerationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generatedAt DateTime?
sourceAsOf DateTime?
refreshFor DateTime? @db.Date
state String @default("missing")
error String?
provenance String @default("daily")
schemaVersion Int @default(1)
rowCounts Json?
updatedAt DateTime @updatedAt
}
model SignatureAnalyticsRun {
id String @id @default(cuid())
scope String
businessDate DateTime @db.Date
refreshFor DateTime @db.Date
generationId String
state String @default("pending")
owner String?
fence Int @default(0)
leaseUntil DateTime?
attempt Int @default(0)
nextAttemptAt DateTime @default(now())
checkpoint Json?
error String?
startedAt DateTime?
finishedAt DateTime?
@@unique([scope, businessDate])
@@index([state, nextAttemptAt])
}
model SignatureQualityDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
signatureId String
signatureName String
tenantId String
tenantName String
applicationNames String
total Int
payload Json
@@id([generationId, signatureId])
@@index([businessDate, generationId, total])
}
model SignatureActivityDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
dimensionKey String
dimensionType String
signatureId String
channelKey String
carrier String
tenantId String
applicationId String?
signatureName String
tenantName String
applicationName String
channelName String
approvedAt DateTime?
submittedAttempts Int
acceptedBusinessCount Int
deliveredBusinessCount Int
applicability String
@@id([generationId, dimensionKey])
@@index([businessDate, generationId, dimensionType, acceptedBusinessCount])
}
model UnreportedSignatureDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
dimensionKey String
tenantId String
applicationId String
signatureName String
tenantName String
applicationName String
messageCount Int
@@id([generationId, dimensionKey])
@@index([businessDate, generationId, messageCount])
}
model HomeProjectionState {
id String @id
version Int @default(0)
seededDay String?
initialized Boolean @default(false)
lastError String?
updatedAt DateTime @default(now())
}
model HomeProjectionDirty {
messageRecordId String @id
enqueuedAt DateTime @default(now())
@@index([enqueuedAt])
}
model HomeMessageFact {
messageRecordId String
fromVersion Int
toVersion Int?
queuedDay String
payload Json
@@id([messageRecordId, fromVersion])
@@index([queuedDay, toVersion])
@@index([toVersion])
}
model HomeSnapshot {
id String @id
userId String
businessDate String
version Int
createdAt DateTime @default(now())
expiresAt DateTime
summary Json
@@index([expiresAt])
}
+19 -2
View File
@@ -1,3 +1,7 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { HomeModule } from './home-dashboard/home.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module';
@@ -27,6 +31,8 @@ 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';
import { ReportNotificationsModule } from './report-notifications/report-notifications.module';
import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
@Module({
imports: [
@@ -55,14 +61,25 @@ import { MetricsModule } from './metrics/metrics.module';
InfrastructureMonitoringModule,
OpenApiModule,
SignatureRetirementModule,
SignatureAnalyticsModule,
HomeModule,
SecurityDetectionModule,
MetricsModule,
ReportNotificationsModule,
SendingMonitorModule,
],
controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
RequestContextMiddleware,
SessionValidationMiddleware,
ManualOperationAuditMiddleware,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware).forRoutes('*');
consumer
.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware)
.forRoutes('*');
}
}
+77 -22
View File
@@ -1,7 +1,9 @@
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 { 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 type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service';
@@ -18,18 +20,29 @@ type CookieResponse = {
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
constructor(
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly sessions: SessionService,
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
) {}
@Get('admin/auth/captcha')
adminCaptcha() {
return this.auth.createCaptcha();
adminCaptcha(@Req() request: SessionRequest) {
return this.auth.createCaptcha(this.sourceIp(request));
}
@Post('admin/auth/login')
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
@UsePipes(strictValidationPipe)
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');
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;
@@ -38,15 +51,20 @@ export class AuthController {
}
@Get('client/auth/captcha')
clientCaptcha() {
return this.auth.createCaptcha();
clientCaptcha(@Req() request: SessionRequest) {
return this.auth.createCaptcha(this.sourceIp(request));
}
@Post('client/auth/login')
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
@UsePipes(strictValidationPipe)
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');
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;
@@ -90,22 +108,32 @@ export class AuthController {
async lock(@Req() request: SessionRequest) {
this.assertSession(request);
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) };
}
@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);
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);
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
return this.sessions.publicSession(result.record);
}
@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);
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
@@ -116,28 +144,45 @@ export class AuthController {
@Post(['admin/auth/logout', 'client/auth/logout'])
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
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);
return { success: true };
}
@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('登录会话无效,请重新登录');
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.clearLegacyCookie(response);
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;
return publicResult;
}
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
private recordLoginFailure(
ruleCode: 'admin_login_failure' | 'client_login_failure',
account: string,
request: SessionRequest,
) {
return this.security.recordEvent({
ruleCode,
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
@@ -148,9 +193,19 @@ export class AuthController {
});
}
private sourceIp(request: SessionRequest) {
return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
}
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
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;
}
+126 -17
View File
@@ -1,6 +1,6 @@
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { hashPassword } from '../users/users.service';
import { legacyHashPassword } from './password-hasher';
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
const user = {
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
email: 'user@example.com',
phone: '13800000000',
displayName: '用户',
passwordHash: hashPassword('secret1'),
passwordHash: legacyHashPassword('secret1'),
status: 'active',
deletedAt: null,
lockedUntil: null,
@@ -20,56 +20,165 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
};
return {
findByLogin: jest.fn().mockResolvedValue(user),
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
verifyCurrentPassword: jest.fn(),
};
}
function createSessionsMock() {
const captchas = new Map<string, string>();
const failures = new Map<string, number>();
const record = {
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
userId: 'user-1',
portal: 'admin',
sessionVersion: 0,
createdAt: 1,
lastActivityAt: 1,
lastAuthenticatedAt: 1,
absoluteExpiresAt: Date.now() + 1000,
};
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 }),
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') {
const captcha = service.createCaptcha();
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
return service.login({
login: 'user@example.com',
password,
captchaId: captcha.captchaId,
captchaText: String(answer),
}, portal);
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);
return service.login(
{
login: 'user@example.com',
password,
captchaId: captcha.captchaId,
captchaText: String(answer),
},
portal,
'203.0.113.10',
);
}
describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
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' }),
);
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
});
it('rejects enterprise admins on admin portal', async () => {
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);
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
});
it('locks user after five failed password attempts', async () => {
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) {
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
}
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 { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service';
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service';
export interface LoginDto {
login: string;
password: string;
captchaId: string;
captchaText: string;
}
import { MetricsService } from '../metrics/metrics.service';
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()
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 right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID();
captchaStore.set(captchaId, {
answer: String(left + right),
expiresAt: Date.now() + 5 * 60 * 1000,
});
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
return {
captchaId,
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();
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
}
this.verifyCaptcha(data.captchaId, data.captchaText);
this.assertAnonymousNotLocked(login);
await this.verifyCaptcha(data.captchaId, data.captchaText);
await this.assertAnonymousNotLocked(login, sourceIp);
const user = await this.users.findByLogin(login);
if (!user) {
this.recordAnonymousFailure(login);
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('Invalid login or password');
}
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');
}
if (user.status !== 'active' || user.deletedAt) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
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);
throw new UnauthorizedException('Invalid login or password');
}
const roleCodes = user.roles.map((item) => item.role.code);
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only platform admins can login to admin portal');
}
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
}
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);
return {
@@ -105,30 +103,25 @@ export class AuthService {
return this.sessions.markReauthenticated(token);
}
private verifyCaptcha(captchaId?: string, captchaText?: string) {
const record = captchaId ? captchaStore.get(captchaId) : undefined;
captchaStore.delete(captchaId ?? '');
if (!record || record.expiresAt < Date.now()) {
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
if (!answer) {
throw new BadRequestException('Captcha expired, refresh and try again');
}
if (record.answer !== captchaText?.trim()) {
if (answer !== captchaText?.trim()) {
throw new BadRequestException('Captcha is incorrect');
}
}
private assertAnonymousNotLocked(login: string) {
const current = anonymousFailures.get(login);
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
private async assertAnonymousNotLocked(login: string, sourceIp: string) {
const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
if (scope) {
this.metrics.recordAuthProtectionResult('login_locked', scope);
throw new UnauthorizedException(
scope === 'account'
? 'User is locked for 24 hours after repeated failures'
: 'Too many login attempts from this source, try again later',
);
}
}
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';
const record = {
@@ -6,10 +6,10 @@ const record = {
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 {
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`);
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 middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request();
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
});
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 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 () => {
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 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 () => {
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 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');
@@ -62,6 +62,33 @@ describe('SessionValidationMiddleware', () => {
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
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 () => {
+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 { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
@@ -8,6 +8,7 @@ export type SessionRequest = {
url?: string;
sessionUserId?: string;
sessionToken?: string;
sessionTenantId?: string;
authSession?: AuthSessionRecord;
};
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
const user = await this.prisma.user.findUnique({
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) {
await this.sessions.remove(token);
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
request.sessionUserId = user.id;
request.sessionToken = token;
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);
if (result.status === 'locked' && !isSessionRecoveryRoute) {
if (result.newlyLocked) {
+122 -6
View File
@@ -1,14 +1,43 @@
const values = new Map<string, string>();
const redis = {
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'); }),
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
getdel: jest.fn((key: string) => {
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(),
};
jest.mock('ioredis', () => jest.fn(() => redis));
import { SessionService } from './session.service';
import { ServiceUnavailableException } from '@nestjs/common';
describe('SessionService', () => {
let now = 1_700_000_000_000;
@@ -47,7 +76,9 @@ describe('SessionService', () => {
const service = new SessionService();
const created = await service.create('user-1', 'client', 2);
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 () => {
@@ -55,7 +86,10 @@ describe('SessionService', () => {
const created = await service.create('user-1', 'admin', 2);
await service.lock(created.token);
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 () => {
@@ -66,8 +100,13 @@ describe('SessionService', () => {
expect(result.status).toBe('active');
if (result.status === 'active' && 'token' in result) {
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(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
await expect(service.validate(created.token, false)).resolves.toEqual({
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');
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' };
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 DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_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));
}
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) {
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
}
@@ -138,7 +248,10 @@ export class SessionService implements OnModuleDestroy {
}
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) {
@@ -172,7 +285,7 @@ export class SessionService implements OnModuleDestroy {
private async read(token: string): Promise<AuthSessionRecord | null> {
try {
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 {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
@@ -195,6 +308,14 @@ export class SessionService implements OnModuleDestroy {
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() {
if (!this.redis) {
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 { 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 { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import {
@@ -125,14 +128,15 @@ export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@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
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.billing.listRechargeOrders(tenantId);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
@UsePipes(strictValidationPipe)
estimateSmsCost(@CurrentTenantId() tenantId: string, @Body() body: ClientBillingEstimateDto) {
return this.billing.estimateSmsCost({ ...body, tenantId });
}
}
@@ -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 { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { CertificationService, ReviewCertificationDto } from './certification.service';
@ApiTags('client-certification')
@Controller('client/enterprise-certification')
@@ -10,13 +12,14 @@ export class ClientCertificationController {
constructor(private readonly certifications: CertificationService) {}
@Get()
list(@TenantId() tenantId?: string) {
list(@CurrentTenantId() tenantId: string) {
return this.certifications.list(tenantId);
}
@Post()
submit(@Body() body: SubmitCertificationDto) {
return this.certifications.submit(body);
@UsePipes(strictValidationPipe)
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
return this.certifications.submit({ ...body, tenantId });
}
}
@@ -0,0 +1,53 @@
import { ChannelConfigurationService } from './channel-configuration.service';
import { selectChannelCandidate } from '../send-chain/send-chain.helpers';
describe('carrier capability reduction', () => {
it('removes incompatible group members in a transaction without reconnecting', async () => {
const channel = {
id: 'c',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
status: 'active',
config: {},
};
const prisma = {
$transaction: jest.fn(),
smsChannel: {
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })),
},
operationLog: { create: jest.fn() },
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([{ id: 'member', group: { name: 'existing', carrier: 'telecom' } }]),
deleteMany: jest.fn(),
},
};
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
carriers: ['mobile', 'unicom'],
});
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
);
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['member'] } } });
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
const candidate = {
channelId: 'c',
carrier: 'telecom',
channel: {
...channel,
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
},
};
expect(
selectChannelCandidate([candidate], {
carrier: 'telecom',
excludedChannelIds: new Set(),
approvedChannelIds: new Set(['c']),
}),
).toBeUndefined();
});
});
+120 -88
View File
@@ -1,17 +1,26 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
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 { 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 type { ChangeChannelStatusDto, CreateChannelDto, UpdateChannelDto } from './channels.contracts';
import {
channelConnectionSettingsChanged,
currentShanghaiDayRange,
legacyCarrierFromCapabilities,
normalizeBusinessCarrier,
normalizeChannelCarriers,
normalizeChannelRateLimit,
normalizeChannelRuntimeConfig,
normalizeCmppVersion,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelConfigurationService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
listChannels() {
return this.prisma.smsChannel.findMany({
@@ -20,7 +29,13 @@ export class ChannelConfigurationService {
});
}
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;
}) {
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 where: Prisma.SmsChannelWhereInput = {
@@ -44,12 +59,18 @@ export class ChannelConfigurationService {
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))
.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 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);
@@ -122,39 +143,28 @@ export class ChannelConfigurationService {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined
|| data.desiredConnections !== undefined
|| data.windowSize !== undefined
|| data.heartbeatIntervalSeconds !== undefined
|| data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const config =
data.config !== undefined ||
data.desiredConnections !== undefined ||
data.windowSize !== undefined ||
data.heartbeatIntervalSeconds !== undefined ||
data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond =
data.rateLimitPerSecond === undefined ? undefined : 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 carriers =
data.carriers !== undefined || data.carrier !== undefined
? normalizeChannelCarriers(data.carriers, data.carrier)
: existingCarriers;
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort,
@@ -163,50 +173,72 @@ export class ChannelConfigurationService {
cmppVersion: cmppVersion ?? channel.cmppVersion,
config: config ?? channel.config,
});
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
await this.prisma.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: data,
} as Prisma.InputJsonValue,
},
const updated = await this.prisma.$transaction(async (tx) => {
const updated = await tx.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
const removedGroupItems =
data.carriers !== undefined || data.carrier !== undefined
? await tx.smsChannelGroupItem.findMany({
where: { channelId, group: { carrier: { notIn: carriers } } },
select: {
id: true,
groupId: true,
carrier: true,
province: true,
group: { select: { name: true, carrier: true } },
},
})
: [];
if (removedGroupItems.length)
await tx.smsChannelGroupItem.deleteMany({ where: { id: { in: removedGroupItems.map((item) => item.id) } } });
await tx.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
removedGroupItems,
} as Prisma.InputJsonValue,
},
});
return updated;
});
const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
+486 -119
View File
@@ -1,13 +1,31 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
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 { 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';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateReportFieldDto,
ReplaceReportFieldsDto,
CreateReportMaterialDto,
CreateReportTaskDto,
ChangeReportTaskStatusesDto,
CreateReportExportDto,
CreateReceiptImportDto,
} from './channels.contracts';
import {
parseReceiptContent,
deriveReceiptStatus,
ChannelReportDeliveryRow,
summarizeChannelReportDelivery,
latestDate,
currentShanghaiDayRange,
normalizeSpreadsheetSize,
normalizeBusinessCarrier,
normalizeChannelCarriers,
normalizeReportType,
summarizeReportStatuses,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelReportingService {
@@ -69,6 +87,9 @@ export class ChannelReportingService {
for (const legacy of legacyBoth) {
if (oppositeCodes.has(legacy.code)) continue;
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
void _id;
void _createdAt;
void _updatedAt;
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
}
for (const [index, configured] of data.fields.entries()) {
@@ -94,7 +115,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' }],
});
});
}
@@ -135,7 +160,12 @@ export class ChannelReportingService {
const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: {
tenantId,
status,
status:
status === 'reporting' || status === 'exporting'
? { in: ['reporting', 'exporting'] }
: status === 'failed'
? { in: ['failed', 'rejected'] }
: status,
channelId,
reportType,
signature: { auditStatus: { not: 'deleted' } },
@@ -165,7 +195,12 @@ export class ChannelReportingService {
SELECT
submit."channelId" AS channel_id,
message."signatureId" AS signature_id,
message.carrier AS carrier,
message."drainageInfoId" AS drainage_info_id,
CASE WHEN COALESCE(submit."drainageGate", message."drainageGate") IS NULL THEN NULL ELSE ARRAY(
SELECT DISTINCT material_id FROM jsonb_array_elements(COALESCE(COALESCE(submit."drainageGate", message."drainageGate")->'targets', '[]'::jsonb)) target
CROSS JOIN LATERAL jsonb_array_elements_text(target->'materialIds') AS ids(material_id)
) END AS drainage_ids,
submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE
@@ -209,13 +244,16 @@ export class ChannelReportingService {
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND NOT (COALESCE(submit."errorCode", '') LIKE 'DRN%' AND submit."firstWireSubmitAt" IS NULL)
AND submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)})
)
SELECT
channel_id AS "channelId",
signature_id AS "signatureId",
carrier,
drainage_info_id AS "drainageInfoId",
drainage_ids AS "drainageIds",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total,
@@ -241,15 +279,20 @@ export class ChannelReportingService {
)::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base
GROUP BY channel_id, signature_id, drainage_info_id
GROUP BY channel_id, signature_id, drainage_info_id, carrier, drainage_ids
`);
return tasks.map((task) => {
const taskRows = rows.filter((row) => (
row.channelId === task.channelId
&& row.signatureId === task.signatureId
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
));
const taskRows = rows.filter(
(row) =>
row.channelId === task.channelId &&
row.signatureId === task.signatureId &&
(!task.carrier || row.carrier === task.carrier) &&
((task.reportType ?? 'signature') === 'signature' ||
(row.drainageIds
? row.drainageIds.includes(task.drainageItemId ?? '')
: row.drainageInfoId === task.drainageItemId)),
);
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
...task,
@@ -261,10 +304,15 @@ export class ChannelReportingService {
async listReportTasksPage(query: {
tenantId?: string;
applicationId?: string;
status?: string;
channelId?: string;
reportType?: string;
keyword?: string;
carrier?: string;
todaySendMin?: number;
todaySendMax?: number;
sort?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
@@ -273,47 +321,194 @@ export class ChannelReportingService {
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 where: Prisma.ChannelSignatureReportTaskWhereInput = {
tenantId: query.tenantId,
status: query.status,
channelId: query.channelId,
reportType: query.reportType,
signature: { auditStatus: { not: 'deleted' } },
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 ? [
{ id: { contains: keyword } },
{ channel: { name: { contains: keyword } } },
{ signature: { name: { contains: keyword } } },
{ signature: { tenant: { name: { contains: keyword } } } },
{ signature: { application: { name: { contains: keyword } } } },
{ drainageInfo: { siteName: { contains: keyword } } },
{ drainageInfo: { url: { contains: keyword } } },
] : undefined,
};
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,
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;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: 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 signatures = await this.prisma.smsSignature.findMany({
where: {
id: query.signatureId,
tenantId: query.tenantId,
applicationId: query.applicationId,
auditStatus: 'approved',
},
include: {
tenant: true,
application: true,
drainageItems: { where: { auditStatus: 'approved' } },
reportTasks: {
include: {
channel: true,
drainageInfo: true,
exportItems: {
include: { exportFile: true, batchItem: { include: { batch: true } } },
orderBy: { id: 'desc' },
take: 1,
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSignatureReportTask.count({ where }),
]);
return { items, total, page, pageSize };
},
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
});
const applicationIds = [
...new Set(signatures.map((item) => item.applicationId).filter((id): id is string => Boolean(id))),
];
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.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => {
const tasks = signature.reportTasks.filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id,
);
const existing = selectDrainageReportTask(tasks, channel.id, carrier);
return existing
? [
{
...existing,
id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`,
carrier,
virtual: !existing.carrier,
signature,
},
]
: [];
}),
),
);
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;
const matches = (value: string | null | undefined, filter?: string) =>
!filter?.trim() || (value ?? '').includes(filter.trim());
if (!matches(task.signature.tenant.name, query.enterpriseKeyword)) return false;
if (!matches(task.signature.application?.name, query.applicationKeyword)) return false;
if (!matches(task.channel.name, query.channelKeyword)) return false;
const objects =
task.reportType === 'drainage'
? [task.drainageInfo?.siteName, task.drainageInfo?.url]
: [task.signature.name];
if (query.objectKeyword?.trim() && !objects.some((value) => matches(value, query.objectKeyword))) 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) {
@@ -321,7 +516,8 @@ export class ChannelReportingService {
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage') {
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('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
}
@@ -333,7 +529,13 @@ export class ChannelReportingService {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
const existing = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null },
where: {
signatureId: data.signatureId,
channelId: data.channelId,
carrier,
reportType: 'signature',
drainageItemId: null,
},
});
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
const task = await this.prisma.channelSignatureReportTask.create({
@@ -355,7 +557,15 @@ export class ChannelReportingService {
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
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) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
@@ -364,43 +574,116 @@ export class ChannelReportingService {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...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 signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
}
const signatureIds = [
...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;
carrier: string | null;
status: string;
}> = [];
for (const item of data.items) {
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 channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
if (reportType === 'drainage') {
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.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId)
throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
const carrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
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 existing = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier,
},
});
if (reportType === 'drainage' && !existing) {
const legacy = carrier
? await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: item.drainageItemId,
carrier: null,
},
})
: null;
if (!legacy) 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
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } })
: 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 });
? await tx.channelSignatureReportTask.update({
where: { id: existing.id },
data: { status: item.status, reason: data.reason, approvedAt },
})
: 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,
carrier,
status: item.status,
});
}
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];
});
}
@@ -408,28 +691,55 @@ export class ChannelReportingService {
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
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 routes = signature.applicationId
? await tx.channelRouteRule.findMany({
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 channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = targets.map((channel) => {
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 carrierReportSummary = Object.fromEntries(
['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
);
const statuses = targets.map((channel) => {
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 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;
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
@@ -487,6 +797,11 @@ export class ChannelReportingService {
async listReportRecordsPage(query: {
taskId?: string;
channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string;
reportType?: string;
createdAtFrom?: string;
@@ -497,23 +812,51 @@ export class ChannelReportingService {
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 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 = {
taskId: query.taskId,
channelId: query.channelId,
task: query.reportType ? { reportType: query.reportType } : 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,
statusAfter: query.statusAfter,
action: query.action,
sourceEntry: query.sourceEntry,
operatorId: operatorIds ? { in: operatorIds } : undefined,
task:
query.reportType || query.batchNo
? {
reportType: query.reportType,
exportItems: query.batchNo
? { some: { batchItem: { batch: { batchNo: { contains: query.batchNo.trim() } } } } }
: 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([
this.prisma.channelSignatureReportRecord.findMany({
@@ -525,11 +868,30 @@ export class ChannelReportingService {
}),
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) {
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) {
throw new NotFoundException('Report task not found');
}
@@ -552,8 +914,13 @@ export class ChannelReportingService {
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.prisma.channelSignatureReportTask.findUnique({
where: { id: taskId },
select: { reportType: true, status: true, approvedAt: true },
})
)?.reportType === 'signature'
? { approvedAt: statusAfter === 'approved' ? (statusBefore === 'approved' ? undefined : new Date()) : null }
: {}),
},
});
+16 -12
View File
@@ -1,18 +1,24 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { moneyToNumber } from '../common/money';
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 { 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 type { TestChannelDto } from './channels.contracts';
import {
normalizeTestPhones,
normalizeTestContent,
normalizeGatewayConnectionStatus,
calculateBillingUnits,
buildChannelTestSubmitCommand,
} from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelTestService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
async testChannel(channelId: string, data: TestChannelDto = {}) {
const phoneNumbers = normalizeTestPhones(data);
@@ -27,8 +33,8 @@ export class ChannelTestService {
if (channel.status !== 'active') {
throw new BadRequestException('通道未启用,不能发送测试短信');
}
const connectedState = channel.connectionStates.find((state) =>
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
const connectedState = channel.connectionStates.find(
(state) => normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
);
if (!connectedState) {
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
@@ -36,7 +42,6 @@ export class ChannelTestService {
const createdAt = new Date();
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const drainageDetection = await detectDrainageContent(this.prisma, content);
const results = [];
for (const [index, phoneNumber] of phoneNumbers.entries()) {
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
@@ -51,7 +56,6 @@ export class ChannelTestService {
messageId,
phoneNumber,
content,
...drainageDetection,
billingUnits: calculateBillingUnits(content),
unitPrice: 0,
amountCents: 0,
+143 -10
View File
@@ -27,10 +27,19 @@ import { ChannelsService } from './channels.service';
@ApiTags('channels')
@Controller('admin')
export class ChannelsController {
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {}
constructor(
private readonly channels: ChannelsService,
private readonly deletions: DeletionGovernanceService,
) {}
@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
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
: this.channels.listChannels();
@@ -68,7 +77,11 @@ export class ChannelsController {
@Delete('channels/:id')
@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 });
}
@@ -159,7 +172,11 @@ export class ChannelsController {
@Put('channels/:channelId/report-fields/:reportType')
@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);
}
@@ -174,12 +191,90 @@ export class ChannelsController {
}
@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) {
return page || pageSize || keyword || createdAtFrom || createdAtTo
? this.channels.listReportTasksPage({ tenantId, status, channelId, reportType, keyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
listReportTasks(
@Query('tenantId') tenantId?: string,
@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);
}
@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('enterpriseKeyword') enterpriseKeyword?: string,
@Query('applicationKeyword') applicationKeyword?: string,
@Query('channelKeyword') channelKeyword?: string,
@Query('objectKeyword') objectKeyword?: 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,
enterpriseKeyword,
applicationKeyword,
channelKeyword,
objectKeyword,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('report-tasks/generate')
createReportTask(@Body() body: CreateReportTaskDto) {
return this.channels.createReportTask(body);
@@ -202,9 +297,47 @@ export class ChannelsController {
}
@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) {
return page || pageSize || keyword || reportType || createdAtFrom || createdAtTo
? this.channels.listReportRecordsPage({ taskId, channelId, keyword, reportType, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
listReportRecords(
@Query('taskId') taskId?: string,
@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);
}
}
+138 -45
View File
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
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 { CreateChannelGroupItemDto, TestChannelDto } from './channels.contracts';
export function summarizeReportStatuses(statuses: string[]) {
return summarizeCommonReportStatuses(statuses);
@@ -141,7 +141,11 @@ export function buildChannelTestSubmitCommand({
account: channel.account,
passwordCipher: channel.passwordCipher,
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
desiredConnections: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'desiredConnections'),
1,
'desiredConnections',
),
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
@@ -254,23 +258,22 @@ export function getRuntimeConfigInteger(
return Number.isInteger(value) && value > 0 ? value : fallback;
}
export function channelConnectionSettingsChanged(
before: ChannelConnectionSettings,
after: ChannelConnectionSettings,
) {
return before.gatewayHost !== after.gatewayHost
|| before.gatewayPort !== after.gatewayPort
|| before.account !== after.account
|| before.passwordCipher !== after.passwordCipher
|| before.cmppVersion !== after.cmppVersion
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
export function channelConnectionSettingsChanged(before: ChannelConnectionSettings, after: ChannelConnectionSettings) {
return (
before.gatewayHost !== after.gatewayHost ||
before.gatewayPort !== after.gatewayPort ||
before.account !== after.account ||
before.passwordCipher !== after.passwordCipher ||
before.cmppVersion !== after.cmppVersion ||
getRuntimeConfigInteger(before.config, 'desiredConnections', 1) !==
getRuntimeConfigInteger(after.config, 'desiredConnections', 1) ||
getRuntimeConfigInteger(before.config, 'windowSize', 16) !==
getRuntimeConfigInteger(after.config, 'windowSize', 16) ||
getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) !==
getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) ||
getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) !==
getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
);
}
export function channelGroupAuditSnapshot(group: {
@@ -320,19 +323,49 @@ export function normalizeChannelRuntimeConfig(
heartbeatIntervalSeconds?: number,
heartbeatMissThreshold?: number,
) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown>
: {};
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
? incomingConfig
: {};
const existing =
existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? (existingConfig as Record<string, unknown>)
: {};
const incoming =
incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {};
const base = { ...existing, ...incoming };
base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections');
base.desiredConnections = boundedRuntimeInteger(
desiredConnections ?? base.desiredConnections,
1,
8,
1,
'desiredConnections',
);
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.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(
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
@@ -423,13 +456,19 @@ export function getPositiveIntegerEnv(name: string, fallback: number) {
}
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const lines = content
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
throw new BadRequestException('Receipt file is empty');
}
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
const firstCells = splitReceiptLine(lines[0], separator);
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
const hasHeader = firstCells.some((cell) =>
['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()),
);
const header = hasHeader ? firstCells : [];
const rows = hasHeader ? lines.slice(1) : lines;
const statusIndex = findReceiptStatusIndex(header);
@@ -445,7 +484,7 @@ export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
failedCount += 1;
}
return {
rowNumber: (hasHeader ? index + 2 : index + 1),
rowNumber: hasHeader ? index + 2 : index + 1,
phone: cells[0] ?? '',
status: normalizedStatus,
rawStatus,
@@ -504,10 +543,39 @@ export function findReceiptStatusIndex(header: string[]) {
export function normalizeReceiptStatus(value: string) {
const normalized = value.trim().toLowerCase();
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
if (
[
'success',
'succeeded',
'approved',
'completed',
'ok',
'pass',
'passed',
'通过',
'成功',
'已完成',
'报备成功',
].includes(normalized)
) {
return 'success';
}
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
if (
[
'failed',
'fail',
'rejected',
'reject',
'error',
'no',
'denied',
'驳回',
'失败',
'不通过',
'拒绝',
'报备失败',
].includes(normalized)
) {
return 'failed';
}
return 'failed';
@@ -524,6 +592,8 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
}
export type ChannelReportDeliveryRow = {
drainageIds?: string[] | null;
carrier: string | null;
channelId: string;
signatureId: string;
drainageInfoId: string | null;
@@ -557,10 +627,13 @@ export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[])
};
}
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>) {
export function sumReportDelivery(
rows: ChannelReportDeliveryRow[],
key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>,
) {
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
}
@@ -580,7 +653,11 @@ export function currentShanghaiDayRange(now = new Date()) {
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
}
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
export function normalizeRetryTimeLimitMinutes(
minutes: number | undefined,
hours: number | undefined,
fallbackMinutes: number,
) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
@@ -588,7 +665,12 @@ export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hour
return value;
}
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
export function normalizeSpreadsheetSize(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
) {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.round(value)));
}
@@ -602,7 +684,9 @@ export function normalizeBusinessCarrier(carrier?: string | null) {
}
export function normalizeChannelCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase();
const value = String(carrier ?? '')
.trim()
.toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
@@ -631,12 +715,18 @@ export function legacyCarrierFromCapabilities(carriers: string[]) {
return 'multi';
}
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
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) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
return String(region ?? '')
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
.trim();
}
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
@@ -646,7 +736,10 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
export function validateGroupItems(
groupCarrier: string,
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
channels: Map<string, { id: string; carrier?: string | null; carriers?: 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 provinces = new Set<string>();
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -1,6 +1,24 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
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 { ChannelConnectionService } from './channel-connection.service';
import { ChannelCopyService } from './channel-copy.service';
@@ -42,7 +60,13 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
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);
}
@@ -152,10 +176,15 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async listReportTasksPage(query: {
tenantId?: string;
applicationId?: string;
status?: string;
channelId?: string;
reportType?: string;
keyword?: string;
carrier?: string;
todaySendMin?: number;
todaySendMax?: number;
sort?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
@@ -164,6 +193,27 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return this.reporting.listReportTasksPage(query);
}
async listReportDetailsPage(query: {
tenantId?: string;
applicationId?: string;
signatureId?: string;
channelId?: string;
carrier?: string;
status?: string;
reportType?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
return this.reporting.listReportDetailsPage(query);
}
async createReportTask(data: CreateReportTaskDto) {
return this.reporting.createReportTask(data);
}
@@ -187,6 +237,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async listReportRecordsPage(query: {
taskId?: string;
channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string;
reportType?: 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;
}
+13
View File
@@ -0,0 +1,13 @@
/** A carrier-specific decision overrides a legacy channel decision, including rejection. */
export function selectDrainageReportTask<
T extends {
channelId: string;
carrier?: string | null;
approvalScope?: string;
},
>(tasks: T[], channelId: string, carrier?: string) {
return (
(carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ??
tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific')
);
}
+22
View File
@@ -0,0 +1,22 @@
import { parse } from 'tldts';
import { isIP } from 'node:net';
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() ?? '';
const normalized = target.normalize('NFKC');
if (!target || !DRAINAGE_TARGET_PATTERN.test(normalized)) return undefined;
if (/[a-z]/i.test(normalized) && !/ext\.?/i.test(normalized)) {
try {
const host = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`).hostname;
if (!isIP(host) && !parse(host, { allowPrivateDomains: true }).domain) return undefined;
} catch {
return undefined;
}
}
return target;
}
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map } from 'rxjs/operators';
import { protocolFieldsToJson } from './protocol-uint32';
@Injectable()
export class ProtocolFieldsInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(map(protocolFieldsToJson));
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
protocolFieldsToJson,
protocolUint32,
protocolUint32FromDb,
protocolUint32ToDb,
parseProtocolSequence,
} from './protocol-uint32';
describe('CMPP unsigned protocol fields', () => {
it.each([0, 2147483647, 2147483648, 4294967295])(
'round trips %s without changing the JSON number contract',
(value) => {
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
expect(
JSON.parse(
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
),
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
},
);
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
expect(() => protocolUint32(value)).toThrow();
expect(() => protocolUint32ToDb(value)).toThrow();
});
it('preserves optional historical nulls and unrelated serializers', () => {
expect(protocolUint32ToDb(null)).toBeUndefined();
expect(protocolUint32FromDb(null)).toBeUndefined();
const date = new Date();
expect(
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
});
it('distinguishes text zero from missing or malformed historical sequences', () => {
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
expect(parseProtocolSequence(value)).toBeUndefined();
expect(parseProtocolSequence('0')).toBe(0);
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
}
return value;
}
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
return value == null ? undefined : BigInt(protocolUint32(value, field));
}
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
if (value == null) return undefined;
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
}
/** Historical Submit sequence columns are text; blanks must never become zero. */
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
if (value == null || !/^\d+$/.test(value)) return undefined;
const number = Number(value);
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
}
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
export function protocolFieldsToJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
? protocolUint32FromDb(item)
: protocolFieldsToJson(item),
]),
);
}
+2
View File
@@ -4,10 +4,12 @@ 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);
});
+2
View File
@@ -11,8 +11,10 @@ export function summarizeReportStatuses(statuses: string[]): ReportStatusSummary
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.
+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 { ApiTags } from '@nestjs/swagger';
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 { TenantId } from '../common/tenant-id.decorator';
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
@ApiTags('deletion-governance')
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@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);
}
@Post(':type/:id')
@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);
}
}
@@ -0,0 +1,23 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
@Controller('admin/dictionaries/channel-sensitive-words')
export class ChannelSensitiveWordsController {
constructor(private readonly service: ChannelSensitiveWordsService) {}
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
return this.service.list(userId, query);
}
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
return this.service.save(userId, body);
}
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
return this.service.save(userId, body, id);
}
@Delete(':id') remove(
@CurrentSessionUserId() userId: string,
@Param('id') id: string,
@Body() body: { version?: unknown },
) {
return this.service.remove(userId, id, body?.version);
}
}
@@ -0,0 +1,40 @@
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
import { PrismaService } from '../prisma/prisma.service';
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
describe('channel word administration', () => {
it('trims only outer whitespace and requires a version for editing', () => {
expect(validateChannelWord(valid).word).toBe('贷 款');
expect(() => validateChannelWord(valid, true)).toThrow('版本');
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
});
it.each([
null,
[],
{ ...valid, word: ' ' },
{ ...valid, word: 'a'.repeat(201) },
{ ...valid, channelId: '' },
{ ...valid, status: 'deleted' },
{ ...valid, remark: 'a'.repeat(501) },
{ ...valid, operatorId: 'spoof' },
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
it('checks active platform admin permission before data access', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue(null) },
channelSensitiveWord: { findMany: jest.fn() },
};
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
}),
);
});
it('rejects invalid pagination before querying rules', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
});
});
@@ -0,0 +1,157 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export function validateChannelWord(value: unknown, editing = false) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
const data = value as Record<string, unknown>;
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
throw new BadRequestException('包含不支持的字段');
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
throw new BadRequestException('请选择通道');
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
throw new BadRequestException('敏感词需为1200个字符');
if (typeof data.status !== 'string' || !['active', 'inactive'].includes(data.status))
throw new BadRequestException('状态无效');
if (data.remark !== undefined && (typeof data.remark !== 'string' || data.remark.length > 500))
throw new BadRequestException('备注最多500个字符');
if (editing && (!Number.isSafeInteger(data.version) || Number(data.version) < 1))
throw new BadRequestException('请提供规则版本');
return {
channelId: data.channelId.trim(),
word: data.word.trim(),
status: data.status as string,
remark: (data.remark as string | undefined) ?? '',
version: editing ? Number(data.version) : undefined,
};
}
@Injectable()
export class ChannelSensitiveWordsService {
constructor(private readonly prisma: PrismaService) {}
async authorize(userId?: string) {
if (
!userId ||
!(await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } },
select: { id: true },
}))
)
throw new ForbiddenException('无敏感词管理权限');
}
async list(userId: string | undefined, query: Record<string, string | undefined>) {
await this.authorize(userId);
const page = Number(query.page ?? 1),
pageSize = Number(query.pageSize ?? 25);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
throw new BadRequestException('分页参数无效');
if (query.status && !['all', 'active', 'inactive'].includes(query.status))
throw new BadRequestException('状态无效');
if (query.keyword && (typeof query.keyword !== 'string' || query.keyword.length > 200))
throw new BadRequestException('搜索词过长');
if (query.channelId && typeof query.channelId !== 'string') throw new BadRequestException('通道参数无效');
const where: Prisma.ChannelSensitiveWordWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
channelId: query.channelId || undefined,
word: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [items, total] = await this.prisma.$transaction([
this.prisma.channelSensitiveWord.findMany({
where,
include: { channel: { select: { id: true, name: true, status: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSensitiveWord.count({ where }),
]);
return { items, total, page, pageSize };
}
async save(userId: string | undefined, value: unknown, id?: string) {
await this.authorize(userId);
const data = validateChannelWord(value, Boolean(id));
try {
return await this.prisma.$transaction(async (tx) => {
if (
!(await tx.smsChannel.findFirst({
where: { id: data.channelId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('通道不存在或已删除');
const current = id
? await tx.channelSensitiveWord.findUnique({ where: { id } })
: await tx.channelSensitiveWord.findUnique({
where: { channelId_word: { channelId: data.channelId, word: data.word } },
});
if (id && (!current || current.status === 'deleted')) throw new NotFoundException('规则不存在或已删除');
if (!id && current && current.status !== 'deleted') throw new ConflictException('该通道已配置相同敏感词');
const fields = {
channelId: data.channelId,
word: data.word,
status: data.status,
remark: data.remark,
updatedBy: userId!,
};
let saved;
if (current) {
const result = await tx.channelSensitiveWord.updateMany({
where: { id: current.id, version: id ? data.version : current.version },
data: { ...fields, version: { increment: 1 } },
});
if (result.count !== 1) throw new ConflictException('规则已被修改,请刷新后重试');
saved = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id: current.id } });
} else saved = await tx.channelSensitiveWord.create({ data: { ...fields, createdBy: userId! } });
await tx.operationLog.create({
data: {
userId,
action:
current?.status === 'deleted'
? 'channel_sensitive_word.restore'
: id
? 'channel_sensitive_word.update'
: 'channel_sensitive_word.create',
resource: 'channel_sensitive_word',
resourceId: saved.id,
detail: JSON.parse(JSON.stringify({ before: current, after: saved })),
},
});
return saved;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new ConflictException('该通道已配置相同敏感词');
throw error;
}
}
async remove(userId: string | undefined, id: string, version: unknown) {
await this.authorize(userId);
if (!Number.isSafeInteger(version) || Number(version) < 1) throw new BadRequestException('请提供规则版本');
return this.prisma.$transaction(async (tx) => {
const before = await tx.channelSensitiveWord.findUnique({ where: { id } });
if (!before || before.status === 'deleted') throw new NotFoundException('规则不存在或已删除');
const result = await tx.channelSensitiveWord.updateMany({
where: { id, version: Number(version) },
data: { status: 'deleted', version: { increment: 1 }, updatedBy: userId! },
});
if (!result.count) throw new ConflictException('规则已被修改,请刷新后重试');
const after = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id } });
await tx.operationLog.create({
data: {
userId,
action: 'channel_sensitive_word.delete',
resource: 'channel_sensitive_word',
resourceId: id,
detail: JSON.parse(JSON.stringify({ before, after })),
},
});
return { deleted: true };
});
}
}
@@ -12,6 +12,8 @@ import {
CreateSensitiveWordDto,
DictionariesService,
DictionaryStatusDto,
ReorderCommonReportFieldsDto,
UpdateDrainageFieldDto,
} from './dictionaries.service';
@ApiTags('dictionaries')
@@ -128,6 +130,15 @@ export class DictionariesController {
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')
deleteDrainageField(@Param('id') id: string) {
return this.dictionaries.deleteDrainageField(id);
@@ -168,8 +179,21 @@ export class DictionariesController {
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')
deleteCommonReportField(@Param('id') id: string) {
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);
}
}
+4 -2
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
import { ChannelSensitiveWordsController } from './channel-sensitive-words.controller';
import { DictionariesController } from './dictionaries.controller';
import { DictionariesService } from './dictionaries.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
@Module({
controllers: [DictionariesController],
providers: [DictionariesService, PhoneRoutingLookupService],
controllers: [DictionariesController, ChannelSensitiveWordsController],
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
exports: [DictionariesService, PhoneRoutingLookupService],
})
export class DictionariesModule {}
@@ -33,6 +33,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
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' }),
},
channelReportField: {
@@ -46,6 +47,7 @@ function createPrismaMock() {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
update: jest.fn(),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
@@ -58,6 +60,60 @@ function createPrismaMock() {
}
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([
@@ -112,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 () => {
const prisma = createPrismaMock();
const tx = {
+130 -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 { PrismaService } from '../prisma/prisma.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
export interface UpsertDrainageDetectionRuleDto {
code: string;
name: string;
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
sortOrder?: number;
}
export interface ReorderCommonReportFieldsDto {
reportType: 'signature' | 'drainage';
ids: string[];
}
export interface DictionaryStatusDto {
status?: string;
operatorId?: string;
@@ -399,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) {
const [usageCount, commonUsageCount] = await Promise.all([
this.prisma.channelReportField.count({
@@ -541,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) {
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>) {
return this.prisma.operationLog.create({
data: {
+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 { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
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 DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@@ -14,14 +28,14 @@ export class ClientFilesController {
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
@UsePipes(strictValidationPipe)
upload(
@CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile,
@Body('purpose') purpose: string,
@Body('prefix') prefix?: string,
@Body() body: ClientFileUploadDto,
) {
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')
+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) {
return this.prisma.fileObject.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
}).then((items) => items.map(serializeFileObject));
return this.prisma.fileObject
.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
})
.then((items) => items.map(serializeFileObject));
}
create(data: CreateFileObjectDto) {
@@ -71,7 +73,7 @@ export class FilesService {
}
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 safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
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 prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
@@ -135,18 +141,33 @@ export class FilesService {
const IMAGE_UPLOAD_MAX_BYTES = 2 * 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;
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 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) {
throw new BadRequestException(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB');
throw new BadRequestException(
reportMaterialImport
? '报备资料文件大小不能超过 100MB'
: image
? '图片大小不能超过 2MB'
: '文件大小不能超过 10MB',
);
}
}
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;
}
+5 -1
View File
@@ -1,3 +1,6 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BillingService } from './billing/billing.service';
@@ -18,8 +21,9 @@ import { SendChainService } from './send-chain/send-chain.service';
MetricsModule,
ProtocolLogsModule,
],
controllers: [GatewayCallbackController],
controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
BillingService,
RiskReviewService,
PhoneFrequencyService,
+108
View File
@@ -0,0 +1,108 @@
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
id: 'a',
accepted: true,
costUnitPrice: 300n,
gatewayId: 'g1',
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
});
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
attemptId: 'a',
gatewayId,
status,
at: at(day),
approximate: false,
});
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
describe('homepage business receipt projection', () => {
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
const a = attempt(Array(size).fill('delivered'), size);
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
const result = homeFact({ ...message, billingUnits: size }, [a], events);
expect(result.successDay).toBe('2026-09-17');
expect(result.revenue).toBe(String(size * 500));
});
it('includes paid successful fragments of prior failed attempts once', () => {
const earlier = attempt(['delivered', 'failed', 'failed']);
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
const result = homeFact(
message,
[earlier, final],
[
event('g1', 'delivered'),
event('g2', 'failed'),
event('g3', 'failed'),
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
],
);
expect(result.units).toBe(3);
expect(result.revenue).toBe('1500');
expect(result.cost).toBe('1200');
});
it('accounts for all expected units when only one failure arrives', () => {
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
expect(f.units).toBe(3);
expect(f.successDay).toBeNull();
expect(f.receiptDays).toEqual(['2026-09-17']);
});
it('rejects partial success and missing parts even if message says delivered', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered'])],
[event('g1', 'delivered'), event('g2', 'delivered')],
);
expect(f.successDay).toBeNull();
expect(f.incomplete).toBe(true);
});
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered', 'delivered'])],
[
event('g1', 'delivered', 16),
event('g2', 'delivered', 16),
event('g3', 'delivered'),
event('g3', 'delivered', 18),
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
expect(f.revenue).toBe('1500');
expect(f.cost).toBe('900');
});
it('does not combine different attempts into a complete message', () => {
const f = homeFact(
message,
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
);
expect(f.successDay).toBeNull();
});
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
const a = attempt(['delivered', 'delivered', 'delivered']);
a.segments[1].inferred = a.segments[2].inferred = true;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
a.segments[1].inferred = false;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
});
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
const f = homeFact(
message,
[{ ...attempt([]), gatewayId: 'g1' }],
[
{ ...event('g1', 'delivered'), approximate: true },
{ ...event('bad', 'failed'), attemptId: null },
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.approximate).toBe(true);
expect(f.incomplete).toBe(true);
});
it('rejects invalid units and unsafe money without rounding', () => {
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
expect(safeMoney('12345')).toBe(12345);
expect(() => safeMoney(9007199254740992n)).toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { todayKey } from '../signature-analytics/analytics-date';
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
export type HomeAttempt = {
id: string;
accepted: boolean;
gatewayId: string | null;
costUnitPrice: bigint;
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
};
export type HomeFact = {
units: number;
delivered: boolean;
successDay: string | null;
successAt: string | null;
receiptDays: string[];
revenue: string;
cost: string;
approximate: boolean;
incomplete: boolean;
};
/** Pure projection: never writes a message status or invents missing supplier receipts. */
export function homeFact(
message: { billingUnits: number; unitPrice: bigint; status: string },
attempts: HomeAttempt[],
incoming: HomeEvent[],
): HomeFact {
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
const events = new Map<string, HomeEvent>();
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
if (!events.has(key)) events.set(key, event);
}
const successes: Date[] = [];
let cost = 0n;
let incomplete = !units || incoming.some((e) => !e.attemptId);
for (const attempt of attempts) {
if (!attempt.accepted) continue;
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
const successFor = (id: string | null) =>
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
if (!attempt.segments.length) {
const success = successFor(attempt.gatewayId);
if (success && units) {
successes.push(success.at);
cost += BigInt(units) * attempt.costUnitPrice;
} else if (receipts.length) incomplete = true;
continue;
}
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
const expected = Math.max(...attempt.segments.map((s) => s.total));
const times: Date[] = [];
for (const part of parts.values()) {
// A contractual message-level receipt can account for the explicitly inferred parts only.
const received =
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
if (part.status === 'delivered' && received) {
times.push(received.at);
cost += attempt.costUnitPrice;
}
}
const complete =
expected > 0 &&
parts.size === expected &&
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
if (!complete) incomplete = true;
}
const success =
message.status === 'delivered' && successes.length
? new Date(Math.min(...successes.map((s) => s.getTime())))
: null;
if (message.status === 'delivered' && !success) incomplete = true;
const effective = [...events.values()].filter((e) => !success || e.at <= success);
return {
units,
delivered: message.status === 'delivered',
successDay: success ? todayKey(success) : null,
successAt: success?.toISOString() ?? null,
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
cost: success ? cost.toString() : '0',
approximate: effective.some((e) => e.approximate),
incomplete,
};
}
export function safeMoney(value: bigint | string | number) {
const integer = BigInt(value);
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
throw new Error('金额超过安全展示范围');
return Number(integer);
}
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
+97
View File
@@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { sourceFacts } from './home-source';
import { pruneHomeVersions } from './home-retention';
@Injectable()
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
private timer?: NodeJS.Timeout;
private readonly logger = new Logger(HomeProjection.name);
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (
process.env.NODE_ENV === 'test' ||
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
)
return;
this.timer = setInterval(() => void this.run(), 10_000);
this.timer.unref();
void this.run();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async run() {
try {
await this.tick();
} catch (error) {
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
await this.db.homeProjectionState
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
.catch(() => undefined);
}
}
async tick(now = new Date()) {
return this.db.$transaction(
async (tx) => {
const [lock] = await tx.$queryRaw<
Array<{ locked: boolean }>
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
if (!lock.locked) return { busy: true };
const date = todayKey(now),
first = addDays(date, -3);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (state.seededDay !== date) {
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
}
const work = await tx.$queryRaw<
Array<{ messageRecordId: string }>
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
const ids = work.map((w) => w.messageRecordId),
version = state.version + 1;
const sources = await sourceFacts(tx, ids);
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
for (const id of ids) {
const next = sources.find((s) => s.message.id === id);
const old = prior.find((p) => p.messageRecordId === id);
const day = next ? todayKey(next.message.queuedAt) : '';
const payload = next && day >= first && day <= date ? next.fact : null;
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
if (old)
await tx.homeMessageFact.update({
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
data: { toVersion: version },
});
if (payload)
await tx.homeMessageFact.create({
data: {
messageRecordId: id,
fromVersion: version,
queuedDay: day,
payload: payload as unknown as Prisma.InputJsonValue,
},
});
}
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
const remaining = await tx.homeProjectionDirty.count();
await tx.homeProjectionState.update({
where: { id: 'home' },
data: {
version,
seededDay: date,
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
updatedAt: now,
lastError: null,
},
});
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
return { remaining, version };
},
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import { Prisma } from '@prisma/client';
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
import { percentage, safeMoney } from './home-fact';
type MetricRow = {
queuedDay: string;
total: bigint;
delivered: bigint;
units: bigint;
successUnits: bigint;
successMessages: bigint;
revenue: bigint;
cost: bigint;
approximate: bigint;
incomplete: bigint;
};
export function metrics(row?: MetricRow) {
const total = Number(row?.total ?? 0),
delivered = Number(row?.delivered ?? 0);
const units = Number(row?.units ?? 0),
success = Number(row?.successUnits ?? 0);
const revenue = safeMoney(row?.revenue ?? 0n),
cost = safeMoney(row?.cost ?? 0n);
return {
sent: total,
delivered: Number(row?.successMessages ?? 0),
successRate: percentage(delivered, total),
receiptUnits: units,
successUnits: success,
receiptSuccessRate: percentage(success, units),
revenueCents: revenue,
profitCents: revenue - cost,
profitRate: percentage(revenue - cost, revenue),
approximate: Number(row?.approximate ?? 0),
incomplete: Number(row?.incomplete ?? 0),
};
}
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
}
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const rows = await tx.$queryRaw<
Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
todayReturnedCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>
>`
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
return rows.map((r) => ({
...r,
todaySpendCents: safeMoney(r.todaySpendCents),
todayReturnedCents: safeMoney(r.todayReturnedCents),
balanceCents: safeMoney(r.balanceCents),
creditCents: safeMoney(r.creditCents),
}));
}
export async function operationStatus(tx: Prisma.TransactionClient) {
const window = downstreamAlertWindows();
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
await Promise.all([
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
tx.smsBatchTask.count(),
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
tx.cmppDownstreamDelivery.count({
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
}),
]);
return {
taskCount,
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
};
}
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
GROUP BY hour ORDER BY hour`);
const byHour = new Map(rows.map((row) => [row.hour, row]));
return Array.from({ length: 24 }, (_, hour) => ({
hour,
label: String(hour).padStart(2, '0') + ':00',
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
successCount: Number(byHour.get(hour)?.successCount ?? 0),
}));
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
/** Only disposable dashboard projections; never source SMS, receipt or accounting records. */
export async function pruneHomeVersions(tx: Prisma.TransactionClient, now: Date, firstDay: string, version: number) {
const active = await tx.homeSnapshot.aggregate({ where: { expiresAt: { gt: now } }, _min: { version: true } });
const minimum = active._min.version ?? version;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "toVersion"<=${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "queuedDay"<${firstDay}
AND "fromVersion"<${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeSnapshot" WHERE id IN (SELECT id FROM "HomeSnapshot"
WHERE "expiresAt"<${new Date(now.getTime() - 86400000)} LIMIT 1000)`;
}
+83
View File
@@ -0,0 +1,83 @@
import { Prisma } from '@prisma/client';
import { homeFact, type HomeEvent } from './home-fact';
export async function sourceFacts(tx: Prisma.TransactionClient, ids: string[]) {
const [messages, inbox, legacy] = await Promise.all([
tx.smsMessageRecord.findMany({
where: { id: { in: ids } },
include: { submitRecords: true, segmentAudits: true },
}),
tx.upstreamReceiptInbox.findMany({ where: { matchedMessageRecordId: { in: ids }, status: 'matched' } }),
tx.smsReceiptRecord.findMany({ where: { messageRecordId: { in: ids } } }),
]);
const ownedReceiptKeys = new Set(
(
await tx.upstreamReceiptInbox.findMany({
where: { receiptKey: { in: legacy.map((r) => r.receiptKey) } },
select: { receiptKey: true },
})
).map((r) => r.receiptKey),
);
return messages.map((message) => {
const parts = (id: string, submitId: string) =>
message.segmentAudits.filter((p) => p.submitRecordId === id || (!p.submitRecordId && p.submitId === submitId));
const candidates = (gatewayId: string, channelId: string | null) =>
message.submitRecords.filter(
(s) =>
s.channelId === channelId &&
(s.gatewayMessageId === gatewayId || parts(s.id, s.submitId).some((p) => p.gatewayMessageId === gatewayId)),
);
const events: HomeEvent[] = inbox
.filter((i) => i.matchedMessageRecordId === message.id)
.map((i) => {
const direct = message.submitRecords.find((s) => s.id === i.matchedSubmitRecordId);
const possible = candidates(i.gatewayMessageId, i.matchedChannelId ?? i.incomingChannelId);
return {
attemptId: direct?.id ?? (possible.length === 1 ? possible[0].id : null),
gatewayId: i.gatewayMessageId,
status: i.receiptStatus,
at: i.gatewayReceivedAt ?? i.receivedAt,
approximate: !i.gatewayReceivedAt,
};
});
for (const r of legacy.filter((r) => r.messageRecordId === message.id)) {
// An Inbox event (including unresolved/re-associated events) is not a legacy fallback.
if (ownedReceiptKeys.has(r.receiptKey)) continue;
const possible = candidates(r.gatewayMessageId, r.channelId);
const attemptId = possible.length === 1 ? possible[0].id : null;
if (
events.some(
(e) => e.attemptId === attemptId && e.gatewayId === r.gatewayMessageId && e.status === r.receiptStatus,
)
)
continue;
events.push({
attemptId,
gatewayId: r.gatewayMessageId,
status: r.receiptStatus,
at: r.createdAt,
approximate: true,
});
}
return {
message,
fact: homeFact(
message,
message.submitRecords.map((s) => ({
id: s.id,
accepted: s.submitStatus === 'accepted',
gatewayId: s.gatewayMessageId,
costUnitPrice: s.costUnitPrice,
segments: parts(s.id, s.submitId).map((p) => ({
index: p.segmentIndex,
total: p.segmentTotal,
gatewayId: p.gatewayMessageId,
status: p.receiptStatus,
inferred: p.compensationType === 'supplier_message_level_receipt',
})),
})),
events,
),
};
});
}
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Module, Query, Req } from '@nestjs/common';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { PrismaModule } from '../prisma/prisma.module';
import { HomeProjection } from './home-projection';
import { HomeService } from './home.service';
@Controller('admin/operations/home')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Get('summary')
async summary(@Req() req: SessionRequest) {
return this.home.summary(await this.home.authorize(req));
}
@Get('receipt-breakdown')
async receipts(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'receipt');
}
@Get('revenue-breakdown')
async revenue(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'revenue');
}
}
@Module({ imports: [PrismaModule], controllers: [HomeController], providers: [HomeService, HomeProjection] })
export class HomeModule {}
+118
View File
@@ -0,0 +1,118 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
@Injectable()
export class HomeService {
constructor(private readonly db: PrismaService) {}
async authorize(req: SessionRequest) {
const user =
req.authSession?.portal === 'admin' &&
req.sessionUserId &&
(await this.db.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!user) throw new ForbiddenException('无运营首页查看权限');
return user.id;
}
async summary(userId: string, now = new Date()) {
return this.db.$transaction(
async (tx) => {
// Read committed after this lock: no token may reference an already pruned version.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(17100917)`;
const date = todayKey(now);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (!state.initialized || state.seededDay !== date)
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
aggregateHome(tx, date, state.version),
enterpriseRanks(tx, date),
operationStatus(tx),
tx.homeProjectionDirty.count(),
tx.upstreamReceiptInbox.count({
where: {
status: { not: 'matched' },
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
},
}),
hourlySendTrend(tx, date),
]);
const values = metrics(rows[0]);
const summary = {
businessDate: date,
asOf: now.toISOString(),
dataThrough: state.updatedAt.toISOString(),
definitionVersion: 1,
processing:
pending > 0 ||
unresolved > 0 ||
Boolean(state.lastError) ||
now.getTime() - state.updatedAt.getTime() > 60_000,
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
today: values,
enterpriseSpendRanks: ranks,
hourlySendTrend: hourlyTrend,
...status,
};
const snapshot = await tx.homeSnapshot.create({
data: {
id: randomUUID(),
userId,
businessDate: date,
version: state.version,
createdAt: now,
expiresAt: new Date(now.getTime() + 15 * 60_000),
summary,
},
});
return { ...summary, snapshotToken: snapshot.id };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30_000 },
);
}
async breakdown(userId: string, token: string | undefined, kind: 'receipt' | 'revenue', now = new Date()) {
if (!token || !/^[0-9a-f-]{36}$/i.test(token)) throw new BadRequestException('统计快照参数无效');
return this.db.$transaction(
async (tx) => {
const snapshot = await tx.homeSnapshot.findUnique({ where: { id: token } });
if (!snapshot || snapshot.userId !== userId) throw new ForbiddenException('统计快照不可访问');
if (snapshot.expiresAt <= now || snapshot.businessDate !== todayKey(now))
throw new ConflictException('统计快照已过期,请刷新首页后重试');
const rows = await aggregateHome(tx, snapshot.businessDate, snapshot.version, true);
return {
snapshotToken: token,
businessDate: snapshot.businessDate,
items: Array.from({ length: 4 }, (_, offset) => {
const submitDate = addDays(snapshot.businessDate, -offset),
value = metrics(rows.find((r) => r.queuedDay === submitDate));
return kind === 'receipt'
? { submitDate, total: value.receiptUnits, success: value.successUnits, rate: value.receiptSuccessRate }
: {
submitDate,
revenueCents: value.revenueCents,
profitCents: value.profitCents,
rate: value.profitRate,
};
}),
};
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+48 -19
View File
@@ -1,12 +1,7 @@
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');
import express from 'express';
import * as http from 'node:http';
describe('configureHttpBodyParsers', () => {
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
@@ -17,7 +12,7 @@ describe('configureHttpBodyParsers', () => {
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
expect(use).toHaveBeenCalledTimes(1);
expect(use).toHaveBeenCalledTimes(2);
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 });
@@ -28,12 +23,16 @@ describe('configureHttpBodyParsers', () => {
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 }));
serverApp.use(
type === 'json'
? express.json({ limit: options.limit })
: express.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/client/send/imports/preview', (request, response) =>
response.json({ size: (request as typeof request & { rawBody?: Buffer }).rawBody?.length ?? 0 }),
);
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
@@ -47,19 +46,49 @@ describe('configureHttpBodyParsers', () => {
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 });
const oversized = await postJSON(address.port, '/api/openapi/v1/sms/messages', body);
expect(oversized.status).toBe(413);
expect(JSON.parse(oversized.body)).toMatchObject({ code: 'PAYLOAD_TOO_LARGE', status: 413 });
for (const malformed of ['{"private-marker":', '"private-marker"']) {
const invalid = await postJSON(address.port, '/api/openapi/v1/sms/messages', malformed);
expect(invalid.status).toBe(400);
expect(JSON.parse(invalid.body)).toMatchObject({ code: 'PARAMETER_INVALID', requestId: invalid.requestId });
expect(invalid.requestId).toMatch(/^req_/);
expect(invalid.contentType).toContain('application/problem+json');
expect(invalid.body).not.toContain('private-marker');
}
const ordinary = await postJSON(address.port, '/api/ordinary', '{');
expect(ordinary.status).toBe(400);
expect(ordinary.requestId).toBeUndefined();
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
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') }));
});
return new Promise<{ status: number; body: string; requestId?: string; contentType?: 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'),
requestId: response.headers['x-request-id'] as string | undefined,
contentType: response.headers['content-type'],
}),
);
},
);
request.once('error', reject);
request.end(body);
});
+12 -13
View File
@@ -1,11 +1,6 @@
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;
};
import { openApiBodyErrorMiddleware } from './open-api/open-api-body-error.middleware';
import express from 'express';
export const DEFAULT_JSON_BODY_LIMIT = '2mb';
export const IMPORT_JSON_BODY_LIMIT = '25mb';
@@ -14,12 +9,16 @@ 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.use(
'/api/client/send/imports',
express.json({
limit: IMPORT_JSON_BODY_LIMIT,
verify(request, _response, buffer) {
(request as typeof request & { rawBody?: Buffer }).rawBody = buffer;
},
}),
);
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
app.use('/api/openapi/v1/sms', openApiBodyErrorMiddleware);
}
@@ -0,0 +1,45 @@
import { alertHistoryRange, mergeAlertHistory } from './alert-history';
describe('historical alert observation cycles', () => {
it('uses seven Shanghai calendar days and rejects invalid or excessive dates', () => {
expect(alertHistoryRange(undefined, undefined, new Date('2026-09-09T16:30:00Z'))).toMatchObject({
startDate: '2026-09-04',
endDate: '2026-09-10',
});
for (const [from, to] of [
['2026-02-30', '2026-03-01'],
['2026-09-09', '2026-09-08'],
['2026-07-01', '2026-09-09'],
]) {
expect(() => alertHistoryRange(from, to)).toThrow();
}
});
it('retains distinct cycles, merges daily boundaries and excludes stale/nonpositive samples', () => {
const result = new Map();
const metric = { __name__: 'ALERTS_FOR_STATE', alertname: 'CPUHigh', instance: 'host', severity: 'warning' };
mergeAlertHistory(
result,
[
{
metric,
values: [
[110, '100'],
[120, '100'],
[130, '0'],
[140, 'NaN'],
[150, '145'],
[200, '145'],
],
},
],
110,
200,
);
mergeAlertHistory(result, [{ metric, values: [[160, '145']] }], 110, 200);
expect(result.size).toBe(2);
expect([...result.values()].map((item) => item.lastObservedAt)).toEqual([
new Date(120000).toISOString(),
new Date(160000).toISOString(),
]);
});
});
@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto';
export function alertHistoryRange(from?: string, to?: string, now = new Date()) {
const dateKey = (date: Date) => new Date(date.getTime() + 8 * 3600_000).toISOString().slice(0, 10);
const endDate = to || dateKey(now);
const startDate = from || dateKey(new Date(now.getTime() - 6 * 86400_000));
const parse = (value: string) => {
const result = new Date(`${value}T00:00:00+08:00`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(result.getTime()) || dateKey(result) !== value) {
throw new BadRequestException('告警日期无效');
}
return result.getTime() / 1000;
};
const start = parse(startDate);
const end = parse(endDate) + 86400;
if (end <= start || end - start > 31 * 86400) throw new BadRequestException('告警日期范围须为1至31天');
return { startDate, endDate, start, end: Math.min(end, now.getTime() / 1000) };
}
export type AlertHistoryItem = {
id: string;
name: string;
severity: string;
service: string;
instance: string;
startedAt: string;
firstObservedAt: string;
lastObservedAt: string;
};
// ALERTS_FOR_STATE stores activeAt as the sample value, separating repeated trigger cycles.
// Observation boundaries are not claimed as exact recovery times.
export function mergeAlertHistory(
target: Map<string, AlertHistoryItem>,
series: Array<{ metric: Record<string, string>; values?: [number, string][] }>,
start: number,
end: number,
) {
for (const { metric, values } of series) {
const labels = Object.entries(metric)
.filter(([key]) => key !== '__name__')
.sort(([a], [b]) => a.localeCompare(b));
const fingerprint = createHash('sha256').update(JSON.stringify(labels)).digest('hex');
for (const [time, rawActiveAt] of values ?? []) {
const activeAt = Number(rawActiveAt);
if (time < start || time >= end || !Number.isFinite(activeAt) || activeAt <= 0 || activeAt > time) continue;
const id = `${fingerprint}:${activeAt}`;
const observed = new Date(time * 1000).toISOString();
const item = target.get(id);
if (item) {
if (observed < item.firstObservedAt) item.firstObservedAt = observed;
if (observed > item.lastObservedAt) item.lastObservedAt = observed;
} else {
target.set(id, {
id,
name: metric.alertname || '未命名告警',
severity: metric.severity || 'info',
service: metric.service || '',
instance: metric.instance || '',
startedAt: new Date(activeAt * 1000).toISOString(),
firstObservedAt: observed,
lastObservedAt: observed,
});
}
}
}
}
@@ -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);
}
@@ -1,5 +1,8 @@
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);
@@ -12,6 +15,27 @@ describe('InfrastructureAlertSettingsService', () => {
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', () => {
@@ -1,26 +1,166 @@
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
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 { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
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: '(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100', 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'] },
{
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(
@@ -31,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr
export class InfrastructureAlertSettingsService {
private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
private readonly rulesPath: string;
private readonly promtoolPath: string;
private readonly promtoolPath: string | undefined;
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');
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 = config.get<string>('PROMTOOL_PATH');
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
}
@@ -52,7 +197,14 @@ export class InfrastructureAlertSettingsService {
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 })),
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({
key,
label,
unit,
min,
max,
step,
})),
};
}
@@ -62,7 +214,13 @@ export class InfrastructureAlertSettingsService {
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 },
data: {
configVersion: { increment: 1 },
thresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'applying',
lastError: null,
updatedById: operatorId,
},
});
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
@@ -70,12 +228,32 @@ export class InfrastructureAlertSettingsService {
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 } } }),
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 } });
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 应用失败,原生效规则已保留');
}
@@ -85,13 +263,20 @@ export class InfrastructureAlertSettingsService {
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('存在不允许配置的告警指标');
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) {
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 };
@@ -100,7 +285,11 @@ export class InfrastructureAlertSettingsService {
}
private asThresholds(value: unknown) {
try { return this.validate(value); } catch { return null; }
try {
return this.validate(value);
} catch {
return null;
}
}
private renderRules(thresholds: InfrastructureAlertThresholds) {
@@ -112,8 +301,26 @@ export class InfrastructureAlertSettingsService {
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}`;
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}。"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
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`;
@@ -126,7 +333,9 @@ export class InfrastructureAlertSettingsService {
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 execFileAsync(await resolvePromtoolPath(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}`);
@@ -142,3 +351,17 @@ export class InfrastructureAlertSettingsService {
}
}
}
// Explicit configuration is authoritative; never silently replace a broken configured binary.
export async function resolvePromtoolPath(configured?: string) {
const candidates = configured ? [configured] : ['/usr/local/bin/promtool', '/usr/bin/promtool'];
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
/* Try the next standard install location. */
}
}
throw new Error('promtool不可执行,请检查PROMTOOL_PATH或标准安装目录');
}
@@ -41,6 +41,18 @@ export type InfrastructureAlert = {
};
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;
@@ -8,7 +8,10 @@ import { InfrastructureMonitoringService } from './infrastructure-monitoring.ser
@ApiTags('infrastructure-monitoring')
@Controller('admin/infrastructure-monitoring')
export class InfrastructureMonitoringController {
constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {}
constructor(
private readonly monitoring: InfrastructureMonitoringService,
private readonly settings: InfrastructureAlertSettingsService,
) {}
@Get('overview')
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
@@ -16,19 +19,44 @@ export class InfrastructureMonitoringController {
}
@Get('notification-summary')
notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); }
notificationSummary(@CurrentSessionUserId() userId?: string) {
return this.monitoring.notificationSummary(userId);
}
@Get('alert-history')
alertHistory(@Query('from') from?: string, @Query('to') to?: string, @Query('page') page?: string) {
return this.monitoring.alertHistory(from, to, page);
}
@Post('alerts/:fingerprint/read')
markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) {
markAlertRead(
@Param('fingerprint') fingerprint: string,
@Body('activeAt') activeAt: unknown,
@CurrentSessionUserId() userId: string,
) {
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
}
@Post('alerts/:fingerprint/clear')
clearAlert(
@Param('fingerprint') fingerprint: string,
@Body('activeAt') activeAt: unknown,
@CurrentSessionUserId() userId: string,
) {
return this.monitoring.clearAlert(fingerprint, activeAt, userId);
}
@Get('alert-thresholds')
alertThresholds() { return this.settings.get(); }
alertThresholds() {
return this.settings.get();
}
@Put('alert-thresholds')
@RequireRecentAuthentication()
updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) {
updateAlertThresholds(
@Body() body: { configVersion?: number; thresholds?: unknown },
@CurrentSessionUserId() operatorId?: string,
) {
return this.settings.update(body, operatorId);
}
}
@@ -1,7 +1,15 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'node:crypto';
import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { retainedAlerts } from './persistent-alerts';
jest.mock('./persistent-alerts', () => ({
retainAlerts: jest.fn(async (_prisma, alerts) => alerts),
retainedAlerts: jest.fn(),
clearRetainedAlert: jest.fn(),
}));
function success(data: unknown) {
return {
@@ -13,7 +21,12 @@ function success(data: unknown) {
describe('InfrastructureMonitoringService', () => {
const prisma = {
infrastructureAlertRead: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), findUniqueOrThrow: jest.fn() },
infrastructureAlertRead: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
findUniqueOrThrow: jest.fn(),
},
operationLog: { create: jest.fn() },
$transaction: jest.fn(),
};
@@ -33,9 +46,27 @@ describe('InfrastructureMonitoringService', () => {
});
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();
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 () => {
@@ -44,34 +75,52 @@ describe('InfrastructureMonitoringService', () => {
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',
}] });
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']] }] });
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'] },
] });
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'] },
] });
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'] }] });
@@ -86,14 +135,27 @@ describe('InfrastructureMonitoringService', () => {
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.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.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');
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 () => {
@@ -108,30 +170,220 @@ describe('InfrastructureMonitoringService', () => {
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 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') }]);
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 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('已结束或已重新触发');
jest.mocked(retainedAlerts).mockResolvedValue([
{
fingerprint,
startedAt: '2026-08-16T02:00:00.000Z',
name: 'QaCritical',
severity: 'critical',
} 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(
'已结束或已重新触发',
);
});
});
@@ -1,8 +1,23 @@
import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { retainAlerts, retainedAlerts, clearRetainedAlert } from './persistent-alerts';
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 { alertHistoryRange, mergeAlertHistory, type AlertHistoryItem } from './alert-history';
import {
compareMountpoints,
FILESYSTEM_LABELS,
FILESYSTEM_SELECTOR,
FILESYSTEM_USAGE_PERCENT,
filesystemIdentity,
} from './filesystem-metrics';
import type {
InfrastructureAlert,
InfrastructureMetricPoint,
@@ -47,16 +62,17 @@ const QUERIES = {
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
memoryTotalBytes: 'node_memory_MemTotal_bytes',
memoryAvailableBytes: 'node_memory_MemAvailable_bytes',
diskUsagePercent: '(1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"})) * 100',
diskTotalBytes: 'node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
diskAvailableBytes: 'node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
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"})',
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 = [
@@ -69,38 +85,62 @@ const SERVICE_DEFINITIONS = [
] 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'],
] },
{
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_.*"}';
@@ -163,12 +203,46 @@ export class InfrastructureMonitoringService {
private readonly logger = new Logger(InfrastructureMonitoringService.name);
private readonly prometheusUrl: string;
private readonly queryTimeoutMs: number;
private alertTimer?: ReturnType<typeof setInterval>;
private alertPoll?: Promise<InfrastructureAlert[]>;
constructor(config: ConfigService, private readonly prisma: PrismaService) {
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)));
}
onModuleInit() {
if (process.env.CMPP_PROCESS_ROLE && !['api', 'all'].includes(process.env.CMPP_PROCESS_ROLE)) return;
const poll = () =>
void this.loadRetainedAlerts().catch(() => this.logger.warn('Persistent alert collection unavailable'));
poll();
this.alertTimer = setInterval(poll, 30_000);
this.alertTimer.unref();
}
onModuleDestroy() {
if (this.alertTimer) clearInterval(this.alertTimer);
}
private loadRetainedAlerts() {
if (!this.alertPoll) {
const observedAt = new Date();
this.alertPoll = this.getJson<PrometheusAlertResponse>('/api/v1/alerts')
.then((response) => retainAlerts(this.prisma, this.parseAlerts(response), observedAt))
.finally(() => {
this.alertPoll = undefined;
});
}
return this.alertPoll;
}
clearAlert(fingerprint: string, activeAt: unknown, userId: string) {
return clearRetainedAlert(this.prisma, fingerprint, activeAt, userId);
}
async overview(rawRange?: string, userId?: string): Promise<InfrastructureMonitoringOverview> {
const range = this.parseRange(rawRange);
const collectedAt = new Date().toISOString();
@@ -178,14 +252,15 @@ export class InfrastructureMonitoringService {
this.loadTrends(range),
this.query(QUERIES.services),
this.query(SERVICE_METRICS_QUERY),
this.getJson<PrometheusAlertResponse>('/api/v1/alerts'),
this.loadRetainedAlerts(),
]);
const services = this.parseServices(serviceResponse);
const serviceMetrics = this.parseServiceMetrics(serviceMetricResponse);
const alerts = await this.attachReadState(this.parseAlerts(alertResponse), userId);
const alerts = await this.attachReadState(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,
@@ -200,25 +275,33 @@ export class InfrastructureMonitoringService {
activeAlerts: alerts.length,
},
metrics: instant.metrics,
trends,
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'}`);
// 客户端保留最后成功快照;采集失败不推断告警恢复
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 alerts = await this.attachReadState(await this.loadRetainedAlerts(), userId);
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length };
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'}`);
this.logger.warn(
`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
);
throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
}
}
@@ -227,13 +310,22 @@ export class InfrastructureMonitoringService {
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());
const activeAlerts = await retainedAlerts(this.prisma);
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 } },
});
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([
@@ -242,15 +334,57 @@ export class InfrastructureMonitoringService {
]);
} catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } });
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(),
]);
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() };
return {
fingerprint,
activeAt: read.activeAt.toISOString(),
acknowledged: true,
acknowledgedAt: read.readAt.toISOString(),
};
}
async alertHistory(from?: string, to?: string, rawPage?: string) {
const range = alertHistoryRange(from, to);
const page = rawPage === undefined ? 1 : Number(rawPage);
if (!Number.isSafeInteger(page) || page < 1) throw new BadRequestException('告警页码无效');
const history = new Map<string, AlertHistoryItem>();
try {
// Daily raw range vectors retain short events that a coarse query_range step would miss.
for (let start = range.start; start < range.end; start += 86400) {
const end = Math.min(start + 86400, range.end);
const response = await this.getJson<PrometheusQueryResponse>('/api/v1/query', {
query: `ALERTS_FOR_STATE[${Math.ceil(end - start)}s]`,
time: String(end),
});
mergeAlertHistory(history, response.data?.result ?? [], range.start, range.end);
}
} catch {
throw new ServiceUnavailableException('历史告警查询失败,请稍后重试');
}
const items = [...history.values()].sort(
(a, b) => b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id),
);
return {
items: items.slice((page - 1) * 25, page * 25),
total: items.length,
page,
pageSize: 25,
startDate: range.startDate,
endDate: range.endDate,
};
}
private parseRange(value?: string): InfrastructureMonitoringRange {
@@ -261,10 +395,60 @@ export class InfrastructureMonitoringService {
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 responses = await Promise.all([
...keys.map((key) => this.query(QUERIES[key])),
this.query(QUERIES.lastSampleAt),
]);
const metrics = emptyMetrics();
keys.forEach((key, index) => { metrics[key] = vectorValue(responses[index]); });
return { metrics, lastSampleAt: vectorValue(responses[responses.length - 1]) };
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) {
@@ -273,17 +457,33 @@ export class InfrastructureMonitoringService {
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 Object.fromEntries(keys.map((key, index) => [key, matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'];
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);
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';
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 };
});
}
@@ -294,7 +494,8 @@ export class InfrastructureMonitoringService {
.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 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),
@@ -313,7 +514,9 @@ export class InfrastructureMonitoringService {
})
.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);
return (
priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt)
);
});
}
@@ -342,23 +545,46 @@ export class InfrastructureMonitoringService {
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 })),
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 }));
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 },
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: [] })),
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({
key: group.key,
name: group.name,
available: false,
metrics: [],
})),
alerts: [],
};
}
@@ -368,15 +594,26 @@ export class InfrastructureMonitoringService {
}
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) });
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> {
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) });
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;
const result = (await response.json()) as T;
if (result.status !== 'success') throw new Error('Prometheus query failed');
return result;
}

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