Compare commits
171
Commits
96e475d60d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cacb6e8e7 | ||
|
|
28951b4fc4 | ||
|
|
001d5f2cbd | ||
|
|
b24cd7c08d | ||
|
|
c20c2246b2 | ||
|
|
1676cfe622 | ||
|
|
5e4d644788 | ||
|
|
627fa7ec97 | ||
|
|
572290308c | ||
|
|
4eb7b16d12 | ||
|
|
010ba32168 | ||
|
|
a350aca883 | ||
|
|
a0209f93bc | ||
|
|
86cb9aea36 | ||
|
|
cbc4a03325 | ||
|
|
c781313de5 | ||
|
|
18ecf8045f | ||
|
|
bcb278be29 | ||
|
|
4665079ca3 | ||
|
|
7f9abe3da0 | ||
|
|
ac6449028c | ||
|
|
97d1334423 | ||
|
|
a420d61b23 | ||
|
|
92b112cc6e | ||
|
|
40c8e279f0 | ||
|
|
04e9f467ab | ||
|
|
f0e843436c | ||
|
|
d13ca0713a | ||
|
|
8e4bc5a20e | ||
|
|
86947827cc | ||
|
|
0c3f820cc9 | ||
|
|
5bcdbb2a03 | ||
|
|
6d63eb5452 | ||
|
|
809175b544 | ||
|
|
6816f56178 | ||
|
|
ebb185b22b | ||
|
|
2a9d03be2e | ||
|
|
6d3c78330d | ||
|
|
2c228a94e1 | ||
|
|
50ae37242b | ||
|
|
633ba59775 | ||
|
|
e281ff853b | ||
|
|
f885f0b907 | ||
|
|
e4f93f7193 | ||
|
|
0e3424c4a7 | ||
|
|
c51255d407 | ||
|
|
4c210723cd | ||
|
|
f059674852 | ||
|
|
247fee6d6b | ||
|
|
457319e627 | ||
|
|
69e3d7368d | ||
|
|
bd920f76b0 | ||
|
|
442dda711d | ||
|
|
1e05a643e5 | ||
|
|
839dba8d9b | ||
|
|
15a1f9d8ed | ||
|
|
ca1fc2847f | ||
|
|
64bfb9ad3d | ||
|
|
798e85c930 | ||
|
|
458ddd97df | ||
|
|
1a7a7245a6 | ||
|
|
65082959c0 | ||
|
|
5925cf493b | ||
|
|
aaf96db2d0 | ||
|
|
857171ce9d | ||
|
|
41962e7a6e | ||
|
|
0ec386e674 | ||
|
|
c88d172af6 | ||
|
|
bb435fb0ac | ||
|
|
fb39c8b606 | ||
|
|
48d0363920 | ||
|
|
bc18c7ff12 | ||
|
|
dada0d978b | ||
|
|
fe3c6e5b58 | ||
|
|
7b8a613afa | ||
|
|
4ff6ed0786 | ||
|
|
55915ed826 | ||
|
|
b0016cfd8d | ||
|
|
dc201bf92e | ||
|
|
a50aafb1ec | ||
|
|
19bcca812b | ||
|
|
068b8047dd | ||
|
|
fe5b25a7e4 | ||
|
|
7cb5dd376e | ||
|
|
2dff1be750 | ||
|
|
ad89e8fed7 | ||
|
|
9b8196ecab | ||
|
|
53d3668306 | ||
|
|
cd824999f3 | ||
|
|
9e34757d6f | ||
|
|
1f2dfb5caf | ||
|
|
12668cee87 | ||
|
|
9a15d28da5 | ||
|
|
fd373d9708 | ||
|
|
b0deef5e6e | ||
|
|
119d57772e | ||
|
|
1a5063a5d9 | ||
|
|
67774509d9 | ||
|
|
4d526b83ab | ||
|
|
5328bb09bf | ||
|
|
c68ac7a3db | ||
|
|
9ac41e9308 | ||
|
|
3834d67a30 | ||
|
|
226527f1ce | ||
|
|
ad27acad7e | ||
|
|
3af145abe5 | ||
|
|
fc4a6a7afc | ||
|
|
d85ff85999 | ||
|
|
2744690f9f | ||
|
|
c3bf8af3e6 | ||
|
|
0b84370270 | ||
|
|
171c7d38e8 | ||
|
|
a70e9e2c07 | ||
|
|
4d4c1f39d4 | ||
|
|
5bd347e010 | ||
|
|
088d7aaacb | ||
|
|
33fa3709d9 | ||
|
|
ec721bf95a | ||
|
|
070a951e7c | ||
|
|
01cffa4758 | ||
|
|
d6edb88b76 | ||
|
|
a280b4bb22 | ||
|
|
898471423f | ||
|
|
444c65288c | ||
|
|
5234812990 | ||
|
|
c769b60aaa | ||
|
|
b0bdb608c1 | ||
|
|
b7ae1aa41a | ||
|
|
8170f727a3 | ||
|
|
4a17df78b8 | ||
|
|
2a181777ad | ||
|
|
b1e297245c | ||
|
|
b5005f21d5 | ||
|
|
394949f9f6 | ||
|
|
76e7c8c401 | ||
|
|
d3ceeb1e16 | ||
|
|
9292352be1 | ||
|
|
761c123b65 | ||
|
|
c6f11014d6 | ||
|
|
03f72c87de | ||
|
|
2b5256d4a1 | ||
|
|
90345fba22 | ||
|
|
3c6f1beed1 | ||
|
|
fcf6d3e6b4 | ||
|
|
487b5282a6 | ||
|
|
0176aa6952 | ||
|
|
52028b9bbd | ||
|
|
57192b7586 | ||
|
|
f4560479d3 | ||
|
|
99fb346566 | ||
|
|
633e7a7055 | ||
|
|
229a0b28fd | ||
|
|
6708f1f7c5 | ||
|
|
53073461e9 | ||
|
|
0b63bcd74e | ||
|
|
26ef67fb6a | ||
|
|
14f993c1f8 | ||
|
|
67b760a599 | ||
|
|
485af688d2 | ||
|
|
e9c73333b3 | ||
|
|
0757a699ff | ||
|
|
b9a71fe0b9 | ||
|
|
c4f36fc50d | ||
|
|
482f7ac1ae | ||
|
|
79f5d3f215 | ||
|
|
2216d00d51 | ||
|
|
0cd09441da | ||
|
|
6ccc102830 | ||
|
|
1ef4380422 | ||
|
|
d30d9ea4d0 | ||
|
|
b78faa1aa2 |
@@ -10,13 +10,29 @@ REDIS_URL=redis://127.0.0.1:6379
|
|||||||
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
|
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
|
||||||
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
|
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
|
||||||
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
|
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
|
||||||
|
# Keep false in production. Only isolated test environments without TLS may opt in to HTTP.
|
||||||
|
HTTP_API_ALLOW_INSECURE_ORIGIN=false
|
||||||
API_ENABLE_SEND_WORKER=true
|
API_ENABLE_SEND_WORKER=true
|
||||||
API_SEND_WORKER_CONCURRENCY=50
|
API_SEND_WORKER_CONCURRENCY=50
|
||||||
|
API_WORKER_DATABASE_URL=
|
||||||
|
API_WORKER_METRICS_HOST=127.0.0.1
|
||||||
|
API_WORKER_METRICS_PORT=9465
|
||||||
|
CMPP_INBOUND_FAST_PATH_ENABLED=true
|
||||||
|
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED=true
|
||||||
|
API_INBOUND_WORKFLOW_CONCURRENCY=32
|
||||||
|
API_INBOUND_WORKFLOW_BATCH_ENABLED=true
|
||||||
|
API_INBOUND_WORKFLOW_BATCH_SIZE=64
|
||||||
|
API_INBOUND_WORKFLOW_POLL_INTERVAL_MS=100
|
||||||
|
API_INBOUND_WORKFLOW_STALE_SECONDS=300
|
||||||
ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000
|
ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000
|
||||||
CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
|
CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
|
||||||
SESSION_LOCK_RECOVERY_MS=14400000
|
SESSION_LOCK_RECOVERY_MS=14400000
|
||||||
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
||||||
SESSION_RECENT_AUTH_MS=1800000
|
SESSION_RECENT_AUTH_MS=1800000
|
||||||
|
# First-rollout safety only: both values must be set and the deadline must be within two hours.
|
||||||
|
# Omit them during normal operation so all password writes use scrypt.
|
||||||
|
PASSWORD_HASH_LEGACY_TRANSITION=false
|
||||||
|
PASSWORD_HASH_LEGACY_WRITE_UNTIL=
|
||||||
OPERATION_LOG_ARCHIVE_ENABLED=true
|
OPERATION_LOG_ARCHIVE_ENABLED=true
|
||||||
OPERATION_LOG_RETENTION_DAYS=180
|
OPERATION_LOG_RETENTION_DAYS=180
|
||||||
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
|
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
|
||||||
@@ -25,6 +41,9 @@ OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000
|
|||||||
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
||||||
SMS_RECEIPT_TIMEOUT_HOURS=72
|
SMS_RECEIPT_TIMEOUT_HOURS=72
|
||||||
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000
|
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000
|
||||||
|
# System monitoring reads only fixed queries from a loopback Prometheus instance.
|
||||||
|
PROMETHEUS_URL=http://127.0.0.1:9090
|
||||||
|
PROMETHEUS_QUERY_TIMEOUT_MS=5000
|
||||||
# Local HTTP development only. Production must use HTTPS and true.
|
# Local HTTP development only. Production must use HTTPS and true.
|
||||||
SESSION_COOKIE_SECURE=false
|
SESSION_COOKIE_SECURE=false
|
||||||
MINIO_ENDPOINT=localhost:9000
|
MINIO_ENDPOINT=localhost:9000
|
||||||
@@ -39,4 +58,11 @@ GATEWAY_CMPP_VERSION=3.0
|
|||||||
GATEWAY_CMPP_ADDR=127.0.0.1:7890
|
GATEWAY_CMPP_ADDR=127.0.0.1:7890
|
||||||
GATEWAY_CMPP_USER=900001
|
GATEWAY_CMPP_USER=900001
|
||||||
GATEWAY_CMPP_PASSWORD=888888
|
GATEWAY_CMPP_PASSWORD=888888
|
||||||
|
GATEWAY_SUBMIT_WORKER_CONCURRENCY=64
|
||||||
|
GATEWAY_SUBMIT_RESULT_STREAM=gateway.submit.results
|
||||||
|
GATEWAY_SUBMIT_RESULT_GROUP=cmpp-api-callback
|
||||||
|
GATEWAY_SUBMIT_RESULT_CONSUMER=gateway-1
|
||||||
|
GATEWAY_SUBMIT_RESULT_WORKER_CONCURRENCY=8
|
||||||
|
GATEWAY_CALLBACK_BATCH_ENABLED=false
|
||||||
|
GATEWAY_CMPP_INBOUND_MAX_CONCURRENCY=64
|
||||||
CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
|
CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,8 +12,15 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
logs/
|
logs/
|
||||||
|
coverage/
|
||||||
gateway/gateway.exe
|
gateway/gateway.exe
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
*.tsbuildinfo
|
||||||
|
outputs/
|
||||||
|
tmp_*.py
|
||||||
|
tmp_*.ps1
|
||||||
|
tmp_*.sh
|
||||||
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
api/dist
|
||||||
|
api/vendor
|
||||||
|
package-lock.json
|
||||||
|
docs
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 拆分不得改变计算样式。发现差异时先恢复原级联关系,不得顺手调整设计。
|
||||||
@@ -9,4 +9,12 @@ module.exports = {
|
|||||||
testMatch: ['**/*.spec.ts'],
|
testMatch: ['**/*.spec.ts'],
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 59,
|
||||||
|
branches: 50,
|
||||||
|
functions: 60,
|
||||||
|
lines: 62,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const base = require('./jest.config.cjs');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...base,
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/auth/auth.service.ts',
|
||||||
|
'src/auth/session.service.ts',
|
||||||
|
'src/common/bounded-json-object.validator.ts',
|
||||||
|
'src/files/client-files.dto.ts',
|
||||||
|
'src/open-api/client-open-api.dto.ts',
|
||||||
|
'src/operations/client-operations.dto.ts',
|
||||||
|
'src/users/client-user.dto.ts',
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: { statements: 80, branches: 70, functions: 80, lines: 80 },
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+178
-107
@@ -12,7 +12,7 @@
|
|||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.28",
|
"@nestjs/core": "^11.1.28",
|
||||||
"@nestjs/platform-express": "^11.1.28",
|
"@nestjs/platform-express": "^11.1.28",
|
||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "11.4.7",
|
||||||
"@prisma/adapter-pg": "^7.9.0",
|
"@prisma/adapter-pg": "^7.9.0",
|
||||||
"@prisma/client": "^7.9.0",
|
"@prisma/client": "^7.9.0",
|
||||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||||
@@ -21,12 +21,15 @@
|
|||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"tldts": "^7.4.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
@@ -617,35 +620,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
"@emnapi/wasi-threads": "1.2.3",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@@ -732,30 +738,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
|
|
||||||
"version": "1.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
|
||||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"sprintf-js": "~1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
|
||||||
"version": "3.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
|
||||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^1.0.7",
|
|
||||||
"esprima": "^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@istanbuljs/schema": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
@@ -1451,20 +1433,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/swagger": {
|
"node_modules/@nestjs/swagger": {
|
||||||
"version": "11.4.5",
|
"version": "11.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
|
||||||
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
|
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/tsdoc": "0.16.0",
|
"@microsoft/tsdoc": "0.16.0",
|
||||||
"@nestjs/mapped-types": "2.1.1",
|
"@nestjs/mapped-types": "2.1.1",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "5.3.0",
|
||||||
"lodash": "4.18.1",
|
"lodash": "4.18.1",
|
||||||
"path-to-regexp": "8.4.2",
|
"path-to-regexp": "8.4.2",
|
||||||
"swagger-ui-dist": "5.32.8"
|
"swagger-ui-dist": "5.32.13"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fastify/static": "^8.0.0 || ^9.0.0",
|
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"class-transformer": "*",
|
"class-transformer": "*",
|
||||||
@@ -2031,6 +2013,27 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||||
@@ -2120,6 +2123,31 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
@@ -2127,6 +2155,13 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"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"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -2202,6 +2251,27 @@
|
|||||||
"csstype": "^3.2.2"
|
"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": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@@ -2345,9 +2415,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2362,9 +2429,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2379,9 +2443,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2396,9 +2457,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2413,9 +2471,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2430,9 +2485,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2447,9 +2499,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2464,9 +2513,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2481,9 +2527,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2498,9 +2541,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2540,6 +2580,40 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||||
"version": "1.12.2",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||||
@@ -3263,9 +3337,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/brace-expansion-safe": {
|
"node_modules/brace-expansion-safe": {
|
||||||
"name": "brace-expansion",
|
"name": "brace-expansion",
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -4510,20 +4584,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esprima": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"bin": {
|
|
||||||
"esparse": "bin/esparse.js",
|
|
||||||
"esvalidate": "bin/esvalidate.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/etag": {
|
"node_modules/etag": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
@@ -4742,9 +4802,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6170,9 +6230,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8208,13 +8268,6 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sprintf-js": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/sqlstring": {
|
"node_modules/sqlstring": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
@@ -8520,9 +8573,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swagger-ui-dist": {
|
"node_modules/swagger-ui-dist": {
|
||||||
"version": "5.32.8",
|
"version": "5.32.13",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
|
||||||
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
|
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scarf/scarf": "=1.4.0"
|
"@scarf/scarf": "=1.4.0"
|
||||||
@@ -8626,6 +8679,24 @@
|
|||||||
"readable-stream": "3"
|
"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": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
"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": {},
|
"node_modules/zip-stream/node_modules/minimatch/vendor/brace-expansion-compat": {},
|
||||||
"vendor/brace-expansion-compat": {
|
"vendor/brace-expansion-compat": {
|
||||||
"name": "brace-expansion",
|
"name": "brace-expansion",
|
||||||
"version": "5.0.8-compat.1",
|
"version": "5.0.9-compat.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion-safe": "npm:brace-expansion@5.0.8"
|
"brace-expansion-safe": "npm:brace-expansion@5.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -6,9 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"test": "jest --runInBand",
|
"test": "jest --runInBand",
|
||||||
|
"test:coverage": "jest --runInBand --coverage",
|
||||||
|
"test:incremental-coverage": "jest --runInBand --coverage --config jest.incremental.config.cjs",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
|
"start:report-material-worker": "node dist/report-material-analysis-worker.js",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate:dev": "prisma migrate dev",
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
"prisma:migrate:deploy": "prisma migrate deploy"
|
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||||
@@ -18,7 +21,7 @@
|
|||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.28",
|
"@nestjs/core": "^11.1.28",
|
||||||
"@nestjs/platform-express": "^11.1.28",
|
"@nestjs/platform-express": "^11.1.28",
|
||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "11.4.7",
|
||||||
"@prisma/adapter-pg": "^7.9.0",
|
"@prisma/adapter-pg": "^7.9.0",
|
||||||
"@prisma/client": "^7.9.0",
|
"@prisma/client": "^7.9.0",
|
||||||
"bullmq": "^5.79.2",
|
"bullmq": "^5.79.2",
|
||||||
@@ -27,12 +30,15 @@
|
|||||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"tldts": "^7.4.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
@@ -46,6 +52,8 @@
|
|||||||
"exceljs": {
|
"exceljs": {
|
||||||
"uuid": "11.1.1"
|
"uuid": "11.1.1"
|
||||||
},
|
},
|
||||||
"find-my-way": "9.7.0"
|
"find-my-way": "9.7.0",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
|
"js-yaml": "4.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { defineConfig } from 'prisma/config';
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
|
||||||
|
if (!databaseUrl && !allowDevelopmentDefault) {
|
||||||
|
throw new Error('DATABASE_URL is required outside development and test environments');
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
schema: 'prisma/schema.prisma',
|
||||||
migrations: {
|
migrations: {
|
||||||
path: 'prisma/migrations',
|
path: 'prisma/migrations',
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url:
|
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
CREATE TABLE "SecurityDetectionRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"sourceType" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"threshold" INTEGER NOT NULL,
|
||||||
|
"windowSeconds" INTEGER NOT NULL,
|
||||||
|
"cooldownSeconds" INTEGER NOT NULL,
|
||||||
|
"severity" TEXT NOT NULL,
|
||||||
|
"defaultBlockSeconds" INTEGER NOT NULL,
|
||||||
|
"maximumBlockSeconds" INTEGER NOT NULL,
|
||||||
|
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"effectiveVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"applyStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"lastApplyError" TEXT,
|
||||||
|
"pendingConfig" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityDetectionRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityDetectionEvent" (
|
||||||
|
"id" TEXT NOT NULL, "eventKey" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||||
|
"sourceIp" TEXT NOT NULL, "sourcePort" INTEGER, "accountHash" TEXT,
|
||||||
|
"path" TEXT, "protocol" TEXT, "resultCode" TEXT, "evidence" JSONB,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SecurityDetectionEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityAlert" (
|
||||||
|
"id" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||||
|
"sourceIp" TEXT NOT NULL, "severity" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"eventCount" INTEGER NOT NULL DEFAULT 0, "windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"firstOccurredAt" TIMESTAMP(3) NOT NULL, "lastOccurredAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"acknowledgedAt" TIMESTAMP(3), "acknowledgedById" TEXT, "ignoredAt" TIMESTAMP(3),
|
||||||
|
"ignoredById" TEXT, "ignoreReason" TEXT, "blockId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityAlert_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityBlock" (
|
||||||
|
"id" TEXT NOT NULL, "operationKey" TEXT NOT NULL, "alertId" TEXT, "sourceIp" TEXT NOT NULL,
|
||||||
|
"executor" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'requested', "durationSeconds" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL, "requestedById" TEXT NOT NULL, "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"appliedAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "releasedAt" TIMESTAMP(3), "releasedById" TEXT,
|
||||||
|
"executorReference" TEXT, "lastError" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SecurityBlock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityProtectedNetwork" (
|
||||||
|
"id" TEXT NOT NULL, "network" TEXT NOT NULL, "name" TEXT NOT NULL, "reason" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true, "createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityProtectedNetwork_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SecurityDetectionRule_code_key" ON "SecurityDetectionRule"("code");
|
||||||
|
CREATE INDEX "SecurityDetectionRule_enabled_sourceType_idx" ON "SecurityDetectionRule"("enabled", "sourceType");
|
||||||
|
CREATE UNIQUE INDEX "SecurityDetectionEvent_eventKey_key" ON "SecurityDetectionEvent"("eventKey");
|
||||||
|
CREATE INDEX "SecurityDetectionEvent_ruleId_occurredAt_idx" ON "SecurityDetectionEvent"("ruleId", "occurredAt");
|
||||||
|
CREATE INDEX "SecurityDetectionEvent_sourceIp_occurredAt_idx" ON "SecurityDetectionEvent"("sourceIp", "occurredAt");
|
||||||
|
CREATE UNIQUE INDEX "SecurityAlert_fingerprint_key" ON "SecurityAlert"("fingerprint");
|
||||||
|
CREATE INDEX "SecurityAlert_status_severity_lastOccurredAt_idx" ON "SecurityAlert"("status", "severity", "lastOccurredAt");
|
||||||
|
CREATE INDEX "SecurityAlert_sourceIp_status_lastOccurredAt_idx" ON "SecurityAlert"("sourceIp", "status", "lastOccurredAt");
|
||||||
|
CREATE INDEX "SecurityAlert_ruleId_status_lastOccurredAt_idx" ON "SecurityAlert"("ruleId", "status", "lastOccurredAt");
|
||||||
|
CREATE UNIQUE INDEX "SecurityBlock_operationKey_key" ON "SecurityBlock"("operationKey");
|
||||||
|
CREATE INDEX "SecurityBlock_status_expiresAt_idx" ON "SecurityBlock"("status", "expiresAt");
|
||||||
|
CREATE INDEX "SecurityBlock_sourceIp_status_requestedAt_idx" ON "SecurityBlock"("sourceIp", "status", "requestedAt");
|
||||||
|
CREATE INDEX "SecurityBlock_alertId_idx" ON "SecurityBlock"("alertId");
|
||||||
|
CREATE UNIQUE INDEX "SecurityProtectedNetwork_network_key" ON "SecurityProtectedNetwork"("network");
|
||||||
|
CREATE INDEX "SecurityProtectedNetwork_enabled_createdAt_idx" ON "SecurityProtectedNetwork"("enabled", "createdAt");
|
||||||
|
ALTER TABLE "SecurityDetectionEvent" ADD CONSTRAINT "SecurityDetectionEvent_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SecurityAlert" ADD CONSTRAINT "SecurityAlert_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO "SecurityDetectionRule" ("id", "code", "name", "sourceType", "threshold", "windowSeconds", "cooldownSeconds", "severity", "defaultBlockSeconds", "maximumBlockSeconds", "configVersion", "effectiveVersion", "applyStatus", "updatedAt") VALUES
|
||||||
|
('sec_admin_login', 'admin_login_failure', '运营端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_client_login', 'client_login_failure', '客户端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_ssh_auth', 'ssh_auth_failure', 'SSH认证失败', 'fail2ban', 6, 600, 1800, 'high', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_cmpp_auth', 'cmpp_auth_failure', 'CMPP认证失败', 'gateway', 5, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_cmpp_abuse', 'cmpp_protocol_abuse', 'CMPP协议滥用', 'gateway', 20, 60, 900, 'critical', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_key', 'http_invalid_api_key', 'HTTP错误密钥', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_sign', 'http_signature_failure', 'HTTP签名错误', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_replay', 'http_replay_attempt', 'HTTP重放尝试', 'application', 3, 600, 1800, 'critical', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_scan', 'http_malicious_scan', 'HTTP恶意扫描', 'fail2ban', 20, 60, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "InfrastructureAlertSetting" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'global',
|
||||||
|
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"effectiveVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"thresholds" JSONB NOT NULL,
|
||||||
|
"effectiveThresholds" JSONB NOT NULL,
|
||||||
|
"applyStatus" TEXT NOT NULL DEFAULT 'effective',
|
||||||
|
"lastError" TEXT,
|
||||||
|
"updatedById" TEXT,
|
||||||
|
"appliedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "InfrastructureAlertSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "InfrastructureAlertSetting" (
|
||||||
|
"id", "thresholds", "effectiveThresholds", "appliedAt"
|
||||||
|
) VALUES (
|
||||||
|
'global',
|
||||||
|
'{"hostCpu":{"warning":80,"critical":90},"hostMemory":{"warning":85,"critical":95},"hostDisk":{"warning":80,"critical":90},"apiError":{"warning":1,"critical":5},"apiLatency":{"warning":1,"critical":3},"apiEventLoop":{"warning":0.2,"critical":1},"gatewayQueue":{"warning":30,"critical":120},"postgresConnections":{"warning":70,"critical":85},"redisMemory":{"warning":70,"critical":85},"minioCapacity":{"warning":80,"critical":90}}'::jsonb,
|
||||||
|
'{"hostCpu":{"warning":80,"critical":90},"hostMemory":{"warning":85,"critical":95},"hostDisk":{"warning":80,"critical":90},"apiError":{"warning":1,"critical":5},"apiLatency":{"warning":1,"critical":3},"apiEventLoop":{"warning":0.2,"critical":1},"gatewayQueue":{"warning":30,"critical":120},"postgresConnections":{"warning":70,"critical":85},"redisMemory":{"warning":70,"critical":85},"minioCapacity":{"warning":80,"critical":90}}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "InfrastructureAlertRead" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fingerprint" TEXT NOT NULL,
|
||||||
|
"activeAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InfrastructureAlertRead_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "InfrastructureAlertRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "InfrastructureAlertRead_fingerprint_userId_key"
|
||||||
|
ON "InfrastructureAlertRead"("fingerprint", "userId");
|
||||||
|
|
||||||
|
CREATE INDEX "InfrastructureAlertRead_userId_readAt_idx"
|
||||||
|
ON "InfrastructureAlertRead"("userId", "readAt");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "SmsSubmitRecord"
|
||||||
|
ADD COLUMN "resultEventId" TEXT,
|
||||||
|
ADD COLUMN "resultProcessedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "SmsSubmitRecord_resultEventId_key"
|
||||||
|
ON "SmsSubmitRecord"("resultEventId");
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
CREATE TABLE "CmppInboundSubmissionInbox" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"requestKey" TEXT NOT NULL,
|
||||||
|
"payloadHash" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"queuePriority" TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"response" JSONB NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lockedAt" TIMESTAMP(3),
|
||||||
|
"lockedBy" TEXT,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"result" JSONB,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CmppInboundSubmissionInbox_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "CmppInboundSubmissionInbox_requestKey_key"
|
||||||
|
ON "CmppInboundSubmissionInbox"("requestKey");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_status_nextAttemptAt_createdAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("status", "nextAttemptAt", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_status_lockedAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("status", "lockedAt");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_applicationId_createdAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("applicationId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "CmppInboundSubmissionInbox_queuePriority_status_createdAt_idx"
|
||||||
|
ON "CmppInboundSubmissionInbox"("queuePriority", "status", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "CmppInboundSubmissionInbox"
|
||||||
|
ADD CONSTRAINT "CmppInboundSubmissionInbox_tenantId_fkey"
|
||||||
|
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "CmppInboundSubmissionInbox"
|
||||||
|
ADD CONSTRAINT "CmppInboundSubmissionInbox_applicationId_fkey"
|
||||||
|
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "SmsApplicationDailyReservation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reservationKey" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"usageDate" DATE NOT NULL,
|
||||||
|
"requestedCount" INTEGER NOT NULL,
|
||||||
|
"dailyLimit" INTEGER NOT NULL,
|
||||||
|
"usedCount" INTEGER,
|
||||||
|
"reserved" BOOLEAN NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SmsApplicationDailyReservation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "SmsApplicationDailyReservation_reservationKey_key"
|
||||||
|
ON "SmsApplicationDailyReservation"("reservationKey");
|
||||||
|
CREATE INDEX "SmsApplicationDailyReservation_applicationId_usageDate_idx"
|
||||||
|
ON "SmsApplicationDailyReservation"("applicationId", "usageDate");
|
||||||
|
CREATE INDEX "SmsApplicationDailyReservation_tenantId_createdAt_idx"
|
||||||
|
ON "SmsApplicationDailyReservation"("tenantId", "createdAt");
|
||||||
|
ALTER TABLE "SmsApplicationDailyReservation" ADD CONSTRAINT "SmsApplicationDailyReservation_tenantId_fkey"
|
||||||
|
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SmsApplicationDailyReservation" ADD CONSTRAINT "SmsApplicationDailyReservation_applicationId_fkey"
|
||||||
|
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "PhoneFrequencyReservation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reservationKey" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"result" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PhoneFrequencyReservation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "PhoneFrequencyReservation_reservationKey_key"
|
||||||
|
ON "PhoneFrequencyReservation"("reservationKey");
|
||||||
|
CREATE INDEX "PhoneFrequencyReservation_applicationId_createdAt_idx"
|
||||||
|
ON "PhoneFrequencyReservation"("applicationId", "createdAt");
|
||||||
|
CREATE INDEX "PhoneFrequencyReservation_tenantId_createdAt_idx"
|
||||||
|
ON "PhoneFrequencyReservation"("tenantId", "createdAt");
|
||||||
|
ALTER TABLE "PhoneFrequencyReservation" ADD CONSTRAINT "PhoneFrequencyReservation_tenantId_fkey"
|
||||||
|
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "PhoneFrequencyReservation" ADD CONSTRAINT "PhoneFrequencyReservation_applicationId_fkey"
|
||||||
|
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "GatewaySubmitOutbox" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"submitId" TEXT NOT NULL,
|
||||||
|
"messageRecordId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"schemaVersion" TEXT NOT NULL DEFAULT 'v1',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"leaseOwner" TEXT,
|
||||||
|
"leaseExpiresAt" TIMESTAMP(3),
|
||||||
|
"streamEntryId" TEXT,
|
||||||
|
"publishedAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "GatewaySubmitOutbox_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "GatewaySubmitOutbox_submitId_key"
|
||||||
|
ON "GatewaySubmitOutbox"("submitId");
|
||||||
|
|
||||||
|
CREATE INDEX "GatewaySubmitOutbox_pending_claim_idx"
|
||||||
|
ON "GatewaySubmitOutbox"("nextAttemptAt", "createdAt")
|
||||||
|
WHERE "status" IN ('pending', 'publishing');
|
||||||
|
|
||||||
|
CREATE INDEX "GatewaySubmitOutbox_messageRecordId_idx"
|
||||||
|
ON "GatewaySubmitOutbox"("messageRecordId");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "SmsUplinkMessage" ADD COLUMN "eventId" TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "SmsUplinkMessage_eventId_key" ON "SmsUplinkMessage"("eventId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "SmsUplinkMessage"
|
||||||
|
ADD COLUMN "gatewayMessageId" TEXT;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "ReportMaterialImportBatch"
|
||||||
|
ADD COLUMN "progress" INTEGER NOT NULL DEFAULT 100,
|
||||||
|
ADD COLUMN "progressStage" TEXT,
|
||||||
|
ADD COLUMN "errorMessage" TEXT,
|
||||||
|
ADD COLUMN "startedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "heartbeatAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX "ReportMaterialImportBatch_status_heartbeatAt_idx"
|
||||||
|
ON "ReportMaterialImportBatch"("status", "heartbeatAt");
|
||||||
@@ -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';
|
||||||
+7
@@ -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();
|
||||||
+723
-7
@@ -52,6 +52,9 @@ model Tenant {
|
|||||||
openApiRequests OpenApiRequest[]
|
openApiRequests OpenApiRequest[]
|
||||||
httpWebhookEvents HttpWebhookEvent[]
|
httpWebhookEvents HttpWebhookEvent[]
|
||||||
cmppInboundLongMessages CmppInboundLongMessage[]
|
cmppInboundLongMessages CmppInboundLongMessage[]
|
||||||
|
cmppInboundSubmissionInboxes CmppInboundSubmissionInbox[]
|
||||||
|
smsApplicationDailyReservations SmsApplicationDailyReservation[]
|
||||||
|
phoneFrequencyReservations PhoneFrequencyReservation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model EnterpriseCertification {
|
model EnterpriseCertification {
|
||||||
@@ -103,6 +106,7 @@ model User {
|
|||||||
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
||||||
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
||||||
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
||||||
|
infrastructureAlertReads InfrastructureAlertRead[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
@@ -262,6 +266,32 @@ model PhoneCarrierRule {
|
|||||||
@@index([status, priority])
|
@@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 {
|
model SensitiveWord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
word String @unique
|
word String @unique
|
||||||
@@ -466,10 +496,13 @@ model SmsApplication {
|
|||||||
httpWebhookEndpoints HttpWebhookEndpoint[]
|
httpWebhookEndpoints HttpWebhookEndpoint[]
|
||||||
httpWebhookEvents HttpWebhookEvent[]
|
httpWebhookEvents HttpWebhookEvent[]
|
||||||
dailyUsages SmsApplicationDailyUsage[]
|
dailyUsages SmsApplicationDailyUsage[]
|
||||||
|
dailyReservations SmsApplicationDailyReservation[]
|
||||||
inboundLongMessages CmppInboundLongMessage[]
|
inboundLongMessages CmppInboundLongMessage[]
|
||||||
|
inboundSubmissionInboxes CmppInboundSubmissionInbox[]
|
||||||
riskRules RiskRule[]
|
riskRules RiskRule[]
|
||||||
phoneFrequencyStates PhoneFrequencyState[]
|
phoneFrequencyStates PhoneFrequencyState[]
|
||||||
phoneFrequencyHits PhoneFrequencyHit[]
|
phoneFrequencyHits PhoneFrequencyHit[]
|
||||||
|
phoneFrequencyReservations PhoneFrequencyReservation[]
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@ -532,6 +565,25 @@ model SmsApplicationDailyUsage {
|
|||||||
@@index([usageDate])
|
@@index([usageDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SmsApplicationDailyReservation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
reservationKey String @unique
|
||||||
|
tenantId String
|
||||||
|
applicationId String
|
||||||
|
usageDate DateTime @db.Date
|
||||||
|
requestedCount Int
|
||||||
|
dailyLimit Int
|
||||||
|
usedCount Int?
|
||||||
|
reserved Boolean
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([applicationId, usageDate])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model SmsApplicationHttpIpAllowlist {
|
model SmsApplicationHttpIpAllowlist {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
applicationId String
|
applicationId String
|
||||||
@@ -634,6 +686,9 @@ model HttpWebhookEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model HttpWebhookDelivery {
|
model HttpWebhookDelivery {
|
||||||
|
recoveryVersion Int @default(0)
|
||||||
|
leaseToken String?
|
||||||
|
leaseUntil DateTime?
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
eventId String
|
eventId String
|
||||||
endpointId String
|
endpointId String
|
||||||
@@ -670,7 +725,59 @@ model HttpWebhookAttempt {
|
|||||||
@@unique([deliveryId, attemptNo])
|
@@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 {
|
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())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
applicationId String?
|
applicationId String?
|
||||||
@@ -746,6 +853,7 @@ model SignatureMaterial {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsTemplate {
|
model SmsTemplate {
|
||||||
|
optOutRules Json @default("[]")
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
applicationId String
|
applicationId String
|
||||||
@@ -805,6 +913,7 @@ model AuditRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsChannel {
|
model SmsChannel {
|
||||||
|
sensitiveWords ChannelSensitiveWord[]
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
@@ -1175,6 +1284,10 @@ model SignatureRetirementDetection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SignatureRetirementMessage {
|
model SignatureRetirementMessage {
|
||||||
|
dailyGroupKey String? @unique
|
||||||
|
notificationDate DateTime? @db.Date
|
||||||
|
applicationId String?
|
||||||
|
detectionIds String[] @default([])
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
detectionId String @unique
|
detectionId String @unique
|
||||||
cycleId String
|
cycleId String
|
||||||
@@ -1369,6 +1482,11 @@ model ReportMaterialImportBatch {
|
|||||||
fileName String
|
fileName String
|
||||||
reportType String
|
reportType String
|
||||||
status String @default("analyzed")
|
status String @default("analyzed")
|
||||||
|
progress Int @default(100)
|
||||||
|
progressStage String?
|
||||||
|
errorMessage String?
|
||||||
|
startedAt DateTime?
|
||||||
|
heartbeatAt DateTime?
|
||||||
sheetName String
|
sheetName String
|
||||||
headerRowCount Int @default(1)
|
headerRowCount Int @default(1)
|
||||||
dataStartRow Int @default(2)
|
dataStartRow Int @default(2)
|
||||||
@@ -1387,6 +1505,7 @@ model ReportMaterialImportBatch {
|
|||||||
|
|
||||||
@@index([tenantId, createdAt])
|
@@index([tenantId, createdAt])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
|
@@index([status, heartbeatAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ReportMaterialImportItem {
|
model ReportMaterialImportItem {
|
||||||
@@ -1429,6 +1548,36 @@ model ReportReceiptImport {
|
|||||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
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 {
|
model RiskRule {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
@@ -1481,6 +1630,8 @@ model SmsSendTask {
|
|||||||
createdById String?
|
createdById String?
|
||||||
reviewedById String?
|
reviewedById String?
|
||||||
reviewedAt DateTime?
|
reviewedAt DateTime?
|
||||||
|
continuationLeaseOwner String?
|
||||||
|
continuationLeaseExpiresAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1577,6 +1728,21 @@ model PhoneFrequencyHit {
|
|||||||
@@index([releasedById])
|
@@index([releasedById])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PhoneFrequencyReservation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
reservationKey String @unique
|
||||||
|
tenantId String
|
||||||
|
applicationId String
|
||||||
|
result Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model PhoneFrequencyWhitelist {
|
model PhoneFrequencyWhitelist {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
phoneNumber String @unique
|
phoneNumber String @unique
|
||||||
@@ -1657,7 +1823,19 @@ model SmsApiRequest {
|
|||||||
@@index([batchTaskId])
|
@@index([batchTaskId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SmsDrainageDecision {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
messageRecordId String
|
||||||
|
decidedAt DateTime @default(now())
|
||||||
|
snapshot Json
|
||||||
|
@@index([messageRecordId, decidedAt])
|
||||||
|
}
|
||||||
|
|
||||||
model SmsMessageRecord {
|
model SmsMessageRecord {
|
||||||
|
originalContent String?
|
||||||
|
channelWordDecisions SmsChannelSensitiveDecision[]
|
||||||
|
channelWordFinalizationPending Boolean @default(false)
|
||||||
|
monitorFacts SendingMonitorFact[]
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
batchTaskId String?
|
batchTaskId String?
|
||||||
@@ -1674,6 +1852,8 @@ model SmsMessageRecord {
|
|||||||
content String
|
content String
|
||||||
hasDrainageContent Boolean?
|
hasDrainageContent Boolean?
|
||||||
drainageDetection Json?
|
drainageDetection Json?
|
||||||
|
drainageGate Json?
|
||||||
|
drainageReceiptPending Boolean @default(false)
|
||||||
drainageDetectionVersion String?
|
drainageDetectionVersion String?
|
||||||
drainageEvaluatedAt DateTime?
|
drainageEvaluatedAt DateTime?
|
||||||
billingUnits Int @default(1)
|
billingUnits Int @default(1)
|
||||||
@@ -1746,6 +1926,9 @@ model CmppSubmitSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsSubmitRecord {
|
model SmsSubmitRecord {
|
||||||
|
sentContent String?
|
||||||
|
contentPolicy Json?
|
||||||
|
drainageGate Json?
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
batchTaskId String?
|
batchTaskId String?
|
||||||
@@ -1756,14 +1939,19 @@ model SmsSubmitRecord {
|
|||||||
sessionId String?
|
sessionId String?
|
||||||
retryOfSubmitRecordId String? @unique
|
retryOfSubmitRecordId String? @unique
|
||||||
submitId String @unique
|
submitId String @unique
|
||||||
sequenceId Int?
|
sequenceId BigInt?
|
||||||
gatewayMessageId String?
|
gatewayMessageId String?
|
||||||
submitStatus String @default("queued")
|
submitStatus String @default("queued")
|
||||||
|
resultEventId String? @unique
|
||||||
|
resultProcessedAt DateTime?
|
||||||
costUnitPrice BigInt @default(0)
|
costUnitPrice BigInt @default(0)
|
||||||
costAmountCents BigInt @default(0)
|
costAmountCents BigInt @default(0)
|
||||||
errorCode String?
|
errorCode String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
submittedAt DateTime?
|
submittedAt DateTime?
|
||||||
|
firstWireSubmitAt DateTime?
|
||||||
|
wireTimeSource String?
|
||||||
|
receiptRequested Boolean?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1783,6 +1971,30 @@ model SmsSubmitRecord {
|
|||||||
@@index([gatewayMessageId])
|
@@index([gatewayMessageId])
|
||||||
@@index([channelId, gatewayMessageId])
|
@@index([channelId, gatewayMessageId])
|
||||||
@@index([channelGroupId])
|
@@index([channelGroupId])
|
||||||
|
@@index([updatedAt,id], map: "SmsSubmitRecord_monitor_updated_idx")
|
||||||
|
@@index([createdAt,id], map: "SmsSubmitRecord_monitor_created_idx")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GatewaySubmitOutbox {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
submitId String @unique
|
||||||
|
messageRecordId String
|
||||||
|
channelId String
|
||||||
|
payload Json
|
||||||
|
schemaVersion String @default("v1")
|
||||||
|
status String @default("pending")
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
leaseOwner String?
|
||||||
|
leaseExpiresAt DateTime?
|
||||||
|
streamEntryId String?
|
||||||
|
publishedAt DateTime?
|
||||||
|
lastError String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([submitId, status])
|
||||||
|
@@index([messageRecordId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DailyReconciliationReport {
|
model DailyReconciliationReport {
|
||||||
@@ -1878,7 +2090,7 @@ model SmsMessageSegmentAudit {
|
|||||||
attempt Int @default(0)
|
attempt Int @default(0)
|
||||||
segmentTotal Int @default(1)
|
segmentTotal Int @default(1)
|
||||||
segmentIndex Int @default(1)
|
segmentIndex Int @default(1)
|
||||||
sequenceId Int?
|
sequenceId BigInt?
|
||||||
gatewayMessageId String?
|
gatewayMessageId String?
|
||||||
submitStatus String @default("queued")
|
submitStatus String @default("queued")
|
||||||
receiptStatus String?
|
receiptStatus String?
|
||||||
@@ -1887,6 +2099,9 @@ model SmsMessageSegmentAudit {
|
|||||||
errorCode String?
|
errorCode String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
submittedAt DateTime?
|
submittedAt DateTime?
|
||||||
|
firstWireSubmitAt DateTime?
|
||||||
|
wireTimeSource String?
|
||||||
|
receiptRequested Boolean?
|
||||||
deliveredAt DateTime?
|
deliveredAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1949,6 +2164,35 @@ model CmppInboundLongMessageSegment {
|
|||||||
@@index([sequenceId])
|
@@index([sequenceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CmppInboundSubmissionInbox {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
requestKey String @unique
|
||||||
|
payloadHash String
|
||||||
|
tenantId String
|
||||||
|
applicationId String
|
||||||
|
queuePriority String @default("normal")
|
||||||
|
payload Json
|
||||||
|
response Json
|
||||||
|
status String @default("pending")
|
||||||
|
attempts Int @default(0)
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
lockedAt DateTime?
|
||||||
|
lockedBy String?
|
||||||
|
lastError String?
|
||||||
|
result Json?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||||
|
|
||||||
|
@@index([status, nextAttemptAt, createdAt])
|
||||||
|
@@index([status, lockedAt])
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
@@index([queuePriority, status, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model SmsReceiptRecord {
|
model SmsReceiptRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
@@ -1959,7 +2203,7 @@ model SmsReceiptRecord {
|
|||||||
messageId String
|
messageId String
|
||||||
gatewayMessageId String
|
gatewayMessageId String
|
||||||
phoneNumber String?
|
phoneNumber String?
|
||||||
sequenceId Int?
|
sequenceId BigInt?
|
||||||
receiptStatus String
|
receiptStatus String
|
||||||
rawStatus String
|
rawStatus String
|
||||||
errorCode String?
|
errorCode String?
|
||||||
@@ -2023,12 +2267,14 @@ model SmsReceiptAnomaly {
|
|||||||
|
|
||||||
model SmsUplinkMessage {
|
model SmsUplinkMessage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
eventId String? @unique
|
||||||
tenantId String?
|
tenantId String?
|
||||||
applicationId String?
|
applicationId String?
|
||||||
channelId String
|
channelId String
|
||||||
messageRecordId String?
|
messageRecordId String?
|
||||||
messageId String?
|
messageId String?
|
||||||
sequenceId Int?
|
gatewayMessageId String?
|
||||||
|
sequenceId BigInt?
|
||||||
phoneNumber String
|
phoneNumber String
|
||||||
destId String
|
destId String
|
||||||
content String
|
content String
|
||||||
@@ -2097,7 +2343,7 @@ model CmppDownstreamDelivery {
|
|||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
acknowledgedAt DateTime?
|
acknowledgedAt DateTime?
|
||||||
ackDeadlineAt DateTime?
|
ackDeadlineAt DateTime?
|
||||||
ackResult Int?
|
ackResult BigInt?
|
||||||
ackSequenceId String?
|
ackSequenceId String?
|
||||||
ackMessageId String?
|
ackMessageId String?
|
||||||
connectionId String?
|
connectionId String?
|
||||||
@@ -2205,7 +2451,7 @@ model CmppDownstreamDeliveryAttempt {
|
|||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
ackDeadlineAt DateTime?
|
ackDeadlineAt DateTime?
|
||||||
acknowledgedAt DateTime?
|
acknowledgedAt DateTime?
|
||||||
ackResult Int?
|
ackResult BigInt?
|
||||||
failureType String?
|
failureType String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -2229,7 +2475,7 @@ model UpstreamReceiptInbox {
|
|||||||
protocol String
|
protocol String
|
||||||
protocolVersion String
|
protocolVersion String
|
||||||
provisionalMessageId String?
|
provisionalMessageId String?
|
||||||
sequenceId Int?
|
sequenceId BigInt?
|
||||||
gatewayMessageId String
|
gatewayMessageId String
|
||||||
phoneNumber String?
|
phoneNumber String?
|
||||||
receiptStatus String
|
receiptStatus String
|
||||||
@@ -2237,6 +2483,7 @@ model UpstreamReceiptInbox {
|
|||||||
errorCode String?
|
errorCode String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
deliveredAt DateTime
|
deliveredAt DateTime
|
||||||
|
gatewayReceivedAt DateTime?
|
||||||
receivedAt DateTime @default(now())
|
receivedAt DateTime @default(now())
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
matchedMessageRecordId String?
|
matchedMessageRecordId String?
|
||||||
@@ -2253,6 +2500,8 @@ model UpstreamReceiptInbox {
|
|||||||
@@index([gatewayMessageId, phoneNumber])
|
@@index([gatewayMessageId, phoneNumber])
|
||||||
@@index([incomingChannelId, receivedAt])
|
@@index([incomingChannelId, receivedAt])
|
||||||
@@index([matchedMessageRecordId])
|
@@index([matchedMessageRecordId])
|
||||||
|
@@index([updatedAt,id], map: "UpstreamReceiptInbox_monitor_updated_idx")
|
||||||
|
@@index([matchedMessageRecordId,gatewayMessageId], map: "UpstreamReceiptInbox_monitor_message_idx")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GatewaySubmitDeadLetter {
|
model GatewaySubmitDeadLetter {
|
||||||
@@ -2321,3 +2570,470 @@ model GatewayDownstreamRecoveryStatus {
|
|||||||
@@index([state, updatedAt])
|
@@index([state, updatedAt])
|
||||||
@@index([nextRetryAt])
|
@@index([nextRetryAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SecurityDetectionRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
sourceType String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
threshold Int
|
||||||
|
windowSeconds Int
|
||||||
|
cooldownSeconds Int
|
||||||
|
severity String
|
||||||
|
defaultBlockSeconds Int
|
||||||
|
maximumBlockSeconds Int
|
||||||
|
configVersion Int @default(1)
|
||||||
|
effectiveVersion Int @default(0)
|
||||||
|
applyStatus String @default("pending")
|
||||||
|
lastApplyError String?
|
||||||
|
pendingConfig Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
events SecurityDetectionEvent[]
|
||||||
|
alerts SecurityAlert[]
|
||||||
|
|
||||||
|
@@index([enabled, sourceType])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityDetectionEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventKey String @unique
|
||||||
|
ruleId String
|
||||||
|
sourceIp String
|
||||||
|
sourcePort Int?
|
||||||
|
accountHash String?
|
||||||
|
path String?
|
||||||
|
protocol String?
|
||||||
|
resultCode String?
|
||||||
|
evidence Json?
|
||||||
|
occurredAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([ruleId, occurredAt])
|
||||||
|
@@index([sourceIp, occurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityAlert {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fingerprint String @unique
|
||||||
|
ruleId String
|
||||||
|
sourceIp String
|
||||||
|
severity String
|
||||||
|
status String @default("open")
|
||||||
|
eventCount Int @default(0)
|
||||||
|
windowStartedAt DateTime
|
||||||
|
firstOccurredAt DateTime
|
||||||
|
lastOccurredAt DateTime
|
||||||
|
acknowledgedAt DateTime?
|
||||||
|
acknowledgedById String?
|
||||||
|
ignoredAt DateTime?
|
||||||
|
ignoredById String?
|
||||||
|
ignoreReason String?
|
||||||
|
blockId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([status, severity, lastOccurredAt])
|
||||||
|
@@index([sourceIp, status, lastOccurredAt])
|
||||||
|
@@index([ruleId, status, lastOccurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityBlock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
operationKey String @unique
|
||||||
|
alertId String?
|
||||||
|
sourceIp String
|
||||||
|
executor String
|
||||||
|
status String @default("requested")
|
||||||
|
durationSeconds Int
|
||||||
|
reason String
|
||||||
|
requestedById String
|
||||||
|
requestedAt DateTime @default(now())
|
||||||
|
appliedAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
releasedAt DateTime?
|
||||||
|
releasedById String?
|
||||||
|
executorReference String?
|
||||||
|
lastError String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([sourceIp, status, requestedAt])
|
||||||
|
@@index([alertId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityProtectedNetwork {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
network String @unique
|
||||||
|
name String
|
||||||
|
reason String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([enabled, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InfrastructureAlertSetting {
|
||||||
|
id String @id @default("global")
|
||||||
|
configVersion Int @default(1)
|
||||||
|
effectiveVersion Int @default(1)
|
||||||
|
thresholds Json
|
||||||
|
effectiveThresholds Json
|
||||||
|
applyStatus String @default("effective")
|
||||||
|
lastError String?
|
||||||
|
updatedById String?
|
||||||
|
appliedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model InfrastructureAlertRead {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fingerprint String
|
||||||
|
activeAt DateTime
|
||||||
|
userId String
|
||||||
|
readAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fingerprint, userId])
|
||||||
|
@@index([userId, readAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|||||||
+25
-2
@@ -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 { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
@@ -12,6 +16,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
|
|||||||
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { InfrastructureMonitoringModule } from './infrastructure-monitoring/infrastructure-monitoring.module';
|
||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
import { OpenApiModule } from './open-api/open-api.module';
|
import { OpenApiModule } from './open-api/open-api.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -24,6 +29,10 @@ import { SmsConfigModule } from './sms-config/sms-config.module';
|
|||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
|
import { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -49,14 +58,28 @@ import { SignatureRetirementModule } from './signature-retirement/signature-reti
|
|||||||
ReportMaterialsModule,
|
ReportMaterialsModule,
|
||||||
SendChainModule,
|
SendChainModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
|
InfrastructureMonitoringModule,
|
||||||
OpenApiModule,
|
OpenApiModule,
|
||||||
SignatureRetirementModule,
|
SignatureRetirementModule,
|
||||||
|
SignatureAnalyticsModule,
|
||||||
|
HomeModule,
|
||||||
|
SecurityDetectionModule,
|
||||||
|
MetricsModule,
|
||||||
|
ReportNotificationsModule,
|
||||||
|
SendingMonitorModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
providers: [
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
|
||||||
|
RequestContextMiddleware,
|
||||||
|
SessionValidationMiddleware,
|
||||||
|
ManualOperationAuditMiddleware,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware).forRoutes('*');
|
consumer
|
||||||
|
.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware)
|
||||||
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-21
@@ -1,12 +1,16 @@
|
|||||||
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UsePipes } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||||
import { AuthService, LoginDto } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { ChangeOwnPasswordDto, LoginDto, PasswordVerificationDto } from './auth.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
||||||
import type { SessionRequest } from './session-validation.middleware';
|
import type { SessionRequest } from './session-validation.middleware';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { requestContext } from '../common/request-context';
|
||||||
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||||
|
|
||||||
type CookieResponse = {
|
type CookieResponse = {
|
||||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||||
@@ -16,26 +20,56 @@ type CookieResponse = {
|
|||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly auth: AuthService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly sessions: SessionService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly security: SecurityDetectionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('admin/auth/captcha')
|
@Get('admin/auth/captcha')
|
||||||
adminCaptcha() {
|
adminCaptcha(@Req() request: SessionRequest) {
|
||||||
return this.auth.createCaptcha();
|
return this.auth.createCaptcha(this.sourceIp(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('admin/auth/login')
|
@Post('admin/auth/login')
|
||||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
|
async adminLogin(
|
||||||
|
@Body() body: LoginDto,
|
||||||
|
@Req() request: SessionRequest,
|
||||||
|
@Res({ passthrough: true }) response: CookieResponse,
|
||||||
|
) {
|
||||||
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
|
try {
|
||||||
|
result = await this.auth.login(body, 'admin', this.sourceIp(request));
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.finishLogin(result, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('client/auth/captcha')
|
@Get('client/auth/captcha')
|
||||||
clientCaptcha() {
|
clientCaptcha(@Req() request: SessionRequest) {
|
||||||
return this.auth.createCaptcha();
|
return this.auth.createCaptcha(this.sourceIp(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('client/auth/login')
|
@Post('client/auth/login')
|
||||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
async clientLogin(
|
||||||
|
@Body() body: LoginDto,
|
||||||
|
@Req() request: SessionRequest,
|
||||||
|
@Res({ passthrough: true }) response: CookieResponse,
|
||||||
|
) {
|
||||||
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
|
try {
|
||||||
|
result = await this.auth.login(body, 'client', this.sourceIp(request));
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.finishLogin(result, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(['admin/auth/session', 'client/auth/session'])
|
@Get(['admin/auth/session', 'client/auth/session'])
|
||||||
@@ -74,22 +108,32 @@ export class AuthController {
|
|||||||
async lock(@Req() request: SessionRequest) {
|
async lock(@Req() request: SessionRequest) {
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const record = await this.sessions.lock(request.sessionToken!);
|
const record = await this.sessions.lock(request.sessionToken!);
|
||||||
if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
|
if (record)
|
||||||
|
await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
|
||||||
return { locked: Boolean(record) };
|
return { locked: Boolean(record) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
|
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
|
||||||
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
|
@UsePipes(strictValidationPipe)
|
||||||
|
async unlock(
|
||||||
|
@Req() request: SessionRequest,
|
||||||
|
@Body() body: PasswordVerificationDto,
|
||||||
|
@Res({ passthrough: true }) response: CookieResponse,
|
||||||
|
) {
|
||||||
|
const { password } = body;
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
||||||
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
if (result.status !== 'active' || !('token' in result))
|
||||||
|
throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
||||||
this.setCookie(response, result.record.portal, result.token);
|
this.setCookie(response, result.record.portal, result.token);
|
||||||
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
||||||
return this.sessions.publicSession(result.record);
|
return this.sessions.publicSession(result.record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
|
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
|
||||||
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
|
@UsePipes(strictValidationPipe)
|
||||||
|
async reauthenticate(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto) {
|
||||||
|
const { password } = body;
|
||||||
this.assertSession(request);
|
this.assertSession(request);
|
||||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||||
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
||||||
@@ -100,30 +144,68 @@ export class AuthController {
|
|||||||
@Post(['admin/auth/logout', 'client/auth/logout'])
|
@Post(['admin/auth/logout', 'client/auth/logout'])
|
||||||
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
||||||
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
if (request.sessionUserId)
|
||||||
|
await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
||||||
this.clearCookie(response, request.authSession?.portal);
|
this.clearCookie(response, request.authSession?.portal);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(['admin/auth/password', 'client/auth/password'])
|
@Post(['admin/auth/password', 'client/auth/password'])
|
||||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
@UsePipes(strictValidationPipe)
|
||||||
|
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: ChangeOwnPasswordDto) {
|
||||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||||
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
return this.users.changeOwnPassword(userId, body.currentPassword, body.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
private async finishLogin(
|
||||||
|
result: Awaited<ReturnType<AuthService['login']>>,
|
||||||
|
request: SessionRequest,
|
||||||
|
response: CookieResponse,
|
||||||
|
) {
|
||||||
this.setCookie(response, result.portal, result.sessionToken);
|
this.setCookie(response, result.portal, result.sessionToken);
|
||||||
this.clearLegacyCookie(response);
|
this.clearLegacyCookie(response);
|
||||||
await this.prisma.operationLog.create({
|
await this.prisma.operationLog.create({
|
||||||
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
|
data: {
|
||||||
|
userId: result.user.id,
|
||||||
|
tenantId: result.user.tenantId,
|
||||||
|
action: 'auth.session_created',
|
||||||
|
resource: 'auth_session',
|
||||||
|
userAgent: request.header('user-agent'),
|
||||||
|
detail: { portal: result.portal },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { sessionToken: _, ...publicResult } = result;
|
const { sessionToken: _, ...publicResult } = result;
|
||||||
return publicResult;
|
return publicResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordLoginFailure(
|
||||||
|
ruleCode: 'admin_login_failure' | 'client_login_failure',
|
||||||
|
account: string,
|
||||||
|
request: SessionRequest,
|
||||||
|
) {
|
||||||
|
return this.security.recordEvent({
|
||||||
|
ruleCode,
|
||||||
|
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
|
||||||
|
account,
|
||||||
|
protocol: 'http',
|
||||||
|
path: ruleCode === 'admin_login_failure' ? '/admin/auth/login' : '/client/auth/login',
|
||||||
|
evidence: { userAgent: request.header('user-agent')?.slice(0, 256) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sourceIp(request: SessionRequest) {
|
||||||
|
return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
||||||
return this.prisma.operationLog.create({
|
return this.prisma.operationLog.create({
|
||||||
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
|
data: {
|
||||||
|
userId: request.sessionUserId,
|
||||||
|
action,
|
||||||
|
resource: 'auth_session',
|
||||||
|
userAgent: request.header('user-agent'),
|
||||||
|
detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) login!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(256) password!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(64) captchaId!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(32) captchaText!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PasswordVerificationDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(256) password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangeOwnPasswordDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(256) currentPassword!: string;
|
||||||
|
@IsString() @MinLength(8) @MaxLength(256) password!: string;
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [UsersModule, SecurityDetectionModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { hashPassword } from '../users/users.service';
|
import { legacyHashPassword } from './password-hasher';
|
||||||
|
|
||||||
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
||||||
const user = {
|
const user = {
|
||||||
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
|||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
phone: '13800000000',
|
phone: '13800000000',
|
||||||
displayName: '用户',
|
displayName: '用户',
|
||||||
passwordHash: hashPassword('secret1'),
|
passwordHash: legacyHashPassword('secret1'),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
lockedUntil: null,
|
lockedUntil: null,
|
||||||
@@ -20,56 +20,165 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
findByLogin: jest.fn().mockResolvedValue(user),
|
findByLogin: jest.fn().mockResolvedValue(user),
|
||||||
|
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
|
||||||
recordLoginSuccess: jest.fn(),
|
recordLoginSuccess: jest.fn(),
|
||||||
recordLoginFailure: jest.fn(),
|
recordLoginFailure: jest.fn(),
|
||||||
|
verifyCurrentPassword: jest.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionsMock() {
|
function createSessionsMock() {
|
||||||
|
const captchas = new Map<string, string>();
|
||||||
|
const failures = new Map<string, number>();
|
||||||
const record = {
|
const record = {
|
||||||
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
userId: 'user-1',
|
||||||
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
portal: 'admin',
|
||||||
|
sessionVersion: 0,
|
||||||
|
createdAt: 1,
|
||||||
|
lastActivityAt: 1,
|
||||||
|
lastAuthenticatedAt: 1,
|
||||||
|
absoluteExpiresAt: Date.now() + 1000,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
storeCaptcha: jest.fn(async (id: string, answer: string) => {
|
||||||
|
captchas.set(id, answer);
|
||||||
|
}),
|
||||||
|
consumeCaptcha: jest.fn(async (id: string) => {
|
||||||
|
const answer = captchas.get(id) ?? null;
|
||||||
|
captchas.delete(id);
|
||||||
|
return answer;
|
||||||
|
}),
|
||||||
|
assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true),
|
||||||
|
anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)),
|
||||||
|
recordAnonymousLoginFailure: jest.fn(async (login: string) => {
|
||||||
|
const count = (failures.get(login) ?? 0) + 1;
|
||||||
|
failures.set(login, count);
|
||||||
|
return [count, count, count];
|
||||||
|
}),
|
||||||
|
clearAnonymousLoginFailures: jest.fn(async (login: string) => {
|
||||||
|
failures.delete(login);
|
||||||
|
}),
|
||||||
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
||||||
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
publicSession: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({
|
||||||
|
idleTimeoutSeconds: 3600,
|
||||||
|
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
|
||||||
|
}),
|
||||||
|
unlock: jest.fn().mockResolvedValue({ status: 'active' }),
|
||||||
|
markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMetricsMock() {
|
||||||
|
return { recordAuthProtectionResult: jest.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||||
const captcha = service.createCaptcha();
|
const captcha = await service.createCaptcha('203.0.113.10');
|
||||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
const answer = captcha.challenge
|
||||||
return service.login({
|
.split('=')[0]
|
||||||
|
.split('+')
|
||||||
|
.map((part) => Number(part.trim()))
|
||||||
|
.reduce((sum, value) => sum + value, 0);
|
||||||
|
return service.login(
|
||||||
|
{
|
||||||
login: 'user@example.com',
|
login: 'user@example.com',
|
||||||
password,
|
password,
|
||||||
captchaId: captcha.captchaId,
|
captchaId: captcha.captchaId,
|
||||||
captchaText: String(answer),
|
captchaText: String(answer),
|
||||||
}, portal);
|
},
|
||||||
|
portal,
|
||||||
|
'203.0.113.10',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
it('allows platform admins to login admin portal', async () => {
|
it('allows platform admins to login admin portal', async () => {
|
||||||
const users = createUsersMock('platform_admin');
|
const users = createUsersMock('platform_admin');
|
||||||
const sessions = createSessionsMock();
|
const sessions = createSessionsMock();
|
||||||
const service = new AuthService(users as never, sessions as never);
|
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
|
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }),
|
||||||
|
);
|
||||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||||
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
|
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects enterprise admins on admin portal', async () => {
|
it('rejects enterprise admins on admin portal', async () => {
|
||||||
const users = createUsersMock('enterprise_admin');
|
const users = createUsersMock('enterprise_admin');
|
||||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
|
||||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('locks user after five failed password attempts', async () => {
|
it('locks user after five failed password attempts', async () => {
|
||||||
const users = createUsersMock('platform_admin');
|
const users = createUsersMock('platform_admin');
|
||||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
}
|
}
|
||||||
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
|
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects captcha bursts before allocating a captcha', async () => {
|
||||||
|
const sessions = createSessionsMock();
|
||||||
|
sessions.assertCaptchaRequestAllowed.mockResolvedValue(false);
|
||||||
|
const metrics = createMetricsMock();
|
||||||
|
const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never);
|
||||||
|
await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 });
|
||||||
|
expect(sessions.storeCaptcha).not.toHaveBeenCalled();
|
||||||
|
expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a tenant-bound enterprise admin to login to the client portal', async () => {
|
||||||
|
const service = new AuthService(
|
||||||
|
createUsersMock('enterprise_admin') as never,
|
||||||
|
createSessionsMock() as never,
|
||||||
|
createMetricsMock() as never,
|
||||||
|
);
|
||||||
|
await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records anonymous failures without disclosing whether an account exists', async () => {
|
||||||
|
const users = createUsersMock('platform_admin');
|
||||||
|
users.findByLogin.mockResolvedValue(null);
|
||||||
|
const sessions = createSessionsMock();
|
||||||
|
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||||
|
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired and incorrect one-time captchas', async () => {
|
||||||
|
const sessions = createSessionsMock();
|
||||||
|
const service = new AuthService(
|
||||||
|
createUsersMock('platform_admin') as never,
|
||||||
|
sessions as never,
|
||||||
|
createMetricsMock() as never,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.login(
|
||||||
|
{ login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' },
|
||||||
|
'admin',
|
||||||
|
'203.0.113.10',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
await sessions.storeCaptcha('captcha-wrong', '7');
|
||||||
|
await expect(
|
||||||
|
service.login(
|
||||||
|
{ login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' },
|
||||||
|
'admin',
|
||||||
|
'203.0.113.10',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates unlock and recent reauthentication to password and session services', async () => {
|
||||||
|
const users = createUsersMock('platform_admin');
|
||||||
|
const sessions = createSessionsMock();
|
||||||
|
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
|
||||||
|
await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
|
||||||
|
await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
|
||||||
|
expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { hashPassword, UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
import type { LoginDto } from './auth.dto';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { MetricsService } from '../metrics/metrics.service';
|
||||||
export interface LoginDto {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
captchaId: string;
|
|
||||||
captchaText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginPortal = 'admin' | 'client';
|
type LoginPortal = 'admin' | 'client';
|
||||||
|
|
||||||
type CaptchaRecord = {
|
|
||||||
answer: string;
|
|
||||||
expiresAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const captchaStore = new Map<string, CaptchaRecord>();
|
|
||||||
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
constructor(
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly sessions: SessionService,
|
||||||
|
private readonly metrics: MetricsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
createCaptcha() {
|
async createCaptcha(sourceIp: string) {
|
||||||
|
if (!(await this.sessions.assertCaptchaRequestAllowed(sourceIp))) {
|
||||||
|
this.metrics.recordAuthProtectionResult('captcha_rejected');
|
||||||
|
throw new HttpException('验证码请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
this.metrics.recordAuthProtectionResult('captcha_allowed');
|
||||||
const left = Math.floor(10 + Math.random() * 40);
|
const left = Math.floor(10 + Math.random() * 40);
|
||||||
const right = Math.floor(1 + Math.random() * 9);
|
const right = Math.floor(1 + Math.random() * 9);
|
||||||
const captchaId = randomUUID();
|
const captchaId = randomUUID();
|
||||||
captchaStore.set(captchaId, {
|
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
|
||||||
answer: String(left + right),
|
|
||||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
captchaId,
|
captchaId,
|
||||||
challenge: `${left} + ${right} = ?`,
|
challenge: `${left} + ${right} = ?`,
|
||||||
@@ -39,43 +32,48 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(data: LoginDto, portal: LoginPortal) {
|
async login(data: LoginDto, portal: LoginPortal, sourceIp: string) {
|
||||||
const login = data.login?.trim();
|
const login = data.login?.trim();
|
||||||
if (!login || !data.password) {
|
if (!login || !data.password) {
|
||||||
throw new BadRequestException('login and password are required');
|
throw new BadRequestException('login and password are required');
|
||||||
}
|
}
|
||||||
this.verifyCaptcha(data.captchaId, data.captchaText);
|
await this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||||
this.assertAnonymousNotLocked(login);
|
await this.assertAnonymousNotLocked(login, sourceIp);
|
||||||
|
|
||||||
const user = await this.users.findByLogin(login);
|
const user = await this.users.findByLogin(login);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.recordAnonymousFailure(login);
|
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||||
throw new UnauthorizedException('Invalid login or password');
|
throw new UnauthorizedException('Invalid login or password');
|
||||||
}
|
}
|
||||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||||
|
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
||||||
}
|
}
|
||||||
if (user.status !== 'active' || user.deletedAt) {
|
if (user.status !== 'active' || user.deletedAt) {
|
||||||
|
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||||
await this.users.recordLoginFailure(user.id);
|
await this.users.recordLoginFailure(user.id);
|
||||||
throw new UnauthorizedException('User is disabled or deleted');
|
throw new UnauthorizedException('User is disabled or deleted');
|
||||||
}
|
}
|
||||||
if (user.passwordHash !== hashPassword(data.password)) {
|
if (!(await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash))) {
|
||||||
|
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||||
await this.users.recordLoginFailure(user.id);
|
await this.users.recordLoginFailure(user.id);
|
||||||
throw new UnauthorizedException('Invalid login or password');
|
throw new UnauthorizedException('Invalid login or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleCodes = user.roles.map((item) => item.role.code);
|
const roleCodes = user.roles.map((item) => item.role.code);
|
||||||
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
|
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
|
||||||
|
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||||
await this.users.recordLoginFailure(user.id);
|
await this.users.recordLoginFailure(user.id);
|
||||||
throw new UnauthorizedException('Only platform admins can login to admin portal');
|
throw new UnauthorizedException('Only platform admins can login to admin portal');
|
||||||
}
|
}
|
||||||
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
|
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
|
||||||
|
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
|
||||||
await this.users.recordLoginFailure(user.id);
|
await this.users.recordLoginFailure(user.id);
|
||||||
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
|
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.users.recordLoginSuccess(user.id);
|
await this.users.recordLoginSuccess(user.id);
|
||||||
anonymousFailures.delete(login);
|
await this.sessions.clearAnonymousLoginFailures(login, sourceIp);
|
||||||
|
|
||||||
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
||||||
return {
|
return {
|
||||||
@@ -105,30 +103,25 @@ export class AuthService {
|
|||||||
return this.sessions.markReauthenticated(token);
|
return this.sessions.markReauthenticated(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
|
||||||
captchaStore.delete(captchaId ?? '');
|
if (!answer) {
|
||||||
if (!record || record.expiresAt < Date.now()) {
|
|
||||||
throw new BadRequestException('Captcha expired, refresh and try again');
|
throw new BadRequestException('Captcha expired, refresh and try again');
|
||||||
}
|
}
|
||||||
if (record.answer !== captchaText?.trim()) {
|
if (answer !== captchaText?.trim()) {
|
||||||
throw new BadRequestException('Captcha is incorrect');
|
throw new BadRequestException('Captcha is incorrect');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertAnonymousNotLocked(login: string) {
|
private async assertAnonymousNotLocked(login: string, sourceIp: string) {
|
||||||
const current = anonymousFailures.get(login);
|
const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
|
||||||
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
|
if (scope) {
|
||||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
this.metrics.recordAuthProtectionResult('login_locked', scope);
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
scope === 'account'
|
||||||
|
? 'User is locked for 24 hours after repeated failures'
|
||||||
|
: 'Too many login attempts from this source, try again later',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordAnonymousFailure(login: string) {
|
|
||||||
const current = anonymousFailures.get(login) ?? { count: 0 };
|
|
||||||
const count = current.count + 1;
|
|
||||||
anonymousFailures.set(login, {
|
|
||||||
count,
|
|
||||||
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ForbiddenException, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tenant bound to the authenticated client session.
|
||||||
|
* Request headers, query parameters and request bodies must never determine this value.
|
||||||
|
*/
|
||||||
|
export const CurrentTenantId = createParamDecorator((_: unknown, context: ExecutionContext) => {
|
||||||
|
const request = context.switchToHttp().getRequest<SessionRequest>();
|
||||||
|
if (request.authSession?.portal !== 'client' || !request.sessionTenantId) {
|
||||||
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '缺少可信企业上下文' });
|
||||||
|
}
|
||||||
|
return request.sessionTenantId;
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
|
||||||
|
|
||||||
|
describe('password hasher', () => {
|
||||||
|
const originalTransition = process.env.PASSWORD_HASH_LEGACY_TRANSITION;
|
||||||
|
const originalUntil = process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalTransition === undefined) delete process.env.PASSWORD_HASH_LEGACY_TRANSITION;
|
||||||
|
else process.env.PASSWORD_HASH_LEGACY_TRANSITION = originalTransition;
|
||||||
|
if (originalUntil === undefined) delete process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL;
|
||||||
|
else process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = originalUntil;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores new passwords using versioned salted scrypt hashes', async () => {
|
||||||
|
const first = await hashPassword('correct horse battery staple');
|
||||||
|
const second = await hashPassword('correct horse battery staple');
|
||||||
|
|
||||||
|
expect(first).toMatch(/^\$scrypt\$v=1\$/);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
await expect(verifyPassword('correct horse battery staple', first)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword('wrong password', first)).resolves.toBe(false);
|
||||||
|
expect(passwordNeedsRehash(first)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes and verifies legacy SHA-256 hashes for transparent migration', async () => {
|
||||||
|
const legacy = legacyHashPassword('legacy-password');
|
||||||
|
|
||||||
|
expect(isLegacySha256(legacy)).toBe(true);
|
||||||
|
expect(passwordNeedsRehash(legacy)).toBe(true);
|
||||||
|
await expect(verifyPassword('legacy-password', legacy)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword('wrong-password', legacy)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed or excessive scrypt parameters', async () => {
|
||||||
|
await expect(verifyPassword('password', '$scrypt$v=1$N=1048576,r=8,p=1$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports only a bounded legacy-write window for the first compatibility rollout', async () => {
|
||||||
|
process.env.PASSWORD_HASH_LEGACY_TRANSITION = 'true';
|
||||||
|
process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 30 * 60 * 1000).toISOString();
|
||||||
|
await expect(hashPassword('transition-password')).resolves.toBe(legacyHashPassword('transition-password'));
|
||||||
|
|
||||||
|
process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
await expect(hashPassword('strong-password')).resolves.toMatch(/^\$scrypt\$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
||||||
|
const VERSION = 1;
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const DEFAULT_N = 32768;
|
||||||
|
const DEFAULT_R = 8;
|
||||||
|
const DEFAULT_P = 1;
|
||||||
|
const MAX_MEMORY = 64 * 1024 * 1024;
|
||||||
|
const MAX_LEGACY_WRITE_WINDOW_MS = 2 * 60 * 60 * 1000;
|
||||||
|
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string) {
|
||||||
|
if (legacyTransitionWriteEnabled()) return legacyHashPassword(password);
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
|
||||||
|
N: DEFAULT_N,
|
||||||
|
r: DEFAULT_R,
|
||||||
|
p: DEFAULT_P,
|
||||||
|
maxmem: MAX_MEMORY,
|
||||||
|
});
|
||||||
|
return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyTransitionWriteEnabled() {
|
||||||
|
if (process.env.PASSWORD_HASH_LEGACY_TRANSITION !== 'true') return false;
|
||||||
|
const until = Date.parse(process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL ?? '');
|
||||||
|
const remaining = until - Date.now();
|
||||||
|
return Number.isFinite(until) && remaining > 0 && remaining <= MAX_LEGACY_WRITE_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, encoded: string) {
|
||||||
|
if (isLegacySha256(encoded)) {
|
||||||
|
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
|
||||||
|
const expected = Buffer.from(encoded, 'hex');
|
||||||
|
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseScryptHash(encoded);
|
||||||
|
if (!parsed) return false;
|
||||||
|
const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, {
|
||||||
|
N: parsed.N,
|
||||||
|
r: parsed.r,
|
||||||
|
p: parsed.p,
|
||||||
|
maxmem: MAX_MEMORY,
|
||||||
|
});
|
||||||
|
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordNeedsRehash(encoded: string) {
|
||||||
|
if (isLegacySha256(encoded)) return true;
|
||||||
|
const parsed = parseScryptHash(encoded);
|
||||||
|
return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacySha256(encoded: string) {
|
||||||
|
return LEGACY_SHA256.test(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyHashPassword(password: string) {
|
||||||
|
return createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScryptHash(encoded: string) {
|
||||||
|
const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const [, version, N, r, p, salt, hash] = match;
|
||||||
|
const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) };
|
||||||
|
if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N
|
||||||
|
|| !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R
|
||||||
|
|| !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined;
|
||||||
|
try {
|
||||||
|
const decodedSalt = Buffer.from(salt, 'base64url');
|
||||||
|
const decodedHash = Buffer.from(hash, 'base64url');
|
||||||
|
if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined;
|
||||||
|
return { ...params, salt: decodedSalt, hash: decodedHash };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
@@ -6,10 +6,10 @@ const record = {
|
|||||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
|
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token', tenantId?: string): SessionRequest {
|
||||||
return {
|
return {
|
||||||
originalUrl: path,
|
originalUrl: path,
|
||||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
|
header: jest.fn((name: string) => name === 'cookie' ? cookie : name === 'x-tenant-id' ? tenantId : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
||||||
|
|
||||||
it('accepts an active Redis session and exposes its user and record', async () => {
|
it('accepts an active Redis session and exposes its user and record', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
const currentRequest = request();
|
const currentRequest = request();
|
||||||
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a session after the user session version changes', async () => {
|
it('rejects a session after the user session version changes', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
|
|
||||||
it('selects only the cookie belonging to the requested portal', async () => {
|
it('selects only the cookie belonging to the requested portal', async () => {
|
||||||
const clientRecord = { ...record, portal: 'client' as const };
|
const clientRecord = { ...record, portal: 'client' as const };
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
|
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
|
||||||
@@ -62,6 +62,33 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
|
|
||||||
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
||||||
expect(currentRequest.sessionToken).toBe('client-token');
|
expect(currentRequest.sessionToken).toBe('client-token');
|
||||||
|
expect(currentRequest.sessionTenantId).toBe('tenant-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a client session whose user is not bound to a tenant', async () => {
|
||||||
|
const clientRecord = { ...record, portal: 'client' as const };
|
||||||
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
|
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token'), {}, jest.fn()))
|
||||||
|
.rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and audits a client tenant header that disagrees with the authenticated user', async () => {
|
||||||
|
const clientRecord = { ...record, portal: 'client' as const };
|
||||||
|
const prisma = {
|
||||||
|
user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) },
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
||||||
|
};
|
||||||
|
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
|
|
||||||
|
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token', 'tenant-b'), {}, jest.fn()))
|
||||||
|
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'CLIENT_TENANT_MISMATCH' }) });
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ tenantId: 'tenant-a', userId: 'user-1', action: 'security.client_tenant_mismatch' }),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not accept an admin cookie for a client route', async () => {
|
it('does not accept an admin cookie for a client route', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ export type SessionRequest = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
sessionUserId?: string;
|
sessionUserId?: string;
|
||||||
sessionToken?: string;
|
sessionToken?: string;
|
||||||
|
sessionTenantId?: string;
|
||||||
authSession?: AuthSessionRecord;
|
authSession?: AuthSessionRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: result.record.userId },
|
where: { id: result.record.userId },
|
||||||
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
|
select: { id: true, tenantId: true, status: true, deletedAt: true, sessionVersion: true },
|
||||||
});
|
});
|
||||||
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
||||||
await this.sessions.remove(token);
|
await this.sessions.remove(token);
|
||||||
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
request.sessionUserId = user.id;
|
request.sessionUserId = user.id;
|
||||||
request.sessionToken = token;
|
request.sessionToken = token;
|
||||||
request.authSession = result.record;
|
request.authSession = result.record;
|
||||||
|
if (portal === 'client') {
|
||||||
|
if (!user.tenantId) {
|
||||||
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '当前客户端账号未关联企业' });
|
||||||
|
}
|
||||||
|
const suppliedTenantId = request.header('x-tenant-id')?.trim();
|
||||||
|
if (suppliedTenantId && suppliedTenantId !== user.tenantId) {
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
userId: user.id,
|
||||||
|
action: 'security.client_tenant_mismatch',
|
||||||
|
resource: 'auth_session',
|
||||||
|
detail: { suppliedTenantId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_MISMATCH', message: '请求企业与登录企业不一致' });
|
||||||
|
}
|
||||||
|
request.sessionTenantId = user.tenantId;
|
||||||
|
}
|
||||||
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
||||||
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
||||||
if (result.newlyLocked) {
|
if (result.newlyLocked) {
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
const redis = {
|
const redis = {
|
||||||
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
|
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
|
||||||
set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }),
|
getdel: jest.fn((key: string) => {
|
||||||
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
|
const value = values.get(key) ?? null;
|
||||||
|
values.delete(key);
|
||||||
|
return Promise.resolve(value);
|
||||||
|
}),
|
||||||
|
mget: jest.fn((...keys: string[]) => Promise.resolve(keys.map((key) => values.get(key) ?? null))),
|
||||||
|
set: jest.fn((key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
return Promise.resolve('OK');
|
||||||
|
}),
|
||||||
|
del: jest.fn((...keys: string[]) => {
|
||||||
|
keys.forEach((key) => values.delete(key));
|
||||||
|
return Promise.resolve(keys.length);
|
||||||
|
}),
|
||||||
|
eval: jest.fn((_script: string, keyCount: number, ...parts: Array<string | number>) => {
|
||||||
|
const keys = parts.slice(0, keyCount).map(String);
|
||||||
|
const args = parts.slice(keyCount).map(Number);
|
||||||
|
if (keyCount === 1) {
|
||||||
|
const count = Number(values.get(keys[0]) ?? 0) + 1;
|
||||||
|
values.set(keys[0], String(count));
|
||||||
|
return Promise.resolve(count);
|
||||||
|
}
|
||||||
|
const counts = keys.slice(0, 3).map((key, index) => {
|
||||||
|
const count = Number(values.get(key) ?? 0) + 1;
|
||||||
|
values.set(key, String(count));
|
||||||
|
if (count >= args[index + 3]) values.set(keys[index + 3], '1');
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
return Promise.resolve(counts);
|
||||||
|
}),
|
||||||
disconnect: jest.fn(),
|
disconnect: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('ioredis', () => jest.fn(() => redis));
|
jest.mock('ioredis', () => jest.fn(() => redis));
|
||||||
|
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
describe('SessionService', () => {
|
describe('SessionService', () => {
|
||||||
let now = 1_700_000_000_000;
|
let now = 1_700_000_000_000;
|
||||||
@@ -47,7 +76,9 @@ describe('SessionService', () => {
|
|||||||
const service = new SessionService();
|
const service = new SessionService();
|
||||||
const created = await service.create('user-1', 'client', 2);
|
const created = await service.create('user-1', 'client', 2);
|
||||||
now += 90 * 60 * 1000;
|
now += 90 * 60 * 1000;
|
||||||
await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
await expect(service.validate(created.token, false)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ status: 'active' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a full login after a session stays locked for four hours', async () => {
|
it('requires a full login after a session stays locked for four hours', async () => {
|
||||||
@@ -55,7 +86,10 @@ describe('SessionService', () => {
|
|||||||
const created = await service.create('user-1', 'admin', 2);
|
const created = await service.create('user-1', 'admin', 2);
|
||||||
await service.lock(created.token);
|
await service.lock(created.token);
|
||||||
now += 4 * 60 * 60 * 1000 + 1;
|
now += 4 * 60 * 60 * 1000 + 1;
|
||||||
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' });
|
await expect(service.validate(created.token, false)).resolves.toEqual({
|
||||||
|
status: 'expired',
|
||||||
|
code: 'SESSION_LOCK_TIMEOUT',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rotates the opaque token when a password unlock succeeds', async () => {
|
it('rotates the opaque token when a password unlock succeeds', async () => {
|
||||||
@@ -66,8 +100,13 @@ describe('SessionService', () => {
|
|||||||
expect(result.status).toBe('active');
|
expect(result.status).toBe('active');
|
||||||
if (result.status === 'active' && 'token' in result) {
|
if (result.status === 'active' && 'token' in result) {
|
||||||
expect(result.token).not.toBe(created.token);
|
expect(result.token).not.toBe(created.token);
|
||||||
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' });
|
await expect(service.validate(created.token, false)).resolves.toEqual({
|
||||||
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
status: 'expired',
|
||||||
|
code: 'SESSION_INVALID',
|
||||||
|
});
|
||||||
|
await expect(service.validate(result.token, false)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ status: 'active' }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,4 +117,81 @@ describe('SessionService', () => {
|
|||||||
expect(service.cookieName('client')).toBe('cmpp_client_session');
|
expect(service.cookieName('client')).toBe('cmpp_client_session');
|
||||||
delete process.env.SESSION_COOKIE_SECURE;
|
delete process.env.SESSION_COOKIE_SECURE;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores and consumes a captcha exactly once', async () => {
|
||||||
|
const service = new SessionService();
|
||||||
|
await service.storeCaptcha('captcha-1', '9', 300);
|
||||||
|
await expect(service.consumeCaptcha('captcha-1')).resolves.toBe('9');
|
||||||
|
await expect(service.consumeCaptcha('captcha-1')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires an absolute session and supports touch plus recent authentication', async () => {
|
||||||
|
const service = new SessionService();
|
||||||
|
const expired = await service.create('expired-user', 'admin', 1);
|
||||||
|
now += 12 * 60 * 60 * 1000 + 1;
|
||||||
|
await expect(service.validate(expired.token, false)).resolves.toEqual({
|
||||||
|
status: 'expired',
|
||||||
|
code: 'SESSION_ABSOLUTE_TIMEOUT',
|
||||||
|
});
|
||||||
|
|
||||||
|
now = 1_700_000_000_000;
|
||||||
|
const active = await service.create('active-user', 'client', 1);
|
||||||
|
now += 31_000;
|
||||||
|
const touched = await service.touch(active.token);
|
||||||
|
expect(touched.status).toBe('active');
|
||||||
|
const reauthenticated = await service.markReauthenticated(active.token);
|
||||||
|
expect(reauthenticated.status).toBe('active');
|
||||||
|
if (reauthenticated.status === 'active') {
|
||||||
|
expect(service.isRecentlyAuthenticated(reauthenticated.record)).toBe(true);
|
||||||
|
expect(service.publicSession(reauthenticated.record)).toEqual(
|
||||||
|
expect.objectContaining({ idleTimeoutSeconds: 7200 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses host-prefixed cookie names when secure cookies are enabled', () => {
|
||||||
|
process.env.SESSION_COOKIE_SECURE = 'true';
|
||||||
|
const service = new SessionService();
|
||||||
|
expect(service.cookieName('admin')).toBe('__Host-cmpp_admin_session');
|
||||||
|
expect(service.cookieName('client')).toBe('__Host-cmpp_client_session');
|
||||||
|
delete process.env.SESSION_COOKIE_SECURE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate limits captcha allocation by hashed source IP', async () => {
|
||||||
|
const service = new SessionService();
|
||||||
|
for (let index = 0; index < 30; index += 1) {
|
||||||
|
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(true);
|
||||||
|
}
|
||||||
|
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(false);
|
||||||
|
expect([...values.keys()].some((key) => key.includes('203.0.113.10'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks anonymous failures independently by account, IP and account-IP pair', async () => {
|
||||||
|
const service = new SessionService();
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await service.recordAnonymousLoginFailure('user@example.com', '203.0.113.10');
|
||||||
|
}
|
||||||
|
await expect(service.anonymousLoginLockScope('user@example.com', '198.51.100.7')).resolves.toBe('account');
|
||||||
|
await expect(service.anonymousLoginLockScope('other@example.com', '203.0.113.10')).resolves.toBeNull();
|
||||||
|
await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10');
|
||||||
|
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when Redis cannot enforce captcha or login protection', async () => {
|
||||||
|
const service = new SessionService();
|
||||||
|
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||||
|
await expect(service.assertCaptchaRequestAllowed('203.0.113.20')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
redis.mget.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||||
|
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||||
|
await expect(service.recordAnonymousLoginFailure('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export type SessionValidationResult =
|
|||||||
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
||||||
|
|
||||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||||
|
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
|
||||||
|
const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:';
|
||||||
|
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
|
||||||
|
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
|
||||||
|
const ANONYMOUS_IP_FAILURE_PREFIX = 'cmpp:auth:failure:ip:';
|
||||||
|
const ANONYMOUS_IP_LOCK_PREFIX = 'cmpp:auth:lock:ip:';
|
||||||
|
const ANONYMOUS_PAIR_FAILURE_PREFIX = 'cmpp:auth:failure:pair:';
|
||||||
|
const ANONYMOUS_PAIR_LOCK_PREFIX = 'cmpp:auth:lock:pair:';
|
||||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||||
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
|
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
|
||||||
@@ -123,6 +131,108 @@ export class SessionService implements OnModuleDestroy {
|
|||||||
return this.client.del(this.key(token));
|
return this.client.del(this.key(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) {
|
||||||
|
try {
|
||||||
|
await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeCaptcha(captchaId: string) {
|
||||||
|
try {
|
||||||
|
return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCaptchaRequestAllowed(sourceIp: string) {
|
||||||
|
const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`;
|
||||||
|
try {
|
||||||
|
const count = Number(
|
||||||
|
await this.client.eval(
|
||||||
|
`local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||||
|
return count`,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
5 * 60,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return count <= 30;
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('验证码服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async anonymousLoginLockScope(login: string, sourceIp: string) {
|
||||||
|
const accountDigest = this.loginDigest(login);
|
||||||
|
const ipDigest = this.valueDigest(sourceIp);
|
||||||
|
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
|
||||||
|
try {
|
||||||
|
const locks = await this.client.mget(
|
||||||
|
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
|
||||||
|
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
|
||||||
|
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
|
||||||
|
);
|
||||||
|
if (locks[0]) return 'account' as const;
|
||||||
|
if (locks[1]) return 'ip' as const;
|
||||||
|
if (locks[2]) return 'pair' as const;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordAnonymousLoginFailure(login: string, sourceIp: string) {
|
||||||
|
const accountDigest = this.loginDigest(login);
|
||||||
|
const ipDigest = this.valueDigest(sourceIp);
|
||||||
|
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
|
||||||
|
try {
|
||||||
|
const result = await this.client.eval(
|
||||||
|
`local counts = {}
|
||||||
|
for i = 1, 3 do
|
||||||
|
counts[i] = redis.call('INCR', KEYS[i])
|
||||||
|
if counts[i] == 1 then redis.call('EXPIRE', KEYS[i], ARGV[i]) end
|
||||||
|
if counts[i] >= tonumber(ARGV[i + 3]) then redis.call('SET', KEYS[i + 3], '1', 'EX', ARGV[i]) end
|
||||||
|
end
|
||||||
|
return counts`,
|
||||||
|
6,
|
||||||
|
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
|
||||||
|
`${ANONYMOUS_IP_FAILURE_PREFIX}${ipDigest}`,
|
||||||
|
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
|
||||||
|
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
|
||||||
|
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
|
||||||
|
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
|
||||||
|
24 * 60 * 60,
|
||||||
|
15 * 60,
|
||||||
|
24 * 60 * 60,
|
||||||
|
5,
|
||||||
|
30,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
return (result as number[]).map(Number);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAnonymousLoginFailures(login: string, sourceIp: string) {
|
||||||
|
const accountDigest = this.loginDigest(login);
|
||||||
|
const pairDigest = this.valueDigest(`${accountDigest}:${this.valueDigest(sourceIp)}`);
|
||||||
|
try {
|
||||||
|
await this.client.del(
|
||||||
|
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
|
||||||
|
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
|
||||||
|
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
|
||||||
|
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('登录保护服务暂不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
||||||
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
||||||
}
|
}
|
||||||
@@ -138,7 +248,10 @@ export class SessionService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get cookieSecure() {
|
get cookieSecure() {
|
||||||
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
|
return (
|
||||||
|
process.env.SESSION_COOKIE_SECURE === 'true' ||
|
||||||
|
(process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieName(portal: SessionPortal) {
|
cookieName(portal: SessionPortal) {
|
||||||
@@ -172,7 +285,7 @@ export class SessionService implements OnModuleDestroy {
|
|||||||
private async read(token: string): Promise<AuthSessionRecord | null> {
|
private async read(token: string): Promise<AuthSessionRecord | null> {
|
||||||
try {
|
try {
|
||||||
const value = await this.client.get(this.key(token));
|
const value = await this.client.get(this.key(token));
|
||||||
return value ? JSON.parse(value) as AuthSessionRecord : null;
|
return value ? (JSON.parse(value) as AuthSessionRecord) : null;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException('登录会话服务暂不可用');
|
throw new ServiceUnavailableException('登录会话服务暂不可用');
|
||||||
}
|
}
|
||||||
@@ -195,6 +308,14 @@ export class SessionService implements OnModuleDestroy {
|
|||||||
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loginDigest(login: string) {
|
||||||
|
return this.valueDigest(login.trim().toLocaleLowerCase('en-US'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueDigest(value: string) {
|
||||||
|
return createHash('sha256').update(value.trim()).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
private get client() {
|
private get client() {
|
||||||
if (!this.redis) {
|
if (!this.redis) {
|
||||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
|
import { ClientBillingEstimateDto } from '../common/client-write.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import {
|
import {
|
||||||
@@ -125,14 +128,15 @@ export class ClientBillingController {
|
|||||||
constructor(private readonly billing: BillingService) {}
|
constructor(private readonly billing: BillingService) {}
|
||||||
|
|
||||||
@Get('orders')
|
@Get('orders')
|
||||||
listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listRechargeOrders(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
return page || pageSize
|
return page || pageSize
|
||||||
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.billing.listRechargeOrders(tenantId);
|
: this.billing.listRechargeOrders(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('estimate')
|
@Post('estimate')
|
||||||
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.billing.estimateSmsCost(body);
|
estimateSmsCost(@CurrentTenantId() tenantId: string, @Body() body: ClientBillingEstimateDto) {
|
||||||
|
return this.billing.estimateSmsCost({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
tenantAccount: {
|
tenantAccount: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
|
findUniqueOrThrow: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
updateMany: jest.fn().mockImplementation(({ data }) => {
|
updateMany: jest.fn().mockImplementation(({ data }) => {
|
||||||
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
||||||
|
else if (data.balanceCents?.decrement !== undefined) accountState.balanceCents -= data.balanceCents.decrement;
|
||||||
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
||||||
return Promise.resolve({ count: 1 });
|
return Promise.resolve({ count: 1 });
|
||||||
}),
|
}),
|
||||||
@@ -28,10 +30,12 @@ function createPrismaMock() {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
accountTransaction: {
|
accountTransaction: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
findUniqueOrThrow: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: 'tx-charged', idempotencyKey: where.idempotencyKey })),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||||
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||||
},
|
},
|
||||||
rechargeOrder: {
|
rechargeOrder: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
@@ -51,6 +55,7 @@ function createPrismaMock() {
|
|||||||
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
||||||
},
|
},
|
||||||
$executeRaw: jest.fn(),
|
$executeRaw: jest.fn(),
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
};
|
};
|
||||||
return Object.assign(prisma, {
|
return Object.assign(prisma, {
|
||||||
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
||||||
@@ -94,7 +99,7 @@ describe('BillingService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
it('allows sending only when cash balance plus credit covers the required amount', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new BillingService(prisma as never);
|
const service = new BillingService(prisma as never);
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ describe('BillingService', () => {
|
|||||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||||
);
|
);
|
||||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
||||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
expect.objectContaining({ availableAmount: 1000, canSend: false }),
|
||||||
);
|
);
|
||||||
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
||||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
||||||
@@ -110,7 +115,7 @@ describe('BillingService', () => {
|
|||||||
);
|
);
|
||||||
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
||||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
||||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }),
|
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: false }),
|
||||||
);
|
);
|
||||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
||||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
@@ -312,6 +317,28 @@ describe('BillingService', () => {
|
|||||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('settles a frozen SMS charge with one account lock and an idempotent ledger pair', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const rows = new Map<string, Record<string, unknown>>();
|
||||||
|
prisma.accountTransaction.findMany.mockImplementation(() => Promise.resolve([...rows.values()]));
|
||||||
|
prisma.$queryRaw.mockImplementation(() => {
|
||||||
|
const charged = { id: 'tx-charged', idempotencyKey: 'sms-charge:msg-paid-1', transactionType: 'charged', amountCents: -325 };
|
||||||
|
rows.set(String(charged.idempotencyKey), charged);
|
||||||
|
return Promise.resolve([charged]);
|
||||||
|
});
|
||||||
|
const service = new BillingService(prisma as never);
|
||||||
|
const input = { tenantId: 'tenant-1', amountCents: 325, messageId: 'msg-paid-1', taskId: 'task-paid-1' };
|
||||||
|
|
||||||
|
const first = await service.settleFrozenCharge(input);
|
||||||
|
const replay = await service.settleFrozenCharge(input);
|
||||||
|
|
||||||
|
expect(first).toEqual(expect.objectContaining({ transactionType: 'charged', amountCents: -325 }));
|
||||||
|
expect(replay).toEqual(first);
|
||||||
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.tenantAccount.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
let transactionChain = Promise.resolve<unknown>(undefined);
|
let transactionChain = Promise.resolve<unknown>(undefined);
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ export class BillingService {
|
|||||||
availableAmount,
|
availableAmount,
|
||||||
balanceCents,
|
balanceCents,
|
||||||
creditCents,
|
creditCents,
|
||||||
canSend: availableAmount > 0,
|
canSend: availableAmount >= requiredAmount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +436,56 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async settleFrozenCharge(data: { tenantId: string; amountCents: number; messageId: string; taskId: string; remark?: string }) {
|
||||||
|
const amountCents = data.amountCents ?? 0;
|
||||||
|
if (amountCents <= 0) return null;
|
||||||
|
const releaseKey = `sms-charge-release:${data.messageId}`;
|
||||||
|
const chargeKey = `sms-charge:${data.messageId}`;
|
||||||
|
const existing = await this.prisma.accountTransaction.findMany({
|
||||||
|
where: { idempotencyKey: { in: [releaseKey, chargeKey] } },
|
||||||
|
});
|
||||||
|
const charge = existing.find((row) => row.idempotencyKey === chargeKey);
|
||||||
|
if (charge) return charge;
|
||||||
|
const release = existing.find((row) => row.idempotencyKey === releaseKey);
|
||||||
|
if (release) {
|
||||||
|
// Recover the legacy two-transaction boundary: a crash may have committed
|
||||||
|
// release before charge, so this path must perform the missing balance debit.
|
||||||
|
return this.applyAccountDelta({
|
||||||
|
tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey,
|
||||||
|
amountCents: -amountCents, relatedType: 'sms_message_record', relatedId: data.messageId,
|
||||||
|
remark: '提交成功扣费(恢复既有已释放冻结)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string; idempotencyKey: string }>>(Prisma.sql`
|
||||||
|
WITH account AS (
|
||||||
|
SELECT "balanceCents" FROM "TenantAccount" WHERE "tenantId" = ${data.tenantId}
|
||||||
|
), inserted AS (
|
||||||
|
INSERT INTO "AccountTransaction" (
|
||||||
|
id, "tenantId", "transactionType", "idempotencyKey", "amountCents",
|
||||||
|
"balanceAfter", "relatedType", "relatedId", remark, "createdAt"
|
||||||
|
)
|
||||||
|
SELECT gen_random_uuid()::text, ${data.tenantId}, ledger."transactionType", ledger."idempotencyKey",
|
||||||
|
ledger."amountCents", account."balanceCents" + ledger."balanceDelta",
|
||||||
|
ledger."relatedType", ledger."relatedId", ledger.remark, (NOW() AT TIME ZONE 'UTC')
|
||||||
|
FROM account
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('released', ${releaseKey}, ${amountCents}::bigint, ${amountCents}::bigint, 'sms_batch_task', ${data.taskId}, ${data.remark ?? null}),
|
||||||
|
('charged', ${chargeKey}, ${-amountCents}::bigint, 0::bigint, 'sms_message_record', ${data.messageId}, '提交成功扣费')
|
||||||
|
) AS ledger("transactionType", "idempotencyKey", "amountCents", "balanceDelta", "relatedType", "relatedId", remark)
|
||||||
|
ON CONFLICT ("idempotencyKey") DO NOTHING
|
||||||
|
RETURNING id, "idempotencyKey"
|
||||||
|
)
|
||||||
|
SELECT id, "idempotencyKey" FROM inserted WHERE "idempotencyKey" = ${chargeKey}
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, "idempotencyKey" FROM "AccountTransaction" WHERE "idempotencyKey" = ${chargeKey}
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
if (rows[0]) return rows[0];
|
||||||
|
// A concurrent identical callback can win ON CONFLICT while remaining
|
||||||
|
// invisible to this statement's snapshot; one read repairs that MVCC edge.
|
||||||
|
return this.prisma.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } });
|
||||||
|
}
|
||||||
|
|
||||||
release(data: BillingActionDto) {
|
release(data: BillingActionDto) {
|
||||||
return this.applyAccountDelta({
|
return this.applyAccountDelta({
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
|
import { CertificationService, ReviewCertificationDto } from './certification.service';
|
||||||
|
|
||||||
@ApiTags('client-certification')
|
@ApiTags('client-certification')
|
||||||
@Controller('client/enterprise-certification')
|
@Controller('client/enterprise-certification')
|
||||||
@@ -10,13 +12,14 @@ export class ClientCertificationController {
|
|||||||
constructor(private readonly certifications: CertificationService) {}
|
constructor(private readonly certifications: CertificationService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@TenantId() tenantId?: string) {
|
list(@CurrentTenantId() tenantId: string) {
|
||||||
return this.certifications.list(tenantId);
|
return this.certifications.list(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
submit(@Body() body: SubmitCertificationDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.certifications.submit(body);
|
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
|
||||||
|
return this.certifications.submit({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import IORedis from 'ioredis';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
|
||||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
|
||||||
import { ChannelConnectionService } from './channel-connection.service';
|
import { ChannelConnectionService } from './channel-connection.service';
|
||||||
|
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. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
export class ChannelConfigurationService {
|
export class ChannelConfigurationService {
|
||||||
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly connection: ChannelConnectionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
listChannels() {
|
listChannels() {
|
||||||
return this.prisma.smsChannel.findMany({
|
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 page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const where: Prisma.SmsChannelWhereInput = {
|
const where: Prisma.SmsChannelWhereInput = {
|
||||||
@@ -44,12 +59,18 @@ export class ChannelConfigurationService {
|
|||||||
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
|
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
|
||||||
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
|
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
|
||||||
const pageIds = candidates
|
const pageIds = candidates
|
||||||
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|
.sort(
|
||||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
(left, right) =>
|
||||||
|| left.id.localeCompare(right.id))
|
(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)
|
.slice((page - 1) * pageSize, page * pageSize)
|
||||||
.map((channel) => channel.id);
|
.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 itemById = new Map(pageItems.map((item) => [item.id, item]));
|
||||||
const items = pageIds.flatMap((id) => {
|
const items = pageIds.flatMap((id) => {
|
||||||
const item = itemById.get(id);
|
const item = itemById.get(id);
|
||||||
@@ -122,11 +143,12 @@ export class ChannelConfigurationService {
|
|||||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||||
}
|
}
|
||||||
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
|
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
|
||||||
const config = data.config !== undefined
|
const config =
|
||||||
|| data.desiredConnections !== undefined
|
data.config !== undefined ||
|
||||||
|| data.windowSize !== undefined
|
data.desiredConnections !== undefined ||
|
||||||
|| data.heartbeatIntervalSeconds !== undefined
|
data.windowSize !== undefined ||
|
||||||
|| data.heartbeatMissThreshold !== undefined
|
data.heartbeatIntervalSeconds !== undefined ||
|
||||||
|
data.heartbeatMissThreshold !== undefined
|
||||||
? normalizeChannelRuntimeConfig(
|
? normalizeChannelRuntimeConfig(
|
||||||
channel.config,
|
channel.config,
|
||||||
data.config,
|
data.config,
|
||||||
@@ -136,25 +158,13 @@ export class ChannelConfigurationService {
|
|||||||
data.heartbeatMissThreshold,
|
data.heartbeatMissThreshold,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
const rateLimitPerSecond =
|
||||||
? undefined
|
data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
|
||||||
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
|
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
|
||||||
const carriers = data.carriers !== undefined || data.carrier !== undefined
|
const carriers =
|
||||||
|
data.carriers !== undefined || data.carrier !== undefined
|
||||||
? normalizeChannelCarriers(data.carriers, data.carrier)
|
? normalizeChannelCarriers(data.carriers, data.carrier)
|
||||||
: existingCarriers;
|
: existingCarriers;
|
||||||
if (data.carriers !== undefined || data.carrier !== undefined) {
|
|
||||||
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
|
|
||||||
if (removed.length) {
|
|
||||||
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
|
|
||||||
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
|
|
||||||
include: { group: true },
|
|
||||||
});
|
|
||||||
if (blockingGroups.length) {
|
|
||||||
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||||
@@ -163,12 +173,16 @@ export class ChannelConfigurationService {
|
|||||||
cmppVersion: cmppVersion ?? channel.cmppVersion,
|
cmppVersion: cmppVersion ?? channel.cmppVersion,
|
||||||
config: config ?? channel.config,
|
config: config ?? channel.config,
|
||||||
});
|
});
|
||||||
const updated = await this.prisma.smsChannel.update({
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.smsChannel.update({
|
||||||
where: { id: channelId },
|
where: { id: channelId },
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
|
carrier:
|
||||||
|
data.carriers !== undefined || data.carrier !== undefined
|
||||||
|
? legacyCarrierFromCapabilities(carriers)
|
||||||
|
: undefined,
|
||||||
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
||||||
sendRegion: data.sendRegion,
|
sendRegion: data.sendRegion,
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
@@ -185,7 +199,22 @@ export class ChannelConfigurationService {
|
|||||||
config: config as Prisma.InputJsonValue | undefined,
|
config: config as Prisma.InputJsonValue | undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.prisma.operationLog.create({
|
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: {
|
data: {
|
||||||
action: 'sms_channel.update',
|
action: 'sms_channel.update',
|
||||||
resource: 'sms_channel',
|
resource: 'sms_channel',
|
||||||
@@ -204,10 +233,13 @@ export class ChannelConfigurationService {
|
|||||||
srcId: channel.srcId,
|
srcId: channel.srcId,
|
||||||
unitPrice: moneyToNumber(channel.unitPrice),
|
unitPrice: moneyToNumber(channel.unitPrice),
|
||||||
},
|
},
|
||||||
after: data,
|
after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
|
||||||
|
removedGroupItems,
|
||||||
} as Prisma.InputJsonValue,
|
} as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
const updatedStatus = data.status ?? channel.status;
|
const updatedStatus = data.status ?? channel.status;
|
||||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||||
await this.connection.requestChannelConnection(updated, 'channel_updated');
|
await this.connection.requestChannelConnection(updated, 'channel_updated');
|
||||||
|
|||||||
@@ -367,6 +367,10 @@ export class ChannelConnectionService {
|
|||||||
cmppVersion: channel.cmppVersion,
|
cmppVersion: channel.cmppVersion,
|
||||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||||
|
connectionWarmupSeconds: Number(getConfigValue(channel.config, 'connectionWarmupSeconds') ?? 30),
|
||||||
|
connectionDrainTimeoutSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'connectionDrainTimeoutSeconds'), 60, 'connectionDrainTimeoutSeconds'),
|
||||||
|
submitResponseTimeoutSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'submitResponseTimeoutSeconds'), 60, 'submitResponseTimeoutSeconds'),
|
||||||
|
connectionFailureCooldownSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'connectionFailureCooldownSeconds'), 30, 'connectionFailureCooldownSeconds'),
|
||||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
import { Queue } from 'bullmq';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
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 { 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. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
export class ChannelReportingService {
|
export class ChannelReportingService {
|
||||||
@@ -69,6 +87,9 @@ export class ChannelReportingService {
|
|||||||
for (const legacy of legacyBoth) {
|
for (const legacy of legacyBoth) {
|
||||||
if (oppositeCodes.has(legacy.code)) continue;
|
if (oppositeCodes.has(legacy.code)) continue;
|
||||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
||||||
|
void _id;
|
||||||
|
void _createdAt;
|
||||||
|
void _updatedAt;
|
||||||
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
||||||
}
|
}
|
||||||
for (const [index, configured] of data.fields.entries()) {
|
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({
|
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
status,
|
status:
|
||||||
|
status === 'reporting' || status === 'exporting'
|
||||||
|
? { in: ['reporting', 'exporting'] }
|
||||||
|
: status === 'failed'
|
||||||
|
? { in: ['failed', 'rejected'] }
|
||||||
|
: status,
|
||||||
channelId,
|
channelId,
|
||||||
reportType,
|
reportType,
|
||||||
signature: { auditStatus: { not: 'deleted' } },
|
signature: { auditStatus: { not: 'deleted' } },
|
||||||
@@ -165,7 +195,12 @@ export class ChannelReportingService {
|
|||||||
SELECT
|
SELECT
|
||||||
submit."channelId" AS channel_id,
|
submit."channelId" AS channel_id,
|
||||||
message."signatureId" AS signature_id,
|
message."signatureId" AS signature_id,
|
||||||
|
message.carrier AS carrier,
|
||||||
message."drainageInfoId" AS drainage_info_id,
|
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,
|
submit."submitStatus" AS submit_status,
|
||||||
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
||||||
CASE
|
CASE
|
||||||
@@ -209,13 +244,16 @@ export class ChannelReportingService {
|
|||||||
AND receipt."receiptStatus" = 'undelivered'
|
AND receipt."receiptStatus" = 'undelivered'
|
||||||
) failed_receipt ON TRUE
|
) failed_receipt ON TRUE
|
||||||
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
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 submit."channelId" IN (${Prisma.join(channelIds)})
|
||||||
AND message."signatureId" IN (${Prisma.join(signatureIds)})
|
AND message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
channel_id AS "channelId",
|
channel_id AS "channelId",
|
||||||
signature_id AS "signatureId",
|
signature_id AS "signatureId",
|
||||||
|
carrier,
|
||||||
drainage_info_id AS "drainageInfoId",
|
drainage_info_id AS "drainageInfoId",
|
||||||
|
drainage_ids AS "drainageIds",
|
||||||
COUNT(*) FILTER (
|
COUNT(*) FILTER (
|
||||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||||
)::integer AS total,
|
)::integer AS total,
|
||||||
@@ -241,15 +279,20 @@ export class ChannelReportingService {
|
|||||||
)::integer AS "failureCount",
|
)::integer AS "failureCount",
|
||||||
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
||||||
FROM base
|
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) => {
|
return tasks.map((task) => {
|
||||||
const taskRows = rows.filter((row) => (
|
const taskRows = rows.filter(
|
||||||
row.channelId === task.channelId
|
(row) =>
|
||||||
&& row.signatureId === task.signatureId
|
row.channelId === task.channelId &&
|
||||||
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
|
row.signatureId === task.signatureId &&
|
||||||
));
|
(!task.carrier || row.carrier === task.carrier) &&
|
||||||
|
((task.reportType ?? 'signature') === 'signature' ||
|
||||||
|
(row.drainageIds
|
||||||
|
? row.drainageIds.includes(task.drainageItemId ?? '')
|
||||||
|
: row.drainageInfoId === task.drainageItemId)),
|
||||||
|
);
|
||||||
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
@@ -261,10 +304,15 @@ export class ChannelReportingService {
|
|||||||
|
|
||||||
async listReportTasksPage(query: {
|
async listReportTasksPage(query: {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
todaySendMin?: number;
|
||||||
|
todaySendMax?: number;
|
||||||
|
sort?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
createdAtTo?: string;
|
createdAtTo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -273,31 +321,72 @@ export class ChannelReportingService {
|
|||||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const keyword = query.keyword?.trim();
|
const keyword = query.keyword?.trim();
|
||||||
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
|
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,
|
tenantId: query.tenantId,
|
||||||
status: query.status,
|
applicationId: query.applicationId,
|
||||||
channelId: query.channelId,
|
auditStatus: 'approved',
|
||||||
reportType: query.reportType,
|
},
|
||||||
signature: { auditStatus: { not: 'deleted' } },
|
include: {
|
||||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
tenant: true,
|
||||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
application: true,
|
||||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
drainageItems: { where: { auditStatus: 'approved' } },
|
||||||
} : undefined,
|
reportTasks: {
|
||||||
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: {
|
include: {
|
||||||
signature: { include: { tenant: true, application: true } },
|
|
||||||
channel: true,
|
channel: true,
|
||||||
drainageInfo: true,
|
drainageInfo: true,
|
||||||
exportItems: {
|
exportItems: {
|
||||||
@@ -307,13 +396,119 @@ export class ChannelReportingService {
|
|||||||
},
|
},
|
||||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
},
|
||||||
skip: (page - 1) * pageSize,
|
},
|
||||||
take: 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,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
this.prisma.channelSignatureReportTask.count({ where }),
|
);
|
||||||
]);
|
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
|
||||||
return { items, total, page, pageSize };
|
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) {
|
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' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||||
if (reportType === 'drainage') {
|
if (reportType === 'drainage') {
|
||||||
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
||||||
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
|
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId)
|
||||||
|
throw new NotFoundException('Drainage info not found');
|
||||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||||
}
|
}
|
||||||
@@ -333,7 +529,13 @@ export class ChannelReportingService {
|
|||||||
throw new BadRequestException('报备运营商不在通道支持范围内');
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
}
|
}
|
||||||
const existing = await this.prisma.channelSignatureReportTask.findFirst({
|
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('该签名在当前通道和运营商下已存在报备任务');
|
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
|
||||||
const task = await this.prisma.channelSignatureReportTask.create({
|
const task = await this.prisma.channelSignatureReportTask.create({
|
||||||
@@ -355,7 +557,15 @@ export class ChannelReportingService {
|
|||||||
|
|
||||||
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
||||||
if (!data.items.length) throw new BadRequestException('items is required');
|
if (!data.items.length) throw new BadRequestException('items is required');
|
||||||
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
const allowed = new Set([
|
||||||
|
'pending',
|
||||||
|
'waiting_material',
|
||||||
|
'reporting',
|
||||||
|
'approved',
|
||||||
|
'failed',
|
||||||
|
'rejected',
|
||||||
|
'abandoned',
|
||||||
|
]);
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
||||||
}
|
}
|
||||||
@@ -364,43 +574,116 @@ export class ChannelReportingService {
|
|||||||
throw new BadRequestException('unsupported report task source entry');
|
throw new BadRequestException('unsupported report task source entry');
|
||||||
}
|
}
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
|
for (const signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) {
|
||||||
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
|
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) {
|
for (const item of data.items) {
|
||||||
const reportType = item.reportType ?? 'signature';
|
const reportType = item.reportType ?? 'signature';
|
||||||
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
if (reportType === 'drainage' && !item.drainageItemId)
|
||||||
|
throw new BadRequestException('drainageItemId is required');
|
||||||
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
||||||
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
||||||
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
||||||
if (reportType === 'drainage') {
|
if (reportType === 'drainage') {
|
||||||
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
||||||
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId)
|
||||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
throw new NotFoundException('Drainage info not found');
|
||||||
|
if (drainageInfo.auditStatus !== 'approved')
|
||||||
|
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
||||||
}
|
}
|
||||||
const 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)) {
|
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
||||||
throw new BadRequestException('报备运营商不在通道支持范围内');
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
}
|
}
|
||||||
const existing = await tx.channelSignatureReportTask.findFirst({ where: {
|
const existing = await tx.channelSignatureReportTask.findFirst({
|
||||||
|
where: {
|
||||||
signatureId: item.signatureId,
|
signatureId: item.signatureId,
|
||||||
channelId: item.channelId,
|
channelId: item.channelId,
|
||||||
reportType,
|
reportType,
|
||||||
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
||||||
carrier: reportType === 'signature' ? carrier : null,
|
carrier,
|
||||||
} });
|
},
|
||||||
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
});
|
||||||
if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商');
|
if (reportType === 'drainage' && !existing) {
|
||||||
const approvedAt = item.status === 'approved'
|
const legacy = carrier
|
||||||
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date()
|
? 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;
|
: null;
|
||||||
const task = existing
|
const task = existing
|
||||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } })
|
? await tx.channelSignatureReportTask.update({
|
||||||
: 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 } });
|
where: { id: existing.id },
|
||||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
|
data: { status: item.status, reason: data.reason, approvedAt },
|
||||||
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
})
|
||||||
|
: await tx.channelSignatureReportTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: signature.tenantId,
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
channelId: item.channelId,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
approvedAt,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined,
|
||||||
|
status: item.status,
|
||||||
|
reason: data.reason,
|
||||||
|
createdById: data.operatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
channelId: item.channelId,
|
||||||
|
action: 'manual_status_change',
|
||||||
|
statusBefore: existing?.status,
|
||||||
|
statusAfter: item.status,
|
||||||
|
reason: data.reason,
|
||||||
|
operatorId: data.operatorId,
|
||||||
|
sourceEntry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (reportType === 'drainage')
|
||||||
|
drainageResults.push({
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: item.drainageItemId!,
|
||||||
|
channelId: item.channelId,
|
||||||
|
carrier,
|
||||||
|
status: item.status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const summaries = [];
|
const summaries = [];
|
||||||
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
for (const signatureId of signatureIds)
|
||||||
|
summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
||||||
return [...summaries, ...drainageResults];
|
return [...summaries, ...drainageResults];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -408,28 +691,55 @@ export class ChannelReportingService {
|
|||||||
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||||
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||||
if (!signature) throw new NotFoundException('Signature not found');
|
if (!signature) throw new NotFoundException('Signature not found');
|
||||||
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
const routes = signature.applicationId
|
||||||
|
? await tx.channelRouteRule.findMany({
|
||||||
where: { applicationId: signature.applicationId, status: 'active' },
|
where: { applicationId: signature.applicationId, status: 'active' },
|
||||||
include: { group: { include: { items: { include: { channel: true } } } } },
|
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||||
}) : [];
|
})
|
||||||
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
: [];
|
||||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
const configuredChannels = routes
|
||||||
|
.flatMap((route) => route.group.items.map((item) => item.channel))
|
||||||
|
.filter((channel) => channel.status !== 'deleted');
|
||||||
|
const tasks = await tx.channelSignatureReportTask.findMany({
|
||||||
|
where: { signatureId, reportType: 'signature' },
|
||||||
|
include: { channel: true },
|
||||||
|
});
|
||||||
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
||||||
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||||
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
const carrierReportSummary = Object.fromEntries(
|
||||||
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||||
|
const targets = uniqueChannels.filter((channel) =>
|
||||||
|
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
|
||||||
|
);
|
||||||
const statuses = targets.map((channel) => {
|
const statuses = targets.map((channel) => {
|
||||||
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
|
const task =
|
||||||
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
|
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 task?.status ?? 'pending';
|
||||||
});
|
});
|
||||||
return [carrier, summarizeReportStatuses(statuses)];
|
return [carrier, summarizeReportStatuses(statuses)];
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
|
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
|
||||||
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
const targets = uniqueChannels.filter((channel) =>
|
||||||
return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status
|
normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier),
|
||||||
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status
|
);
|
||||||
?? 'pending');
|
return targets.map(
|
||||||
|
(channel) =>
|
||||||
|
tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status ??
|
||||||
|
tasks.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.channelId === channel.id &&
|
||||||
|
candidate.carrier === null &&
|
||||||
|
candidate.approvalScope === 'legacy_channel',
|
||||||
|
)?.status ??
|
||||||
|
'pending',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
||||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||||
@@ -487,6 +797,11 @@ export class ChannelReportingService {
|
|||||||
async listReportRecordsPage(query: {
|
async listReportRecordsPage(query: {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
batchNo?: string;
|
||||||
|
statusAfter?: string;
|
||||||
|
action?: string;
|
||||||
|
sourceEntry?: string;
|
||||||
|
operatorKeyword?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
@@ -497,15 +812,42 @@ export class ChannelReportingService {
|
|||||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const keyword = query.keyword?.trim();
|
const keyword = query.keyword?.trim();
|
||||||
|
const operatorKeyword = query.operatorKeyword?.trim();
|
||||||
|
const operatorIds = operatorKeyword
|
||||||
|
? (
|
||||||
|
await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ username: { contains: operatorKeyword } }, { displayName: { contains: operatorKeyword } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((item) => item.id)
|
||||||
|
: undefined;
|
||||||
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
||||||
taskId: query.taskId,
|
taskId: query.taskId,
|
||||||
channelId: query.channelId,
|
channelId: query.channelId,
|
||||||
task: query.reportType ? { reportType: query.reportType } : undefined,
|
statusAfter: query.statusAfter,
|
||||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
action: query.action,
|
||||||
|
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,
|
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,
|
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
||||||
} : undefined,
|
}
|
||||||
OR: keyword ? [
|
: undefined,
|
||||||
|
OR: keyword
|
||||||
|
? [
|
||||||
{ taskId: { contains: keyword } },
|
{ taskId: { contains: keyword } },
|
||||||
{ action: { contains: keyword } },
|
{ action: { contains: keyword } },
|
||||||
{ reason: { contains: keyword } },
|
{ reason: { contains: keyword } },
|
||||||
@@ -513,7 +855,8 @@ export class ChannelReportingService {
|
|||||||
{ task: { signature: { name: { contains: keyword } } } },
|
{ task: { signature: { name: { contains: keyword } } } },
|
||||||
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
||||||
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
||||||
] : undefined,
|
]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.channelSignatureReportRecord.findMany({
|
this.prisma.channelSignatureReportRecord.findMany({
|
||||||
@@ -525,11 +868,30 @@ export class ChannelReportingService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.channelSignatureReportRecord.count({ where }),
|
this.prisma.channelSignatureReportRecord.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, pageSize };
|
const userIds = [...new Set(items.map((item) => item.operatorId).filter((id): id is string => Boolean(id)))];
|
||||||
|
const operators = userIds.length
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const operatorMap = new Map(operators.map((item) => [item.id, item]));
|
||||||
|
return {
|
||||||
|
items: items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
operator: item.operatorId ? operatorMap.get(item.operatorId) : undefined,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportTaskOrThrow(taskId: string) {
|
async getReportTaskOrThrow(taskId: string) {
|
||||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
const task = await this.prisma.channelSignatureReportTask.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: { drainageInfo: true },
|
||||||
|
});
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFoundException('Report task not found');
|
throw new NotFoundException('Report task not found');
|
||||||
}
|
}
|
||||||
@@ -552,8 +914,13 @@ export class ChannelReportingService {
|
|||||||
data: {
|
data: {
|
||||||
status: statusAfter,
|
status: statusAfter,
|
||||||
reason,
|
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 }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import IORedis from 'ioredis';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type { 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 {
|
||||||
|
normalizeTestPhones,
|
||||||
|
normalizeTestContent,
|
||||||
|
normalizeGatewayConnectionStatus,
|
||||||
|
calculateBillingUnits,
|
||||||
|
buildChannelTestSubmitCommand,
|
||||||
|
} from './channels.helpers';
|
||||||
import { ChannelConnectionService } from './channel-connection.service';
|
import { ChannelConnectionService } from './channel-connection.service';
|
||||||
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
|
|
||||||
|
|
||||||
/** R5 channel domain service composed behind ChannelsService. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
export class ChannelTestService {
|
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 = {}) {
|
async testChannel(channelId: string, data: TestChannelDto = {}) {
|
||||||
const phoneNumbers = normalizeTestPhones(data);
|
const phoneNumbers = normalizeTestPhones(data);
|
||||||
@@ -27,8 +33,8 @@ export class ChannelTestService {
|
|||||||
if (channel.status !== 'active') {
|
if (channel.status !== 'active') {
|
||||||
throw new BadRequestException('通道未启用,不能发送测试短信');
|
throw new BadRequestException('通道未启用,不能发送测试短信');
|
||||||
}
|
}
|
||||||
const connectedState = channel.connectionStates.find((state) =>
|
const connectedState = channel.connectionStates.find(
|
||||||
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
|
(state) => normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
|
||||||
);
|
);
|
||||||
if (!connectedState) {
|
if (!connectedState) {
|
||||||
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
|
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
|
||||||
@@ -36,7 +42,6 @@ export class ChannelTestService {
|
|||||||
|
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||||
const drainageDetection = await detectDrainageContent(this.prisma, content);
|
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||||
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||||
@@ -51,7 +56,6 @@ export class ChannelTestService {
|
|||||||
messageId,
|
messageId,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
content,
|
content,
|
||||||
...drainageDetection,
|
|
||||||
billingUnits: calculateBillingUnits(content),
|
billingUnits: calculateBillingUnits(content),
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
amountCents: 0,
|
amountCents: 0,
|
||||||
|
|||||||
@@ -27,10 +27,19 @@ import { ChannelsService } from './channels.service';
|
|||||||
@ApiTags('channels')
|
@ApiTags('channels')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class ChannelsController {
|
export class ChannelsController {
|
||||||
constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {}
|
constructor(
|
||||||
|
private readonly channels: ChannelsService,
|
||||||
|
private readonly deletions: DeletionGovernanceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('channels')
|
@Get('channels')
|
||||||
listChannels(@Query('keyword') keyword?: string, @Query('carrier') carrier?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listChannels(
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
return page || pageSize
|
return page || pageSize
|
||||||
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
|
? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.channels.listChannels();
|
: this.channels.listChannels();
|
||||||
@@ -68,7 +77,11 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Delete('channels/:id')
|
@Delete('channels/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
deleteChannel(@Param('id') channelId: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
|
deleteChannel(
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Body() body: DeleteTargetDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
return this.deletions.delete('channel', channelId, { ...body, operatorId });
|
return this.deletions.delete('channel', channelId, { ...body, operatorId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +172,11 @@ export class ChannelsController {
|
|||||||
|
|
||||||
@Put('channels/:channelId/report-fields/:reportType')
|
@Put('channels/:channelId/report-fields/:reportType')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
replaceReportFields(@Param('channelId') channelId: string, @Param('reportType') reportType: 'signature' | 'drainage', @Body() body: ReplaceReportFieldsDto) {
|
replaceReportFields(
|
||||||
|
@Param('channelId') channelId: string,
|
||||||
|
@Param('reportType') reportType: 'signature' | 'drainage',
|
||||||
|
@Body() body: ReplaceReportFieldsDto,
|
||||||
|
) {
|
||||||
return this.channels.replaceReportFields(channelId, reportType, body);
|
return this.channels.replaceReportFields(channelId, reportType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,12 +191,90 @@ export class ChannelsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('report-tasks')
|
@Get('report-tasks')
|
||||||
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string, @Query('keyword') keyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listReportTasks(
|
||||||
return page || pageSize || keyword || createdAtFrom || createdAtTo
|
@Query('tenantId') tenantId?: string,
|
||||||
? this.channels.listReportTasksPage({ tenantId, status, channelId, reportType, keyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('reportType') reportType?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
|
@Query('todaySendMin') todaySendMin?: string,
|
||||||
|
@Query('todaySendMax') todaySendMax?: string,
|
||||||
|
@Query('sort') sort?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return page ||
|
||||||
|
pageSize ||
|
||||||
|
keyword ||
|
||||||
|
applicationId ||
|
||||||
|
carrier ||
|
||||||
|
todaySendMin ||
|
||||||
|
todaySendMax ||
|
||||||
|
sort ||
|
||||||
|
createdAtFrom ||
|
||||||
|
createdAtTo
|
||||||
|
? this.channels.listReportTasksPage({
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
status,
|
||||||
|
channelId,
|
||||||
|
reportType,
|
||||||
|
keyword,
|
||||||
|
carrier,
|
||||||
|
todaySendMin: Number(todaySendMin),
|
||||||
|
todaySendMax: Number(todaySendMax),
|
||||||
|
sort,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
})
|
||||||
: this.channels.listReportTasks(tenantId, status, channelId, reportType);
|
: this.channels.listReportTasks(tenantId, status, channelId, reportType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('report-details')
|
||||||
|
listReportDetails(
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('signatureId') signatureId?: string,
|
||||||
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('carrier') carrier?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('reportType') reportType?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('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')
|
@Post('report-tasks/generate')
|
||||||
createReportTask(@Body() body: CreateReportTaskDto) {
|
createReportTask(@Body() body: CreateReportTaskDto) {
|
||||||
return this.channels.createReportTask(body);
|
return this.channels.createReportTask(body);
|
||||||
@@ -202,9 +297,47 @@ export class ChannelsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('report-records')
|
@Get('report-records')
|
||||||
listReportRecords(@Query('taskId') taskId?: string, @Query('channelId') channelId?: string, @Query('keyword') keyword?: string, @Query('reportType') reportType?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
listReportRecords(
|
||||||
return page || pageSize || keyword || reportType || createdAtFrom || createdAtTo
|
@Query('taskId') taskId?: string,
|
||||||
? this.channels.listReportRecordsPage({ taskId, channelId, keyword, reportType, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('batchNo') batchNo?: string,
|
||||||
|
@Query('statusAfter') statusAfter?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
@Query('sourceEntry') sourceEntry?: string,
|
||||||
|
@Query('operatorKeyword') operatorKeyword?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('reportType') reportType?: string,
|
||||||
|
@Query('createdAtFrom') createdAtFrom?: string,
|
||||||
|
@Query('createdAtTo') createdAtTo?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return page ||
|
||||||
|
pageSize ||
|
||||||
|
keyword ||
|
||||||
|
batchNo ||
|
||||||
|
statusAfter ||
|
||||||
|
action ||
|
||||||
|
sourceEntry ||
|
||||||
|
operatorKeyword ||
|
||||||
|
reportType ||
|
||||||
|
createdAtFrom ||
|
||||||
|
createdAtTo
|
||||||
|
? this.channels.listReportRecordsPage({
|
||||||
|
taskId,
|
||||||
|
channelId,
|
||||||
|
batchNo,
|
||||||
|
statusAfter,
|
||||||
|
action,
|
||||||
|
sourceEntry,
|
||||||
|
operatorKeyword,
|
||||||
|
keyword,
|
||||||
|
reportType,
|
||||||
|
createdAtFrom,
|
||||||
|
createdAtTo,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
})
|
||||||
: this.channels.listReportRecords(taskId, channelId);
|
: this.channels.listReportRecords(taskId, channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { normalizeChannelRuntimeConfig } from './channels.helpers';
|
||||||
|
|
||||||
|
describe('Gateway channel capacity validation', () => {
|
||||||
|
it.each([1, 2, 4, 8])('accepts %i supplier connections', (desiredConnections) => {
|
||||||
|
expect(normalizeChannelRuntimeConfig(undefined, undefined, desiredConnections, 16)).toEqual(expect.objectContaining({ desiredConnections, windowSize: 16 }));
|
||||||
|
});
|
||||||
|
it.each([1, 16, 32, 64])('accepts supplier window %i', (windowSize) => {
|
||||||
|
expect(normalizeChannelRuntimeConfig(undefined, undefined, 1, windowSize)).toEqual(expect.objectContaining({ desiredConnections: 1, windowSize }));
|
||||||
|
});
|
||||||
|
it.each([[0, 16], [9, 16], [1, 0], [1, 65]])('rejects capacity outside 1..8 connections and 1..64 window', (connections, window) => {
|
||||||
|
expect(() => normalizeChannelRuntimeConfig(undefined, undefined, connections, window)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
import { 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[]) {
|
export function summarizeReportStatuses(statuses: string[]) {
|
||||||
return summarizeCommonReportStatuses(statuses);
|
return summarizeCommonReportStatuses(statuses);
|
||||||
@@ -141,7 +141,11 @@ export function buildChannelTestSubmitCommand({
|
|||||||
account: channel.account,
|
account: channel.account,
|
||||||
passwordCipher: channel.passwordCipher,
|
passwordCipher: channel.passwordCipher,
|
||||||
cmppVersion: channel.cmppVersion,
|
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'),
|
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||||
@@ -228,7 +232,7 @@ export function defaultChannelConnectionId(channelId: string) {
|
|||||||
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||||
const value = Number(config.desiredConnections);
|
const value = Number(config.desiredConnections);
|
||||||
if (Number.isInteger(value) && value > 0) {
|
if (Number.isInteger(value) && value >= 1 && value <= 8) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,23 +258,22 @@ export function getRuntimeConfigInteger(
|
|||||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelConnectionSettingsChanged(
|
export function channelConnectionSettingsChanged(before: ChannelConnectionSettings, after: ChannelConnectionSettings) {
|
||||||
before: ChannelConnectionSettings,
|
return (
|
||||||
after: ChannelConnectionSettings,
|
before.gatewayHost !== after.gatewayHost ||
|
||||||
) {
|
before.gatewayPort !== after.gatewayPort ||
|
||||||
return before.gatewayHost !== after.gatewayHost
|
before.account !== after.account ||
|
||||||
|| before.gatewayPort !== after.gatewayPort
|
before.passwordCipher !== after.passwordCipher ||
|
||||||
|| before.account !== after.account
|
before.cmppVersion !== after.cmppVersion ||
|
||||||
|| before.passwordCipher !== after.passwordCipher
|
getRuntimeConfigInteger(before.config, 'desiredConnections', 1) !==
|
||||||
|| before.cmppVersion !== after.cmppVersion
|
getRuntimeConfigInteger(after.config, 'desiredConnections', 1) ||
|
||||||
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
|
getRuntimeConfigInteger(before.config, 'windowSize', 16) !==
|
||||||
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|
getRuntimeConfigInteger(after.config, 'windowSize', 16) ||
|
||||||
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
|
getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) !==
|
||||||
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|
getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) ||
|
||||||
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) !==
|
||||||
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||||
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
);
|
||||||
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelGroupAuditSnapshot(group: {
|
export function channelGroupAuditSnapshot(group: {
|
||||||
@@ -320,15 +323,49 @@ export function normalizeChannelRuntimeConfig(
|
|||||||
heartbeatIntervalSeconds?: number,
|
heartbeatIntervalSeconds?: number,
|
||||||
heartbeatMissThreshold?: number,
|
heartbeatMissThreshold?: number,
|
||||||
) {
|
) {
|
||||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
const existing =
|
||||||
? existingConfig as Record<string, unknown>
|
existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||||
: {};
|
? (existingConfig as Record<string, unknown>)
|
||||||
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
|
|
||||||
? incomingConfig
|
|
||||||
: {};
|
: {};
|
||||||
|
const incoming =
|
||||||
|
incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {};
|
||||||
const base = { ...existing, ...incoming };
|
const base = { ...existing, ...incoming };
|
||||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
base.desiredConnections = boundedRuntimeInteger(
|
||||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
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.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
@@ -345,6 +382,14 @@ export function normalizeChannelRuntimeConfig(
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundedRuntimeInteger(value: unknown, minimum: number, maximum: number, fallback: number, field: string) {
|
||||||
|
const normalized = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||||||
|
if (!Number.isInteger(normalized) || normalized < minimum || normalized > maximum) {
|
||||||
|
throw new BadRequestException(`${field} must be an integer between ${minimum} and ${maximum}`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLongMessageReceiptMode(value: unknown) {
|
export function normalizeLongMessageReceiptMode(value: unknown) {
|
||||||
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
||||||
if (!['per_segment', 'message_level'].includes(normalized)) {
|
if (!['per_segment', 'message_level'].includes(normalized)) {
|
||||||
@@ -411,13 +456,19 @@ export function getPositiveIntegerEnv(name: string, fallback: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
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) {
|
if (lines.length === 0) {
|
||||||
throw new BadRequestException('Receipt file is empty');
|
throw new BadRequestException('Receipt file is empty');
|
||||||
}
|
}
|
||||||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||||||
const firstCells = splitReceiptLine(lines[0], separator);
|
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 header = hasHeader ? firstCells : [];
|
||||||
const rows = hasHeader ? lines.slice(1) : lines;
|
const rows = hasHeader ? lines.slice(1) : lines;
|
||||||
const statusIndex = findReceiptStatusIndex(header);
|
const statusIndex = findReceiptStatusIndex(header);
|
||||||
@@ -433,7 +484,7 @@ export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
|||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
rowNumber: hasHeader ? index + 2 : index + 1,
|
||||||
phone: cells[0] ?? '',
|
phone: cells[0] ?? '',
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
rawStatus,
|
rawStatus,
|
||||||
@@ -492,10 +543,39 @@ export function findReceiptStatusIndex(header: string[]) {
|
|||||||
|
|
||||||
export function normalizeReceiptStatus(value: string) {
|
export function normalizeReceiptStatus(value: string) {
|
||||||
const normalized = value.trim().toLowerCase();
|
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';
|
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';
|
||||||
}
|
}
|
||||||
return 'failed';
|
return 'failed';
|
||||||
@@ -512,6 +592,8 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ChannelReportDeliveryRow = {
|
export type ChannelReportDeliveryRow = {
|
||||||
|
drainageIds?: string[] | null;
|
||||||
|
carrier: string | null;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
signatureId: string;
|
signatureId: string;
|
||||||
drainageInfoId: string | null;
|
drainageInfoId: string | null;
|
||||||
@@ -545,10 +627,13 @@ export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[])
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
|
export function sumReportDelivery(
|
||||||
|
rows: ChannelReportDeliveryRow[],
|
||||||
|
key: keyof Pick<
|
||||||
ChannelReportDeliveryRow,
|
ChannelReportDeliveryRow,
|
||||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||||||
>) {
|
>,
|
||||||
|
) {
|
||||||
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +653,11 @@ export function currentShanghaiDayRange(now = new Date()) {
|
|||||||
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
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);
|
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||||
@@ -576,7 +665,12 @@ export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hour
|
|||||||
return value;
|
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;
|
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -590,7 +684,9 @@ export function normalizeBusinessCarrier(carrier?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeChannelCarrier(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 (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||||||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||||
@@ -619,12 +715,18 @@ export function legacyCarrierFromCapabilities(carriers: string[]) {
|
|||||||
return 'multi';
|
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));
|
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRegion(region?: string | null) {
|
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) {
|
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
||||||
@@ -634,7 +736,10 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
|
|||||||
export function validateGroupItems(
|
export function validateGroupItems(
|
||||||
groupCarrier: string,
|
groupCarrier: string,
|
||||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||||
channels: Map<string, { id: string; carrier?: string | null; 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 channelIds = new Set<string>();
|
||||||
const provinces = new Set<string>();
|
const provinces = new Set<string>();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,24 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type {
|
||||||
|
CreateChannelDto,
|
||||||
|
UpdateChannelDto,
|
||||||
|
CreateChannelGroupDto,
|
||||||
|
CreateChannelGroupItemDto,
|
||||||
|
UpdateChannelGroupDto,
|
||||||
|
CreateRouteRuleDto,
|
||||||
|
CreateReportFieldDto,
|
||||||
|
ReplaceReportFieldsDto,
|
||||||
|
CreateReportMaterialDto,
|
||||||
|
CreateReportTaskDto,
|
||||||
|
ChangeReportTaskStatusesDto,
|
||||||
|
CreateReportExportDto,
|
||||||
|
CreateReceiptImportDto,
|
||||||
|
UpsertConnectionStateDto,
|
||||||
|
ChangeChannelStatusDto,
|
||||||
|
CopyChannelDto,
|
||||||
|
TestChannelDto,
|
||||||
|
} from './channels.contracts';
|
||||||
import { ChannelConfigurationService } from './channel-configuration.service';
|
import { ChannelConfigurationService } from './channel-configuration.service';
|
||||||
import { ChannelConnectionService } from './channel-connection.service';
|
import { ChannelConnectionService } from './channel-connection.service';
|
||||||
import { ChannelCopyService } from './channel-copy.service';
|
import { ChannelCopyService } from './channel-copy.service';
|
||||||
@@ -42,7 +60,13 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.configuration.listChannels();
|
return this.configuration.listChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
|
async listChannelsPage(query: {
|
||||||
|
keyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
return this.configuration.listChannelsPage(query);
|
return this.configuration.listChannelsPage(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +176,15 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async listReportTasksPage(query: {
|
async listReportTasksPage(query: {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
todaySendMin?: number;
|
||||||
|
todaySendMax?: number;
|
||||||
|
sort?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
createdAtTo?: string;
|
createdAtTo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -164,6 +193,27 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.reporting.listReportTasksPage(query);
|
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) {
|
async createReportTask(data: CreateReportTaskDto) {
|
||||||
return this.reporting.createReportTask(data);
|
return this.reporting.createReportTask(data);
|
||||||
}
|
}
|
||||||
@@ -187,6 +237,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async listReportRecordsPage(query: {
|
async listReportRecordsPage(query: {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
batchNo?: string;
|
||||||
|
statusAfter?: string;
|
||||||
|
action?: string;
|
||||||
|
sourceEntry?: string;
|
||||||
|
operatorKeyword?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
createdAtFrom?: string;
|
createdAtFrom?: string;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { registerDecorator, type ValidationArguments, type ValidationOptions } from 'class-validator';
|
||||||
|
|
||||||
|
type BoundedJsonOptions = {
|
||||||
|
maxDepth?: number;
|
||||||
|
maxKeys?: number;
|
||||||
|
maxStringLength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
export function IsBoundedJsonObject(options: BoundedJsonOptions = {}, validationOptions?: ValidationOptions) {
|
||||||
|
return (target: object, propertyName: string) =>
|
||||||
|
registerDecorator({
|
||||||
|
name: 'isBoundedJsonObject',
|
||||||
|
target: target.constructor,
|
||||||
|
propertyName,
|
||||||
|
constraints: [options],
|
||||||
|
options: validationOptions,
|
||||||
|
validator: {
|
||||||
|
validate(value: unknown, args: ValidationArguments) {
|
||||||
|
if (value === undefined || value === null) return true;
|
||||||
|
const [constraints] = args.constraints as [BoundedJsonOptions];
|
||||||
|
return isBoundedJsonValue(value, {
|
||||||
|
maxDepth: constraints.maxDepth ?? 4,
|
||||||
|
maxKeys: constraints.maxKeys ?? 100,
|
||||||
|
maxStringLength: constraints.maxStringLength ?? 2_000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
defaultMessage(args: ValidationArguments) {
|
||||||
|
return `${args.property} contains too many, too deeply nested, or unsafe values`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoundedJsonValue(value: unknown, limits: Required<BoundedJsonOptions>) {
|
||||||
|
let keyCount = 0;
|
||||||
|
const visit = (current: unknown, depth: number): boolean => {
|
||||||
|
if (depth > limits.maxDepth) return false;
|
||||||
|
if (current == null || typeof current === 'boolean' || typeof current === 'number') return true;
|
||||||
|
if (typeof current === 'string') return current.length <= limits.maxStringLength;
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
keyCount += current.length;
|
||||||
|
return keyCount <= limits.maxKeys && current.every((item) => visit(item, depth + 1));
|
||||||
|
}
|
||||||
|
if (typeof current !== 'object') return false;
|
||||||
|
const entries = Object.entries(current as Record<string, unknown>);
|
||||||
|
keyCount += entries.length;
|
||||||
|
return (
|
||||||
|
keyCount <= limits.maxKeys &&
|
||||||
|
entries.every(([key, item]) => key.length <= 128 && !FORBIDDEN_KEYS.has(key) && visit(item, depth + 1))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return visit(value, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientDrainageInfoDto, ClientImportConfirmDto } from './client-write.dto';
|
||||||
|
import { strictValidationPipe } from './strict-validation.pipe';
|
||||||
|
|
||||||
|
function validate<T>(metatype: new () => T, value: unknown) {
|
||||||
|
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('strict client write DTOs', () => {
|
||||||
|
it('accepts an import confirmation without a client-supplied phones array', async () => {
|
||||||
|
await expect(
|
||||||
|
validate(ClientImportConfirmDto, {
|
||||||
|
content: '【测试】验证码 ${code}',
|
||||||
|
importContent: 'phone,code\n13800000001,1234',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a direct batch task without validated phone numbers', async () => {
|
||||||
|
await expect(validate(ClientBatchTaskDto, { content: '【测试】通知' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a client-supplied operator identity', async () => {
|
||||||
|
await expect(
|
||||||
|
validate(ClientDeleteResourceDto, { status: 'deleted', operatorId: 'another-user' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a client-supplied tenant identity', async () => {
|
||||||
|
await expect(
|
||||||
|
validate(ClientBatchTaskDto, {
|
||||||
|
tenantId: 'other-tenant',
|
||||||
|
content: '【测试】通知',
|
||||||
|
phones: ['13800000001'],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deeply nested or prototype-like dynamic values', async () => {
|
||||||
|
await expect(
|
||||||
|
validate(ClientBatchTaskDto, {
|
||||||
|
content: '【测试】通知',
|
||||||
|
phones: ['13800000001'],
|
||||||
|
variables: { safe: { nested: { too: { deep: { value: 'x' } } } } },
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://example.com/path',
|
||||||
|
'example.com/path?source=sms',
|
||||||
|
'www.example.com',
|
||||||
|
'192.0.2.10:8080/landing',
|
||||||
|
'13800138000',
|
||||||
|
'+86 138-0013-8000',
|
||||||
|
'0755-12345678',
|
||||||
|
'(010) 12345678-123',
|
||||||
|
])('accepts a drainage URL or phone number without a separate name: %s', async (url) => {
|
||||||
|
await expect(validate(ClientDrainageInfoDto, { url })).resolves.toEqual(expect.objectContaining({ url }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects arbitrary drainage text that is neither a URL nor a phone number', async () => {
|
||||||
|
await expect(validate(ClientDrainageInfoDto, { url: '品牌官网' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
IsDateString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { IsBoundedJsonObject } from './bounded-json-object.validator';
|
||||||
|
import { DRAINAGE_TARGET_ERROR, DRAINAGE_TARGET_PATTERN } from './drainage-target';
|
||||||
|
|
||||||
|
export class ClientCertificationSubmissionDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
|
||||||
|
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) materials?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientTaskBaseDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||||
|
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
|
||||||
|
@IsOptional() @IsDateString({ strict: true }) scheduledAt?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) variables?: Record<string, unknown>;
|
||||||
|
@IsOptional() @IsDateString({ strict: true }) requestedAt?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientBatchTaskDto extends ClientTaskBaseDto {
|
||||||
|
@IsArray() @ArrayMaxSize(100000) @Matches(/^1\d{10}$/, { each: true }) phones!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientImportPreviewDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
|
||||||
|
@IsOptional() @IsIn(['utf8', 'gbk']) encoding?: 'utf8' | 'gbk';
|
||||||
|
@IsOptional() @IsIn([',', '\t']) delimiter?: ',' | '\t';
|
||||||
|
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientImportConfirmDto extends ClientTaskBaseDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5_000_000) importContent!: string;
|
||||||
|
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientBillingEstimateDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||||
|
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
|
||||||
|
@IsOptional() @Type(() => Number) @Min(0) unitPrice?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) taskId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsApplicationDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) scene?: string;
|
||||||
|
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) cmppAccount?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) cmppEnterpriseCode?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) cmppApplicationExtension?: string;
|
||||||
|
@IsOptional() @IsBoolean() cmppAccessNumberFillEnabled?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) cmppAccessNumberFillPrefix?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(4096) passwordCipher?: string;
|
||||||
|
@IsOptional() @IsBoolean() interfaceEnabled?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) interfaceType?: string;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) cmppMaxConnections?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1000) cmppWindowSize?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(0) dailyLimit?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @Min(0) customerUnitPrice?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) queuePriority?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) templateMismatchMode?: string;
|
||||||
|
@IsOptional() @IsBoolean() downstreamReceiptRetryEnabled?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() downstreamUplinkRetryEnabled?: boolean;
|
||||||
|
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) ipAllowlist?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsSignatureDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(100) name!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) drainageInfo?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientDrainageInfoDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) siteName?: string;
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2048)
|
||||||
|
@Matches(DRAINAGE_TARGET_PATTERN, {
|
||||||
|
message: DRAINAGE_TARGET_ERROR,
|
||||||
|
})
|
||||||
|
url!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
|
||||||
|
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
|
||||||
|
|
||||||
|
export class ClientSignatureMaterialDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) fileObjectId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(64) materialType!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) title!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(2000) description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TemplateVariableDto {
|
||||||
|
@IsString() @MinLength(1) @MaxLength(64) name!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) example?: string;
|
||||||
|
@IsOptional() @IsBoolean() required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsTemplateDto {
|
||||||
|
@IsString() @MaxLength(64) applicationId!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(200) name!: string;
|
||||||
|
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) category?: string;
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TemplateVariableDto)
|
||||||
|
variables?: TemplateVariableDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientStatusChangeDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) status?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||||
|
@IsOptional() @IsBoolean() force?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||||
|
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSecretResetDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientApplicationStatusDto {
|
||||||
|
@IsOptional() @IsIn(['active', 'disabled', 'disabling', 'deleted']) status?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||||
|
@IsOptional() @IsBoolean() force?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientDeleteResourceDto {
|
||||||
|
@IsIn(['deleted']) status!: 'deleted';
|
||||||
|
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
|
||||||
|
@IsOptional() @IsBoolean() force?: boolean;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
|
||||||
|
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ describe('summarizeReportStatuses', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
[[], { status: 'not_applicable', approved: 0, total: 0 }],
|
[[], { status: 'not_applicable', approved: 0, total: 0 }],
|
||||||
[['approved', 'approved'], { status: 'approved', approved: 2, total: 2 }],
|
[['approved', 'approved'], { status: 'approved', approved: 2, total: 2 }],
|
||||||
|
[['abandoned', 'abandoned'], { status: 'abandoned', approved: 0, total: 2 }],
|
||||||
[['failed', 'rejected'], { status: 'failed', approved: 0, total: 2 }],
|
[['failed', 'rejected'], { status: 'failed', approved: 0, total: 2 }],
|
||||||
[['approved', 'failed'], { status: 'partial_success', approved: 1, total: 2 }],
|
[['approved', 'failed'], { status: 'partial_success', approved: 1, total: 2 }],
|
||||||
[['failed', 'pending'], { status: 'reporting', approved: 0, total: 2 }],
|
[['failed', 'pending'], { status: 'reporting', approved: 0, total: 2 }],
|
||||||
[['waiting_material', 'pending'], { status: 'waiting_material', 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) => {
|
])('summarizes %j without allowing one failure to override other targets', (statuses, expected) => {
|
||||||
expect(summarizeReportStatuses(statuses)).toEqual(expected);
|
expect(summarizeReportStatuses(statuses)).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export function summarizeReportStatuses(statuses: string[]): ReportStatusSummary
|
|||||||
|
|
||||||
const approved = statuses.filter((status) => status === 'approved').length;
|
const approved = statuses.filter((status) => status === 'approved').length;
|
||||||
const failed = statuses.filter((status) => FAILED_REPORT_STATUSES.has(status)).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 (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
|
// Overall failure means every current target failed. A single failed channel must not
|
||||||
// erase successful channels or targets that can still finish reporting.
|
// erase successful channels or targets that can still finish reporting.
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export class RequestContextMiddleware implements NestMiddleware {
|
|||||||
use(request: RequestLike, _response: unknown, next: () => void) {
|
use(request: RequestLike, _response: unknown, next: () => void) {
|
||||||
const forwarded = request.headers['x-forwarded-for'];
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
||||||
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
|
const remoteAddress = request.socket?.remoteAddress?.trim().replace(/^::ffff:/, '');
|
||||||
|
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||||
|
// 仅可信反向代理可以声明客户端地址,防止攻击者伪造 X-Forwarded-For 绕过保护名单或嫁祸他人。
|
||||||
|
const ipAddress = (remoteAddress && trustedProxies.has(remoteAddress) ? firstForwarded : remoteAddress)?.trim().replace(/^::ffff:/, '');
|
||||||
requestContext.run({ ipAddress }, next);
|
requestContext.run({ ipAddress }, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const strictValidationPipe = new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
stopAtFirstError: false,
|
||||||
|
transformOptions: { enableImplicitConversion: false },
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
|
||||||
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
||||||
|
|
||||||
@ApiTags('deletion-governance')
|
@ApiTags('deletion-governance')
|
||||||
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
|
|||||||
constructor(private readonly deletions: DeletionGovernanceService) {}
|
constructor(private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get(':type/:id/preflight')
|
@Get(':type/:id/preflight')
|
||||||
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
|
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @CurrentTenantId() tenantId: string) {
|
||||||
return this.deletions.preflight(type, id, tenantId);
|
return this.deletions.preflight(type, id, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':type/:id')
|
@Post(':type/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||||
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
|
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('敏感词需为1~200个字符');
|
||||||
|
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,
|
CreateSensitiveWordDto,
|
||||||
DictionariesService,
|
DictionariesService,
|
||||||
DictionaryStatusDto,
|
DictionaryStatusDto,
|
||||||
|
ReorderCommonReportFieldsDto,
|
||||||
|
UpdateDrainageFieldDto,
|
||||||
} from './dictionaries.service';
|
} from './dictionaries.service';
|
||||||
|
|
||||||
@ApiTags('dictionaries')
|
@ApiTags('dictionaries')
|
||||||
@@ -128,6 +130,15 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createDrainageField(body);
|
return this.dictionaries.createDrainageField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('drainage-fields/:id')
|
||||||
|
updateDrainageField(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateDrainageFieldDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.dictionaries.updateDrainageField(id, body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('drainage-fields/:id')
|
@Delete('drainage-fields/:id')
|
||||||
deleteDrainageField(@Param('id') id: string) {
|
deleteDrainageField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteDrainageField(id);
|
return this.dictionaries.deleteDrainageField(id);
|
||||||
@@ -168,8 +179,21 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createCommonReportField(body);
|
return this.dictionaries.createCommonReportField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('common-report-fields/order')
|
||||||
|
reorderCommonReportFields(
|
||||||
|
@Body() body: ReorderCommonReportFieldsDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.dictionaries.reorderCommonReportFields(body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('common-report-fields/:id')
|
@Delete('common-report-fields/:id')
|
||||||
deleteCommonReportField(@Param('id') id: string) {
|
deleteCommonReportField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteCommonReportField(id);
|
return this.dictionaries.deleteCommonReportField(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('common-report-fields/:id')
|
||||||
|
updateCommonReportField(@Param('id') id: string, @Body() body: CreateCommonReportFieldDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.dictionaries.updateCommonReportField(id, body, operatorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { DictionariesController } from './dictionaries.controller';
|
||||||
import { DictionariesService } from './dictionaries.service';
|
import { DictionariesService } from './dictionaries.service';
|
||||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [DictionariesController],
|
controllers: [DictionariesController, ChannelSensitiveWordsController],
|
||||||
providers: [DictionariesService, PhoneRoutingLookupService],
|
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
|
||||||
exports: [DictionariesService, PhoneRoutingLookupService],
|
exports: [DictionariesService, PhoneRoutingLookupService],
|
||||||
})
|
})
|
||||||
export class DictionariesModule {}
|
export class DictionariesModule {}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||||
},
|
},
|
||||||
channelReportField: {
|
channelReportField: {
|
||||||
@@ -46,6 +47,7 @@ function createPrismaMock() {
|
|||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||||
@@ -58,6 +60,60 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DictionariesService', () => {
|
describe('DictionariesService', () => {
|
||||||
|
it('edits common configuration in place with an audit trail and rejects duplicate or inactive fields', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const existing = { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false };
|
||||||
|
prisma.commonReportField.findUnique.mockImplementation(({ where }: { where: { id?: string } }) => Promise.resolve(where.id ? existing : null) as never);
|
||||||
|
prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'active' } as never);
|
||||||
|
const tx = { commonReportField: { update: jest.fn().mockResolvedValue({ ...existing, required: true }) }, operationLog: { create: jest.fn() } };
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
const body = { drainageFieldId: 'field-2', reportType: 'drainage' as const, required: true };
|
||||||
|
await service.updateCommonReportField('common-1', body, 'admin-1');
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'common-1' }, data: { ...body, sortOrder: undefined } }));
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'common_report_field.update', userId: 'admin-1' }) }));
|
||||||
|
prisma.commonReportField.findUnique.mockResolvedValue({ id: 'other' } as never);
|
||||||
|
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已配置');
|
||||||
|
prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'inactive' } as never);
|
||||||
|
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
||||||
|
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders every common field in one report type transaction and writes an audit trail', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const tx = {
|
||||||
|
commonReportField: {
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]),
|
||||||
|
},
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'),
|
||||||
|
).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]);
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { id: 'common-2' },
|
||||||
|
data: { sortOrder: 10 },
|
||||||
|
});
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: 'common-1' },
|
||||||
|
data: { sortOrder: 20 },
|
||||||
|
});
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }),
|
||||||
|
});
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
|
||||||
|
tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]);
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }),
|
||||||
|
).resolves.toEqual([{ id: 'common-1' }]);
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }),
|
||||||
|
).rejects.toThrow('排序范围已变化');
|
||||||
|
});
|
||||||
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
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 () => {
|
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
|
||||||
|
|
||||||
export interface UpsertDrainageDetectionRuleDto {
|
export interface UpsertDrainageDetectionRuleDto {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReorderCommonReportFieldsDto {
|
||||||
|
reportType: 'signature' | 'drainage';
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DictionaryStatusDto {
|
export interface DictionaryStatusDto {
|
||||||
status?: string;
|
status?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
@@ -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) {
|
async deleteDrainageField(id: string) {
|
||||||
const [usageCount, commonUsageCount] = await Promise.all([
|
const [usageCount, commonUsageCount] = await Promise.all([
|
||||||
this.prisma.channelReportField.count({
|
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) {
|
deleteCommonReportField(id: string) {
|
||||||
return this.prisma.commonReportField.delete({ where: { id } });
|
return this.prisma.commonReportField.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCommonReportField(id: string, data: CreateCommonReportFieldDto, operatorId?: string) {
|
||||||
|
if (!['signature', 'drainage'].includes(data.reportType) || typeof data.required !== 'boolean') {
|
||||||
|
throw new BadRequestException('资料用途或是否必填无效');
|
||||||
|
}
|
||||||
|
if (data.sortOrder !== undefined && !Number.isInteger(data.sortOrder)) {
|
||||||
|
throw new BadRequestException('排序值必须为整数');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.commonReportField.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('通用字段配置不存在');
|
||||||
|
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||||
|
if (!field || field.status !== 'active') throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||||
|
const duplicate = await this.prisma.commonReportField.findUnique({
|
||||||
|
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
|
||||||
|
});
|
||||||
|
if (duplicate && duplicate.id !== id) throw new ConflictException('该字段已配置为对应类型的通用字段');
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.commonReportField.update({
|
||||||
|
where: { id },
|
||||||
|
data: { drainageFieldId: field.id, reportType: data.reportType, required: data.required, sortOrder: data.sortOrder },
|
||||||
|
include: { drainageField: true },
|
||||||
|
});
|
||||||
|
await tx.operationLog.create({ data: {
|
||||||
|
userId: operatorId, action: 'common_report_field.update', resource: 'common_report_field', resourceId: id,
|
||||||
|
detail: { before: { drainageFieldId: existing.drainageFieldId, reportType: existing.reportType, required: existing.required }, after: { drainageFieldId: field.id, reportType: data.reportType, required: data.required } },
|
||||||
|
} });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
throw new ConflictException('该字段已配置为对应类型的通用字段');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||||
return this.prisma.operationLog.create({
|
return this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -22,18 +22,25 @@ export class PhoneRoutingLookupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async identifyProvince(phoneNumber: string) {
|
async identifyProvince(phoneNumber: string) {
|
||||||
const prefixes = phonePrefixes(phoneNumber);
|
return (await this.identifyProvinces([phoneNumber])).get(phoneNumber) ?? null;
|
||||||
if (prefixes.length === 0) return null;
|
}
|
||||||
|
|
||||||
|
async identifyProvinces(phoneNumbers: string[]) {
|
||||||
|
const uniquePhones = [...new Set(phoneNumbers)];
|
||||||
|
const prefixesByPhone = new Map(uniquePhones.map((phone) => [phone, phonePrefixes(phone)]));
|
||||||
|
const prefixes = [...new Set([...prefixesByPhone.values()].flat())];
|
||||||
|
if (prefixes.length === 0) return new Map(uniquePhones.map((phone) => [phone, null]));
|
||||||
const segments = await this.prisma.phoneSegment.findMany({
|
const segments = await this.prisma.phoneSegment.findMany({
|
||||||
where: { prefix: { in: prefixes } },
|
where: { prefix: { in: prefixes } },
|
||||||
select: { prefix: true, province: true },
|
select: { prefix: true, province: true },
|
||||||
});
|
});
|
||||||
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
||||||
for (const prefix of prefixes) {
|
return new Map(uniquePhones.map((phone) => {
|
||||||
const province = provinceByPrefix.get(prefix);
|
const province = (prefixesByPhone.get(phone) ?? [])
|
||||||
if (province) return province;
|
.map((prefix) => provinceByPrefix.get(prefix))
|
||||||
}
|
.find((value): value is string => Boolean(value)) ?? null;
|
||||||
return null;
|
return [phone, province];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateCarrierRules() {
|
invalidateCarrierRules() {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
UsePipes,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import { FilesService } from './files.service';
|
import { FilesService } from './files.service';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
|
import { ClientFileUploadDto } from './client-files.dto';
|
||||||
|
|
||||||
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
||||||
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
|
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
|
||||||
@@ -14,14 +28,14 @@ export class ClientFilesController {
|
|||||||
|
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
|
||||||
|
@UsePipes(strictValidationPipe)
|
||||||
upload(
|
upload(
|
||||||
@CurrentSessionUserId() userId: string | undefined,
|
@CurrentSessionUserId() userId: string | undefined,
|
||||||
@UploadedFile() file: UploadedMultipartFile,
|
@UploadedFile() file: UploadedMultipartFile,
|
||||||
@Body('purpose') purpose: string,
|
@Body() body: ClientFileUploadDto,
|
||||||
@Body('prefix') prefix?: string,
|
|
||||||
) {
|
) {
|
||||||
if (!file) throw new BadRequestException('Upload file is required');
|
if (!file) throw new BadRequestException('Upload file is required');
|
||||||
return this.files.uploadForClient(userId, { purpose, prefix }, file);
|
return this.files.uploadForClient(userId, body, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/download')
|
@Get(':id/download')
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
|
import { ClientFileUploadDto } from './client-files.dto';
|
||||||
|
|
||||||
|
describe('ClientFileUploadDto', () => {
|
||||||
|
it('accepts bounded client material paths and rejects traversal before storage', async () => {
|
||||||
|
await expect(
|
||||||
|
strictValidationPipe.transform(
|
||||||
|
{ purpose: 'drainage_report_material', prefix: 'drainage-materials/item-1' },
|
||||||
|
{
|
||||||
|
type: 'body',
|
||||||
|
metatype: ClientFileUploadDto,
|
||||||
|
data: undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ purpose: 'drainage_report_material' }));
|
||||||
|
await expect(
|
||||||
|
strictValidationPipe.transform(
|
||||||
|
{ purpose: 'enterprise_certification', prefix: '../admin' },
|
||||||
|
{
|
||||||
|
type: 'body',
|
||||||
|
metatype: ClientFileUploadDto,
|
||||||
|
data: undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ClientFileUploadDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['enterprise_certification', 'signature_report_material', 'drainage_report_material'])
|
||||||
|
purpose!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/)
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { assertUploadSize } from './files.service';
|
||||||
|
|
||||||
|
describe('FilesService report-material upload limits', () => {
|
||||||
|
const workbook = {
|
||||||
|
originalname: '行业报备.xlsx',
|
||||||
|
mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('allows report-material imports up to 100MB without raising the generic file limit', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertUploadSize('report_material_import', { ...workbook, size: 40 * 1024 * 1024 }),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => assertUploadSize('other', { ...workbook, size: 40 * 1024 * 1024 })).toThrow('10MB');
|
||||||
|
expect(() =>
|
||||||
|
assertUploadSize('report_material_import', { ...workbook, size: 101 * 1024 * 1024 }),
|
||||||
|
).toThrow('100MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,10 +40,12 @@ export class FilesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
list(tenantId?: string) {
|
list(tenantId?: string) {
|
||||||
return this.prisma.fileObject.findMany({
|
return this.prisma.fileObject
|
||||||
|
.findMany({
|
||||||
where: tenantId ? { tenantId } : undefined,
|
where: tenantId ? { tenantId } : undefined,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
}).then((items) => items.map(serializeFileObject));
|
})
|
||||||
|
.then((items) => items.map(serializeFileObject));
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data: CreateFileObjectDto) {
|
create(data: CreateFileObjectDto) {
|
||||||
@@ -71,7 +73,7 @@ export class FilesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
||||||
assertUploadSize(file);
|
assertUploadSize(data.purpose, file);
|
||||||
const fileName = normalizeMultipartFileName(file.originalname);
|
const fileName = normalizeMultipartFileName(file.originalname);
|
||||||
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
||||||
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
||||||
@@ -87,7 +89,11 @@ export class FilesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadForClient(userId: string | undefined, data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
async uploadForClient(
|
||||||
|
userId: string | undefined,
|
||||||
|
data: UploadFileDto,
|
||||||
|
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||||
|
) {
|
||||||
const tenantId = await this.resolveClientTenantId(userId);
|
const tenantId = await this.resolveClientTenantId(userId);
|
||||||
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
|
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
|
||||||
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
|
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
|
||||||
@@ -135,18 +141,33 @@ export class FilesService {
|
|||||||
|
|
||||||
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
const REPORT_MATERIAL_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i;
|
const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i;
|
||||||
|
|
||||||
function assertUploadSize(file: { originalname: string; mimetype: string; size: number }) {
|
export function assertUploadSize(purpose: string, file: { originalname: string; mimetype: string; size: number }) {
|
||||||
const image = file.mimetype.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.originalname);
|
const image = file.mimetype.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.originalname);
|
||||||
const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES;
|
const reportMaterialImport = purpose === 'report_material_import';
|
||||||
|
const limit = reportMaterialImport
|
||||||
|
? REPORT_MATERIAL_IMPORT_MAX_BYTES
|
||||||
|
: image
|
||||||
|
? IMAGE_UPLOAD_MAX_BYTES
|
||||||
|
: FILE_UPLOAD_MAX_BYTES;
|
||||||
if (file.size > limit) {
|
if (file.size > limit) {
|
||||||
throw new BadRequestException(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB');
|
throw new BadRequestException(
|
||||||
|
reportMaterialImport
|
||||||
|
? '报备资料文件大小不能超过 100MB'
|
||||||
|
: image
|
||||||
|
? '图片大小不能超过 2MB'
|
||||||
|
: '文件大小不能超过 10MB',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMultipartFileName(value: string) {
|
function normalizeMultipartFileName(value: string) {
|
||||||
if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) {
|
if (
|
||||||
|
![...value].some((character) => character.charCodeAt(0) > 0x7f) ||
|
||||||
|
[...value].some((character) => character.charCodeAt(0) > 0xff)
|
||||||
|
) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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';
|
||||||
|
import { PhoneRoutingLookupService } from './dictionaries/phone-routing-lookup.service';
|
||||||
|
import { MetricsModule } from './metrics/metrics.module';
|
||||||
|
import { OpenApiService } from './open-api/open-api.service';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module';
|
||||||
|
import { PhoneFrequencyService } from './risk-review/phone-frequency.service';
|
||||||
|
import { RiskReviewService } from './risk-review/risk-review.service';
|
||||||
|
import { GatewayCallbackController } from './send-chain/gateway-callback.controller';
|
||||||
|
import { SendChainService } from './send-chain/send-chain.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }),
|
||||||
|
PrismaModule,
|
||||||
|
MetricsModule,
|
||||||
|
ProtocolLogsModule,
|
||||||
|
],
|
||||||
|
controllers: [DrainageSubmitGuardController, GatewayCallbackController],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
|
||||||
|
BillingService,
|
||||||
|
RiskReviewService,
|
||||||
|
PhoneFrequencyService,
|
||||||
|
PhoneRoutingLookupService,
|
||||||
|
SendChainService,
|
||||||
|
OpenApiService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class GatewayCallbackModule {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { GatewayCallbackModule } from './gateway-callback.module';
|
||||||
|
import { configureHttpBodyParsers } from './http-body-limits';
|
||||||
|
import { MetricsService } from './metrics/metrics.service';
|
||||||
|
|
||||||
|
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: bigint) {
|
||||||
|
const result = Number(this);
|
||||||
|
if (!Number.isSafeInteger(result)) throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (process.env.CMPP_PROCESS_ROLE !== 'callback') {
|
||||||
|
throw new Error('gateway-callback requires CMPP_PROCESS_ROLE=callback');
|
||||||
|
}
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(GatewayCallbackModule, { rawBody: true, bodyParser: false });
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
configureHttpBodyParsers(app);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
const host = process.env.API_CALLBACK_HOST?.trim() || '127.0.0.1';
|
||||||
|
const port = Number(process.env.API_CALLBACK_PORT ?? 3001);
|
||||||
|
await app.listen(port, host);
|
||||||
|
|
||||||
|
const metrics = app.get(MetricsService);
|
||||||
|
const metricsHost = process.env.API_CALLBACK_METRICS_HOST?.trim() || '127.0.0.1';
|
||||||
|
const metricsPort = Number(process.env.API_CALLBACK_METRICS_PORT ?? 9468);
|
||||||
|
const metricsServer = createServer((request, response) => {
|
||||||
|
if (request.method !== 'GET' || request.url !== '/metrics') return void response.writeHead(404).end();
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
response.end(metrics.render());
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
metricsServer.once('error', reject);
|
||||||
|
metricsServer.listen(metricsPort, metricsHost, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user