49 Commits
Author SHA1 Message Date
hectorzhao 3cacb6e8e7 fix: 修复模板唯一性及六项运营页面问题
CSS quality / css-quality (push) Canceled after 0s
2026-09-21 19:28:50 +08:00
hectorzhao 28951b4fc4 fix: 修复未报备签名统计的中文编码兼容性 2026-09-21 15:39:45 +08:00
hectorzhao 001d5f2cbd fix: 修复 CMPP 协议字段容量与版本兼容性
CSS quality / css-quality (push) Has been cancelled
2026-09-20 15:49:37 +08:00
hectorzhao b24cd7c08d feat: 增加模板通道拒收策略并修复运营页面 2026-09-20 15:09:57 +08:00
hectorzhao c20c2246b2 fix: 固化长短信回执终态并隔离发送尝试归属 2026-09-18 13:20:03 +08:00
hectorzhao 1676cfe622 fix: 限制有效签名名称唯一并保留批量导入补资料 2026-09-17 16:49:58 +08:00
hectorzhao 5e4d644788 feat: 重构首页回执营业统计并增加企业返还金额
CSS quality / css-quality (push) Has been cancelled
2026-09-17 13:11:35 +08:00
hectorzhao 627fa7ec97 fix: 固定跨午夜日报刷新基准并补充验收记录 2026-09-17 11:14:21 +08:00
hectorzhao 572290308c fix: 修复上行归属并实现签名质量日报优化 2026-09-17 11:12:58 +08:00
hectorzhao 4eb7b16d12 docs: record test deployment and long-message completion acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-16 19:22:16 +08:00
hectorzhao 010ba32168 fix: register supplier response waiter before reader can consume reply
CSS quality / css-quality (push) Has been cancelled
2026-09-16 19:08:42 +08:00
hectorzhao a350aca883 fix: coordinate SMS completion and improve operations diagnostics
CSS quality / css-quality (push) Has been cancelled
2026-09-16 18:27:29 +08:00
hectorzhao a0209f93bc chore: rename product to 聆界短信平台 2026-09-16 11:31:45 +08:00
hectorzhao 86cb9aea36 docs: record HTTP integration acceptance and test deployment
CSS quality / css-quality (push) Has been cancelled
2026-09-15 17:42:28 +08:00
hectorzhao cbc4a03325 fix: exclude prose colons from drainage URL boundaries
CSS quality / css-quality (push) Has been cancelled
2026-09-15 16:52:30 +08:00
hectorzhao c781313de5 feat: add HTTP signature tools and fix independent HTTP send validation
CSS quality / css-quality (push) Has been cancelled
2026-09-15 15:58:29 +08:00
hectorzhao 18ecf8045f feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled
2026-09-15 14:58:31 +08:00
hectorzhao bcb278be29 docs: record client fixes test deployment and acceptance boundaries
CSS quality / css-quality (push) Has been cancelled
2026-09-14 23:29:37 +08:00
hectorzhao 4665079ca3 fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled
2026-09-14 22:53:34 +08:00
hectorzhao 7f9abe3da0 fix: enforce drainage uniqueness and carrier-specific reporting 2026-09-14 18:12:38 +08:00
hectorzhao ac6449028c docs: close HTTP API real simulator acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:53:22 +08:00
hectorzhao 97d1334423 fix: preserve fractional timestamp input compatibility
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:20:30 +08:00
hectorzhao a420d61b23 fix: validate HTTP dates IPv6 URLs and parser errors
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:15:58 +08:00
hectorzhao 92b112cc6e fix: honor Node all-address DNS lookup for HTTP webhooks
CSS quality / css-quality (push) Has been cancelled
2026-09-14 14:03:00 +08:00
hectorzhao 40c8e279f0 docs: close HTTP API test deployment with verification evidence
CSS quality / css-quality (push) Has been cancelled
2026-09-14 13:20:37 +08:00
hectorzhao 04e9f467ab docs: record HTTP remediation validation and test release handoff
CSS quality / css-quality (push) Has been cancelled
2026-09-14 13:02:16 +08:00
hectorzhao f0e843436c feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled
2026-09-14 12:48:10 +08:00
hectorzhao d13ca0713a docs: record channel sensitive words test deployment
CSS quality / css-quality (push) Has been cancelled
2026-09-10 16:11:13 +08:00
hectorzhao 8e4bc5a20e feat: filter SMS routes by channel sensitive words 2026-09-10 15:50:56 +08:00
hectorzhao 86947827cc docs: record drainage test deployment and recovery evidence 2026-09-10 14:06:54 +08:00
hectorzhao 0c3f820cc9 feat: enforce signature-scoped drainage authorization before SMS submission 2026-09-10 13:29:04 +08:00
hectorzhao 5bcdbb2a03 fix: correct operational statistics and form interactions 2026-09-09 23:15:42 +08:00
hectorzhao 6d63eb5452 docs: record test and preproduction release acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-08 23:17:11 +08:00
hectorzhao 809175b544 fix: enforce production React release builds
CSS quality / css-quality (push) Has been cancelled
2026-09-08 22:39:32 +08:00
hectorzhao 6816f56178 fix: harden notification polling and simplify pagination controls 2026-09-08 21:44:08 +08:00
hectorzhao ebb185b22b fix: prevent daily report refresh timeouts
CSS quality / css-quality (push) Has been cancelled
2026-09-08 17:16:43 +08:00
hectorzhao 2a9d03be2e fix: unify analytics and report pagination controls 2026-09-08 14:52:12 +08:00
hectorzhao 6d3c78330d docs: record test release and real operations acceptance 2026-09-08 13:41:49 +08:00
hectorzhao 2c228a94e1 fix: bound formatter memory and improve operations workflows 2026-09-08 12:46:15 +08:00
hectorzhao 50ae37242b docs: 记录夜间累计审核双环境发布与验收
CSS quality / css-quality (push) Has been cancelled
2026-09-07 23:26:27 +08:00
hectorzhao 633ba59775 feat: 按企业应用累计夜间短信并复用聚合审核
CSS quality / css-quality (push) Has been cancelled
2026-09-07 22:55:29 +08:00
hectorzhao e281ff853b fix: 修复WPS图片跨节点误配并定位损坏单元格 2026-09-07 15:17:09 +08:00
hectorzhao f885f0b907 docs: 记录规则管理双环境发布与验收结果
CSS quality / css-quality (push) Has been cancelled
2026-09-06 22:44:24 +08:00
hectorzhao e4f93f7193 feat: 优化整体兜底个性化规则管理交互
CSS quality / css-quality (push) Has been cancelled
2026-09-06 22:16:09 +08:00
hectorzhao 0e3424c4a7 docs: 补记验收文档推送认证重试结果
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:36:08 +08:00
hectorzhao c51255d407 docs: 记录监控配置修复发布与告警演示验收
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:34:37 +08:00
hectorzhao 4c210723cd fix: 修复监控纳管保存并移除运行概况最近记录
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:22:48 +08:00
hectorzhao f059674852 docs: 记录发送监控与报备通知测试发布验收
CSS quality / css-quality (push) Has been cancelled
2026-09-06 20:00:21 +08:00
hectorzhao 247fee6d6b fix: 完善监控页签与通知弹窗异步交互
CSS quality / css-quality (push) Has been cancelled
2026-09-06 19:46:08 +08:00
357 changed files with 43416 additions and 8908 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# CMPP 平台仓库开发约束 # 聆界短信平台仓库开发约束
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。 本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
+109 -1
View File
@@ -25,9 +25,11 @@
"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",
@@ -2011,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",
@@ -2100,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",
@@ -2107,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",
@@ -2172,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",
@@ -2182,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",
@@ -8589,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",
+3 -1
View File
@@ -34,9 +34,11 @@
"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",
@@ -0,0 +1,38 @@
CREATE TABLE "NightSendingWindow" (
"id" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"windowEndsAt" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"baselineCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE UNIQUE INDEX "NightSendingWindow_applicationId_windowStartedAt_key" ON "NightSendingWindow"("applicationId", "windowStartedAt");
CREATE INDEX "NightSendingWindow_applicationId_windowEndsAt_idx" ON "NightSendingWindow"("applicationId", "windowEndsAt");
CREATE TABLE "NightSendingReservation" (
"messageRecordId" TEXT PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"windowId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"thresholdValue" INTEGER NOT NULL,
"reviewTaskId" TEXT,
"continuedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "NightSendingReservation_applicationId_windowId_idx" ON "NightSendingReservation"("applicationId", "windowId");
CREATE INDEX "NightSendingReservation_reviewTaskId_idx" ON "NightSendingReservation"("reviewTaskId");
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseOwner" TEXT;
ALTER TABLE "SmsSendTask" ADD COLUMN "continuationLeaseExpiresAt" TIMESTAMP(3);
-- Preserve rule IDs and thresholds for application overrides and historical hits.
UPDATE "RiskRule" SET "name" = '夜间累计发送量审核',
"description" = '同一企业应用在夜间累计业务短信超过阈值后进入人工审核,所有入口与内容合并计数。',
"metric" = 'nightSendingCount', "action" = 'manual_review',
"config" = COALESCE("config", '{}'::jsonb) || '{"timeZone":"Asia/Shanghai"}'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK';
-- The newly approved policy applies by default to every application.
UPDATE "RiskRule" SET "status" = 'active', "updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'NON_WORKING_MARKETING_BULK' AND "applicationId" IS NULL AND "status" <> 'deleted';
@@ -0,0 +1,7 @@
-- Nullable additive fields preserve historical messages and old readers.
ALTER TABLE "SignatureRetirementMessage"
ADD COLUMN "dailyGroupKey" TEXT,
ADD COLUMN "notificationDate" DATE,
ADD COLUMN "applicationId" TEXT,
ADD COLUMN "detectionIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE UNIQUE INDEX "SignatureRetirementMessage_dailyGroupKey_key" ON "SignatureRetirementMessage"("dailyGroupKey");
@@ -0,0 +1,30 @@
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageReceiptPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_drainage_receipt_pending" ON "SmsMessageRecord" ("updatedAt") WHERE "drainageReceiptPending" = true;
CREATE TABLE "SmsDrainageDecision" (
"id" TEXT PRIMARY KEY,
"messageRecordId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshot" JSONB NOT NULL
);
CREATE INDEX "SmsDrainageDecision_messageRecordId_decidedAt_idx" ON "SmsDrainageDecision" ("messageRecordId", "decidedAt");
CREATE OR REPLACE FUNCTION drainage_authorization_lock() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."signatureId" IS DISTINCT FROM NEW."signatureId" THEN
PERFORM pg_advisory_xact_lock(hashtextextended(value, 910))
FROM unnest(ARRAY[OLD."signatureId", NEW."signatureId"]) AS ids(value) ORDER BY value;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
PERFORM pg_advisory_xact_lock(hashtextextended(OLD."signatureId", 910));
RETURN OLD;
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(NEW."signatureId", 910));
RETURN NEW;
END $$;
CREATE TRIGGER drainage_material_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "SmsDrainageInfo"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
CREATE TRIGGER drainage_report_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
@@ -0,0 +1,20 @@
CREATE TABLE "ChannelSensitiveWord" (
"id" TEXT PRIMARY KEY, "channelId" TEXT NOT NULL, "word" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active', "remark" TEXT NOT NULL DEFAULT '',
"version" INTEGER NOT NULL DEFAULT 1, "createdBy" TEXT NOT NULL, "updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChannelSensitiveWord_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChannelSensitiveWord_status_check" CHECK ("status" IN ('active','inactive','deleted')),
CONSTRAINT "ChannelSensitiveWord_word_check" CHECK (char_length("word") BETWEEN 1 AND 200)
);
CREATE UNIQUE INDEX "ChannelSensitiveWord_channelId_word_key" ON "ChannelSensitiveWord"("channelId","word");
CREATE INDEX "ChannelSensitiveWord_channelId_status_idx" ON "ChannelSensitiveWord"("channelId","status");
CREATE TABLE "SmsChannelSensitiveDecision" (
"id" TEXT PRIMARY KEY, "messageRecordId" TEXT NOT NULL, "routeAttemptId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "snapshot" JSONB NOT NULL,
CONSTRAINT "SmsChannelSensitiveDecision_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "SmsChannelSensitiveDecision_routeAttemptId_key" ON "SmsChannelSensitiveDecision"("routeAttemptId");
CREATE INDEX "SmsChannelSensitiveDecision_messageRecordId_decidedAt_idx" ON "SmsChannelSensitiveDecision"("messageRecordId","decidedAt");
ALTER TABLE "SmsMessageRecord" ADD COLUMN "channelWordFinalizationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_channelWordFinalizationPending_idx" ON "SmsMessageRecord"("updatedAt") WHERE "channelWordFinalizationPending" = true;
@@ -0,0 +1,11 @@
ALTER TABLE "HttpWebhookDelivery" ADD COLUMN "recoveryVersion" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "leaseToken" TEXT, ADD COLUMN "leaseUntil" TIMESTAMP(3);
CREATE TABLE "OpenApiDispatchOutbox" (
"id" TEXT NOT NULL, "requestId" TEXT NOT NULL, "batchTaskId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending', "leaseToken" TEXT, "leaseUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenApiDispatchOutbox_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_requestId_key" ON "OpenApiDispatchOutbox"("requestId");
CREATE UNIQUE INDEX "OpenApiDispatchOutbox_batchTaskId_key" ON "OpenApiDispatchOutbox"("batchTaskId");
CREATE INDEX "OpenApiDispatchOutbox_status_leaseUntil_idx" ON "OpenApiDispatchOutbox"("status", "leaseUntil");
@@ -0,0 +1,10 @@
-- Preserve all legacy rows; independent carrier states require distinct business keys.
BEGIN;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_carrier_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId", "carrier")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NOT NULL;
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_legacy_key"
ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId")
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL AND "carrier" IS NULL;
DROP INDEX "ChannelSignatureReportTask_drainage_target_key";
COMMIT;
@@ -0,0 +1,17 @@
CREATE TABLE "InfrastructureAlertCollection" (
"id" TEXT NOT NULL PRIMARY KEY,
"observedAt" TIMESTAMP(3) NOT NULL
);
CREATE TABLE "InfrastructureAlertEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"fingerprint" TEXT NOT NULL,
"activeAt" TIMESTAMP(3) NOT NULL,
"payload" JSONB NOT NULL,
"lastObservedAt" TIMESTAMP(3) NOT NULL,
"recoveredAt" TIMESTAMP(3),
"clearedAt" TIMESTAMP(3),
"clearedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "InfrastructureAlertEvent_fingerprint_activeAt_key" ON "InfrastructureAlertEvent"("fingerprint", "activeAt");
CREATE INDEX "InfrastructureAlertEvent_clearedAt_activeAt_idx" ON "InfrastructureAlertEvent"("clearedAt", "activeAt");
@@ -0,0 +1,15 @@
CREATE TABLE "SmsAttemptCompletionWork" (
"id" TEXT PRIMARY KEY, "workKey" TEXT NOT NULL, "tenantId" TEXT, "messageRecordId" TEXT NOT NULL, "sourceSubmitRecordId" TEXT,
"revision" INTEGER NOT NULL DEFAULT 0, "processedRevision" INTEGER NOT NULL DEFAULT 0, "state" TEXT NOT NULL DEFAULT 'pending',
"leaseOwner" TEXT, "leaseUntil" TIMESTAMP(3), "fenceVersion" INTEGER NOT NULL DEFAULT 0, "attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "decision" TEXT, "retrySubmitRecordId" TEXT, "lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'));
CREATE UNIQUE INDEX "SmsAttemptCompletionWork_workKey_key" ON "SmsAttemptCompletionWork"("workKey");
CREATE UNIQUE INDEX "SmsAttemptCompletionWork_sourceSubmitRecordId_key" ON "SmsAttemptCompletionWork"("sourceSubmitRecordId");
CREATE INDEX "SmsAttemptCompletionWork_state_nextAttemptAt_idx" ON "SmsAttemptCompletionWork"("state", "nextAttemptAt");
CREATE INDEX "SmsAttemptCompletionWork_state_leaseUntil_idx" ON "SmsAttemptCompletionWork"("state", "leaseUntil");
CREATE INDEX "SmsAttemptCompletionWork_messageRecordId_idx" ON "SmsAttemptCompletionWork"("messageRecordId");
CREATE TABLE "SmsCompletionEvent" ("id" TEXT PRIMARY KEY, "eventKey" TEXT NOT NULL, "workId" TEXT NOT NULL REFERENCES "SmsAttemptCompletionWork"("id") ON DELETE RESTRICT,
"kind" TEXT NOT NULL, "payload" JSONB NOT NULL, "processedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'));
CREATE UNIQUE INDEX "SmsCompletionEvent_eventKey_key" ON "SmsCompletionEvent"("eventKey");
CREATE INDEX "SmsCompletionEvent_workId_processedAt_createdAt_idx" ON "SmsCompletionEvent"("workId", "processedAt", "createdAt");
@@ -0,0 +1,130 @@
-- CreateTable
CREATE TABLE "SignatureAnalyticsGeneration" (
"id" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"sourceAsOf" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsGeneration_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsDay" (
"businessDate" DATE NOT NULL,
"publishedGenerationId" TEXT,
"generatedAt" TIMESTAMP(3),
"sourceAsOf" TIMESTAMP(3),
"refreshFor" DATE,
"state" TEXT NOT NULL DEFAULT 'missing',
"error" TEXT,
"provenance" TEXT NOT NULL DEFAULT 'daily',
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"rowCounts" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsDay_pkey" PRIMARY KEY ("businessDate")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsRun" (
"id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"refreshFor" DATE NOT NULL,
"generationId" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'pending',
"owner" TEXT,
"fence" INTEGER NOT NULL DEFAULT 0,
"leaseUntil" TIMESTAMP(3),
"attempt" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkpoint" JSONB,
"error" TEXT,
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
CONSTRAINT "SignatureAnalyticsRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureQualityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"signatureId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationNames" TEXT NOT NULL,
"total" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
CONSTRAINT "SignatureQualityDaily_pkey" PRIMARY KEY ("generationId","signatureId")
);
-- CreateTable
CREATE TABLE "SignatureActivityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"dimensionType" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"channelKey" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"channelName" TEXT NOT NULL,
"approvedAt" TIMESTAMP(3),
"submittedAttempts" INTEGER NOT NULL,
"acceptedBusinessCount" INTEGER NOT NULL,
"deliveredBusinessCount" INTEGER NOT NULL,
"applicability" TEXT NOT NULL,
CONSTRAINT "SignatureActivityDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateTable
CREATE TABLE "UnreportedSignatureDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"messageCount" INTEGER NOT NULL,
CONSTRAINT "UnreportedSignatureDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsGeneration_id_businessDate_key" ON "SignatureAnalyticsGeneration"("id", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureAnalyticsRun_state_nextAttemptAt_idx" ON "SignatureAnalyticsRun"("state", "nextAttemptAt");
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsRun_scope_businessDate_key" ON "SignatureAnalyticsRun"("scope", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureQualityDaily_businessDate_generationId_total_idx" ON "SignatureQualityDaily"("businessDate", "generationId", "total");
-- CreateIndex
CREATE INDEX "SignatureActivityDaily_businessDate_generationId_dimensionT_idx" ON "SignatureActivityDaily"("businessDate", "generationId", "dimensionType", "acceptedBusinessCount");
-- CreateIndex
CREATE INDEX "UnreportedSignatureDaily_businessDate_generationId_messageC_idx" ON "UnreportedSignatureDaily"("businessDate", "generationId", "messageCount");
-- AddForeignKey
ALTER TABLE "SignatureAnalyticsDay" ADD CONSTRAINT "SignatureAnalyticsDay_publishedGenerationId_businessDate_fkey" FOREIGN KEY ("publishedGenerationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureQualityDaily" ADD CONSTRAINT "SignatureQualityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureActivityDaily" ADD CONSTRAINT "SignatureActivityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UnreportedSignatureDaily" ADD CONSTRAINT "UnreportedSignatureDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- Expression index matches the actual report/retirement time predicate, including legacy NULL submittedAt.
-- Deliberately outside a transaction: online construction must not block SMS writes.
CREATE INDEX CONCURRENTLY "SmsSubmitRecord_effective_at_idx"
ON "SmsSubmitRecord" ((COALESCE("submittedAt", "createdAt")));
@@ -0,0 +1,24 @@
CREATE TABLE "HomeProjectionState" (id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0, "seededDay" TEXT, initialized BOOLEAN NOT NULL DEFAULT false, "lastError" TEXT, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE "HomeProjectionDirty" ("messageRecordId" TEXT PRIMARY KEY,"enqueuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE "HomeMessageFact" ("messageRecordId" TEXT NOT NULL,"fromVersion" INTEGER NOT NULL,"toVersion" INTEGER,"queuedDay" TEXT NOT NULL,payload JSONB NOT NULL,PRIMARY KEY("messageRecordId","fromVersion"));
CREATE INDEX "HomeMessageFact_queuedDay_toVersion_idx" ON "HomeMessageFact"("queuedDay","toVersion");
CREATE TABLE "HomeSnapshot" (id TEXT PRIMARY KEY,"userId" TEXT NOT NULL,"businessDate" TEXT NOT NULL,version INTEGER NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"expiresAt" TIMESTAMP(3) NOT NULL,summary JSONB NOT NULL);
CREATE INDEX "HomeSnapshot_expiresAt_idx" ON "HomeSnapshot"("expiresAt");
INSERT INTO "HomeProjectionState"(id) VALUES ('home');
-- A durable, transactional invalidation only: no business state or external effects.
CREATE FUNCTION home_mark_dirty() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mid TEXT;
BEGIN
IF TG_TABLE_NAME = 'SmsMessageRecord' THEN mid := COALESCE(NEW.id,OLD.id);
ELSIF TG_TABLE_NAME = 'UpstreamReceiptInbox' THEN mid := COALESCE(NEW."matchedMessageRecordId",OLD."matchedMessageRecordId");
ELSE mid := COALESCE(NEW."messageRecordId",OLD."messageRecordId"); END IF;
IF mid IS NOT NULL THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(mid) ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER home_message_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsMessageRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_submit_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsSubmitRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_segment_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsMessageSegmentAudit" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_inbox_dirty AFTER INSERT OR UPDATE OR DELETE ON "UpstreamReceiptInbox" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_receipt_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsReceiptRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
@@ -0,0 +1,24 @@
-- Re-association invalidates both owners; source writes and the durable invalidation are atomic.
CREATE OR REPLACE FUNCTION home_mark_dirty() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mid TEXT; previous_mid TEXT;
BEGIN
IF TG_TABLE_NAME = 'SmsMessageRecord' THEN
mid := COALESCE(NEW.id,OLD.id); previous_mid := OLD.id;
ELSIF TG_TABLE_NAME = 'UpstreamReceiptInbox' THEN
mid := COALESCE(NEW."matchedMessageRecordId",OLD."matchedMessageRecordId"); previous_mid := OLD."matchedMessageRecordId";
ELSE
mid := COALESCE(NEW."messageRecordId",OLD."messageRecordId"); previous_mid := OLD."messageRecordId";
END IF;
IF mid IS NOT NULL THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(mid)
ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
IF previous_mid IS NOT NULL AND previous_mid IS DISTINCT FROM mid THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(previous_mid)
ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
RETURN NULL;
END $$;
CREATE INDEX "HomeProjectionDirty_enqueuedAt_idx" ON "HomeProjectionDirty"("enqueuedAt");
CREATE INDEX "HomeMessageFact_toVersion_idx" ON "HomeMessageFact"("toVersion");
CREATE UNIQUE INDEX "HomeMessageFact_current_key" ON "HomeMessageFact"("messageRecordId") WHERE "toVersion" IS NULL;
@@ -0,0 +1,24 @@
BEGIN;
-- Preserve every historical record. A conflicting installation must be reviewed before release.
LOCK TABLE "SmsSignature" IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "SmsSignature"
WHERE "auditStatus" NOT IN ('deleted', 'disabled')
GROUP BY "tenantId", "applicationId", "name" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce signature uniqueness: duplicate active signatures exist; review tenantId/applicationId/name groups without deleting or merging automatically';
END IF;
END $$;
CREATE UNIQUE INDEX "SmsSignature_active_application_name_key"
ON "SmsSignature" ("tenantId", "applicationId", "name")
WHERE "applicationId" IS NOT NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
CREATE UNIQUE INDEX "SmsSignature_active_unbound_name_key"
ON "SmsSignature" ("tenantId", "name")
WHERE "applicationId" IS NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
COMMIT;
@@ -0,0 +1,4 @@
ALTER TABLE "SmsTemplate" ADD COLUMN "optOutRules" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "SmsTemplate" ADD CONSTRAINT "SmsTemplate_optOutRules_array" CHECK (jsonb_typeof("optOutRules") = 'array');
ALTER TABLE "SmsMessageRecord" ADD COLUMN "originalContent" TEXT;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "sentContent" TEXT, ADD COLUMN "contentPolicy" JSONB;
@@ -0,0 +1,19 @@
-- Widen only; historical invalid values abort the entire transaction. Never narrow on rollback.
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '5min';
ALTER TABLE "UpstreamReceiptInbox" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "UpstreamReceiptInbox_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsReceiptRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsReceiptRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsUplinkMessage" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsUplinkMessage_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsSubmitRecord" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsSubmitRecord_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "SmsMessageSegmentAudit" ALTER COLUMN "sequenceId" TYPE BIGINT,
ADD CONSTRAINT "SmsMessageSegmentAudit_sequenceId_uint32_check" CHECK ("sequenceId" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDelivery" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDelivery_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
ALTER TABLE "CmppDownstreamDeliveryAttempt" ALTER COLUMN "ackResult" TYPE BIGINT,
ADD CONSTRAINT "CmppDownstreamDeliveryAttempt_ackResult_uint32_check" CHECK ("ackResult" BETWEEN 0 AND 4294967295);
COMMIT;
@@ -0,0 +1,4 @@
-- Deleted records remain available for audit but do not reserve an application template name.
-- Fail on conflicting legacy rows; never rename or delete business data during migration.
CREATE UNIQUE INDEX "SmsTemplate_application_name_active_key"
ON "SmsTemplate" ("applicationId", btrim(name)) WHERE "auditStatus" <> 'deleted';
@@ -0,0 +1,21 @@
-- Serialize member writes against channel capability changes; validate whole carrier strings.
CREATE FUNCTION cmpp_check_group_channel_carrier() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
capabilities text[];
legacy text;
target_carrier text;
BEGIN
SELECT carriers, carrier INTO capabilities, legacy FROM "SmsChannel" WHERE id=NEW."channelId" FOR SHARE;
SELECT carrier INTO target_carrier FROM "SmsChannelGroup" WHERE id=NEW."groupId";
IF cardinality(capabilities) = 0 THEN
capabilities := CASE WHEN legacy='all' THEN ARRAY['mobile','unicom','telecom'] ELSE ARRAY[legacy] END;
END IF;
IF target_carrier IS NOT NULL AND NOT (target_carrier=ANY(capabilities)) THEN
RAISE EXCEPTION 'Channel carrier is not compatible with the channel group carrier' USING ERRCODE='23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER "SmsChannelGroupItem_carrier_guard"
BEFORE INSERT OR UPDATE OF "groupId", "channelId" ON "SmsChannelGroupItem"
FOR EACH ROW EXECUTE FUNCTION cmpp_check_group_channel_carrier();
+295 -7
View File
@@ -266,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
@@ -660,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
@@ -747,6 +776,8 @@ model ReportNotificationRead {
} }
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?
@@ -822,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
@@ -881,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
@@ -1251,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
@@ -1511,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?
@@ -1563,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
@@ -1754,7 +1823,18 @@ 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[] monitorFacts SendingMonitorFact[]
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
@@ -1772,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)
@@ -1844,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?
@@ -1854,7 +1939,7 @@ 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 resultEventId String? @unique
@@ -2005,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?
@@ -2118,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?
@@ -2189,7 +2274,7 @@ model SmsUplinkMessage {
messageRecordId String? messageRecordId String?
messageId String? messageId String?
gatewayMessageId String? gatewayMessageId String?
sequenceId Int? sequenceId BigInt?
phoneNumber String phoneNumber String
destId String destId String
content String content String
@@ -2258,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?
@@ -2366,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())
@@ -2390,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
@@ -2749,3 +2834,206 @@ model SendingMonitorTargetVersion {
updatedBy String updatedBy String
@@id([channelId,version]) @@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])
}
+12 -1
View File
@@ -1,3 +1,7 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { HomeModule } from './home-dashboard/home.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { 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';
@@ -57,13 +61,20 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
InfrastructureMonitoringModule, InfrastructureMonitoringModule,
OpenApiModule, OpenApiModule,
SignatureRetirementModule, SignatureRetirementModule,
SignatureAnalyticsModule,
HomeModule,
SecurityDetectionModule, SecurityDetectionModule,
MetricsModule, MetricsModule,
ReportNotificationsModule, ReportNotificationsModule,
SendingMonitorModule, 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) {
@@ -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');
+81 -90
View File
@@ -1,24 +1,10 @@
import { import { selectDrainageReportTask } from '../common/drainage-report-task';
BadRequestException, import { BadRequestException, NotFoundException } from '@nestjs/common';
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} 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 { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
CreateChannelDto,
UpdateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
UpdateChannelGroupDto,
CreateRouteRuleDto,
CreateReportFieldDto, CreateReportFieldDto,
ReplaceReportFieldsDto, ReplaceReportFieldsDto,
CreateReportMaterialDto, CreateReportMaterialDto,
@@ -26,73 +12,19 @@ import type {
ChangeReportTaskStatusesDto, ChangeReportTaskStatusesDto,
CreateReportExportDto, CreateReportExportDto,
CreateReceiptImportDto, CreateReceiptImportDto,
UpsertConnectionStateDto,
ChangeChannelStatusDto,
CopyChannelDto,
TestChannelDto,
} from './channels.contracts'; } from './channels.contracts';
import { 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, parseReceiptContent,
splitReceiptLine,
stripReceiptCell,
findReceiptStatusIndex,
normalizeReceiptStatus,
deriveReceiptStatus, deriveReceiptStatus,
ChannelReportDeliveryRow, ChannelReportDeliveryRow,
summarizeChannelReportDelivery, summarizeChannelReportDelivery,
sumReportDelivery,
percentage,
latestDate, latestDate,
currentShanghaiDayRange, currentShanghaiDayRange,
normalizeRetryTimeLimitMinutes,
normalizeSpreadsheetSize, normalizeSpreadsheetSize,
normalizeBusinessCarrier, normalizeBusinessCarrier,
normalizeChannelCarrier,
normalizeChannelCarriers, normalizeChannelCarriers,
isChannelCarrierCompatible,
normalizeRegion,
isRegionCompatible,
validateGroupItems,
normalizeReportType, normalizeReportType,
summarizeReportStatuses, summarizeReportStatuses,
normalizeLinkEvent,
} from './channels.helpers'; } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */ /** R5 channel domain service composed behind ChannelsService. */
@@ -155,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()) {
@@ -225,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' } },
@@ -255,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
@@ -299,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,
@@ -331,7 +279,7 @@ 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) => {
@@ -339,7 +287,11 @@ export class ChannelReportingService {
(row) => (row) =>
row.channelId === task.channelId && row.channelId === task.channelId &&
row.signatureId === task.signatureId && row.signatureId === task.signatureId &&
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId), (!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 {
@@ -411,6 +363,10 @@ export class ChannelReportingService {
status?: string; status?: string;
reportType?: string; reportType?: string;
keyword?: string; keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string; createdAtFrom?: string;
createdAtTo?: string; createdAtTo?: string;
page?: number; page?: number;
@@ -499,17 +455,25 @@ export class ChannelReportingService {
}), }),
); );
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) => const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
channels channels.flatMap((channel) =>
.map((channel) => { normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => {
const existing = signature.reportTasks.find( const tasks = signature.reportTasks.filter(
(task) => (task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id,
task.reportType === 'drainage' &&
task.channelId === channel.id &&
task.drainageItemId === drainageInfo.id,
); );
return existing ? { ...existing, signature } : undefined; const existing = selectDrainageReportTask(tasks, channel.id, carrier);
}) return existing
.filter(Boolean), ? [
{
...existing,
id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`,
carrier,
virtual: !existing.carrier,
signature,
},
]
: [];
}),
),
); );
return [...signatureDetails, ...drainageDetails]; return [...signatureDetails, ...drainageDetails];
}) })
@@ -522,6 +486,16 @@ export class ChannelReportingService {
const changedAt = new Date(task.updatedAt); const changedAt = new Date(task.updatedAt);
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false; if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false; if (query.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; if (!query.keyword?.trim()) return true;
const keyword = query.keyword.trim(); const keyword = query.keyword.trim();
return [ return [
@@ -600,6 +574,9 @@ 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) => {
for (const signatureId of [...new Set(data.items.map((item) => item.signatureId))].sort()) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${signatureId}, 910))`;
}
const signatureIds = [ const signatureIds = [
...new Set( ...new Set(
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId), data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
@@ -610,6 +587,7 @@ export class ChannelReportingService {
reportType: 'drainage'; reportType: 'drainage';
drainageItemId: string; drainageItemId: string;
channelId: string; channelId: string;
carrier: string | null;
status: string; status: string;
}> = []; }> = [];
for (const item of data.items) { for (const item of data.items) {
@@ -626,7 +604,7 @@ export class ChannelReportingService {
if (drainageInfo.auditStatus !== 'approved') if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态'); 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('报备运营商不在通道支持范围内');
} }
@@ -636,11 +614,23 @@ export class ChannelReportingService {
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) if (reportType === 'drainage' && !existing) {
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); const legacy = carrier
? await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: item.drainageItemId,
carrier: null,
},
})
: null;
if (!legacy) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
}
if (reportType === 'signature' && !carrier && !existing) if (reportType === 'signature' && !carrier && !existing)
throw new BadRequestException('签名报备状态必须指定运营商'); throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt = const approvedAt =
@@ -652,7 +642,7 @@ export class ChannelReportingService {
const task = existing const task = existing
? await tx.channelSignatureReportTask.update({ ? await tx.channelSignatureReportTask.update({
where: { id: existing.id }, where: { id: existing.id },
data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) }, data: { status: item.status, reason: data.reason, approvedAt },
}) })
: await tx.channelSignatureReportTask.create({ : await tx.channelSignatureReportTask.create({
data: { data: {
@@ -687,6 +677,7 @@ export class ChannelReportingService {
reportType, reportType,
drainageItemId: item.drainageItemId!, drainageItemId: item.drainageItemId!,
channelId: item.channelId, channelId: item.channelId,
carrier,
status: item.status, status: item.status,
}); });
} }
+16 -12
View File
@@ -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,
+8
View File
@@ -246,6 +246,10 @@ export class ChannelsController {
@Query('status') status?: string, @Query('status') status?: string,
@Query('reportType') reportType?: string, @Query('reportType') reportType?: string,
@Query('keyword') keyword?: 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('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string, @Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string, @Query('page') page?: string,
@@ -260,6 +264,10 @@ export class ChannelsController {
status, status,
reportType, reportType,
keyword, keyword,
enterpriseKeyword,
applicationKeyword,
channelKeyword,
objectKeyword,
createdAtFrom, createdAtFrom,
createdAtTo, createdAtTo,
page: Number(page), page: Number(page),
+135 -42
View File
@@ -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'),
@@ -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,19 +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 = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections'); base.desiredConnections = boundedRuntimeInteger(
desiredConnections ?? base.desiredConnections,
1,
8,
1,
'desiredConnections',
);
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize'); base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds'); base.connectionWarmupSeconds = boundedRuntimeInteger(
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds'); base.connectionWarmupSeconds,
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds'); 0,
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds'); 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,
@@ -423,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);
@@ -445,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,
@@ -504,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';
@@ -524,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;
@@ -557,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);
} }
@@ -580,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');
@@ -588,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)));
} }
@@ -602,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';
@@ -631,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) {
@@ -646,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
+4
View File
@@ -202,6 +202,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
status?: string; status?: string;
reportType?: string; reportType?: string;
keyword?: string; keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string; createdAtFrom?: string;
createdAtTo?: string; createdAtTo?: string;
page?: number; page?: number;
+13
View File
@@ -0,0 +1,13 @@
/** A carrier-specific decision overrides a legacy channel decision, including rejection. */
export function selectDrainageReportTask<
T extends {
channelId: string;
carrier?: string | null;
approvalScope?: string;
},
>(tasks: T[], channelId: string, carrier?: string) {
return (
(carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ??
tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific')
);
}
+16 -2
View File
@@ -1,8 +1,22 @@
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; 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 const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码';
export function normalizeDrainageTarget(value?: string) { export function normalizeDrainageTarget(value?: string) {
const target = value?.trim() ?? ''; const target = value?.trim() ?? '';
return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined; const normalized = target.normalize('NFKC');
if (!target || !DRAINAGE_TARGET_PATTERN.test(normalized)) return undefined;
if (/[a-z]/i.test(normalized) && !/ext\.?/i.test(normalized)) {
try {
const host = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`).hostname;
if (!isIP(host) && !parse(host, { allowPrivateDomains: true }).domain) return undefined;
} catch {
return undefined;
}
}
return target;
} }
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map } from 'rxjs/operators';
import { protocolFieldsToJson } from './protocol-uint32';
@Injectable()
export class ProtocolFieldsInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(map(protocolFieldsToJson));
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
protocolFieldsToJson,
protocolUint32,
protocolUint32FromDb,
protocolUint32ToDb,
parseProtocolSequence,
} from './protocol-uint32';
describe('CMPP unsigned protocol fields', () => {
it.each([0, 2147483647, 2147483648, 4294967295])(
'round trips %s without changing the JSON number contract',
(value) => {
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
expect(
JSON.parse(
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
),
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
},
);
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
expect(() => protocolUint32(value)).toThrow();
expect(() => protocolUint32ToDb(value)).toThrow();
});
it('preserves optional historical nulls and unrelated serializers', () => {
expect(protocolUint32ToDb(null)).toBeUndefined();
expect(protocolUint32FromDb(null)).toBeUndefined();
const date = new Date();
expect(
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
});
it('distinguishes text zero from missing or malformed historical sequences', () => {
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
expect(parseProtocolSequence(value)).toBeUndefined();
expect(parseProtocolSequence('0')).toBe(0);
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
}
return value;
}
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
return value == null ? undefined : BigInt(protocolUint32(value, field));
}
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
if (value == null) return undefined;
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
}
/** Historical Submit sequence columns are text; blanks must never become zero. */
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
if (value == null || !/^\d+$/.test(value)) return undefined;
const number = Number(value);
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
}
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
export function protocolFieldsToJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
? protocolUint32FromDb(item)
: protocolFieldsToJson(item),
]),
);
}
@@ -0,0 +1,23 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
@Controller('admin/dictionaries/channel-sensitive-words')
export class ChannelSensitiveWordsController {
constructor(private readonly service: ChannelSensitiveWordsService) {}
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
return this.service.list(userId, query);
}
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
return this.service.save(userId, body);
}
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
return this.service.save(userId, body, id);
}
@Delete(':id') remove(
@CurrentSessionUserId() userId: string,
@Param('id') id: string,
@Body() body: { version?: unknown },
) {
return this.service.remove(userId, id, body?.version);
}
}
@@ -0,0 +1,40 @@
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
import { PrismaService } from '../prisma/prisma.service';
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
describe('channel word administration', () => {
it('trims only outer whitespace and requires a version for editing', () => {
expect(validateChannelWord(valid).word).toBe('贷 款');
expect(() => validateChannelWord(valid, true)).toThrow('版本');
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
});
it.each([
null,
[],
{ ...valid, word: ' ' },
{ ...valid, word: 'a'.repeat(201) },
{ ...valid, channelId: '' },
{ ...valid, status: 'deleted' },
{ ...valid, remark: 'a'.repeat(501) },
{ ...valid, operatorId: 'spoof' },
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
it('checks active platform admin permission before data access', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue(null) },
channelSensitiveWord: { findMany: jest.fn() },
};
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
}),
);
});
it('rejects invalid pagination before querying rules', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
});
});
@@ -0,0 +1,157 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export function validateChannelWord(value: unknown, editing = false) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
const data = value as Record<string, unknown>;
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
throw new BadRequestException('包含不支持的字段');
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
throw new BadRequestException('请选择通道');
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
throw new BadRequestException('敏感词需为1200个字符');
if (typeof data.status !== 'string' || !['active', 'inactive'].includes(data.status))
throw new BadRequestException('状态无效');
if (data.remark !== undefined && (typeof data.remark !== 'string' || data.remark.length > 500))
throw new BadRequestException('备注最多500个字符');
if (editing && (!Number.isSafeInteger(data.version) || Number(data.version) < 1))
throw new BadRequestException('请提供规则版本');
return {
channelId: data.channelId.trim(),
word: data.word.trim(),
status: data.status as string,
remark: (data.remark as string | undefined) ?? '',
version: editing ? Number(data.version) : undefined,
};
}
@Injectable()
export class ChannelSensitiveWordsService {
constructor(private readonly prisma: PrismaService) {}
async authorize(userId?: string) {
if (
!userId ||
!(await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } },
select: { id: true },
}))
)
throw new ForbiddenException('无敏感词管理权限');
}
async list(userId: string | undefined, query: Record<string, string | undefined>) {
await this.authorize(userId);
const page = Number(query.page ?? 1),
pageSize = Number(query.pageSize ?? 25);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
throw new BadRequestException('分页参数无效');
if (query.status && !['all', 'active', 'inactive'].includes(query.status))
throw new BadRequestException('状态无效');
if (query.keyword && (typeof query.keyword !== 'string' || query.keyword.length > 200))
throw new BadRequestException('搜索词过长');
if (query.channelId && typeof query.channelId !== 'string') throw new BadRequestException('通道参数无效');
const where: Prisma.ChannelSensitiveWordWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
channelId: query.channelId || undefined,
word: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [items, total] = await this.prisma.$transaction([
this.prisma.channelSensitiveWord.findMany({
where,
include: { channel: { select: { id: true, name: true, status: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSensitiveWord.count({ where }),
]);
return { items, total, page, pageSize };
}
async save(userId: string | undefined, value: unknown, id?: string) {
await this.authorize(userId);
const data = validateChannelWord(value, Boolean(id));
try {
return await this.prisma.$transaction(async (tx) => {
if (
!(await tx.smsChannel.findFirst({
where: { id: data.channelId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('通道不存在或已删除');
const current = id
? await tx.channelSensitiveWord.findUnique({ where: { id } })
: await tx.channelSensitiveWord.findUnique({
where: { channelId_word: { channelId: data.channelId, word: data.word } },
});
if (id && (!current || current.status === 'deleted')) throw new NotFoundException('规则不存在或已删除');
if (!id && current && current.status !== 'deleted') throw new ConflictException('该通道已配置相同敏感词');
const fields = {
channelId: data.channelId,
word: data.word,
status: data.status,
remark: data.remark,
updatedBy: userId!,
};
let saved;
if (current) {
const result = await tx.channelSensitiveWord.updateMany({
where: { id: current.id, version: id ? data.version : current.version },
data: { ...fields, version: { increment: 1 } },
});
if (result.count !== 1) throw new ConflictException('规则已被修改,请刷新后重试');
saved = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id: current.id } });
} else saved = await tx.channelSensitiveWord.create({ data: { ...fields, createdBy: userId! } });
await tx.operationLog.create({
data: {
userId,
action:
current?.status === 'deleted'
? 'channel_sensitive_word.restore'
: id
? 'channel_sensitive_word.update'
: 'channel_sensitive_word.create',
resource: 'channel_sensitive_word',
resourceId: saved.id,
detail: JSON.parse(JSON.stringify({ before: current, after: saved })),
},
});
return saved;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new ConflictException('该通道已配置相同敏感词');
throw error;
}
}
async remove(userId: string | undefined, id: string, version: unknown) {
await this.authorize(userId);
if (!Number.isSafeInteger(version) || Number(version) < 1) throw new BadRequestException('请提供规则版本');
return this.prisma.$transaction(async (tx) => {
const before = await tx.channelSensitiveWord.findUnique({ where: { id } });
if (!before || before.status === 'deleted') throw new NotFoundException('规则不存在或已删除');
const result = await tx.channelSensitiveWord.updateMany({
where: { id, version: Number(version) },
data: { status: 'deleted', version: { increment: 1 }, updatedBy: userId! },
});
if (!result.count) throw new ConflictException('规则已被修改,请刷新后重试');
const after = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id } });
await tx.operationLog.create({
data: {
userId,
action: 'channel_sensitive_word.delete',
resource: 'channel_sensitive_word',
resourceId: id,
detail: JSON.parse(JSON.stringify({ before, after })),
},
});
return { deleted: true };
});
}
}
+4 -2
View File
@@ -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 {}
+5 -1
View File
@@ -1,3 +1,6 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { BillingService } from './billing/billing.service'; import { BillingService } from './billing/billing.service';
@@ -18,8 +21,9 @@ import { SendChainService } from './send-chain/send-chain.service';
MetricsModule, MetricsModule,
ProtocolLogsModule, ProtocolLogsModule,
], ],
controllers: [GatewayCallbackController], controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [ providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
BillingService, BillingService,
RiskReviewService, RiskReviewService,
PhoneFrequencyService, PhoneFrequencyService,
+108
View File
@@ -0,0 +1,108 @@
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
id: 'a',
accepted: true,
costUnitPrice: 300n,
gatewayId: 'g1',
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
});
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
attemptId: 'a',
gatewayId,
status,
at: at(day),
approximate: false,
});
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
describe('homepage business receipt projection', () => {
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
const a = attempt(Array(size).fill('delivered'), size);
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
const result = homeFact({ ...message, billingUnits: size }, [a], events);
expect(result.successDay).toBe('2026-09-17');
expect(result.revenue).toBe(String(size * 500));
});
it('includes paid successful fragments of prior failed attempts once', () => {
const earlier = attempt(['delivered', 'failed', 'failed']);
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
const result = homeFact(
message,
[earlier, final],
[
event('g1', 'delivered'),
event('g2', 'failed'),
event('g3', 'failed'),
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
],
);
expect(result.units).toBe(3);
expect(result.revenue).toBe('1500');
expect(result.cost).toBe('1200');
});
it('accounts for all expected units when only one failure arrives', () => {
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
expect(f.units).toBe(3);
expect(f.successDay).toBeNull();
expect(f.receiptDays).toEqual(['2026-09-17']);
});
it('rejects partial success and missing parts even if message says delivered', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered'])],
[event('g1', 'delivered'), event('g2', 'delivered')],
);
expect(f.successDay).toBeNull();
expect(f.incomplete).toBe(true);
});
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered', 'delivered'])],
[
event('g1', 'delivered', 16),
event('g2', 'delivered', 16),
event('g3', 'delivered'),
event('g3', 'delivered', 18),
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
expect(f.revenue).toBe('1500');
expect(f.cost).toBe('900');
});
it('does not combine different attempts into a complete message', () => {
const f = homeFact(
message,
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
);
expect(f.successDay).toBeNull();
});
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
const a = attempt(['delivered', 'delivered', 'delivered']);
a.segments[1].inferred = a.segments[2].inferred = true;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
a.segments[1].inferred = false;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
});
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
const f = homeFact(
message,
[{ ...attempt([]), gatewayId: 'g1' }],
[
{ ...event('g1', 'delivered'), approximate: true },
{ ...event('bad', 'failed'), attemptId: null },
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.approximate).toBe(true);
expect(f.incomplete).toBe(true);
});
it('rejects invalid units and unsafe money without rounding', () => {
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
expect(safeMoney('12345')).toBe(12345);
expect(() => safeMoney(9007199254740992n)).toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { todayKey } from '../signature-analytics/analytics-date';
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
export type HomeAttempt = {
id: string;
accepted: boolean;
gatewayId: string | null;
costUnitPrice: bigint;
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
};
export type HomeFact = {
units: number;
delivered: boolean;
successDay: string | null;
successAt: string | null;
receiptDays: string[];
revenue: string;
cost: string;
approximate: boolean;
incomplete: boolean;
};
/** Pure projection: never writes a message status or invents missing supplier receipts. */
export function homeFact(
message: { billingUnits: number; unitPrice: bigint; status: string },
attempts: HomeAttempt[],
incoming: HomeEvent[],
): HomeFact {
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
const events = new Map<string, HomeEvent>();
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
if (!events.has(key)) events.set(key, event);
}
const successes: Date[] = [];
let cost = 0n;
let incomplete = !units || incoming.some((e) => !e.attemptId);
for (const attempt of attempts) {
if (!attempt.accepted) continue;
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
const successFor = (id: string | null) =>
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
if (!attempt.segments.length) {
const success = successFor(attempt.gatewayId);
if (success && units) {
successes.push(success.at);
cost += BigInt(units) * attempt.costUnitPrice;
} else if (receipts.length) incomplete = true;
continue;
}
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
const expected = Math.max(...attempt.segments.map((s) => s.total));
const times: Date[] = [];
for (const part of parts.values()) {
// A contractual message-level receipt can account for the explicitly inferred parts only.
const received =
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
if (part.status === 'delivered' && received) {
times.push(received.at);
cost += attempt.costUnitPrice;
}
}
const complete =
expected > 0 &&
parts.size === expected &&
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
if (!complete) incomplete = true;
}
const success =
message.status === 'delivered' && successes.length
? new Date(Math.min(...successes.map((s) => s.getTime())))
: null;
if (message.status === 'delivered' && !success) incomplete = true;
const effective = [...events.values()].filter((e) => !success || e.at <= success);
return {
units,
delivered: message.status === 'delivered',
successDay: success ? todayKey(success) : null,
successAt: success?.toISOString() ?? null,
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
cost: success ? cost.toString() : '0',
approximate: effective.some((e) => e.approximate),
incomplete,
};
}
export function safeMoney(value: bigint | string | number) {
const integer = BigInt(value);
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
throw new Error('金额超过安全展示范围');
return Number(integer);
}
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
+97
View File
@@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { sourceFacts } from './home-source';
import { pruneHomeVersions } from './home-retention';
@Injectable()
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
private timer?: NodeJS.Timeout;
private readonly logger = new Logger(HomeProjection.name);
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (
process.env.NODE_ENV === 'test' ||
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
)
return;
this.timer = setInterval(() => void this.run(), 10_000);
this.timer.unref();
void this.run();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async run() {
try {
await this.tick();
} catch (error) {
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
await this.db.homeProjectionState
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
.catch(() => undefined);
}
}
async tick(now = new Date()) {
return this.db.$transaction(
async (tx) => {
const [lock] = await tx.$queryRaw<
Array<{ locked: boolean }>
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
if (!lock.locked) return { busy: true };
const date = todayKey(now),
first = addDays(date, -3);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (state.seededDay !== date) {
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
}
const work = await tx.$queryRaw<
Array<{ messageRecordId: string }>
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
const ids = work.map((w) => w.messageRecordId),
version = state.version + 1;
const sources = await sourceFacts(tx, ids);
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
for (const id of ids) {
const next = sources.find((s) => s.message.id === id);
const old = prior.find((p) => p.messageRecordId === id);
const day = next ? todayKey(next.message.queuedAt) : '';
const payload = next && day >= first && day <= date ? next.fact : null;
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
if (old)
await tx.homeMessageFact.update({
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
data: { toVersion: version },
});
if (payload)
await tx.homeMessageFact.create({
data: {
messageRecordId: id,
fromVersion: version,
queuedDay: day,
payload: payload as unknown as Prisma.InputJsonValue,
},
});
}
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
const remaining = await tx.homeProjectionDirty.count();
await tx.homeProjectionState.update({
where: { id: 'home' },
data: {
version,
seededDay: date,
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
updatedAt: now,
lastError: null,
},
});
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
return { remaining, version };
},
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import { Prisma } from '@prisma/client';
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
import { percentage, safeMoney } from './home-fact';
type MetricRow = {
queuedDay: string;
total: bigint;
delivered: bigint;
units: bigint;
successUnits: bigint;
successMessages: bigint;
revenue: bigint;
cost: bigint;
approximate: bigint;
incomplete: bigint;
};
export function metrics(row?: MetricRow) {
const total = Number(row?.total ?? 0),
delivered = Number(row?.delivered ?? 0);
const units = Number(row?.units ?? 0),
success = Number(row?.successUnits ?? 0);
const revenue = safeMoney(row?.revenue ?? 0n),
cost = safeMoney(row?.cost ?? 0n);
return {
sent: total,
delivered: Number(row?.successMessages ?? 0),
successRate: percentage(delivered, total),
receiptUnits: units,
successUnits: success,
receiptSuccessRate: percentage(success, units),
revenueCents: revenue,
profitCents: revenue - cost,
profitRate: percentage(revenue - cost, revenue),
approximate: Number(row?.approximate ?? 0),
incomplete: Number(row?.incomplete ?? 0),
};
}
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
}
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const rows = await tx.$queryRaw<
Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
todayReturnedCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>
>`
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
return rows.map((r) => ({
...r,
todaySpendCents: safeMoney(r.todaySpendCents),
todayReturnedCents: safeMoney(r.todayReturnedCents),
balanceCents: safeMoney(r.balanceCents),
creditCents: safeMoney(r.creditCents),
}));
}
export async function operationStatus(tx: Prisma.TransactionClient) {
const window = downstreamAlertWindows();
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
await Promise.all([
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
tx.smsBatchTask.count(),
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
tx.cmppDownstreamDelivery.count({
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
}),
]);
return {
taskCount,
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
};
}
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
GROUP BY hour ORDER BY hour`);
const byHour = new Map(rows.map((row) => [row.hour, row]));
return Array.from({ length: 24 }, (_, hour) => ({
hour,
label: String(hour).padStart(2, '0') + ':00',
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
successCount: Number(byHour.get(hour)?.successCount ?? 0),
}));
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
/** Only disposable dashboard projections; never source SMS, receipt or accounting records. */
export async function pruneHomeVersions(tx: Prisma.TransactionClient, now: Date, firstDay: string, version: number) {
const active = await tx.homeSnapshot.aggregate({ where: { expiresAt: { gt: now } }, _min: { version: true } });
const minimum = active._min.version ?? version;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "toVersion"<=${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "queuedDay"<${firstDay}
AND "fromVersion"<${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeSnapshot" WHERE id IN (SELECT id FROM "HomeSnapshot"
WHERE "expiresAt"<${new Date(now.getTime() - 86400000)} LIMIT 1000)`;
}
+83
View File
@@ -0,0 +1,83 @@
import { Prisma } from '@prisma/client';
import { homeFact, type HomeEvent } from './home-fact';
export async function sourceFacts(tx: Prisma.TransactionClient, ids: string[]) {
const [messages, inbox, legacy] = await Promise.all([
tx.smsMessageRecord.findMany({
where: { id: { in: ids } },
include: { submitRecords: true, segmentAudits: true },
}),
tx.upstreamReceiptInbox.findMany({ where: { matchedMessageRecordId: { in: ids }, status: 'matched' } }),
tx.smsReceiptRecord.findMany({ where: { messageRecordId: { in: ids } } }),
]);
const ownedReceiptKeys = new Set(
(
await tx.upstreamReceiptInbox.findMany({
where: { receiptKey: { in: legacy.map((r) => r.receiptKey) } },
select: { receiptKey: true },
})
).map((r) => r.receiptKey),
);
return messages.map((message) => {
const parts = (id: string, submitId: string) =>
message.segmentAudits.filter((p) => p.submitRecordId === id || (!p.submitRecordId && p.submitId === submitId));
const candidates = (gatewayId: string, channelId: string | null) =>
message.submitRecords.filter(
(s) =>
s.channelId === channelId &&
(s.gatewayMessageId === gatewayId || parts(s.id, s.submitId).some((p) => p.gatewayMessageId === gatewayId)),
);
const events: HomeEvent[] = inbox
.filter((i) => i.matchedMessageRecordId === message.id)
.map((i) => {
const direct = message.submitRecords.find((s) => s.id === i.matchedSubmitRecordId);
const possible = candidates(i.gatewayMessageId, i.matchedChannelId ?? i.incomingChannelId);
return {
attemptId: direct?.id ?? (possible.length === 1 ? possible[0].id : null),
gatewayId: i.gatewayMessageId,
status: i.receiptStatus,
at: i.gatewayReceivedAt ?? i.receivedAt,
approximate: !i.gatewayReceivedAt,
};
});
for (const r of legacy.filter((r) => r.messageRecordId === message.id)) {
// An Inbox event (including unresolved/re-associated events) is not a legacy fallback.
if (ownedReceiptKeys.has(r.receiptKey)) continue;
const possible = candidates(r.gatewayMessageId, r.channelId);
const attemptId = possible.length === 1 ? possible[0].id : null;
if (
events.some(
(e) => e.attemptId === attemptId && e.gatewayId === r.gatewayMessageId && e.status === r.receiptStatus,
)
)
continue;
events.push({
attemptId,
gatewayId: r.gatewayMessageId,
status: r.receiptStatus,
at: r.createdAt,
approximate: true,
});
}
return {
message,
fact: homeFact(
message,
message.submitRecords.map((s) => ({
id: s.id,
accepted: s.submitStatus === 'accepted',
gatewayId: s.gatewayMessageId,
costUnitPrice: s.costUnitPrice,
segments: parts(s.id, s.submitId).map((p) => ({
index: p.segmentIndex,
total: p.segmentTotal,
gatewayId: p.gatewayMessageId,
status: p.receiptStatus,
inferred: p.compensationType === 'supplier_message_level_receipt',
})),
})),
events,
),
};
});
}
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Module, Query, Req } from '@nestjs/common';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { PrismaModule } from '../prisma/prisma.module';
import { HomeProjection } from './home-projection';
import { HomeService } from './home.service';
@Controller('admin/operations/home')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Get('summary')
async summary(@Req() req: SessionRequest) {
return this.home.summary(await this.home.authorize(req));
}
@Get('receipt-breakdown')
async receipts(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'receipt');
}
@Get('revenue-breakdown')
async revenue(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'revenue');
}
}
@Module({ imports: [PrismaModule], controllers: [HomeController], providers: [HomeService, HomeProjection] })
export class HomeModule {}
+118
View File
@@ -0,0 +1,118 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
@Injectable()
export class HomeService {
constructor(private readonly db: PrismaService) {}
async authorize(req: SessionRequest) {
const user =
req.authSession?.portal === 'admin' &&
req.sessionUserId &&
(await this.db.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!user) throw new ForbiddenException('无运营首页查看权限');
return user.id;
}
async summary(userId: string, now = new Date()) {
return this.db.$transaction(
async (tx) => {
// Read committed after this lock: no token may reference an already pruned version.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(17100917)`;
const date = todayKey(now);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (!state.initialized || state.seededDay !== date)
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
aggregateHome(tx, date, state.version),
enterpriseRanks(tx, date),
operationStatus(tx),
tx.homeProjectionDirty.count(),
tx.upstreamReceiptInbox.count({
where: {
status: { not: 'matched' },
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
},
}),
hourlySendTrend(tx, date),
]);
const values = metrics(rows[0]);
const summary = {
businessDate: date,
asOf: now.toISOString(),
dataThrough: state.updatedAt.toISOString(),
definitionVersion: 1,
processing:
pending > 0 ||
unresolved > 0 ||
Boolean(state.lastError) ||
now.getTime() - state.updatedAt.getTime() > 60_000,
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
today: values,
enterpriseSpendRanks: ranks,
hourlySendTrend: hourlyTrend,
...status,
};
const snapshot = await tx.homeSnapshot.create({
data: {
id: randomUUID(),
userId,
businessDate: date,
version: state.version,
createdAt: now,
expiresAt: new Date(now.getTime() + 15 * 60_000),
summary,
},
});
return { ...summary, snapshotToken: snapshot.id };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30_000 },
);
}
async breakdown(userId: string, token: string | undefined, kind: 'receipt' | 'revenue', now = new Date()) {
if (!token || !/^[0-9a-f-]{36}$/i.test(token)) throw new BadRequestException('统计快照参数无效');
return this.db.$transaction(
async (tx) => {
const snapshot = await tx.homeSnapshot.findUnique({ where: { id: token } });
if (!snapshot || snapshot.userId !== userId) throw new ForbiddenException('统计快照不可访问');
if (snapshot.expiresAt <= now || snapshot.businessDate !== todayKey(now))
throw new ConflictException('统计快照已过期,请刷新首页后重试');
const rows = await aggregateHome(tx, snapshot.businessDate, snapshot.version, true);
return {
snapshotToken: token,
businessDate: snapshot.businessDate,
items: Array.from({ length: 4 }, (_, offset) => {
const submitDate = addDays(snapshot.businessDate, -offset),
value = metrics(rows.find((r) => r.queuedDay === submitDate));
return kind === 'receipt'
? { submitDate, total: value.receiptUnits, success: value.successUnits, rate: value.receiptSuccessRate }
: {
submitDate,
revenueCents: value.revenueCents,
profitCents: value.profitCents,
rate: value.profitRate,
};
}),
};
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+46 -17
View File
@@ -1,12 +1,7 @@
import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits'; import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits';
const express = require('express') as () => { import express from 'express';
use(...args: unknown[]): void; import * as http from 'node:http';
post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void;
listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null };
};
const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown };
const http = require('node:http') as typeof import('node:http');
describe('configureHttpBodyParsers', () => { describe('configureHttpBodyParsers', () => {
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => { it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
@@ -17,7 +12,7 @@ describe('configureHttpBodyParsers', () => {
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb'); expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb'); expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
expect(use).toHaveBeenCalledTimes(1); expect(use).toHaveBeenCalledTimes(2);
expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function)); expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function));
expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' }); expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' });
expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true }); expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true });
@@ -28,12 +23,16 @@ describe('configureHttpBodyParsers', () => {
configureHttpBodyParsers({ configureHttpBodyParsers({
use: serverApp.use.bind(serverApp), use: serverApp.use.bind(serverApp),
useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) { useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) {
serverApp.use(type === 'json' serverApp.use(
? expressModule.json({ limit: options.limit }) type === 'json'
: expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true })); ? express.json({ limit: options.limit })
: express.urlencoded({ limit: options.limit, extended: options.extended ?? true }),
);
}, },
} as never); } as never);
serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 })); serverApp.post('/api/client/send/imports/preview', (request, response) =>
response.json({ size: (request as typeof request & { rawBody?: Buffer }).rawBody?.length ?? 0 }),
);
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true })); serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => { const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
@@ -47,19 +46,49 @@ describe('configureHttpBodyParsers', () => {
expect(importResponse.status).toBe(200); expect(importResponse.status).toBe(200);
expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) }); expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) });
await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 }); await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 });
const oversized = await postJSON(address.port, '/api/openapi/v1/sms/messages', body);
expect(oversized.status).toBe(413);
expect(JSON.parse(oversized.body)).toMatchObject({ code: 'PAYLOAD_TOO_LARGE', status: 413 });
for (const malformed of ['{"private-marker":', '"private-marker"']) {
const invalid = await postJSON(address.port, '/api/openapi/v1/sms/messages', malformed);
expect(invalid.status).toBe(400);
expect(JSON.parse(invalid.body)).toMatchObject({ code: 'PARAMETER_INVALID', requestId: invalid.requestId });
expect(invalid.requestId).toMatch(/^req_/);
expect(invalid.contentType).toContain('application/problem+json');
expect(invalid.body).not.toContain('private-marker');
}
const ordinary = await postJSON(address.port, '/api/ordinary', '{');
expect(ordinary.status).toBe(400);
expect(ordinary.requestId).toBeUndefined();
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
} }
}); });
}); });
function postJSON(port: number, path: string, body: string) { function postJSON(port: number, path: string, body: string) {
return new Promise<{ status: number; body: string }>((resolve, reject) => { return new Promise<{ status: number; body: string; requestId?: string; contentType?: string }>((resolve, reject) => {
const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => { const request = http.request(
{
hostname: '127.0.0.1',
port,
path,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
},
(response) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk)); response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); response.once('end', () =>
}); resolve({
status: response.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8'),
requestId: response.headers['x-request-id'] as string | undefined,
contentType: response.headers['content-type'],
}),
);
},
);
request.once('error', reject); request.once('error', reject);
request.end(body); request.end(body);
}); });
+9 -10
View File
@@ -1,11 +1,6 @@
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { openApiBodyErrorMiddleware } from './open-api/open-api-body-error.middleware';
const express = require('express') as { import express from 'express';
json(options: {
limit: string;
verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void;
}): (...args: unknown[]) => unknown;
};
export const DEFAULT_JSON_BODY_LIMIT = '2mb'; export const DEFAULT_JSON_BODY_LIMIT = '2mb';
export const IMPORT_JSON_BODY_LIMIT = '25mb'; export const IMPORT_JSON_BODY_LIMIT = '25mb';
@@ -14,12 +9,16 @@ export function configureHttpBodyParsers(app: NestExpressApplication) {
// Import preview/confirmation temporarily carries the source CSV/TSV in // Import preview/confirmation temporarily carries the source CSV/TSV in
// JSON. Give only these endpoints the larger boundary; keeping ordinary // JSON. Give only these endpoints the larger boundary; keeping ordinary
// JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint. // JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint.
app.use('/api/client/send/imports', express.json({ app.use(
'/api/client/send/imports',
express.json({
limit: IMPORT_JSON_BODY_LIMIT, limit: IMPORT_JSON_BODY_LIMIT,
verify(request, _response, buffer) { verify(request, _response, buffer) {
request.rawBody = buffer; (request as typeof request & { rawBody?: Buffer }).rawBody = buffer;
}, },
})); }),
);
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT }); app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true }); app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
app.use('/api/openapi/v1/sms', openApiBodyErrorMiddleware);
} }
@@ -0,0 +1,45 @@
import { alertHistoryRange, mergeAlertHistory } from './alert-history';
describe('historical alert observation cycles', () => {
it('uses seven Shanghai calendar days and rejects invalid or excessive dates', () => {
expect(alertHistoryRange(undefined, undefined, new Date('2026-09-09T16:30:00Z'))).toMatchObject({
startDate: '2026-09-04',
endDate: '2026-09-10',
});
for (const [from, to] of [
['2026-02-30', '2026-03-01'],
['2026-09-09', '2026-09-08'],
['2026-07-01', '2026-09-09'],
]) {
expect(() => alertHistoryRange(from, to)).toThrow();
}
});
it('retains distinct cycles, merges daily boundaries and excludes stale/nonpositive samples', () => {
const result = new Map();
const metric = { __name__: 'ALERTS_FOR_STATE', alertname: 'CPUHigh', instance: 'host', severity: 'warning' };
mergeAlertHistory(
result,
[
{
metric,
values: [
[110, '100'],
[120, '100'],
[130, '0'],
[140, 'NaN'],
[150, '145'],
[200, '145'],
],
},
],
110,
200,
);
mergeAlertHistory(result, [{ metric, values: [[160, '145']] }], 110, 200);
expect(result.size).toBe(2);
expect([...result.values()].map((item) => item.lastObservedAt)).toEqual([
new Date(120000).toISOString(),
new Date(160000).toISOString(),
]);
});
});
@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto';
export function alertHistoryRange(from?: string, to?: string, now = new Date()) {
const dateKey = (date: Date) => new Date(date.getTime() + 8 * 3600_000).toISOString().slice(0, 10);
const endDate = to || dateKey(now);
const startDate = from || dateKey(new Date(now.getTime() - 6 * 86400_000));
const parse = (value: string) => {
const result = new Date(`${value}T00:00:00+08:00`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(result.getTime()) || dateKey(result) !== value) {
throw new BadRequestException('告警日期无效');
}
return result.getTime() / 1000;
};
const start = parse(startDate);
const end = parse(endDate) + 86400;
if (end <= start || end - start > 31 * 86400) throw new BadRequestException('告警日期范围须为1至31天');
return { startDate, endDate, start, end: Math.min(end, now.getTime() / 1000) };
}
export type AlertHistoryItem = {
id: string;
name: string;
severity: string;
service: string;
instance: string;
startedAt: string;
firstObservedAt: string;
lastObservedAt: string;
};
// ALERTS_FOR_STATE stores activeAt as the sample value, separating repeated trigger cycles.
// Observation boundaries are not claimed as exact recovery times.
export function mergeAlertHistory(
target: Map<string, AlertHistoryItem>,
series: Array<{ metric: Record<string, string>; values?: [number, string][] }>,
start: number,
end: number,
) {
for (const { metric, values } of series) {
const labels = Object.entries(metric)
.filter(([key]) => key !== '__name__')
.sort(([a], [b]) => a.localeCompare(b));
const fingerprint = createHash('sha256').update(JSON.stringify(labels)).digest('hex');
for (const [time, rawActiveAt] of values ?? []) {
const activeAt = Number(rawActiveAt);
if (time < start || time >= end || !Number.isFinite(activeAt) || activeAt <= 0 || activeAt > time) continue;
const id = `${fingerprint}:${activeAt}`;
const observed = new Date(time * 1000).toISOString();
const item = target.get(id);
if (item) {
if (observed < item.firstObservedAt) item.firstObservedAt = observed;
if (observed > item.lastObservedAt) item.lastObservedAt = observed;
} else {
target.set(id, {
id,
name: metric.alertname || '未命名告警',
severity: metric.severity || 'info',
service: metric.service || '',
instance: metric.instance || '',
startedAt: new Date(activeAt * 1000).toISOString(),
firstObservedAt: observed,
lastObservedAt: observed,
});
}
}
}
}
@@ -1,8 +1,15 @@
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -12,16 +19,148 @@ import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export const ALERT_THRESHOLD_DEFINITIONS = [ export const ALERT_THRESHOLD_DEFINITIONS = [
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] }, {
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] }, key: 'hostCpu',
{ key: 'hostDisk', label: '磁盘(独立文件系统)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: FILESYSTEM_USAGE_PERCENT, names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] }, label: '主机 CPU 使用率',
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] }, unit: '%',
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] }, min: 1,
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] }, max: 100,
{ key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] }, step: 1,
{ key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] }, warning: 80,
{ key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] }, critical: 90,
{ key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] }, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'],
service: 'host',
durations: ['10m', '5m'],
},
{
key: 'hostMemory',
label: '主机内存使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 85,
critical: 95,
expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100',
names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'],
service: 'host',
durations: ['10m', '5m'],
},
{
key: 'hostDisk',
label: '磁盘(独立文件系统)使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: FILESYSTEM_USAGE_PERCENT,
names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'],
service: 'host',
durations: ['15m', '5m'],
},
{
key: 'apiError',
label: 'API 5xx 错误率',
unit: '%',
min: 0.1,
max: 100,
step: 0.1,
warning: 1,
critical: 5,
expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)',
guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5',
names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'],
service: 'api',
durations: ['5m', '5m'],
},
{
key: 'apiLatency',
label: 'API P95 响应时间',
unit: '秒',
min: 0.1,
max: 60,
step: 0.1,
warning: 1,
critical: 3,
expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))',
names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'],
service: 'api',
durations: ['10m', '5m'],
},
{
key: 'apiEventLoop',
label: 'API 事件循环 P99',
unit: '秒',
min: 0.01,
max: 10,
step: 0.01,
warning: 0.2,
critical: 1,
expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds',
names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'],
service: 'api',
durations: ['10m', '5m'],
},
{
key: 'gatewayQueue',
label: 'Gateway 最旧 pending',
unit: '秒',
min: 1,
max: 3600,
step: 1,
warning: 30,
critical: 120,
expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds',
names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'],
service: 'gateway',
durations: ['2m', '2m'],
},
{
key: 'postgresConnections',
label: 'PostgreSQL 连接使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 70,
critical: 85,
expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)',
names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'],
service: 'postgresql',
durations: ['10m', '5m'],
},
{
key: 'redisMemory',
label: 'Redis 内存使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 70,
critical: 85,
expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes',
guard: 'redis_memory_max_bytes > 0',
names: ['RedisMemoryWarning', 'RedisMemoryCritical'],
service: 'redis',
durations: ['10m', '5m'],
},
{
key: 'minioCapacity',
label: 'MinIO 容量使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)',
names: ['MinioCapacityWarning', 'MinioCapacityCritical'],
service: 'minio',
durations: ['15m', '5m'],
},
] as const; ] as const;
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries( export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
@@ -32,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr
export class InfrastructureAlertSettingsService { export class InfrastructureAlertSettingsService {
private readonly logger = new Logger(InfrastructureAlertSettingsService.name); private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
private readonly rulesPath: string; private readonly rulesPath: string;
private readonly promtoolPath: string; private readonly promtoolPath: string | undefined;
private readonly reloadUrl: string; private readonly reloadUrl: string;
constructor(private readonly prisma: PrismaService, config: ConfigService) { constructor(
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml'); private readonly prisma: PrismaService,
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool'); config: ConfigService,
) {
this.rulesPath = String(
config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml',
);
this.promtoolPath = config.get<string>('PROMTOOL_PATH');
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload'); this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
} }
@@ -53,7 +197,14 @@ export class InfrastructureAlertSettingsService {
appliedAt: row?.appliedAt?.toISOString() ?? null, appliedAt: row?.appliedAt?.toISOString() ?? null,
thresholds, thresholds,
effectiveThresholds: effective, effectiveThresholds: effective,
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })), definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({
key,
label,
unit,
min,
max,
step,
})),
}; };
} }
@@ -63,7 +214,13 @@ export class InfrastructureAlertSettingsService {
const thresholds = this.validate(body.thresholds); const thresholds = this.validate(body.thresholds);
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({ const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
where: { id: 'global', configVersion: expectedVersion }, where: { id: 'global', configVersion: expectedVersion },
data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId }, data: {
configVersion: { increment: 1 },
thresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'applying',
lastError: null,
updatedById: operatorId,
},
}); });
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。 // 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试'); if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
@@ -71,12 +228,32 @@ export class InfrastructureAlertSettingsService {
try { try {
await this.applyRules(thresholds); await this.applyRules(thresholds);
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }), this.prisma.infrastructureAlertSetting.update({
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }), where: { id: 'global' },
data: {
effectiveVersion: nextVersion,
effectiveThresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'effective',
lastError: null,
appliedAt: new Date(),
},
}),
this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'monitoring.alert_thresholds_updated',
resource: 'infrastructure_alert_setting',
resourceId: 'global',
detail: { configVersion: nextVersion, thresholds },
},
}),
]); ]);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error'; const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } }); await this.prisma.infrastructureAlertSetting.update({
where: { id: 'global' },
data: { applyStatus: 'failed', lastError: message },
});
this.logger.error(`Prometheus managed rules apply failed: ${message}`); this.logger.error(`Prometheus managed rules apply failed: ${message}`);
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留'); throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
} }
@@ -86,13 +263,20 @@ export class InfrastructureAlertSettingsService {
private validate(value: unknown): InfrastructureAlertThresholds { private validate(value: unknown): InfrastructureAlertThresholds {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效'); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
const input = value as Record<string, unknown>; const input = value as Record<string, unknown>;
if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) throw new BadRequestException('存在不允许配置的告警指标'); if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key)))
throw new BadRequestException('存在不允许配置的告警指标');
const result: InfrastructureAlertThresholds = {}; const result: InfrastructureAlertThresholds = {};
for (const definition of ALERT_THRESHOLD_DEFINITIONS) { for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined; const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
const warning = Number(pair?.warning); const warning = Number(pair?.warning);
const critical = Number(pair?.critical); const critical = Number(pair?.critical);
if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) { if (
!Number.isFinite(warning) ||
!Number.isFinite(critical) ||
warning < definition.min ||
critical > definition.max ||
warning >= critical
) {
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`); throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
} }
result[definition.key] = { warning, critical }; result[definition.key] = { warning, critical };
@@ -101,7 +285,11 @@ export class InfrastructureAlertSettingsService {
} }
private asThresholds(value: unknown) { private asThresholds(value: unknown) {
try { return this.validate(value); } catch { return null; } try {
return this.validate(value);
} catch {
return null;
}
} }
private renderRules(thresholds: InfrastructureAlertThresholds) { private renderRules(thresholds: InfrastructureAlertThresholds) {
@@ -113,9 +301,26 @@ export class InfrastructureAlertSettingsService {
const isWarning = index === 0; const isWarning = index === 0;
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。 // 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
const guard = 'guard' in definition ? ` and (${definition.guard})` : ''; const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`; const expr = isWarning
const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : ''; ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}`
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`); : `(${definition.expr} > ${values[1]})${guard}`;
const diskLocation =
definition.key === 'hostDisk'
? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。'
: '';
lines.push(
` - alert: ${definition.names[index]}`,
` expr: ${expr}`,
` for: ${definition.durations[index]}`,
' labels:',
` severity: ${isWarning ? 'warning' : 'critical'}`,
` service: ${definition.service}`,
' annotations:',
` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`,
` description: "${definition.label}持续超过${values[index]}${definition.unit}${diskLocation}"`,
' currentValue: "{{ $value }}"',
` threshold: "${values[index]}${definition.unit}"`,
);
} }
} }
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
@@ -128,7 +333,9 @@ export class InfrastructureAlertSettingsService {
const previous = await readFile(this.rulesPath).catch(() => null); const previous = await readFile(this.rulesPath).catch(() => null);
try { try {
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 }); await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 }); await execFileAsync(await resolvePromtoolPath(this.promtoolPath), ['check', 'rules', temporary], {
timeout: 10_000,
});
await rename(temporary, this.rulesPath); await rename(temporary, this.rulesPath);
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }); const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`); if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
@@ -144,3 +351,17 @@ export class InfrastructureAlertSettingsService {
} }
} }
} }
// Explicit configuration is authoritative; never silently replace a broken configured binary.
export async function resolvePromtoolPath(configured?: string) {
const candidates = configured ? [configured] : ['/usr/local/bin/promtool', '/usr/bin/promtool'];
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
/* Try the next standard install location. */
}
}
throw new Error('promtool不可执行,请检查PROMTOOL_PATH或标准安装目录');
}
@@ -8,7 +8,10 @@ import { InfrastructureMonitoringService } from './infrastructure-monitoring.ser
@ApiTags('infrastructure-monitoring') @ApiTags('infrastructure-monitoring')
@Controller('admin/infrastructure-monitoring') @Controller('admin/infrastructure-monitoring')
export class InfrastructureMonitoringController { export class InfrastructureMonitoringController {
constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {} constructor(
private readonly monitoring: InfrastructureMonitoringService,
private readonly settings: InfrastructureAlertSettingsService,
) {}
@Get('overview') @Get('overview')
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) { overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
@@ -16,19 +19,44 @@ export class InfrastructureMonitoringController {
} }
@Get('notification-summary') @Get('notification-summary')
notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); } notificationSummary(@CurrentSessionUserId() userId?: string) {
return this.monitoring.notificationSummary(userId);
}
@Get('alert-history')
alertHistory(@Query('from') from?: string, @Query('to') to?: string, @Query('page') page?: string) {
return this.monitoring.alertHistory(from, to, page);
}
@Post('alerts/:fingerprint/read') @Post('alerts/:fingerprint/read')
markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) { markAlertRead(
@Param('fingerprint') fingerprint: string,
@Body('activeAt') activeAt: unknown,
@CurrentSessionUserId() userId: string,
) {
return this.monitoring.markAlertRead(fingerprint, activeAt, userId); return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
} }
@Post('alerts/:fingerprint/clear')
clearAlert(
@Param('fingerprint') fingerprint: string,
@Body('activeAt') activeAt: unknown,
@CurrentSessionUserId() userId: string,
) {
return this.monitoring.clearAlert(fingerprint, activeAt, userId);
}
@Get('alert-thresholds') @Get('alert-thresholds')
alertThresholds() { return this.settings.get(); } alertThresholds() {
return this.settings.get();
}
@Put('alert-thresholds') @Put('alert-thresholds')
@RequireRecentAuthentication() @RequireRecentAuthentication()
updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) { updateAlertThresholds(
@Body() body: { configVersion?: number; thresholds?: unknown },
@CurrentSessionUserId() operatorId?: string,
) {
return this.settings.update(body, operatorId); return this.settings.update(body, operatorId);
} }
} }
@@ -1,8 +1,15 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics'; import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { retainedAlerts } from './persistent-alerts';
jest.mock('./persistent-alerts', () => ({
retainAlerts: jest.fn(async (_prisma, alerts) => alerts),
retainedAlerts: jest.fn(),
clearRetainedAlert: jest.fn(),
}));
function success(data: unknown) { function success(data: unknown) {
return { return {
@@ -14,7 +21,12 @@ function success(data: unknown) {
describe('InfrastructureMonitoringService', () => { describe('InfrastructureMonitoringService', () => {
const prisma = { const prisma = {
infrastructureAlertRead: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), findUniqueOrThrow: jest.fn() }, infrastructureAlertRead: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
findUniqueOrThrow: jest.fn(),
},
operationLog: { create: jest.fn() }, operationLog: { create: jest.fn() },
$transaction: jest.fn(), $transaction: jest.fn(),
}; };
@@ -34,9 +46,27 @@ describe('InfrastructureMonitoringService', () => {
}); });
it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => { it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => {
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }), prisma as never)).toThrow('must not contain credentials'); expect(
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }), prisma as never)).toThrow('must use HTTPS'); () =>
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }), prisma as never)).not.toThrow(); new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }),
prisma as never,
),
).toThrow('must not contain credentials');
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }),
prisma as never,
),
).toThrow('must use HTTPS');
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }),
prisma as never,
),
).not.toThrow();
}); });
it('loads real Prometheus vectors, ranges, services and active alerts', async () => { it('loads real Prometheus vectors, ranges, services and active alerts', async () => {
@@ -45,34 +75,52 @@ describe('InfrastructureMonitoringService', () => {
const url = new URL(String(input)); const url = new URL(String(input));
requestedUrls.push(url); requestedUrls.push(url);
if (url.pathname.endsWith('/alerts')) { if (url.pathname.endsWith('/alerts')) {
return success({ alerts: [{ return success({
alerts: [
{
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' }, labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
annotations: { summary: 'CPU持续偏高', threshold: '85%' }, annotations: { summary: 'CPU持续偏高', threshold: '85%' },
state: 'firing', state: 'firing',
activeAt: '2026-08-14T03:00:00.000Z', activeAt: '2026-08-14T03:00:00.000Z',
value: '88.2', value: '88.2',
}] }); },
],
});
} }
const query = url.searchParams.get('query') ?? ''; const query = url.searchParams.get('query') ?? '';
if (url.pathname.endsWith('/query_range')) { if (url.pathname.endsWith('/query_range')) {
return success({ result: [{ metric: {}, values: [[1_765_000_000, '12.5'], [1_765_000_060, '14.5']] }] }); return success({
result: [
{
metric: {},
values: [
[1_765_000_000, '12.5'],
[1_765_000_060, '14.5'],
],
},
],
});
} }
if (query.includes('node_systemd_unit_state')) { if (query.includes('node_systemd_unit_state')) {
return success({ result: [ return success({
result: [
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
] }); ],
});
} }
if (query.includes('cmpp:service_.*')) { if (query.includes('cmpp:service_.*')) {
return success({ result: [ return success({
result: [
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] }, { metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] }, { metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] }, { metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
] }); ],
});
} }
if (query.includes('timestamp(node_uname_info)')) { if (query.includes('timestamp(node_uname_info)')) {
return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] }); return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] });
@@ -87,14 +135,27 @@ describe('InfrastructureMonitoringService', () => {
expect(result.metrics.cpuUsagePercent).toBe(25); expect(result.metrics.cpuUsagePercent).toBe(25);
expect(result.trends.cpuUsagePercent).toHaveLength(2); expect(result.trends.cpuUsagePercent).toHaveLength(2);
expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 }); expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 });
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({ unit: 'redis-server.service', status: 'healthy' }); expect(result.services.find((item) => item.key === 'redis')).toMatchObject({
unit: 'redis-server.service',
status: 'healthy',
});
expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true }); expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true });
expect(result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')?.value).toBe(3); expect(
result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')
?.value,
).toBe(3);
expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' }); expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' });
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5); expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5);
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range')).every((url) => url.searchParams.get('step') === '60')).toBe(true); expect(
expect(requestedUrls.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))?.searchParams.get('query')) requestedUrls
.toContain('cmpp-api\\\\.service'); .filter((url) => url.pathname.endsWith('/query_range'))
.every((url) => url.searchParams.get('step') === '60'),
).toBe(true);
expect(
requestedUrls
.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))
?.searchParams.get('query'),
).toContain('cmpp-api\\\\.service');
}); });
it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => { it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => {
@@ -124,12 +185,36 @@ describe('InfrastructureMonitoringService', () => {
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] }); if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
if (!query.includes('node_filesystem_')) return success({ result: [] }); if (!query.includes('node_filesystem_')) return success({ result: [] });
expect(query).not.toContain('mountpoint="/"'); expect(query).not.toContain('mountpoint="/"');
if (url.pathname.endsWith('/query_range')) return success({ result: [...metrics].reverse().map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })) }); if (url.pathname.endsWith('/query_range'))
return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, query === FILESYSTEM_USAGE_PERCENT ? (metric.mountpoint === '/' ? '91' : '12') : query.includes('avail') ? '9' : '100'] })) }); return success({
result: [...metrics]
.reverse()
.map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })),
});
return success({
result: metrics.map((metric) => ({
metric,
value: [
1765000060,
query === FILESYSTEM_USAGE_PERCENT
? metric.mountpoint === '/'
? '91'
: '12'
: query.includes('avail')
? '9'
: '100',
],
})),
});
}); });
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h'); const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']); expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']);
expect(result.disks[0]).toMatchObject({ usagePercent: 91, totalBytes: 100, availableBytes: 9, trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }] }); expect(result.disks[0]).toMatchObject({
usagePercent: 91,
totalBytes: 100,
availableBytes: 9,
trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }],
});
expect(result.disks[2].trend[0].value).toBe(12); expect(result.disks[2].trend[0].value).toBe(12);
expect(result.metrics.diskUsagePercent).toBe(91); expect(result.metrics.diskUsagePercent).toBe(91);
expect(result.trends.diskUsagePercent[0].value).toBe(91); expect(result.trends.diskUsagePercent[0].value).toBe(91);
@@ -146,20 +231,44 @@ describe('InfrastructureMonitoringService', () => {
if (!query.includes('node_filesystem_')) return success({ result: [] }); if (!query.includes('node_filesystem_')) return success({ result: [] });
if (url.pathname.endsWith('/query_range')) { if (url.pathname.endsWith('/query_range')) {
expect(query).toBe(FILESYSTEM_USAGE_PERCENT); expect(query).toBe(FILESYSTEM_USAGE_PERCENT);
return success({ result: [ return success({
{ metric: data, values: [[1765000000, '82'], [1765000060, 'NaN'], [1765000120, '83.5']] }, result: [
{
metric: data,
values: [
[1765000000, '82'],
[1765000060, 'NaN'],
[1765000120, '83.5'],
],
},
{ metric: root, values: [[1765000000, '91']] }, { metric: root, values: [[1765000000, '91']] },
] }); ],
});
} }
if (query.startsWith('node_filesystem_size_bytes')) return success({ result: [ if (query.startsWith('node_filesystem_size_bytes'))
return success({
result: [
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })), ...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
...['/var/root-bind', '/'].map((mountpoint) => ({ metric: { ...root, mountpoint }, value: [1765000120, '200'] })), ...['/var/root-bind', '/'].map((mountpoint) => ({
] }); metric: { ...root, mountpoint },
if (query === FILESYSTEM_USAGE_PERCENT) return success({ result: [ value: [1765000120, '200'],
{ metric: data, value: [1765000120, '83.5'] }, { metric: root, value: [1765000120, '91'] }, })),
] }); ],
});
if (query === FILESYSTEM_USAGE_PERCENT)
return success({
result: [
{ metric: data, value: [1765000120, '83.5'] },
{ metric: root, value: [1765000120, '91'] },
],
});
expect(query).toContain('min by (instance, device, fstype)'); expect(query).toContain('min by (instance, device, fstype)');
return success({ result: [{ metric: data, value: [1765000120, '16.5'] }, { metric: root, value: [1765000120, '18'] }] }); return success({
result: [
{ metric: data, value: [1765000120, '16.5'] },
{ metric: root, value: [1765000120, '18'] },
],
});
}); });
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never); const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
const result = await service.overview('1h'); const result = await service.overview('1h');
@@ -169,7 +278,13 @@ describe('InfrastructureMonitoringService', () => {
expect(result.metrics.diskUsagePercent).toBe(91); expect(result.metrics.diskUsagePercent).toBe(91);
expect(result.trends.diskUsagePercent[0].value).toBe(91); expect(result.trends.diskUsagePercent[0].value).toBe(91);
const disk = result.disks[1]; const disk = result.disks[1];
expect(disk).toMatchObject({ id: filesystemIdentity(data), mountpoint: '/data', totalBytes: 100, availableBytes: 16.5, usagePercent: 83.5 }); expect(disk).toMatchObject({
id: filesystemIdentity(data),
mountpoint: '/data',
totalBytes: 100,
availableBytes: 16.5,
usagePercent: 83.5,
});
expect(disk.mountpoints).toEqual(['/data', '/var/lib/minio', '/var/lib/pgsql', '/var/lib/redis']); expect(disk.mountpoints).toEqual(['/data', '/var/lib/minio', '/var/lib/pgsql', '/var/lib/redis']);
expect(disk.trend.map((point) => point.value)).toEqual([82, 83.5]); expect(disk.trend.map((point) => point.value)).toEqual([82, 83.5]);
mounts.reverse(); mounts.reverse();
@@ -198,33 +313,77 @@ describe('InfrastructureMonitoringService', () => {
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h'); const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
expect(result.disks).toHaveLength(4); expect(result.disks).toHaveLength(4);
expect(new Set(result.disks.map((disk) => disk.id)).size).toBe(4); expect(new Set(result.disks.map((disk) => disk.id)).size).toBe(4);
expect(result.disks.every((disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0)).toBe(true); expect(
result.disks.every(
(disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0,
),
).toBe(true);
expect(result.metrics.diskUsagePercent).toBeNull(); expect(result.metrics.diskUsagePercent).toBeNull();
expect(result.trends.diskUsagePercent).toEqual([]); expect(result.trends.diskUsagePercent).toEqual([]);
}); });
it('excludes only the current alert occurrence after the current administrator marks it read', async () => { it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' }; const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' };
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24); const fingerprint = createHash('sha256')
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' }] })); .update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))))
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') }]); .digest('hex')
.slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(
success({
alerts: [
{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' },
],
}),
);
prisma.infrastructureAlertRead.findMany.mockResolvedValue([
{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') },
]);
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never); const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 }); await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 });
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') }]); prisma.infrastructureAlertRead.findMany.mockResolvedValue([
{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') },
]);
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 }); await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 });
}); });
it('upserts an idempotent per-user read record only for a currently active occurrence', async () => { it('upserts an idempotent per-user read record only for a currently active occurrence', async () => {
const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' }; const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' };
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24); const fingerprint = createHash('sha256')
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' }] })); .update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))))
prisma.$transaction.mockResolvedValue([{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') }, {}]); .digest('hex')
.slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(
success({
alerts: [
{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' },
],
}),
);
prisma.$transaction.mockResolvedValue([
{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') },
{},
]);
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never); const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({ fingerprint, acknowledged: true }); jest.mocked(retainedAlerts).mockResolvedValue([
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) })); {
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow('已结束或已重新触发'); fingerprint,
startedAt: '2026-08-16T02:00:00.000Z',
name: 'QaCritical',
severity: 'critical',
} as never,
]);
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({
fingerprint,
acknowledged: true,
});
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) }),
);
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow(
'已结束或已重新触发',
);
}); });
}); });
@@ -1,9 +1,23 @@
import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { retainAlerts, retainedAlerts, clearRetainedAlert } from './persistent-alerts';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { compareMountpoints, FILESYSTEM_LABELS, FILESYSTEM_SELECTOR, FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics'; import { alertHistoryRange, mergeAlertHistory, type AlertHistoryItem } from './alert-history';
import {
compareMountpoints,
FILESYSTEM_LABELS,
FILESYSTEM_SELECTOR,
FILESYSTEM_USAGE_PERCENT,
filesystemIdentity,
} from './filesystem-metrics';
import type { import type {
InfrastructureAlert, InfrastructureAlert,
InfrastructureMetricPoint, InfrastructureMetricPoint,
@@ -57,7 +71,8 @@ const QUERIES = {
uptimeSeconds: 'time() - node_boot_time_seconds', uptimeSeconds: 'time() - node_boot_time_seconds',
lastSampleAt: 'max(timestamp(node_uname_info))', lastSampleAt: 'max(timestamp(node_uname_info))',
// PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。 // PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。
services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})', services:
'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
} as const; } as const;
const SERVICE_DEFINITIONS = [ const SERVICE_DEFINITIONS = [
@@ -70,38 +85,62 @@ const SERVICE_DEFINITIONS = [
] as const; ] as const;
const SERVICE_METRIC_DEFINITIONS = [ const SERVICE_METRIC_DEFINITIONS = [
{ key: 'api', name: 'API服务', metrics: [ {
key: 'api',
name: 'API服务',
metrics: [
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'], ['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'], ['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'], ['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'], ['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
] }, ],
{ key: 'gateway', name: 'Gateway服务', metrics: [ },
{
key: 'gateway',
name: 'Gateway服务',
metrics: [
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'], ['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'], ['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'], ['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'], ['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
] }, ],
{ key: 'postgresql', name: 'PostgreSQL', metrics: [ },
{
key: 'postgresql',
name: 'PostgreSQL',
metrics: [
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'], ['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'], ['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
] }, ],
{ key: 'redis', name: 'Redis', metrics: [ },
{
key: 'redis',
name: 'Redis',
metrics: [
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'], ['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'], ['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'], ['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'], ['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
] }, ],
{ key: 'minio', name: 'MinIO', metrics: [ },
{
key: 'minio',
name: 'MinIO',
metrics: [
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'], ['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'], ['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
['objects', '对象数', 'cmpp:service_minio:objects', 'count'], ['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'], ['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
] }, ],
{ key: 'nginx', name: 'Nginx', metrics: [ },
{
key: 'nginx',
name: 'Nginx',
metrics: [
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'], ['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'], ['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
] }, ],
},
] as const; ] as const;
const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}'; const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}';
@@ -164,12 +203,46 @@ export class InfrastructureMonitoringService {
private readonly logger = new Logger(InfrastructureMonitoringService.name); private readonly logger = new Logger(InfrastructureMonitoringService.name);
private readonly prometheusUrl: string; private readonly prometheusUrl: string;
private readonly queryTimeoutMs: number; private readonly queryTimeoutMs: number;
private alertTimer?: ReturnType<typeof setInterval>;
private alertPoll?: Promise<InfrastructureAlert[]>;
constructor(config: ConfigService, private readonly prisma: PrismaService) { constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL')); this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000))); this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
} }
onModuleInit() {
if (process.env.CMPP_PROCESS_ROLE && !['api', 'all'].includes(process.env.CMPP_PROCESS_ROLE)) return;
const poll = () =>
void this.loadRetainedAlerts().catch(() => this.logger.warn('Persistent alert collection unavailable'));
poll();
this.alertTimer = setInterval(poll, 30_000);
this.alertTimer.unref();
}
onModuleDestroy() {
if (this.alertTimer) clearInterval(this.alertTimer);
}
private loadRetainedAlerts() {
if (!this.alertPoll) {
const observedAt = new Date();
this.alertPoll = this.getJson<PrometheusAlertResponse>('/api/v1/alerts')
.then((response) => retainAlerts(this.prisma, this.parseAlerts(response), observedAt))
.finally(() => {
this.alertPoll = undefined;
});
}
return this.alertPoll;
}
clearAlert(fingerprint: string, activeAt: unknown, userId: string) {
return clearRetainedAlert(this.prisma, fingerprint, activeAt, userId);
}
async overview(rawRange?: string, userId?: string): Promise<InfrastructureMonitoringOverview> { async overview(rawRange?: string, userId?: string): Promise<InfrastructureMonitoringOverview> {
const range = this.parseRange(rawRange); const range = this.parseRange(rawRange);
const collectedAt = new Date().toISOString(); const collectedAt = new Date().toISOString();
@@ -179,11 +252,11 @@ export class InfrastructureMonitoringService {
this.loadTrends(range), this.loadTrends(range),
this.query(QUERIES.services), this.query(QUERIES.services),
this.query(SERVICE_METRICS_QUERY), this.query(SERVICE_METRICS_QUERY),
this.getJson<PrometheusAlertResponse>('/api/v1/alerts'), this.loadRetainedAlerts(),
]); ]);
const services = this.parseServices(serviceResponse); const services = this.parseServices(serviceResponse);
const serviceMetrics = this.parseServiceMetrics(serviceMetricResponse); const serviceMetrics = this.parseServiceMetrics(serviceMetricResponse);
const alerts = await this.attachReadState(this.parseAlerts(alertResponse), userId); const alerts = await this.attachReadState(alertResponse, userId);
const warningAlerts = alerts.filter((item) => item.severity === 'warning').length; const warningAlerts = alerts.filter((item) => item.severity === 'warning').length;
const criticalAlerts = alerts.filter((item) => item.severity === 'critical').length; const criticalAlerts = alerts.filter((item) => item.severity === 'critical').length;
const overallStatus = criticalAlerts > 0 ? 'critical' : warningAlerts > 0 ? 'warning' : 'healthy'; const overallStatus = criticalAlerts > 0 ? 'critical' : warningAlerts > 0 ? 'warning' : 'healthy';
@@ -202,26 +275,33 @@ export class InfrastructureMonitoringService {
activeAlerts: alerts.length, activeAlerts: alerts.length,
}, },
metrics: instant.metrics, metrics: instant.metrics,
trends: { ...trends.metrics, diskUsagePercent: rootDisk ? trends.disks.get(rootDisk.id) ?? [] : [] }, trends: { ...trends.metrics, diskUsagePercent: rootDisk ? (trends.disks.get(rootDisk.id) ?? []) : [] },
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })), disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
services, services,
serviceMetrics, serviceMetrics,
alerts, alerts,
}; };
} catch (error) { } catch (error) {
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断 // 客户端保留最后成功快照;采集失败不推断告警恢复
this.logger.warn(`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`); this.logger.warn(
`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
);
return this.unavailable(range, collectedAt); return this.unavailable(range, collectedAt);
} }
} }
async notificationSummary(userId?: string) { async notificationSummary(userId?: string) {
try { try {
const alerts = await this.attachReadState(this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')), userId); const alerts = await this.attachReadState(await this.loadRetainedAlerts(), userId);
const unreadAlerts = alerts.filter((item) => !item.acknowledged); const unreadAlerts = alerts.filter((item) => !item.acknowledged);
return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length }; return {
count: unreadAlerts.length,
criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length,
};
} catch (error) { } catch (error) {
this.logger.warn(`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`); this.logger.warn(
`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
);
throw new ServiceUnavailableException('Prometheus活动告警当前不可用'); throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
} }
} }
@@ -230,12 +310,21 @@ export class InfrastructureMonitoringService {
if (!/^[a-f0-9]{24}$/.test(fingerprint)) throw new BadRequestException('告警指纹无效'); if (!/^[a-f0-9]{24}$/.test(fingerprint)) throw new BadRequestException('告警指纹无效');
const activeAt = new Date(String(rawActiveAt ?? '')); const activeAt = new Date(String(rawActiveAt ?? ''));
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效'); if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')); const activeAlerts = await retainedAlerts(this.prisma);
const current = activeAlerts.find((item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime()); const current = activeAlerts.find(
(item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime(),
);
if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试'); if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试');
const readAt = new Date(); const readAt = new Date();
const log = () => this.prisma.operationLog.create({ const log = () =>
data: { userId, action: 'monitoring.alert_marked_read', resource: 'infrastructure_alert', resourceId: fingerprint, detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity } }, this.prisma.operationLog.create({
data: {
userId,
action: 'monitoring.alert_marked_read',
resource: 'infrastructure_alert',
resourceId: fingerprint,
detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity },
},
}); });
let read; let read;
try { try {
@@ -245,15 +334,57 @@ export class InfrastructureMonitoringService {
]); ]);
} catch (error) { } catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error; if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } }); const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({
where: { fingerprint_userId: { fingerprint, userId } },
});
// 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。 // 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。
if (existing.activeAt.getTime() === activeAt.getTime()) read = existing; if (existing.activeAt.getTime() === activeAt.getTime()) read = existing;
else [read] = await this.prisma.$transaction([ else
this.prisma.infrastructureAlertRead.update({ where: { fingerprint_userId: { fingerprint, userId } }, data: { activeAt, readAt } }), [read] = await this.prisma.$transaction([
this.prisma.infrastructureAlertRead.update({
where: { fingerprint_userId: { fingerprint, userId } },
data: { activeAt, readAt },
}),
log(), log(),
]); ]);
} }
return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() }; return {
fingerprint,
activeAt: read.activeAt.toISOString(),
acknowledged: true,
acknowledgedAt: read.readAt.toISOString(),
};
}
async alertHistory(from?: string, to?: string, rawPage?: string) {
const range = alertHistoryRange(from, to);
const page = rawPage === undefined ? 1 : Number(rawPage);
if (!Number.isSafeInteger(page) || page < 1) throw new BadRequestException('告警页码无效');
const history = new Map<string, AlertHistoryItem>();
try {
// Daily raw range vectors retain short events that a coarse query_range step would miss.
for (let start = range.start; start < range.end; start += 86400) {
const end = Math.min(start + 86400, range.end);
const response = await this.getJson<PrometheusQueryResponse>('/api/v1/query', {
query: `ALERTS_FOR_STATE[${Math.ceil(end - start)}s]`,
time: String(end),
});
mergeAlertHistory(history, response.data?.result ?? [], range.start, range.end);
}
} catch {
throw new ServiceUnavailableException('历史告警查询失败,请稍后重试');
}
const items = [...history.values()].sort(
(a, b) => b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id),
);
return {
items: items.slice((page - 1) * 25, page * 25),
total: items.length,
page,
pageSize: 25,
startDate: range.startDate,
endDate: range.endDate,
};
} }
private parseRange(value?: string): InfrastructureMonitoringRange { private parseRange(value?: string): InfrastructureMonitoringRange {
@@ -264,12 +395,21 @@ export class InfrastructureMonitoringService {
private async loadInstantMetrics() { private async loadInstantMetrics() {
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>; const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]); const responses = await Promise.all([
...keys.map((key) => this.query(QUERIES[key])),
this.query(QUERIES.lastSampleAt),
]);
const metrics = emptyMetrics(); const metrics = emptyMetrics();
keys.forEach((key, index) => { if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]); }); keys.forEach((key, index) => {
if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]);
});
const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? []; const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? [];
const usage = new Map(diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])])); const usage = new Map(
const available = new Map(diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])])); diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]),
);
const available = new Map(
diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]),
);
const groups = new Map<string, PrometheusSeries[]>(); const groups = new Map<string, PrometheusSeries[]>();
for (const item of diskSamples('diskTotalBytes')) { for (const item of diskSamples('diskTotalBytes')) {
if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue; if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue;
@@ -278,18 +418,32 @@ export class InfrastructureMonitoringService {
group.push(item); group.push(item);
groups.set(id, group); groups.set(id, group);
} }
const disks = [...groups].map(([id, items]) => { const disks = [...groups]
.map(([id, items]) => {
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints); const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
const metric = items[0].metric; const metric = items[0].metric;
return { return {
id, instance: metric.instance ?? '', device: metric.device, filesystem: metric.fstype ?? '', id,
mountpoint: mountpoints[0], mountpoints, instance: metric.instance ?? '',
device: metric.device,
filesystem: metric.fstype ?? '',
mountpoint: mountpoints[0],
mountpoints,
// Never sum aliases. Max/min also tolerate slight sampling differences. // Never sum aliases. Max/min also tolerate slight sampling differences.
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)), totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
availableBytes: available.get(id) ?? null, usagePercent: usage.get(id) ?? null, availableBytes: available.get(id) ?? null,
usagePercent: usage.get(id) ?? null,
}; };
}) })
.sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint))); .sort(
(left, right) =>
left.instance.localeCompare(right.instance) ||
(left.mountpoint === '/'
? -1
: right.mountpoint === '/'
? 1
: left.mountpoint.localeCompare(right.mountpoint)),
);
const rootDisk = disks.find((disk) => disk.mountpoints.includes('/')); const rootDisk = disks.find((disk) => disk.mountpoints.includes('/'));
metrics.diskUsagePercent = rootDisk?.usagePercent ?? null; metrics.diskUsagePercent = rootDisk?.usagePercent ?? null;
metrics.diskTotalBytes = rootDisk?.totalBytes ?? null; metrics.diskTotalBytes = rootDisk?.totalBytes ?? null;
@@ -304,21 +458,32 @@ export class InfrastructureMonitoringService {
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>; const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step))); const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
return { return {
metrics: Object.fromEntries(keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'], metrics: Object.fromEntries(
disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [ keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])]),
filesystemIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }), ) as InfrastructureMonitoringOverview['trends'],
])), disks: new Map(
(responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
filesystemIdentity(item.metric),
matrixValues({ status: 'success', data: { result: [item] } }),
]),
),
}; };
} }
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] { private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
const values = new Map<string, number>(); const values = new Map<string, number>();
for (const item of response.data?.result ?? []) { for (const item of response.data?.result ?? []) {
if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0); if (item.metric.name)
values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
} }
return SERVICE_DEFINITIONS.map((definition) => { return SERVICE_DEFINITIONS.map((definition) => {
const present = definition.units.filter((unit) => values.has(unit)); const present = definition.units.filter((unit) => values.has(unit));
const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy'; const status =
present.length === 0
? 'unknown'
: present.some((unit) => (values.get(unit) ?? 0) >= 1)
? 'healthy'
: 'unhealthy';
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status }; return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
}); });
} }
@@ -329,7 +494,8 @@ export class InfrastructureMonitoringService {
.map<InfrastructureAlert>((item) => { .map<InfrastructureAlert>((item) => {
const labels = item.labels ?? {}; const labels = item.labels ?? {};
const annotations = item.annotations ?? {}; const annotations = item.annotations ?? {};
const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info'; const severity: InfrastructureAlert['severity'] =
labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))); const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
return { return {
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24), fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
@@ -348,7 +514,9 @@ export class InfrastructureMonitoringService {
}) })
.sort((left, right) => { .sort((left, right) => {
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 }; const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt); return (
priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt)
);
}); });
} }
@@ -377,24 +545,46 @@ export class InfrastructureMonitoringService {
key: group.key, key: group.key,
name: group.name, name: group.name,
available: group.metrics.some((metric) => values.has(metric[2])), available: group.metrics.some((metric) => values.has(metric[2])),
metrics: group.metrics.map(([key, label, metricName, unit]) => ({ key, label, value: values.get(metricName) ?? null, unit })), metrics: group.metrics.map(([key, label, metricName, unit]) => ({
key,
label,
value: values.get(metricName) ?? null,
unit,
})),
})); }));
} }
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview { private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const })); const services = SERVICE_DEFINITIONS.map((item) => ({
key: item.key,
name: item.name,
unit: item.units[0],
status: 'unknown' as const,
}));
return { return {
available: false, available: false,
range, range,
collectedAt, collectedAt,
lastSampleAt: null, lastSampleAt: null,
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态', error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 }, summary: {
overallStatus: 'unknown',
serviceTotal: services.length,
serviceHealthy: 0,
warningAlerts: 0,
criticalAlerts: 0,
activeAlerts: 0,
},
metrics: emptyMetrics(), metrics: emptyMetrics(),
disks: [], disks: [],
trends: emptyTrends(), trends: emptyTrends(),
services, services,
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })), serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({
key: group.key,
name: group.name,
available: false,
metrics: [],
})),
alerts: [], alerts: [],
}; };
} }
@@ -404,15 +594,26 @@ export class InfrastructureMonitoringService {
} }
private queryRange(query: string, start: number, end: number, step: number) { private queryRange(query: string, start: number, end: number, step: number) {
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) }); return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', {
query,
start: String(start),
end: String(end),
step: String(step),
});
} }
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(path: string, params: Record<string, string> = {}): Promise<T> { private async getJson<T extends { status: 'success' | 'error'; error?: string }>(
path: string,
params: Record<string, string> = {},
): Promise<T> {
const url = new URL(`${this.prometheusUrl}${path}`); const url = new URL(`${this.prometheusUrl}${path}`);
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) }); const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(this.queryTimeoutMs),
});
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`); if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
const result = await response.json() as T; const result = (await response.json()) as T;
if (result.status !== 'success') throw new Error('Prometheus query failed'); if (result.status !== 'success') throw new Error('Prometheus query failed');
return result; return result;
} }
@@ -0,0 +1,143 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { InfrastructureAlert } from './infrastructure-monitoring.contracts';
export async function retainAlerts(prisma: PrismaService, alerts: InfrastructureAlert[], observedAt: Date) {
await prisma.$transaction(async (tx) => {
// Serialize snapshots across API processes; timestamps reject late HTTP results.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(160916, 1)`;
const previous = await tx.infrastructureAlertCollection.findUnique({ where: { id: 'prometheus' } });
if (previous && previous.observedAt >= observedAt) return;
// Read durable work directly: application releases do not install Prometheus rules.
// Keep one occurrence identity until the condition really recovers, even after manual clear.
const reviewCount = await tx.smsAttemptCompletionWork.count({ where: { state: 'needs_review' } });
const oldest = await tx.smsCompletionEvent.findFirst({
where: { processedAt: null, work: { state: { in: ['pending', 'processing', 'retry_wait'] } } },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
});
const age = oldest ? Math.max(0, (observedAt.getTime() - oldest.createdAt.getTime()) / 1000) : 0;
alerts = [...alerts];
for (const condition of [
{
name: 'SmsCompletionNeedsReview',
active: reviewCount > 0,
severity: 'critical' as const,
summary: '短信收尾工作需要人工排查',
value: String(reviewCount),
threshold: '0',
},
{
name: 'SmsCompletionBacklog',
active: age > 300,
severity: 'warning' as const,
summary: '短信收尾工作等待超过5分钟',
value: `${Math.floor(age)}`,
threshold: '300秒',
},
]) {
if (!condition.active) continue;
const fingerprint = createHash('sha256').update(`durable:${condition.name}`).digest('hex').slice(0, 24);
const occurrence = await tx.infrastructureAlertEvent.findFirst({
where: { fingerprint, recoveredAt: null },
orderBy: { activeAt: 'desc' },
});
alerts.push({
fingerprint,
name: condition.name,
severity: condition.severity,
status: 'firing',
startedAt: (occurrence?.activeAt ?? observedAt).toISOString(),
summary: condition.summary,
currentValue: condition.value,
threshold: condition.threshold,
service: '短信收尾',
acknowledged: false,
});
}
await tx.infrastructureAlertCollection.upsert({
where: { id: 'prometheus' },
create: { id: 'prometheus', observedAt },
update: { observedAt },
});
for (const alert of alerts) {
const activeAt = new Date(alert.startedAt);
const payload = JSON.parse(JSON.stringify(alert)) as Prisma.InputJsonValue;
await tx.infrastructureAlertEvent.createMany({
data: [{ fingerprint: alert.fingerprint, activeAt, payload, lastObservedAt: observedAt }],
skipDuplicates: true,
});
await tx.infrastructureAlertEvent.updateMany({
where: { fingerprint: alert.fingerprint, activeAt, lastObservedAt: { lte: observedAt } },
data: { payload, lastObservedAt: observedAt, recoveredAt: null },
});
}
await tx.infrastructureAlertEvent.updateMany({
where: {
recoveredAt: null,
lastObservedAt: { lt: observedAt },
...(alerts.length
? {
NOT: {
OR: alerts.map((alert) => ({ fingerprint: alert.fingerprint, activeAt: new Date(alert.startedAt) })),
},
}
: {}),
},
data: { recoveredAt: observedAt },
});
});
return retainedAlerts(prisma);
}
export async function retainedAlerts(prisma: PrismaService): Promise<InfrastructureAlert[]> {
const records = await prisma.infrastructureAlertEvent.findMany({
where: { clearedAt: null },
orderBy: [{ activeAt: 'desc' }, { id: 'asc' }],
});
return records.map((record) => ({
...(record.payload as unknown as InfrastructureAlert),
...(record.recoveredAt ? { status: 'resolved' } : {}),
acknowledged: false,
}));
}
export async function clearRetainedAlert(
prisma: PrismaService,
fingerprint: string,
rawActiveAt: unknown,
userId: string,
) {
const activeAt = new Date(String(rawActiveAt ?? ''));
if (!/^[a-f0-9]{24}$/.test(fingerprint) || !Number.isFinite(activeAt.getTime()))
throw new BadRequestException('告警标识无效');
return prisma.$transaction(async (tx) => {
const record = await tx.infrastructureAlertEvent.findUnique({
where: { fingerprint_activeAt: { fingerprint, activeAt } },
});
if (!record) throw new NotFoundException('告警记录不存在');
const clearedAt = new Date();
const result = await tx.infrastructureAlertEvent.updateMany({
where: { id: record.id, clearedAt: null },
data: { clearedAt, clearedBy: userId },
});
if (result.count)
await tx.operationLog.create({
data: {
userId,
action: 'monitoring.alert_cleared',
resource: 'infrastructure_alert',
resourceId: record.id,
detail: { fingerprint, activeAt: activeAt.toISOString() },
},
});
return {
fingerprint,
activeAt: activeAt.toISOString(),
cleared: true,
clearedAt: (record.clearedAt ?? clearedAt).toISOString(),
};
});
}
@@ -0,0 +1,22 @@
import * as fs from 'node:fs/promises';
import { resolvePromtoolPath } from './infrastructure-alert-settings.service';
jest.mock('node:fs/promises', () => ({ ...jest.requireActual('node:fs/promises'), access: jest.fn() }));
describe('Prometheus binary resolution', () => {
beforeEach(() => jest.resetAllMocks());
it('uses the official installer location when available', async () => {
const check = jest.mocked(fs.access).mockResolvedValue(undefined);
expect(await resolvePromtoolPath()).toBe('/usr/local/bin/promtool');
expect(check).toHaveBeenCalledTimes(1);
});
it('supports the distribution package location', async () => {
jest.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')).mockResolvedValueOnce(undefined);
expect(await resolvePromtoolPath()).toBe('/usr/bin/promtool');
});
it('fails rather than silently overriding an invalid explicitly configured binary', async () => {
const check = jest.mocked(fs.access).mockRejectedValue(new Error('EACCES'));
await expect(resolvePromtoolPath('/custom/promtool')).rejects.toThrow('promtool不可执行');
expect(check).toHaveBeenCalledTimes(1);
});
});
+17 -8
View File
@@ -26,20 +26,26 @@ async function bootstrap() {
configureHttpBodyParsers(app); configureHttpBodyParsers(app);
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle('CMPP Platform API') .setTitle('聆界短信平台 API')
.setDescription('First-version CMPP SMS platform API') .setDescription('聆界短信平台 API')
.setVersion('0.1.0') .setVersion('0.1.0')
.build(); .build();
const document = SwaggerModule.createDocument(app, swaggerConfig); const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder() const clientDocument = SwaggerModule.createDocument(
.setTitle('CMPP短信平台 HTTP 客户接口') app,
new DocumentBuilder()
.setTitle('聆界短信平台 HTTP 客户接口')
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口') .setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
.setVersion('1.0.0') .setVersion('1.0.0')
.build(), { include: [OpenApiModule] }); .build(),
clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/'))); { include: [OpenApiModule] },
SwaggerModule.setup('api/client-docs', app, clientDocument); );
clientDocument.paths = Object.fromEntries(
Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')),
);
SwaggerModule.setup('api/client-docs', app, clientDocument, { ui: false });
const port = Number(process.env.API_PORT ?? 3000); const port = Number(process.env.API_PORT ?? 3000);
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。 // 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
@@ -55,7 +61,10 @@ async function bootstrap() {
response.writeHead(404).end(); response.writeHead(404).end();
return; return;
} }
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' }); response.writeHead(200, {
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(metrics.render()); response.end(metrics.render());
}); });
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/. // Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
+2
View File
@@ -1,3 +1,4 @@
import { renderCompletionMetrics } from '../send-chain/completion-metrics';
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { monitorEventLoopDelay } from 'node:perf_hooks'; import { monitorEventLoopDelay } from 'node:perf_hooks';
@@ -314,6 +315,7 @@ export class MetricsService implements OnModuleDestroy {
lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope })); lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope }));
} }
this.eventLoopDelay.reset(); this.eventLoopDelay.reset();
lines.push(...renderCompletionMetrics());
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
} }
+182
View File
@@ -0,0 +1,182 @@
.http-developer-docs {
margin: 0;
color: #1f2937;
background: #f6f7f9;
font:
14px/1.6 system-ui,
sans-serif;
}
.http-developer-docs * {
box-sizing: border-box;
}
.http-developer-docs .http-doc-header {
padding: 24px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
display: flex;
gap: 20px;
justify-content: space-between;
align-items: center;
}
.http-developer-docs h1 {
font-size: 24px;
margin: 8px 0;
}
.http-developer-docs h2 {
font-size: 20px;
margin: 0 0 16px;
}
.http-developer-docs h3 {
font-size: 16px;
margin: 20px 0 12px;
}
.http-developer-docs p {
overflow-wrap: anywhere;
}
.http-developer-docs a {
color: #2563eb;
text-decoration: none;
overflow-wrap: anywhere;
}
.http-developer-docs a:hover {
text-decoration: underline;
}
.http-developer-docs .http-doc-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.http-developer-docs .http-doc-layout {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
}
.http-developer-docs nav {
padding: 20px;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
}
.http-developer-docs nav a {
display: block;
padding: 7px 0;
font-size: 13px;
}
.http-developer-docs nav label {
display: block;
margin-top: 20px;
}
.http-developer-docs input {
width: 100%;
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
.http-developer-docs main {
min-width: 0;
}
.http-developer-docs section {
display: block;
border-bottom: 1px solid #e5e7eb;
scroll-margin-top: 20px;
}
.http-developer-docs section[hidden] {
display: none;
}
.http-developer-docs .http-doc-body {
padding: 24px;
background: #fff;
min-width: 0;
}
.http-developer-docs .http-doc-sample {
margin-bottom: 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.http-developer-docs .http-doc-sample-bar {
padding: 10px;
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #6b7280;
}
.http-developer-docs button {
padding: 5px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #1f2937;
background: #fff;
cursor: pointer;
flex-shrink: 0;
}
.http-developer-docs button:focus-visible,
.http-developer-docs a:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.http-developer-docs pre {
margin: 0;
padding: 16px;
overflow: auto;
max-height: 560px;
font-size: 13px;
background: #f4f6f8;
}
.http-developer-docs code {
font-family: ui-monospace, monospace;
overflow-wrap: anywhere;
}
.http-developer-docs .http-doc-table {
overflow-x: auto;
}
.http-developer-docs table {
border-collapse: collapse;
min-width: 100%;
}
.http-developer-docs td {
padding: 9px;
border: 1px solid #e5e7eb;
min-width: 100px;
overflow-wrap: anywhere;
}
.http-developer-docs tr:first-child {
font-weight: 600;
background: #f4f6f8;
}
.http-developer-docs .http-doc-copy-status {
position: fixed;
bottom: 12px;
right: 12px;
max-width: 80vw;
background: #fff;
border-radius: 6px;
padding: 8px;
box-shadow: 0 2px 12px #0002;
}
.http-developer-docs .http-doc-copy-status:empty {
display: none;
}
@media (width <= 700px) {
.http-developer-docs .http-doc-header {
padding: 16px;
display: block;
}
.http-developer-docs .http-doc-layout {
grid-template-columns: minmax(0, 1fr);
}
.http-developer-docs nav {
position: static;
max-height: none;
padding: 16px;
}
.http-developer-docs .http-doc-body {
padding: 16px;
}
}
+31
View File
@@ -0,0 +1,31 @@
/* global document, navigator, window, Event */
const copyStatus = document.getElementById('copy-status');
document.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-copy]');
if (!button) return;
const content = document.getElementById(button.dataset.copy);
try {
if (!navigator.clipboard) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(content.textContent);
copyStatus.textContent = '已复制示例;未执行任何请求。';
} catch {
const range = document.createRange(); range.selectNodeContents(content);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
copyStatus.textContent = '自动复制不可用,已选中示例,请手动复制。';
}
});
const search = document.getElementById('doc-search');
search.addEventListener('input', () => {
const term = search.value.trim().toLowerCase(); let visible = 0;
document.querySelectorAll('[data-doc-section]').forEach((section) => {
section.hidden = !!term && !section.textContent.toLowerCase().includes(term);
if (!section.hidden) visible++;
});
document.getElementById('no-results').hidden = visible !== 0;
document.getElementById('search-status').textContent = term ? `${visible} 个章节匹配` : '';
});
document.querySelector('nav').addEventListener('click', (event) => {
if (!event.target.closest('a')) return;
search.value = ''; search.dispatchEvent(new Event('input'));
});
if (window.innerWidth <= 700) document.querySelector('nav details').open = false;
+67
View File
@@ -0,0 +1,67 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
export function httpDocVersion(markdown: string) {
const metadata = markdown.split(/\r?\n/).find((line) => line.startsWith('**接口版本:')) ?? '';
const version = /接口版本:([a-zA-Z0-9.-]+)/.exec(metadata)?.[1];
const revision = /\b\d{4}-\d{2}-\d{2}\b/.exec(metadata)?.[0];
if (!version || !revision) throw new Error('HTTP document metadata is missing');
return version + ' / ' + revision;
}
export function readHttpGuide() {
return readFileSync(resolve(__dirname, '../../../../docs/client-http-api-guide.md'), 'utf8');
}
function asset(name: string) {
return readFileSync(resolve(__dirname, '../../../src/open-api/docs', name), 'utf8');
}
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!);
}
function inline(value: string): string {
// Escape first; raw HTML can never execute. Only HTTP(S) and local anchors become links.
return escapeHtml(value).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, url: string) => /^(https?:\/\/|#)/i.test(url) ? `<a href="${url}" rel="noreferrer">${label}</a>` : label);
}
export function renderHttpGuide(markdown: string, origin: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const sections: Array<{ id: string; title: string; body: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[] };
sections.push(current);
let code: string[] | null = null;
let language = '';
let sampleTitle = '示例';
let table = false;
let sampleCount = 0;
const closeTable = () => { if (table) { current.body.push('</tbody></table></div>'); table = false; } };
for (const line of lines) {
if (code) {
if (/^```/.test(line)) {
current.body.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制代码</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
code = null;
} else code.push(line);
continue;
}
if (/^```/.test(line)) { closeTable(); code = []; language = line.slice(3).trim(); continue; }
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) {
closeTable();
sampleTitle = heading[2];
if (heading[1].length === 2) {
current = { id: 'section-' + sections.length, title: heading[2], body: [] };
sections.push(current);
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
continue;
}
if (/^\s*\|/.test(line)) {
if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
if (!table) { current.body.push('<div class="http-doc-table"><table><tbody>'); table = true; }
current.body.push('<tr>' + line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => `<td>${inline(cell.trim())}</td>`).join('') + '</tr>');
continue;
}
closeTable();
const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1');
if (caption.endsWith('') && caption.length < 70) sampleTitle = caption.replace(/$/, '');
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
}
closeTable();
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信平台 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信平台 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
}
+100 -15
View File
@@ -1,19 +1,61 @@
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common'; import {
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; CanActivate,
ExecutionContext,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
OnModuleDestroy,
Optional,
UnauthorizedException,
} from '@nestjs/common';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import IORedis from 'ioredis'; import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto'; import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types'; import type { OpenApiRequestLike } from './open-api.types';
import { openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service'; import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable() @Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy { export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis; private redis?: IORedis;
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {} constructor(
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
request.openApiRequestId = 'req_' + randomUUID();
context
.switchToHttp()
.getResponse<{ setHeader: (name: string, value: string) => void }>()
.setHeader('X-Request-Id', request.openApiRequestId);
const startedAt = Date.now();
try {
return await this.authenticate(context);
} catch (error) {
const failure = publicOpenApiFailure(error);
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'authentication',
status: 'failed',
requestId: request.openApiRequestId,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
detail: { method: request.method, path: '/api/openapi/v1/sms' },
});
throw error;
}
}
private async authenticate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>(); const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const accessKey = header(request, 'x-app-key'); const accessKey = header(request, 'x-app-key');
const timestampText = header(request, 'x-timestamp'); const timestampText = header(request, 'x-timestamp');
@@ -40,26 +82,45 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' }); throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
} }
const timestamp = Number(timestampText); const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) { if (
!Number.isFinite(timestamp) ||
Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000
) {
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED'); await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' }); throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
} }
const sourceIp = requestIp(request); const sourceIp = requestIp(request);
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) { if (
credential.application.httpIpAllowlist.length > 0 &&
(!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))
) {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' }); throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
} }
const path = (request.originalUrl ?? request.url ?? '').split('?')[0]; const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex'); const expected = openApiSignature(
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n'); decryptSecret(credential.secretEncrypted),
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex'); request.method,
path,
timestampText,
nonce,
request.rawBody,
);
const expectedBuffer = Buffer.from(expected, 'hex'); const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0); const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
? Buffer.from(suppliedSignature, 'hex')
: Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) { if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID'); await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' }); throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
} }
const redis = this.getRedis(); const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX'); const nonceAccepted = await redis.set(
`openapi:nonce:${credential.id}:${nonce}`,
'1',
'EX',
config.timestampToleranceSeconds * 2,
'NX',
);
if (nonceAccepted !== 'OK') { if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED'); await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' }); throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
@@ -78,22 +139,41 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
accessKey, accessKey,
sourceIp, sourceIp,
}; };
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } }); await this.prisma.httpApiCredential.update({
where: { id: credential.id },
data: { lastUsedAt: new Date(), lastUsedIp: sourceIp },
});
return true; return true;
} }
onModuleDestroy() { this.redis?.disconnect(); } onModuleDestroy() {
this.redis?.disconnect();
}
private getRedis() { private getRedis() {
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 }); this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis; return this.redis;
} }
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) { private async recordFailure(
ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt',
request: OpenApiRequestLike,
account: string | undefined,
resultCode: string,
) {
const sourceIp = requestIp(request); const sourceIp = requestIp(request);
if (!sourceIp) return; if (!sourceIp) return;
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。 // 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined); await this.security
.recordEvent({
ruleCode,
sourceIp,
account,
resultCode,
protocol: 'http',
path: (request.originalUrl ?? request.url ?? '').split('?')[0],
})
.catch(() => undefined);
} }
} }
@@ -105,7 +185,12 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) { function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim(); const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, ''); const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean)); const trustedProxies = new Set(
(process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, ''); return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
} }
@@ -0,0 +1,21 @@
import { randomUUID } from 'node:crypto';
import { sendOpenApiProblem, type OpenApiProblemResponse } from './open-api.protocol';
/** Mounted after parsers, before routes; never expose parser errors containing raw input. */
export function openApiBodyErrorMiddleware(
error: unknown,
request: { openApiRequestId?: string },
response: OpenApiProblemResponse,
next: (error: unknown) => void,
) {
const type = error && typeof error === 'object' && 'type' in error ? error.type : undefined;
const failures: Record<string, { status: number; code: string; message: string }> = {
'entity.parse.failed': { status: 400, code: 'PARAMETER_INVALID', message: '请求体必须为有效的JSON对象' },
'entity.too.large': { status: 413, code: 'PAYLOAD_TOO_LARGE', message: '请求体超过大小限制' },
'charset.unsupported': { status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: '请求体字符集不受支持' },
'encoding.unsupported': { status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: '请求体编码不受支持' },
};
const failure = typeof type === 'string' && Object.hasOwn(failures, type) ? failures[type] : undefined;
if (!failure) return next(error);
sendOpenApiProblem(response, (request.openApiRequestId ??= `req_${randomUUID()}`), failure);
}
@@ -0,0 +1,66 @@
import { PrismaService } from '../prisma/prisma.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
@Module({
controllers: [OpenApiController],
providers: [
{ provide: PrismaService, useValue: {} },
{ provide: SecurityDetectionService, useValue: {} },
{ provide: OpenApiService, useValue: {} },
{ provide: OpenApiAuthGuard, useValue: {} },
{ provide: OpenApiTraceInterceptor, useValue: {} },
],
})
class ContractModule {}
describe('generated public OpenAPI contract', () => {
it('describes exactly four operations, seven query fields, nullable IDs and both callbacks', async () => {
const app = await NestFactory.create(ContractModule, { logger: false, abortOnError: false });
try {
app.setGlobalPrefix('api');
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder().setTitle('contract').setVersion('v1').build(),
);
expect(
Object.values(document.paths).reduce(
(count, path) => count + Object.keys(path).filter((key) => ['get', 'post'].includes(key)).length,
0,
),
).toBe(4);
const post = document.paths['/api/openapi/v1/sms/messages'].post!;
const headers = post.parameters as Array<{ name: string; in: string; required?: boolean }>;
expect(headers.filter((field) => field.name.toLowerCase() === 'idempotency-key')).toHaveLength(1);
expect(headers.some((field) => field.name.toLowerCase() === 'user-agent' && field.required)).toBe(false);
const query = document.paths['/api/openapi/v1/sms/uplinks'].get!.parameters as Array<{
name: string;
in: string;
}>;
expect(
query
.filter((field) => field.in === 'query')
.map((field) => field.name)
.sort(),
).toEqual(['accessNumber', 'cursor', 'endTime', 'keyword', 'limit', 'mobile', 'startTime']);
for (const path of Object.values(document.paths)) {
if (path.get) expect(path.get.responses['200']).toHaveProperty('content.application/json.schema');
}
expect(document.components!.schemas!.OpenApiSendMessageResponseDto).toMatchObject({
properties: { clientMessageId: { type: 'string', nullable: true } },
});
expect(document.components!.schemas).toHaveProperty('OpenApiReceiptEventDto');
expect(document.components!.schemas).toHaveProperty('OpenApiUplinkEventDto');
const detail = JSON.stringify(document.components!.schemas!.OpenApiUplinkDetailDto);
expect(detail).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,33 @@
import { Controller, Get, Header, Query, Res } from '@nestjs/common';
import { httpDocVersion, readHttpGuide, renderHttpGuide } from './docs/reader';
@Controller('client-docs')
export class OpenApiDocsController {
@Get()
@Header('Cache-Control', 'no-cache')
getGuide(
@Query('format') format: string | undefined,
@Res()
response: {
type: (value: string) => void;
setHeader: (name: string, value: string) => void;
send: (value: string) => void;
},
) {
const markdown = readHttpGuide();
response.setHeader('X-Document-Version', httpDocVersion(markdown));
response.setHeader('X-Content-Type-Options', 'nosniff');
if (format === 'md') {
response.type('text/markdown; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="client-http-api-guide.md"');
response.send(markdown);
return;
}
response.type('text/html; charset=utf-8');
response.setHeader(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
response.send(renderHttpGuide(markdown, process.env.HTTP_API_PUBLIC_ORIGIN?.replace(/\/+$/, '') ?? ''));
}
}
+29 -13
View File
@@ -1,20 +1,36 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { publicOpenApiFailure, sendOpenApiProblem } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Catch() @Catch()
export class OpenApiExceptionFilter implements ExceptionFilter { export class OpenApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(OpenApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<{ status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } } }>(); const http = host.switchToHttp();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const request = http.getRequest<OpenApiRequestLike>();
const value = exception instanceof HttpException ? exception.getResponse() : {}; const response = http.getResponse<{
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {}; setHeader: (name: string, value: string) => void;
const rawMessage = object.message ?? (exception instanceof Error ? exception.message : 'Internal server error'); status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } };
const detail = Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage); }>();
response.status(status).type('application/problem+json').send({ const requestId = (request.openApiRequestId ??= `req_${randomUUID()}`);
type: `https://cmpp-platform.local/problems/${String(object.code ?? 'REQUEST_FAILED').toLowerCase()}`, const failure = publicOpenApiFailure(exception);
title: String(object.error ?? HttpStatus[status] ?? 'Request failed'), // Dependency messages may include SQL values or credentials; keep safe correlation only.
status, if (failure.status >= 500)
code: String(object.code ?? 'REQUEST_FAILED'), this.logger.error({
detail, requestId,
code: failure.code,
errorType: exception instanceof Error ? exception.name : 'UnknownError',
stack:
exception instanceof Error
? exception.stack
?.split('\n')
.filter((line) => /^\s*at /.test(line))
.slice(0, 8)
.join('\n')
: undefined,
}); });
sendOpenApiProblem(response, requestId, failure);
} }
} }
@@ -0,0 +1,79 @@
import { BadRequestException } from '@nestjs/common';
import * as dns from 'node:dns/promises';
import { parseOpenApiDate } from './open-api.protocol';
import { OpenApiService, resolveWebhookTarget } from './open-api.service';
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }));
describe('public HTTP input boundaries', () => {
it.each([
'2026-02-30',
'2025-02-29T00:00:00Z',
'2026-04-31T00:00:00+08:00',
'2026-01-01T24:00:00Z',
'2026-01-01T12:60:00Z',
'2026-01-01T00:00:00+24:00',
'2026-01-01T00:00:00',
'09/14/2026',
'',
'2026-00-01',
'2026-01-00',
])('rejects invalid ISO calendar/time %s', (value) => {
expect(() => parseOpenApiDate(value)).toThrow(BadRequestException);
});
it.each([
['2024-02-29', '2024-02-29T00:00:00.000Z'],
['2026-09-14T08:00:00+08:00', '2026-09-14T00:00:00.000Z'],
['2026-09-14T00:00Z', '2026-09-14T00:00:00.000Z'],
['2000-02-29T00:00:00.123Z', '2000-02-29T00:00:00.123Z'],
['2026-09-14T00:00:00.123456789Z', '2026-09-14T00:00:00.123Z'],
])('preserves valid calendar dates/timezones %s', (value, expected) => {
expect(parseOpenApiDate(value).toISOString()).toBe(expected);
});
it('rejects an impossible cursor date before calling PostgreSQL', async () => {
const prisma = { smsUplinkMessage: { findMany: jest.fn() } };
const service = new OpenApiService(prisma as never, {} as never);
const cursor = Buffer.from(JSON.stringify(['2026-02-30T00:00:00Z', 'id'])).toString('base64url');
await expect(
service.listUplinks({ config: { uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 } } as never, {
cursor,
}),
).rejects.toMatchObject({ response: { code: 'CURSOR_INVALID' } });
expect(prisma.smsUplinkMessage.findMany).not.toHaveBeenCalled();
});
it.each([
'::1',
'::',
'fd00::1',
'fe80::1',
'::ffff:127.0.0.1',
'::ffff:7f00:1',
'::ffff:192.168.1.1',
'0:0:0:0:0:ffff:0a00:0001',
])('rejects private IPv6 literal %s without DNS', async (address) => {
const lookup = jest.mocked(dns.lookup);
try {
await expect(resolveWebhookTarget(`https://[${address}]/hook`, true)).rejects.toThrow(BadRequestException);
expect(lookup).not.toHaveBeenCalled();
} finally {
lookup.mockReset();
}
});
it('preserves public IPv6 addresses for TLS URLs and fixed-address connection', async () => {
await expect(resolveWebhookTarget('https://[2606:4700:4700::1111]/hook', true)).resolves.toMatchObject({
address: '2606:4700:4700::1111',
family: 6,
});
});
it('returns a controlled 400 on DNS failure without exposing resolver diagnostics', async () => {
const lookup = jest.mocked(dns.lookup).mockRejectedValueOnce(new Error('ENOTFOUND internal-resolver-detail'));
try {
await expect(resolveWebhookTarget('https://unavailable.invalid/hook', true)).rejects.toMatchObject({
status: 400,
message: 'Webhook域名未解析到可用地址',
});
} finally {
lookup.mockReset();
}
});
});
@@ -0,0 +1,202 @@
import { BadRequestException, HttpException } from '@nestjs/common';
import { Job } from 'bullmq';
import { createHash, createHmac } from 'node:crypto';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { OpenApiService } from './open-api.service';
import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { renderHttpGuide } from './docs/reader';
const auth = {
application: { id: 'own-app', tenantId: 'own-tenant' },
config: { sendEnabled: true, uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 },
};
describe('HTTP API remediation boundaries', () => {
it.each([
{ mobile: 'abc' },
{ mobile: '1' },
{ mobile: '' },
{ mobile: '138001380001' },
{ accessNumber: '<script>' },
{ accessNumber: '' },
{ accessNumber: '1'.repeat(22) },
])('rejects malformed uplink number filters before querying: %o', async (query) => {
const findMany = jest.fn();
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
await expect(service.listUplinks(auth as never, query)).rejects.toMatchObject({ status: 400 });
expect(findMany).not.toHaveBeenCalled();
});
it('accepts numeric uplink filter boundaries without changing exact matches', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
await service.listUplinks(auth as never, { mobile: '13800138000', accessNumber: '1'.repeat(21) });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ phoneNumber: '13800138000', destId: '1'.repeat(21) }),
}),
);
});
it('matches the published fixed GET signature vector', () => {
expect(
openApiSignature(
'doc-example-secret',
'GET',
'/api/openapi/v1/sms/uplinks',
'1789344000',
'550e8400-e29b-41d4-a716-446655440000',
undefined,
),
).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
});
it('preserves internal idempotency hashes but signs exact POST UTF8 bytes', () => {
expect(openApiBodyHash(undefined, undefined)).toBe(
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
);
const raw = Buffer.from('{ "content": "中文\\n正文" }');
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw.toString('utf8')].join('\n');
expect(
openApiSignature('offline-secret', 'post', '/api/openapi/v1/sms/messages?ignored=1', '123', 'nonce-0001', raw),
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
for (const separator of ['\r\n', '\\n'])
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw),
);
});
it('uses stable colon-free job IDs accepted by the actual BullMQ validator', () => {
const validate = (jobId: string) =>
(Job.prototype as unknown as { validateOptions: (data: unknown) => void }).validateOptions.call(
{ opts: { jobId } },
{ data: '{}' },
);
expect(() => validate('delivery:2')).toThrow('Custom Id cannot contain :');
expect(() => validate(webhookJobId('delivery:legacy', 2))).not.toThrow();
expect(webhookJobId('delivery:legacy', 2)).toBe(webhookJobId('delivery:legacy', 2));
expect(webhookJobId('delivery:legacy', 2)).not.toBe(webhookJobId('delivery:legacy', 3));
});
it.each([new Error('postgres://private:secret@host/secret'), new HttpException('private-secret', 503)])(
'does not expose dependency failures',
(error) => {
expect(publicOpenApiFailure(error)).toEqual({
status: 500,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
},
);
it('keeps first and replayed failure bodies consistent', () => {
const first = publicOpenApiFailure(new Error('private'));
const replay = publicOpenApiFailure(new HttpException({ code: first.code, message: first.message }, first.status));
expect(replay).toEqual(first);
expect(publicOpenApiFailure(new BadRequestException({ code: 'LIMIT_INVALID', message: 'bad limit' })).code).toBe(
'LIMIT_INVALID',
);
});
it('returns a safe request ID with the problem response', () => {
const send = jest.fn();
const setHeader = jest.fn();
const response = { setHeader, status: jest.fn(() => ({ type: () => ({ send }) })) };
new OpenApiExceptionFilter().catch(new Error('secret'), {
switchToHttp: () => ({ getRequest: () => ({ openApiRequestId: 'req-test' }), getResponse: () => response }),
} as never);
expect(setHeader).toHaveBeenCalledWith('X-Request-Id', 'req-test');
expect(send).toHaveBeenCalledWith(expect.objectContaining({ code: 'INTERNAL_ERROR', requestId: 'req-test' }));
expect(JSON.stringify(send.mock.calls)).not.toContain('secret');
});
it.each([1, {}, [], 'x'.repeat(129)])(
'rejects invalid clientMessageId before persistence',
async (clientMessageId) => {
const service = new OpenApiService({} as never, {} as never);
await expect(
service.sendMessage(auth as never, { mobile: '13800138000', content: '示例', clientMessageId } as never, {
idempotencyKey: 'offline-0001',
bodyHash: 'hash',
}),
).rejects.toMatchObject({ status: 400 });
},
);
it.each(['1.5', '0', '-1', 'NaN', 'Infinity', '', '9999999999999999999'])(
'rejects invalid limit %s before Prisma',
async (limit) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { limit })).rejects.toMatchObject({ status: 400 });
},
);
it.each([
'not-base64!',
Buffer.from(JSON.stringify(['2026-09-14', {}])).toString('base64url'),
Buffer.from(JSON.stringify(['bad-date', 'row'])).toString('base64url'),
])('rejects malformed cursor', async (cursor) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { cursor })).rejects.toMatchObject({ status: 400 });
});
it('projects only public detail fields and the authenticated tenant/application', async () => {
const findFirst = jest.fn().mockResolvedValue({ id: 'uplink' });
const service = new OpenApiService({ smsUplinkMessage: { findFirst } } as never, {} as never);
await service.getUplink(auth as never, 'uplink');
expect(findFirst).toHaveBeenCalledWith({
where: { id: 'uplink', applicationId: 'own-app', tenantId: 'own-tenant', matchStatus: 'matched' },
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
});
expect(JSON.stringify(findFirst.mock.calls)).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
});
it('retains application page-size clipping and a real empty response', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, {} as never);
await expect(service.listUplinks(auth as never, { limit: '1000' })).resolves.toEqual({
items: [],
nextCursor: null,
});
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 101 }));
});
it('never rebuilds an interrupted or uncertain request', async () => {
const send = jest.fn();
const service = new OpenApiService(
{
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'hash', status: 'requires_review' }) },
} as never,
{ createHttpBatchTask: send } as never,
);
await expect(
service.sendMessage(
auth as never,
{ mobile: '13800138000', content: '示例' },
{ idempotencyKey: 'offline-0001', bodyHash: 'hash' },
),
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
expect(send).not.toHaveBeenCalled();
});
it('keeps named code examples beside their source paragraphs', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n**签名原文:**\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
'',
);
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
expect(html).toContain('签名原文 · text');
expect(html).not.toContain('data-show-sample');
expect(html).not.toContain('<aside');
expect(html).not.toMatch(/>示例 \d+</);
});
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
'https://example.test',
);
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('href="javascript:');
expect(html).not.toContain('<img src=x');
expect(html).toContain('data-copy="sample-1"');
});
});
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { createHash, createHmac } from 'node:crypto';
import { openApiSignature } from './open-api.protocol';
describe('raw-body request signing contract', () => {
const path = '/api/openapi/v1/sms/messages';
const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db';
const body = Buffer.from(
'{"mobile":"13800138000","content":"【示例签名】您的验证码是1234565分钟内有效。","clientMessageId":"doc-example-20260914-0001"}',
);
const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL';
const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw);
it('matches the independently computed published POST vector', () => {
expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124');
});
it('does not accept legacy body digests or changed body bytes', () => {
const legacy = createHmac('sha256', secret)
.update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n'))
.digest('hex');
expect(sign(body)).not.toBe(legacy);
for (const changed of [
Buffer.concat([body, Buffer.from('\n')]),
Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)),
Buffer.from(body.toString().replace('123456', '654321')),
]) {
expect(sign(changed)).not.toBe(sign(body));
}
});
it('rejects missing POST raw bytes instead of reconstructing JSON', () => {
expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体');
});
it('rejects nonempty GET bodies and distinguishes a trailing LF', () => {
const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce];
const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]);
expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex'));
expect(actual).not.toBe(
createHmac('sha256', secret)
.update(fields.join('\n') + '\n')
.digest('hex'),
);
expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文');
});
it('executes both handbook examples and verifies every complete request packet', () => {
const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace(
/\r\n/g,
'\n',
);
expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例');
expect(guide).not.toContain('### 2.4');
const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
expect(scripts).toHaveLength(2);
for (const script of scripts) {
const outputs: string[] = [];
runInNewContext(script[1], {
Buffer,
require: () => ({ createHmac }),
console: { log: (value: string) => outputs.push(value) },
});
expect(outputs).toHaveLength(1);
expect(guide).toContain(outputs[0]);
}
const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)];
expect(packets).toHaveLength(5);
for (const [, packet] of packets) {
const split = packet.indexOf('\n\n');
const headers = packet.slice(0, split);
const [method, url] = headers.split('\n')[0].split(' ');
const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1];
const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined;
if (raw) expect(raw.length).toBe(Number(header('Content-Length')));
expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe(
header('X-Signature'),
);
}
});
});
@@ -0,0 +1,28 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Optional } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { publicOpenApiFailure } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Injectable()
export class OpenApiTraceInterceptor implements NestInterceptor {
constructor(@Optional() private readonly logs?: ProtocolLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const startedAt = Date.now();
const record = (error?: unknown) =>
this.logs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: request.method === 'GET' ? 'query_request' : 'send_request',
status: error ? 'failed' : 'success',
requestId: request.openApiRequestId,
tenantId: request.openApiAuth?.application.tenantId,
applicationId: request.openApiAuth?.application.id,
resultCode: error ? publicOpenApiFailure(error).code : 'OK',
durationMs: Date.now() - startedAt,
detail: { method: request.method, operation: context.getHandler().name },
});
return next.handle().pipe(tap({ next: () => record(), error: (error: unknown) => record(error) }));
}
}
@@ -0,0 +1,42 @@
import { createServer, get, type RequestOptions } from 'node:http';
import type { AddressInfo } from 'node:net';
import { pinnedWebhookLookup } from './open-api.protocol';
describe('webhook pinned DNS lookup', () => {
it('supports single-address and all-address callbacks without resolving another address', () => {
const callback = jest.fn();
const lookup = pinnedWebhookLookup('203.0.113.10', 4);
lookup('ignored.example', {}, callback);
expect(callback).toHaveBeenLastCalledWith(null, '203.0.113.10', 4);
lookup('ignored.example', { all: true }, callback);
expect(callback).toHaveBeenLastCalledWith(null, [{ address: '203.0.113.10', family: 4 }]);
pinnedWebhookLookup('2001:db8::10', 6)('ignored.example', { all: true }, callback);
expect(callback).toHaveBeenLastCalledWith(null, [{ address: '2001:db8::10', family: 6 }]);
});
it('delivers through the real Node HTTP connector when automatic family selection requests all addresses', async () => {
const server = createServer((_request, response) => response.end('received'));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const port = (server.address() as AddressInfo).port;
const options: RequestOptions & { autoSelectFamily: boolean } = {
lookup: pinnedWebhookLookup('127.0.0.1', 4),
autoSelectFamily: true,
agent: false,
};
const body = await new Promise<string>((resolve, reject) => {
const request = get(`http://webhook.invalid:${port}/`, options, (response) => {
let received = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => (received += chunk));
response.on('end', () => resolve(received));
});
request.setTimeout(3000, () => request.destroy(new Error('test HTTP timeout')));
request.on('error', reject);
});
expect(body).toBe('received');
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
});
+84 -7
View File
@@ -1,19 +1,61 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common'; import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import { ApiBody, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import {
import { createHash } from 'node:crypto'; Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
BadRequestException,
} from '@nestjs/common';
import { ApiExtraModels, ApiBody, ApiHeader, ApiQuery, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { openApiBodyHash } from './open-api.protocol';
import { OpenApiAuthGuard } from './open-api-auth.guard'; import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiService } from './open-api.service'; import { OpenApiService } from './open-api.service';
import type { OpenApiRequestLike } from './open-api.types'; import type { OpenApiRequestLike } from './open-api.types';
import { OpenApiExceptionFilter } from './open-api-exception.filter'; import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { OpenApiSendMessageDto, OpenApiSendMessageResponseDto } from './open-api.dto'; import {
OpenApiSendMessageDto,
OpenApiSendMessageResponseDto,
OpenApiMessageDto,
OpenApiUplinksDto,
OpenApiUplinkDetailDto,
OpenApiProblemDto,
OpenApiReceiptEventDto,
OpenApiUplinkEventDto,
} from './open-api.dto';
@ApiExtraModels(OpenApiReceiptEventDto, OpenApiUplinkEventDto)
@ApiTags('client-open-api-v1') @ApiTags('client-open-api-v1')
@ApiHeader({ name: 'X-App-Key', required: true }) @ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', required: true }) @ApiHeader({ name: 'X-Timestamp', required: true })
@ApiHeader({ name: 'X-Nonce', required: true }) @ApiHeader({ name: 'X-Nonce', required: true })
@ApiHeader({ name: 'X-Signature', required: true }) @ApiHeader({
name: 'X-Signature',
required: true,
description:
'HMAC-SHA256小写十六进制。方法、路径(不含query)、时间戳、nonce以LF分隔;GET末尾无LFPOST追加LF及原始UTF-8正文,不计算正文摘要。',
})
@ApiResponse({ status: 400, type: OpenApiProblemDto })
@ApiResponse({ status: 401, type: OpenApiProblemDto })
@ApiResponse({ status: 403, type: OpenApiProblemDto })
@ApiResponse({ status: 404, type: OpenApiProblemDto })
@ApiResponse({ status: 409, type: OpenApiProblemDto })
@ApiResponse({ status: 413, type: OpenApiProblemDto })
@ApiResponse({ status: 415, type: OpenApiProblemDto })
@ApiResponse({ status: 422, type: OpenApiProblemDto })
@ApiResponse({ status: 429, type: OpenApiProblemDto })
@ApiResponse({ status: 500, type: OpenApiProblemDto })
@UseGuards(OpenApiAuthGuard) @UseGuards(OpenApiAuthGuard)
@UseFilters(OpenApiExceptionFilter) @UseFilters(OpenApiExceptionFilter)
@UseInterceptors(OpenApiTraceInterceptor)
@Controller('openapi/v1/sms') @Controller('openapi/v1/sms')
export class OpenApiController { export class OpenApiController {
constructor(private readonly service: OpenApiService) {} constructor(private readonly service: OpenApiService) {}
@@ -22,27 +64,62 @@ export class OpenApiController {
@HttpCode(202) @HttpCode(202)
@ApiHeader({ name: 'Idempotency-Key', required: true }) @ApiHeader({ name: 'Idempotency-Key', required: true })
@ApiOperation({ summary: '发送单条短信' }) @ApiOperation({ summary: '发送单条短信' })
@UsePipes(
new ValidationPipe({
transform: true,
exceptionFactory: () => new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' }),
}),
)
@ApiBody({ type: OpenApiSendMessageDto }) @ApiBody({ type: OpenApiSendMessageDto })
@ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto }) @ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto })
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) { sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto) {
return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent }); return this.service.sendMessage(request.openApiAuth!, body, {
idempotencyKey: scalarHeader(request, 'idempotency-key'),
bodyHash: openApiBodyHash(request.rawBody, body),
userAgent: scalarHeader(request, 'user-agent'),
});
} }
@ApiResponse({ status: 200, type: OpenApiMessageDto })
@Get('messages/:messageId') @Get('messages/:messageId')
@ApiOperation({ summary: '查询短信状态' }) @ApiOperation({ summary: '查询短信状态' })
getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) { getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) {
return this.service.getMessage(request.openApiAuth!, messageId); return this.service.getMessage(request.openApiAuth!, messageId);
} }
@ApiResponse({ status: 200, type: OpenApiUplinksDto })
@ApiQuery({ name: 'startTime', required: false, type: String, description: 'ISO8601时间,默认endTime前24小时' })
@ApiQuery({
name: 'endTime',
required: false,
type: String,
description: 'ISO8601时间,默认当前时间;翻页固定时间范围',
})
@ApiQuery({ name: 'mobile', required: false, type: String })
@ApiQuery({ name: 'accessNumber', required: false, type: String })
@ApiQuery({ name: 'keyword', required: false, type: String })
@ApiQuery({
name: 'limit',
required: false,
schema: { type: 'integer', minimum: 1, default: 50 },
description: '按当前应用maxPageSize裁剪',
})
@ApiQuery({ name: 'cursor', required: false, type: String })
@Get('uplinks') @Get('uplinks')
@ApiOperation({ summary: '游标分页查询上行短信' }) @ApiOperation({ summary: '游标分页查询上行短信' })
listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) { listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) {
return this.service.listUplinks(request.openApiAuth!, query); return this.service.listUplinks(request.openApiAuth!, query);
} }
@ApiResponse({ status: 200, type: OpenApiUplinkDetailDto })
@Get('uplinks/:uplinkId') @Get('uplinks/:uplinkId')
@ApiOperation({ summary: '查询上行短信详情' }) @ApiOperation({ summary: '查询上行短信详情' })
getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) { getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) {
return this.service.getUplink(request.openApiAuth!, uplinkId); return this.service.getUplink(request.openApiAuth!, uplinkId);
} }
} }
function scalarHeader(request: OpenApiRequestLike, name: string) {
const value = request.headers[name];
return Array.isArray(value) ? value[0] : value;
}
+89 -2
View File
@@ -1,16 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, Matches, IsNotEmpty } from 'class-validator';
export class OpenApiSendMessageDto { export class OpenApiSendMessageDto {
@ApiProperty({ example: '13800138000', description: '中国大陆手机号' }) @ApiProperty({ example: '13800138000', description: '中国大陆手机号' })
@IsString()
@Matches(/^1\d{10}$/)
mobile!: string; mobile!: string;
@ApiProperty({ @ApiProperty({
example: '【示例签名】您的验证码是123456,5分钟内有效。', example: '【示例签名】您的验证码是123456,5分钟内有效。',
description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID', description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID',
}) })
@IsString()
@IsNotEmpty()
content!: string; content!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', maxLength: 128 }) @ApiPropertyOptional({ type: String, nullable: true, example: 'order-20260720-0001', maxLength: 128 })
@IsOptional()
@IsString()
@MaxLength(128)
clientMessageId?: string; clientMessageId?: string;
} }
@@ -24,7 +32,7 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' }) @ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' })
messageId!: string; messageId!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', nullable: true }) @ApiProperty({ type: String, example: 'order-20260720-0001', nullable: true })
clientMessageId!: string | null; clientMessageId!: string | null;
@ApiProperty({ example: 'queued' }) @ApiProperty({ example: 'queued' })
@@ -33,3 +41,82 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: '2026-07-20T08:00:00.000Z' }) @ApiProperty({ example: '2026-07-20T08:00:00.000Z' })
acceptedAt!: string; acceptedAt!: string;
} }
export class OpenApiProblemDto {
@ApiProperty() type!: string;
@ApiProperty() title!: string;
@ApiProperty() status!: number;
@ApiProperty() code!: string;
@ApiProperty() detail!: string;
@ApiProperty() requestId!: string;
}
export class OpenApiMessageDto {
@ApiProperty() messageId!: string;
@ApiProperty({ type: String, nullable: true }) clientMessageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() status!: string;
@ApiProperty() submitStatus!: string;
@ApiProperty() receiptStatus!: string;
@ApiProperty({ type: String, nullable: true }) errorCode!: string | null;
@ApiProperty({ type: String, nullable: true }) errorMessage!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) queuedAt!: string;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) submittedAt!: string | null;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) deliveredAt!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) updatedAt!: string;
}
export class OpenApiUplinkDto {
@ApiProperty() id!: string;
@ApiProperty({ type: String, nullable: true }) messageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
}
export class OpenApiUplinkDetailDto extends OpenApiUplinkDto {
@ApiProperty() tenantId!: string;
@ApiProperty() applicationId!: string;
}
export class OpenApiUplinksDto {
@ApiProperty({ type: [OpenApiUplinkDto] }) items!: OpenApiUplinkDto[];
@ApiProperty({ type: String, nullable: true }) nextCursor!: string | null;
}
export class OpenApiReceiptDataDto {
@ApiProperty() messageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) gatewayMessageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() receiptStatus!: string;
@ApiPropertyOptional({ type: String, nullable: true }) rawStatus?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorCode?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorMessage?: string | null;
@ApiPropertyOptional({ type: String, format: 'date-time', nullable: true }) deliveredAt?: string | null;
}
export class OpenApiUplinkDataDto {
@ApiProperty() applicationId!: string;
@ApiProperty() uplinkMessageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) messageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
@ApiPropertyOptional() manualClaim?: boolean;
}
export class OpenApiReceiptEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['receipt'] }) eventType!: 'receipt';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiReceiptDataDto }) data!: OpenApiReceiptDataDto;
}
export class OpenApiUplinkEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['uplink'] }) eventType!: 'uplink';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiUplinkDataDto }) data!: OpenApiUplinkDataDto;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import { OpenApiDocsController } from './open-api-docs.controller';
import { forwardRef, Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { SendChainModule } from '../send-chain/send-chain.module'; import { SendChainModule } from '../send-chain/send-chain.module';
@@ -10,8 +12,8 @@ import { SecurityDetectionModule } from '../security-detection/security-detectio
@Module({ @Module({
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule], imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController], controllers: [OpenApiDocsController, OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard], providers: [OpenApiTraceInterceptor, OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService], exports: [OpenApiService],
}) })
export class OpenApiModule {} export class OpenApiModule {}
+113
View File
@@ -0,0 +1,113 @@
import { createHash, createHmac } from 'node:crypto';
import { BadRequestException, HttpException } from '@nestjs/common';
import type { LookupFunction } from 'node:net';
/** Keep the validated address pinned while honoring Node's all-address lookup contract. */
export function pinnedWebhookLookup(address: string, family: number): LookupFunction {
return (_hostname, options, callback) => {
if (options.all) callback(null, [{ address, family }]);
else callback(null, address, family);
};
}
/** Validate calendar components before Date can normalize an impossible day. */
export function parseOpenApiDate(value: string): Date {
const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2}))?$/.exec(
value,
);
if (parts) {
const year = Number(parts[1]);
const month = Number(parts[2]);
const day = Number(parts[3]);
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const zone = parts[8];
const validZone = !zone || zone === 'Z' || (Number(zone.slice(1, 3)) < 24 && Number(zone.slice(4)) < 60);
const date = new Date(value);
if (
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= days[month - 1] &&
Number(parts[4] ?? 0) < 24 &&
Number(parts[5] ?? 0) < 60 &&
Number(parts[6] ?? 0) < 60 &&
validZone &&
Number.isFinite(date.getTime())
)
return date;
}
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '时间必须为有效的ISO8601日期或带时区时间' });
}
export type OpenApiProblemResponse = {
setHeader(name: string, value: string): void;
status(code: number): { type(value: string): { send(body: unknown): void } };
};
export function sendOpenApiProblem(
response: OpenApiProblemResponse,
requestId: string,
failure: { status: number; code: string; message: string },
) {
response.setHeader('X-Request-Id', requestId);
response
.status(failure.status)
.type('application/problem+json')
.send({
type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`,
title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed',
status: failure.status,
code: failure.code,
detail: failure.message,
requestId,
});
}
/** Internal idempotency fingerprint; this digest is not part of request authentication. */
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
return createHash('sha256')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
.digest('hex');
}
export function openApiSignature(
secret: string,
method: string,
path: string,
timestamp: string,
nonce: string,
rawBody?: Buffer,
) {
const verb = method.toUpperCase();
if (verb === 'GET' && rawBody?.length) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'GET请求不得携带正文' });
}
if (verb !== 'GET' && !rawBody) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '缺少原始请求体' });
}
const signature = createHmac('sha256', secret).update(
[verb, path.split('?')[0], timestamp, nonce].join('\n'),
'utf8',
);
if (verb !== 'GET') signature.update('\n').update(rawBody!);
return signature.digest('hex');
}
export function publicOpenApiFailure(error: unknown) {
if (!(error instanceof HttpException) || error.getStatus() >= 500) {
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
}
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const message = object.message ?? value;
return {
status: error.getStatus(),
code: String(object.code ?? 'REQUEST_FAILED'),
message: Array.isArray(message) ? message.join('') : String(message),
};
}
export function webhookJobId(deliveryId: string, attemptNo: number) {
return `webhook-${createHash('sha256').update(deliveryId).digest('hex')}-${attemptNo}`;
}
@@ -0,0 +1,70 @@
import { OpenApiRecovery } from './open-api.recovery';
describe('OpenApiRecovery', () => {
function setup() {
const prisma = {
openApiDispatchOutbox: {
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsBatchTask: { findUnique: jest.fn().mockResolvedValue({ status: 'queued' }) },
httpWebhookDelivery: { findMany: jest.fn().mockResolvedValue([]) },
};
const send = { enqueueBatchTask: jest.fn().mockResolvedValue({}) };
const queue = { getJob: jest.fn().mockResolvedValue(undefined), add: jest.fn().mockResolvedValue({}) };
return { prisma, send, queue, recovery: new OpenApiRecovery(prisma as never, send as never, queue as never) };
}
it('keeps publication failures durable without marking them dispatched', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
send.enqueueBatchTask.mockRejectedValue(new Error('controlled failure'));
await recovery.tick();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'pending' }),
data: expect.objectContaining({ leaseToken: expect.any(String), leaseUntil: expect.any(Date) }),
}),
);
});
it('does not dispatch a batch whose lease was taken by another instance', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.openApiDispatchOutbox.updateMany.mockResolvedValue({ count: 0 });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
});
it.each(['canceled', 'sending', 'finished', 'rejected', 'pending_review'])(
'does not enqueue non-eligible batch %s',
async (status) => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.smsBatchTask.findUnique.mockResolvedValue({ status });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'closed' }) }),
);
},
);
it('scans only versioned webhook deliveries and preserves active Redis jobs', async () => {
const { prisma, queue, recovery } = setup();
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'active' });
await recovery.tick();
expect(queue.add).not.toHaveBeenCalled();
expect(prisma.httpWebhookDelivery.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ recoveryVersion: 1 }), take: 50 }),
);
});
it('recovers a failed Redis job only when its durable PG row still needs delivery', async () => {
const { prisma, queue, recovery } = setup();
const remove = jest.fn().mockResolvedValue(undefined);
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'failed', remove });
await recovery.tick();
expect(remove).toHaveBeenCalledTimes(1);
expect(queue.add).toHaveBeenCalledTimes(1);
expect(queue.add.mock.calls[0][2].jobId).not.toContain(':');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { PrismaService } from '../prisma/prisma.service';
import type { SendChainService } from '../send-chain/send-chain.service';
import type { Queue } from 'bullmq';
import { webhookJobId } from './open-api.protocol';
/** Only versioned/new durable work is eligible; never infer or replay historical work. */
export class OpenApiRecovery {
private readonly logger = new Logger(OpenApiRecovery.name);
private pending?: Promise<void>;
private stopped = false;
constructor(
private readonly prisma: PrismaService,
private readonly sendChain: SendChainService,
private readonly queue: Queue<{ deliveryId: string }>,
) {}
tick(): Promise<void> {
if (this.stopped) return Promise.resolve();
if (this.pending) return this.pending;
this.pending = this.run().finally(() => {
this.pending = undefined;
});
return this.pending;
}
async close() {
this.stopped = true;
await this.pending;
}
private async run() {
try {
await this.dispatchMessages();
await this.dispatchWebhooks();
} catch (error) {
this.logger.error({
code: 'OPENAPI_RECOVERY_FAILED',
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
private async dispatchMessages() {
const now = new Date();
const rows = await this.prisma.openApiDispatchOutbox.findMany({
where: { status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
orderBy: { createdAt: 'asc' },
take: 50,
});
for (const row of rows) {
if (this.stopped) return;
const leaseToken = randomUUID();
const claimed = await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
data: { leaseToken, leaseUntil: new Date(Date.now() + 120_000) },
});
if (!claimed.count) continue;
try {
const task = await this.prisma.smsBatchTask.findUnique({
where: { id: row.batchTaskId },
select: { status: true },
});
if (!task || !['ready', 'queued'].includes(task.status)) {
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'closed', leaseToken: null, leaseUntil: null },
});
continue;
}
await this.sendChain.enqueueBatchTask(row.batchTaskId);
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'dispatched', leaseToken: null, leaseUntil: null },
});
} catch (error) {
this.logger.error({
code: 'OPENAPI_DISPATCH_PENDING',
outboxId: row.id,
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
}
private async dispatchWebhooks() {
const now = new Date();
const rows = await this.prisma.httpWebhookDelivery.findMany({
where: {
recoveryVersion: 1,
status: { in: ['pending', 'retrying', 'delivering'] },
AND: [
{ OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }] },
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
],
},
orderBy: { nextRetryAt: 'asc' },
take: 50,
});
for (const row of rows) {
const jobId = webhookJobId(row.id, row.attemptCount + 1);
const job = await this.queue.getJob(jobId);
if (job) {
const state = await job.getState();
if (!['failed', 'completed'].includes(state)) continue;
// Failed/finished jobs are no longer executing; DB state is the durable authority.
await job.remove();
}
await this.queue.add('deliver', { deliveryId: row.id }, { jobId, removeOnComplete: 1000, removeOnFail: 1000 });
}
}
}
+21 -38
View File
@@ -60,9 +60,7 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => { it('replays a completed request for the same idempotency key and body', async () => {
const prisma = { const prisma = {
openApiRequest: { openApiRequest: {
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
bodyHash: 'same', bodyHash: 'same',
status: 'completed', status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' }, responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
@@ -97,9 +95,7 @@ describe('OpenApiService', () => {
it('replays the same persisted business rejection', async () => { it('replays the same persisted business rejection', async () => {
const prisma = { const prisma = {
openApiRequest: { openApiRequest: {
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
bodyHash: 'same', bodyHash: 'same',
status: 'failed', status: 'failed',
httpStatus: 422, httpStatus: 422,
@@ -117,37 +113,29 @@ describe('OpenApiService', () => {
).rejects.toMatchObject({ status: 422 }); ).rejects.toMatchObject({ status: 422 });
}); });
it('uses the real send chain and persists the accepted response', async () => { it('returns only the response snapshot committed by the send chain', async () => {
const response = { code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' };
const prisma = { const prisma = {
openApiRequest: { openApiRequest: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue({ status: 'completed', responseBody: response }),
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
update: jest.fn().mockResolvedValue({}), update: jest.fn(),
}, },
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
}; };
const sendChain = { const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({}) };
createHttpBatchTask: jest
.fn()
.mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
};
const service = new OpenApiService(prisma as never, sendChain as never); const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage( await expect(
service.sendMessage(
auth() as never, auth() as never,
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { mobile: '18821203795', content: '示例', clientMessageId: 'client-1' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
); ),
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith( ).resolves.toEqual(response);
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }), expect(prisma.openApiRequest.update).not.toHaveBeenCalled();
);
expect(result).toEqual(
expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
);
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
}),
);
}); });
it('persists a 422 result when the real send chain rejects the business request', async () => { it('persists a 422 result when the real send chain rejects the business request', async () => {
@@ -180,13 +168,12 @@ describe('OpenApiService', () => {
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => { it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = { const prisma = {
smsApplication: { smsApplication: {
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' }, httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
}), }),
}, },
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) }, httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
$transaction: jest.fn().mockImplementation(async (callback) => callback(prisma)),
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) }, httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) }, httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
}; };
@@ -209,7 +196,7 @@ describe('OpenApiService', () => {
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2); expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith( expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' }, create: { eventId: 'event-row-1', endpointId: 'endpoint-1', recoveryVersion: 1 },
}), }),
); );
}); });
@@ -217,9 +204,7 @@ describe('OpenApiService', () => {
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => { it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = { const prisma = {
smsApplication: { smsApplication: {
findFirst: jest findFirst: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
id: 'app-1', id: 'app-1',
name: '应用A', name: '应用A',
interfaceEnabled: true, interfaceEnabled: true,
@@ -280,9 +265,7 @@ describe('OpenApiService', () => {
it('rejects an already expired credential before writing a secret', async () => { it('rejects an already expired credential before writing a secret', async () => {
const prisma = { const prisma = {
smsApplication: { smsApplication: {
findFirst: jest findFirst: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
id: 'app-1', id: 'app-1',
name: '应用A', name: '应用A',
interfaceEnabled: true, interfaceEnabled: true,
+207 -158
View File
@@ -1,3 +1,5 @@
import { OpenApiRecovery } from './open-api.recovery';
import { HTTP_REQUEST_CONTEXT } from '../send-chain/send-chain.contracts';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -14,7 +16,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; import { createHmac, randomBytes, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { request as httpRequest } from 'node:http'; import { request as httpRequest } from 'node:http';
@@ -24,8 +26,18 @@ import { SendChainService } from '../send-chain/send-chain.service';
import { decryptSecret, encryptSecret } from './open-api.crypto'; import { decryptSecret, encryptSecret } from './open-api.crypto';
import type { OpenApiAuthContext } from './open-api.types'; import type { OpenApiAuthContext } from './open-api.types';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { parseOpenApiDate, pinnedWebhookLookup, publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { automaticDeliveryMode } from './delivery-mode'; import { automaticDeliveryMode } from './delivery-mode';
export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport');
export type OpenApiWebhookTransport = (
url: string,
body: string,
headers: Record<string, string>,
timeoutMs: number,
requireHttps: boolean,
) => Promise<{ status: number; body: string }>;
const WEBHOOK_QUEUE = 'http-webhook-delivery'; const WEBHOOK_QUEUE = 'http-webhook-delivery';
const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400]; const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
@@ -58,11 +70,14 @@ export type HttpConfigInput = {
export class OpenApiService implements OnModuleInit, OnModuleDestroy { export class OpenApiService implements OnModuleInit, OnModuleDestroy {
private queue?: Queue<{ deliveryId: string }>; private queue?: Queue<{ deliveryId: string }>;
private worker?: Worker<{ deliveryId: string }>; private worker?: Worker<{ deliveryId: string }>;
private recovery?: OpenApiRecovery;
private recoveryTimer?: ReturnType<typeof setInterval>;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService, @Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService,
@Optional() private readonly protocolLogs?: ProtocolLogsService, @Optional() private readonly protocolLogs?: ProtocolLogsService,
@Optional() @Inject(OPEN_API_WEBHOOK_TRANSPORT) private readonly webhookTransport?: OpenApiWebhookTransport,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -72,6 +87,9 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity // Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints. // cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return; if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.recovery = new OpenApiRecovery(this.prisma, this.sendChain, this.queue);
this.recoveryTimer = setInterval(() => void this.recovery?.tick(), 15_000);
this.recoveryTimer.unref?.();
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
connection, connection,
concurrency: 10, concurrency: 10,
@@ -79,6 +97,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
} }
async onModuleDestroy() { async onModuleDestroy() {
if (this.recoveryTimer) clearInterval(this.recoveryTimer);
await this.recovery?.close();
await this.worker?.close(); await this.worker?.close();
await this.queue?.close(); await this.queue?.close();
} }
@@ -262,10 +282,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
) { ) {
if (!auth.config.sendEnabled) if (!auth.config.sendEnabled)
throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' }); throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim(); if (
typeof input.mobile !== 'string' ||
typeof input.content !== 'string' ||
(input.clientMessageId != null &&
(typeof input.clientMessageId !== 'string' || Array.from(input.clientMessageId).length > 128))
) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' });
}
const mobile = input.mobile.trim();
const content = String(input.content ?? ''); const content = String(input.content ?? '');
if (!/^1[3-9]\d{9}$/.test(mobile)) if (!/^1\d{10}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' }); if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim(); const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
@@ -283,8 +310,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致', message: '同一Idempotency-Key对应的请求内容不一致',
}); });
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody; if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) if (['failed', 'requires_review'].includes(existing.status) && existing.responseBody && existing.httpStatus)
throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus); throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
if (
existing.status === 'requires_review' ||
(existing.createdAt && Date.now() - existing.createdAt.getTime() > 600_000)
)
throw new ConflictException({
code: 'REQUEST_REQUIRES_REVIEW',
message: '请求结果待核对,请提供requestId联系支持,勿更换幂等键重发',
requestId: existing.requestId,
});
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
} }
if (input.clientMessageId) { if (input.clientMessageId) {
@@ -326,14 +362,15 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致', message: '同一Idempotency-Key对应的请求内容不一致',
}); });
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody; if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) if (raced && ['failed', 'requires_review'].includes(raced.status) && raced.responseBody && raced.httpStatus)
throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus); throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
} }
throw error; throw error;
} }
try { try {
const task = await this.sendChain.createHttpBatchTask({ await this.sendChain.createHttpBatchTask({
[HTTP_REQUEST_CONTEXT]: { id: request.id, requestId },
tenantId: auth.application.tenantId, tenantId: auth.application.tenantId,
applicationId: auth.application.id, applicationId: auth.application.id,
content, content,
@@ -342,50 +379,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
userAgent: meta.userAgent, userAgent: meta.userAgent,
clientMessageId: input.clientMessageId, clientMessageId: input.clientMessageId,
}); });
const message = task.messages?.[0]; const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (task.status === 'rejected' || message?.status === 'rejected') { if (frozen?.status === 'completed' && frozen.responseBody) {
throw new UnprocessableEntityException({ void this.recovery?.tick();
code: 'SEND_REJECTED', return frozen.responseBody;
message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
});
} }
const response = { if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
code: 'ACCEPTED', throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
requestId, throw new Error('HTTP acceptance snapshot was not committed');
messageId: message?.messageId,
clientMessageId: input.clientMessageId ?? null,
status: message?.status ?? task.status,
acceptedAt: new Date().toISOString(),
};
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'completed',
httpStatus: 202,
businessCode: 'ACCEPTED',
responseBody: response,
messageRecordId: message?.id,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'send_request',
status: 'accepted',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
messageId: message?.messageId,
requestId,
phone: mobile,
resultCode: 'ACCEPTED',
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
detail: { clientMessageId: input.clientMessageId },
});
return response;
} catch (error) { } catch (error) {
const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (frozen?.status === 'completed' && frozen.responseBody) return frozen.responseBody;
if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
let outwardError = error; let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) { if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse(); const response = error.getResponse();
@@ -399,7 +405,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.prisma.openApiRequest.update({ await this.prisma.openApiRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { data: {
status: 'failed', status: failure.httpStatus >= 500 ? 'requires_review' : 'failed',
httpStatus: failure.httpStatus, httpStatus: failure.httpStatus,
businessCode: failure.code, businessCode: failure.code,
responseBody: failure.responseBody, responseBody: failure.responseBody,
@@ -407,20 +413,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
completedAt: new Date(), completedAt: new Date(),
}, },
}); });
this.protocolLogs?.record({
protocol: 'http', throw new HttpException(failure.responseBody as Record<string, unknown>, failure.httpStatus);
direction: 'client_to_platform',
eventType: 'send_request',
status: 'failed',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
requestId,
phone: mobile,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
});
throw outwardError;
} }
} }
@@ -451,8 +445,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) { async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
if (!auth.config.uplinkQueryEnabled) if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const endTime = query.endTime ? new Date(query.endTime) : new Date(); for (const value of Object.values(query)) {
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000); if (value !== undefined && typeof value !== 'string')
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
}
if (query.mobile !== undefined && !/^1\d{10}$/.test(query.mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: 'mobile必须为1开头的11位手机号' });
if (query.accessNumber !== undefined && !/^\d{1,21}$/.test(query.accessNumber))
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'accessNumber必须为1至21位数字接入号' });
const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date();
const startTime =
query.startTime !== undefined ? parseOpenApiDate(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' }); throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000)
@@ -460,7 +463,12 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
code: 'TIME_RANGE_TOO_LARGE', code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}`, message: `单次查询不能超过${auth.config.maxQueryRangeDays}`,
}); });
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize); if (
query.limit !== undefined &&
(!/^\d+$/.test(query.limit) || !Number.isSafeInteger(Number(query.limit)) || Number(query.limit) < 1)
)
throw new BadRequestException({ code: 'LIMIT_INVALID', message: 'limit必须为正整数' });
const limit = Math.min(Number(query.limit ?? 50), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor); const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({ const rows = await this.prisma.smsUplinkMessage.findMany({
where: { where: {
@@ -482,8 +490,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: true, phoneNumber: true,
destId: true, destId: true,
content: true, content: true,
matchStatus: true,
matchReason: true,
receivedAt: true, receivedAt: true,
}, },
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }], orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
@@ -499,13 +505,29 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
if (!auth.config.uplinkQueryEnabled) if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({ const row = await this.prisma.smsUplinkMessage.findFirst({
where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' }, where: {
id: uplinkId,
applicationId: auth.application.id,
tenantId: auth.application.tenantId,
matchStatus: 'matched',
},
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
}); });
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' }); if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row; return row;
} }
async queueWebhookEvent(data: { async queueWebhookEvent(
data: {
tenantId: string; tenantId: string;
applicationId?: string | null; applicationId?: string | null;
messageRecordId?: string | null; messageRecordId?: string | null;
@@ -513,16 +535,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
uplinkMessageId?: string | null; uplinkMessageId?: string | null;
eventType: 'receipt' | 'uplink'; eventType: 'receipt' | 'uplink';
payload: Record<string, unknown>; payload: Record<string, unknown>;
}) { },
transaction?: Prisma.TransactionClient,
) {
const db = transaction ?? this.prisma;
if (!data.applicationId) return null; if (!data.applicationId) return null;
const application = await this.prisma.smsApplication.findUnique({ const application = await db.smsApplication.findUnique({
where: { id: data.applicationId }, where: { id: data.applicationId },
include: { httpConfig: true }, include: { httpConfig: true },
}); });
const config = application?.httpConfig; const config = application?.httpConfig;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled; const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
if (!config?.enabled || !enabled) return null; if (!config?.enabled || !enabled) return null;
const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ const endpoint = await db.httpWebhookEndpoint.findUnique({
where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } }, where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } },
}); });
if (!endpoint || endpoint.status !== 'active') return null; if (!endpoint || endpoint.status !== 'active') return null;
@@ -532,13 +557,14 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
: data.eventType === 'uplink' && data.uplinkMessageId : data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}` ? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`; : `evt_${randomUUID()}`;
const event = await this.prisma.httpWebhookEvent.upsert({ const persist = async (tx: Prisma.TransactionClient) => {
const event = await tx.httpWebhookEvent.upsert({
where: { eventId }, where: { eventId },
update: {}, update: {},
create: { create: {
eventId, eventId,
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId!,
eventType: data.eventType, eventType: data.eventType,
messageRecordId: data.messageRecordId, messageRecordId: data.messageRecordId,
messageId: data.messageId, messageId: data.messageId,
@@ -546,16 +572,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
payload: data.payload as Prisma.InputJsonValue, payload: data.payload as Prisma.InputJsonValue,
}, },
}); });
const delivery = await this.prisma.httpWebhookDelivery.upsert({ return tx.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } }, where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
update: {}, update: {},
create: { eventId: event.id, endpointId: endpoint.id }, create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 },
}); });
if (delivery.status === 'delivered') return delivery; };
const delivery = transaction ? await persist(transaction) : await this.prisma.$transaction(persist);
if (transaction) return delivery;
if (delivery.status !== 'pending' || delivery.recoveryVersion !== 1) return delivery;
await this.queue?.add( await this.queue?.add(
'deliver', 'deliver',
{ deliveryId: delivery.id }, { deliveryId: delivery.id },
{ jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 }, { jobId: webhookJobId(delivery.id, 1), removeOnComplete: 1000, removeOnFail: 1000 },
); );
return delivery; return delivery;
} }
@@ -603,14 +632,27 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId, event: { applicationId } }, where: { id: deliveryId, event: { applicationId } },
}); });
if (!delivery) throw new NotFoundException('Webhook投递记录不存在'); if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
await this.prisma.httpWebhookDelivery.update({ const reset = await this.prisma.httpWebhookDelivery.updateMany({
where: { id: delivery.id }, where: {
data: { status: 'pending', nextRetryAt: null, lastError: null }, id: delivery.id,
status: { in: ['pending', 'retrying', 'failed'] },
attemptCount: delivery.attemptCount,
OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
},
data: {
status: 'pending',
nextRetryAt: null,
lastError: null,
recoveryVersion: 1,
leaseToken: null,
leaseUntil: null,
},
}); });
if (!reset.count) throw new ConflictException('回调正在投递或已成功,不能重投');
await this.queue?.add( await this.queue?.add(
'deliver', 'deliver',
{ deliveryId }, { deliveryId },
{ jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 }, { jobId: webhookJobId(deliveryId, Date.now()), removeOnComplete: 1000, removeOnFail: 1000 },
); );
return { id: deliveryId, status: 'pending' }; return { id: deliveryId, status: 'pending' };
} }
@@ -620,11 +662,32 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId }, where: { id: deliveryId },
include: { event: true, endpoint: true }, include: { event: true, endpoint: true },
}); });
if (!delivery || delivery.status === 'delivered') return; if (!delivery || !['pending', 'retrying', 'delivering'].includes(delivery.status)) return;
if (delivery.nextRetryAt && delivery.nextRetryAt.getTime() > Date.now()) return;
const config = await this.prisma.smsApplicationHttpConfig.findUnique({ const config = await this.prisma.smsApplicationHttpConfig.findUnique({
where: { applicationId: delivery.event.applicationId }, where: { applicationId: delivery.event.applicationId },
}); });
if (!config) return; if (
!config?.enabled ||
delivery.endpoint.status !== 'active' ||
!(delivery.event.eventType === 'receipt' ? config.receiptWebhookEnabled : config.uplinkWebhookEnabled)
)
return;
const leaseToken = randomUUID();
const claimed = await this.prisma.httpWebhookDelivery.updateMany({
where: {
id: deliveryId,
status: delivery.status,
attemptCount: delivery.attemptCount,
OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
},
data: {
status: 'delivering',
leaseToken,
leaseUntil: new Date(Date.now() + config.webhookTimeoutSeconds * 1000 + 60_000),
},
});
if (!claimed.count) return;
const attemptNo = delivery.attemptCount + 1; const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000)); const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({ const body = JSON.stringify({
@@ -641,7 +704,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let responseSummary: string | undefined; let responseSummary: string | undefined;
let errorMessage: string | undefined; let errorMessage: string | undefined;
try { try {
const response = await postWebhook( const response = await (this.webhookTransport ?? postWebhook)(
delivery.endpoint.url, delivery.endpoint.url,
body, body,
{ {
@@ -665,22 +728,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
responseStatus === 408 || responseStatus === 408 ||
responseStatus === 429 || responseStatus === 429 ||
(responseStatus !== undefined && responseStatus >= 500); (responseStatus !== undefined && responseStatus >= 500);
await this.prisma.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
this.protocolLogs?.record({ this.protocolLogs?.record({
protocol: 'http', protocol: 'http',
direction: 'platform_to_client', direction: 'platform_to_client',
@@ -696,56 +743,53 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
retryCount: attemptNo - 1, retryCount: attemptNo - 1,
detail: { deliveryId, attemptNo, error: errorMessage }, detail: { deliveryId, attemptNo, error: errorMessage },
}); });
if (success) {
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'delivered',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: null,
deliveredAt: new Date(),
nextRetryAt: null,
},
});
return;
}
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length); const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) { const willRetry = !success && config.webhookRetryEnabled && retryable && attemptNo < maxAttempts;
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!; const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000); const nextRetryAt = willRetry ? new Date(Date.now() + delaySeconds * 1000) : null;
await this.prisma.httpWebhookDelivery.update({ await this.prisma.$transaction(async (tx) => {
where: { id: deliveryId }, const updated = await tx.httpWebhookDelivery.updateMany({
where: { id: deliveryId, leaseToken },
data: { data: {
status: 'retrying', status: success ? 'delivered' : willRetry ? 'retrying' : 'failed',
attemptCount: attemptNo, attemptCount: attemptNo,
lastHttpStatus: responseStatus, lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`, lastError: success ? null : (errorMessage ?? 'HTTP ' + responseStatus),
deliveredAt: success ? new Date() : null,
nextRetryAt, nextRetryAt,
leaseToken: null,
leaseUntil: null,
}, },
}); });
if (!updated.count) return;
await tx.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
});
if (willRetry)
await this.queue?.add( await this.queue?.add(
'deliver', 'deliver',
{ deliveryId }, { deliveryId },
{ {
jobId: `${deliveryId}:${attemptNo + 1}`, jobId: webhookJobId(deliveryId, attemptNo + 1),
delay: delaySeconds * 1000, delay: delaySeconds * 1000,
removeOnComplete: 1000, removeOnComplete: 1000,
removeOnFail: 1000, removeOnFail: 1000,
}, },
); );
return;
}
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt: null,
},
});
} }
private async requireApplication(applicationId: string, tenantId?: string) { private async requireApplication(applicationId: string, tenantId?: string) {
@@ -783,23 +827,11 @@ function httpApiPublicOrigin() {
} }
function normalizeOpenApiFailure(error: unknown) { function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) { const failure = publicOpenApiFailure(error);
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const rawMessage = object.message ?? error.message;
return { return {
httpStatus: error.getStatus(), httpStatus: failure.status,
code: String(object.code ?? 'SEND_REJECTED'), code: failure.code,
responseBody: { responseBody: { code: failure.code, message: failure.message } as Prisma.InputJsonValue,
code: String(object.code ?? 'SEND_REJECTED'),
message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage),
} as Prisma.InputJsonValue,
};
}
return {
httpStatus: 500,
code: 'INTERNAL_ERROR',
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
}; };
} }
@@ -880,7 +912,7 @@ async function validateWebhookUrl(value: string, requireHttps: boolean) {
return (await resolveWebhookTarget(value, requireHttps)).url.toString(); return (await resolveWebhookTarget(value, requireHttps)).url.toString();
} }
async function resolveWebhookTarget(value: string, requireHttps: boolean) { export async function resolveWebhookTarget(value: string, requireHttps: boolean) {
let url: URL; let url: URL;
try { try {
url = new URL(String(value ?? '').trim()); url = new URL(String(value ?? '').trim());
@@ -890,7 +922,13 @@ async function resolveWebhookTarget(value: string, requireHttps: boolean) {
if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS'); if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS');
if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS'); if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码'); if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码');
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }); const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
let addresses: Array<{ address: string }>;
try {
addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true });
} catch {
throw new BadRequestException('Webhook域名未解析到可用地址');
}
if (addresses.some(({ address }) => isPrivateAddress(address))) if (addresses.some(({ address }) => isPrivateAddress(address)))
throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址'); throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
const selected = addresses[0]; const selected = addresses[0];
@@ -913,7 +951,7 @@ async function postWebhook(
{ {
method: 'POST', method: 'POST',
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) }, headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family), lookup: pinnedWebhookLookup(target.address, target.family),
}, },
(response) => { (response) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -937,7 +975,14 @@ async function postWebhook(
} }
function isPrivateAddress(address: string) { function isPrivateAddress(address: string) {
const normalized = address.replace(/^::ffff:/, ''); const canonical = isIP(address) === 6 ? new URL(`http://[${address}]`).hostname.slice(1, -1) : address;
const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/i.exec(canonical);
if (mapped) {
const high = parseInt(mapped[1], 16),
low = parseInt(mapped[2], 16);
return isPrivateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
}
const normalized = canonical.toLowerCase();
if ( if (
normalized === '::1' || normalized === '::1' ||
normalized === '::' || normalized === '::' ||
@@ -968,8 +1013,12 @@ function encodeCursor(receivedAt: Date, id: string) {
function decodeCursor(value?: string) { function decodeCursor(value?: string) {
if (!value) return null; if (!value) return null;
try { try {
const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string]; if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error();
const receivedAt = new Date(date); const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
throw new Error();
const [date, id] = parsed;
const receivedAt = parseOpenApiDate(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error(); if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id }; return { receivedAt, id };
} catch { } catch {
+1
View File
@@ -17,4 +17,5 @@ export type OpenApiRequestLike = {
headers: Record<string, string | string[] | undefined>; headers: Record<string, string | string[] | undefined>;
socket?: { remoteAddress?: string }; socket?: { remoteAddress?: string };
openApiAuth?: OpenApiAuthContext; openApiAuth?: OpenApiAuthContext;
openApiRequestId?: string;
}; };
@@ -178,8 +178,8 @@ export class AdminOperationsController {
return this.operations.signatureQuality({ return this.operations.signatureQuality({
date, date,
keyword, keyword,
page: Number(page), page: page === undefined ? 1 : Number(page),
pageSize: Number(pageSize), pageSize: pageSize === undefined ? 25 : Number(pageSize),
}); });
} }
+127 -40
View File
@@ -1,11 +1,18 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { moneyToNumber } from '../common/money'; import { moneyToNumber } from '../common/money';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts'; import type {
MessageQuery,
DownstreamDeliveryDashboardQuery,
DownstreamRecoveryStatusQuery,
} from './operations.contracts';
// Pure query builders and response mappers shared by the R2 query domains. // Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere = query.status === 'submit_failed' const statusWhere =
query.status === 'unknown'
? { status: { in: ['submitted', 'unknown'] } }
: query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed' : query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' } ? { status: 'failed', submitStatus: 'accepted' }
@@ -21,25 +28,39 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI
phoneNumber: query.phoneNumber, phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier), ...carrierWhere(query.carrier),
...statusWhere, ...statusWhere,
...(query.hasDrainage === 'true' ? { hasDrainageContent: true } ...(query.hasDrainage === 'true'
: query.hasDrainage === 'false' ? { hasDrainageContent: false } ? { hasDrainageContent: true }
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null } : query.hasDrainage === 'false'
? { hasDrainageContent: false }
: query.hasDrainage === 'unknown'
? { hasDrainageContent: null }
: {}), : {}),
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? { ...(query.queuedAtFrom || query.queuedAtTo
? {
queuedAt: { queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
}, },
} : {}), }
: {}),
}; };
} }
export const recognizedCarrierValues = [ export const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动', 'mobile',
'unicom', 'cucc', '联通', '中国联通', 'cmcc',
'telecom', 'ctcc', '电信', '中国电信', '移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
]; ];
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput { export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {}; if (!carrier) return {};
@@ -48,10 +69,7 @@ export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInpu
return { return {
AND: [ AND: [
{ {
OR: [ OR: [{ carrier: null }, { carrier: { notIn: recognizedCarrierValues } }],
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
}, },
], ],
}; };
@@ -107,10 +125,7 @@ export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma
return { return {
tenantId, tenantId,
createdAt: { gte: since }, createdAt: { gte: since },
OR: [ OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
}; };
} }
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
@@ -161,13 +176,12 @@ export function downstreamAlertWhere(
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
return { return {
status: 'pending', status: 'pending',
OR: [ OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }],
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
}; };
} }
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput { export function downstreamDeliveryScopedWhere(
query: DownstreamDeliveryDashboardQuery,
): Prisma.CmppDownstreamDeliveryWhereInput {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false); const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true); const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return { return {
@@ -191,14 +205,16 @@ export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQue
state: query.state && query.state !== 'all' ? query.state : undefined, state: query.state && query.state !== 'all' ? query.state : undefined,
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: query.keyword ? [ OR: query.keyword
? [
{ account: { contains: query.keyword } }, { account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } }, { gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } }, { lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } }, { lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } }, { tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } },
] : undefined, ]
: undefined,
}; };
} }
export function escapeCsvCell(value: string) { export function escapeCsvCell(value: string) {
@@ -254,6 +270,22 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null, carrier: message.carrier ?? null,
province: message.province ?? null, province: message.province ?? null,
content: message.content, content: message.content,
originalContent: message.originalContent ?? null,
drainageGate: message.drainageGate
? {
version: message.drainageGate.version,
evaluatedAt: message.drainageGate.evaluatedAt,
reason: message.drainageGate.reason,
reasonCode: message.drainageGate.reasonCode,
targets: (message.drainageGate.targets ?? []).map(
(target: { text: string; category: string; value: string }) => ({
text: target.text,
category: target.category,
value: target.value,
}),
),
}
: null,
billingUnits: message.billingUnits, billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents), amountCents: moneyToNumber(message.amountCents),
status: message.status, status: message.status,
@@ -338,7 +370,13 @@ export function clientRechargeView(order: Record<string, any>) {
completedAt: order.completedAt ?? null, completedAt: order.completedAt ?? null,
}; };
} }
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) { export function summarizeMessageGroups(
groups: Array<{
status: string;
_count: { _all: number };
_sum: { amountCents: number | bigint | null; billingUnits: number | null };
}>,
) {
return groups.reduce( return groups.reduce(
(summary, group) => { (summary, group) => {
const count = group._count._all; const count = group._count._all;
@@ -360,8 +398,29 @@ export function summarizeMessageGroups(groups: Array<{ status: string; _count: {
export function groupDownstreamByType( export function groupDownstreamByType(
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>, groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
) { ) {
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => { return groups.reduce<
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 }; Record<
string,
{
total: number;
pending: number;
awaitingAck: number;
delivered: number;
failed: number;
unconfirmed: number;
rejected: number;
}
>
>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? {
total: 0,
pending: 0,
awaitingAck: 0,
delivered: 0,
failed: 0,
unconfirmed: 0,
rejected: 0,
};
current.total += item._count._all; current.total += item._count._all;
if (item.status === 'pending') { if (item.status === 'pending') {
current.pending += item._count._all; current.pending += item._count._all;
@@ -385,7 +444,20 @@ export function groupDownstreamByApplication(
applicationMap: Map<string, string>, applicationMap: Map<string, string>,
applicationAlertMap: Map<string, number>, applicationAlertMap: Map<string, number>,
) { ) {
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>(); const summaryMap = new Map<
string,
{
applicationId: string;
name: string;
pending: number;
awaitingAck: number;
failed: number;
unconfirmed: number;
rejected: number;
delivered: number;
alertCount: number;
}
>();
groups.forEach((item) => { groups.forEach((item) => {
const current = summaryMap.get(item.applicationId) ?? { const current = summaryMap.get(item.applicationId) ?? {
applicationId: item.applicationId, applicationId: item.applicationId,
@@ -430,10 +502,7 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
], ],
}; };
const warning: Prisma.OperationLogWhereInput = { const warning: Prisma.OperationLogWhereInput = {
OR: [ OR: [{ action: { contains: 'warning' } }, { action: { contains: 'risk' } }],
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
}; };
const success: Prisma.OperationLogWhereInput = { const success: Prisma.OperationLogWhereInput = {
OR: [ OR: [
@@ -459,7 +528,8 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>; const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? ''); const result = String(detail.result ?? detail.status ?? '');
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') const level =
result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error' ? 'error'
: log.action.includes('warning') || log.action.includes('risk') : log.action.includes('warning') || log.action.includes('risk')
? 'warning' ? 'warning'
@@ -482,14 +552,23 @@ export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ inclu
} }
export function sanitizeGatewaySubmitException( export function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>, item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string }, messageState?: {
status: string;
submitStatus: string | null;
receiptStatus: string | null;
phoneNumber: string;
content: string;
},
) { ) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item; const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return { return {
...record, ...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null, tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null, application: application
channel: channel ? { ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status }
: null,
channel: channel
? {
id: channel.id, id: channel.id,
code: channel.code, code: channel.code,
name: channel.name, name: channel.name,
@@ -497,7 +576,8 @@ export function sanitizeGatewaySubmitException(
carrier: channel.carrier, carrier: channel.carrier,
sendRegion: channel.sendRegion, sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond, rateLimitPerSecond: channel.rateLimitPerSecond,
} : null, }
: null,
rawPayloadAvailable: Boolean(rawPayload), rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload), commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null, messageState: messageState ?? null,
@@ -512,8 +592,15 @@ export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prism
for (const [key, child] of Object.entries(value)) { for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase(); const normalizedKey = key.toLowerCase();
redacted[key] = [ redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource', 'password',
'token', 'apikey', 'accesskey', 'secretkey', 'passwordcipher',
'secret',
'secrethash',
'authsource',
'token',
'apikey',
'accesskey',
'secretkey',
].includes(normalizedKey) ].includes(normalizedKey)
? '[REDACTED]' ? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue); : redactGatewayCommandValue(child as Prisma.JsonValue);
+320 -131
View File
@@ -1,3 +1,4 @@
import { OperationsQualityQueries } from './queries/quality.queries';
import { OperationsService } from './operations.service'; import { OperationsService } from './operations.service';
function createPrismaMock() { function createPrismaMock() {
@@ -18,9 +19,17 @@ function createPrismaMock() {
}, },
smsMessageRecord: { smsMessageRecord: {
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]), findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', messageId: 'MSG-1', submitRecords: [], receiptRecords: [], downstreamDeliveries: [] }), findUnique: jest.fn().mockResolvedValue({
id: 'message-1',
messageId: 'MSG-1',
submitRecords: [],
receiptRecords: [],
downstreamDeliveries: [],
}),
count: jest.fn().mockResolvedValue(51), count: jest.fn().mockResolvedValue(51),
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]), groupBy: jest
.fn()
.mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
}, },
smsReceiptRecord: { smsReceiptRecord: {
@@ -64,10 +73,15 @@ function createPrismaMock() {
]), ]),
}, },
cmppConnectionState: { cmppConnectionState: {
groupBy: jest.fn().mockResolvedValue([{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]), groupBy: jest
.fn()
.mockResolvedValue([
{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } },
]),
}, },
operationLog: { operationLog: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'log-1', id: 'log-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
tenant: { name: '租户A' }, tenant: { name: '租户A' },
@@ -77,12 +91,14 @@ function createPrismaMock() {
resourceId: 'order-1', resourceId: 'order-1',
detail: { amountCents: 1000 }, detail: { amountCents: 1000 },
createdAt: new Date('2026-07-02T01:00:00.000Z'), createdAt: new Date('2026-07-02T01:00:00.000Z'),
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]), groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
}, },
gatewaySubmitDeadLetter: { gatewaySubmitDeadLetter: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'dead-1', id: 'dead-1',
streamMessageId: '1710000000000-0', streamMessageId: '1710000000000-0',
status: 'pending', status: 'pending',
@@ -93,13 +109,15 @@ function createPrismaMock() {
tenant: { name: '租户A' }, tenant: { name: '租户A' },
application: { name: '应用A' }, application: { name: '应用A' },
channel: { code: 'CMPP-A' }, channel: { code: 'CMPP-A' },
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }), findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
}, },
smsReceiptAnomaly: { smsReceiptAnomaly: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'receipt-anomaly-1', id: 'receipt-anomaly-1',
anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1', anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1',
anomalyType: 'aggregate_success_then_failure', anomalyType: 'aggregate_success_then_failure',
@@ -113,13 +131,15 @@ function createPrismaMock() {
messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' }, messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' },
submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' }, submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' },
receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' }, receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' },
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }), findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }),
}, },
gatewayDownstreamRecoveryStatus: { gatewayDownstreamRecoveryStatus: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'recover-1', id: 'recover-1',
account: '100001', account: '100001',
state: 'waiting_connection', state: 'waiting_connection',
@@ -131,7 +151,8 @@ function createPrismaMock() {
lastError: 'downstream client is not connected', lastError: 'downstream client is not connected',
tenant: { name: '租户A' }, tenant: { name: '租户A' },
application: { name: '应用A' }, application: { name: '应用A' },
}]), },
]),
findUnique: jest.fn().mockResolvedValue({ findUnique: jest.fn().mockResolvedValue({
id: 'recover-1', id: 'recover-1',
account: '100001', account: '100001',
@@ -153,12 +174,11 @@ function createPrismaMock() {
application: { name: '应用A' }, application: { name: '应用A' },
}), }),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([ groupBy: jest.fn().mockResolvedValue([{ failureCategory: 'client_disconnected', _count: { _all: 1 } }]),
{ failureCategory: 'client_disconnected', _count: { _all: 1 } },
]),
}, },
smsMessageSegmentAudit: { smsMessageSegmentAudit: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'segment-1', id: 'segment-1',
messageRecordId: 'record-1', messageRecordId: 'record-1',
submitId: 'SUB-1', submitId: 'SUB-1',
@@ -169,10 +189,12 @@ function createPrismaMock() {
submitStatus: 'accepted', submitStatus: 'accepted',
receiptStatus: 'delivered', receiptStatus: 'delivered',
channel: { name: '通道A' }, channel: { name: '通道A' },
}]), },
]),
}, },
cmppDownstreamDelivery: { cmppDownstreamDelivery: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'delivery-1', id: 'delivery-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
@@ -185,7 +207,8 @@ function createPrismaMock() {
tenant: { name: '租户A' }, tenant: { name: '租户A' },
application: { name: '应用A' }, application: { name: '应用A' },
messageRecord: { messageId: 'MSG-1' }, messageRecord: { messageId: 'MSG-1' },
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([ groupBy: jest.fn().mockResolvedValue([
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } }, { deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
@@ -210,9 +233,11 @@ describe('OperationsService', () => {
await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' }); await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' });
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' },
})); }),
);
}); });
it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => { it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => {
@@ -274,7 +299,8 @@ describe('OperationsService', () => {
await service.listMessages({ carrier: 'unknown' }); await service.listMessages({ carrier: 'unknown' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
AND: [ AND: [
{ {
@@ -283,9 +309,18 @@ describe('OperationsService', () => {
{ {
carrier: { carrier: {
notIn: [ notIn: [
'mobile', 'cmcc', '移动', '中国移动', 'mobile',
'unicom', 'cucc', '联通', '中国联通', 'cmcc',
'telecom', 'ctcc', '电信', '中国电信', '移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
], ],
}, },
}, },
@@ -293,7 +328,8 @@ describe('OperationsService', () => {
}, },
], ],
}), }),
})); }),
);
}); });
it('separates upstream submit failures from post-acceptance delivery failures', async () => { it('separates upstream submit failures from post-acceptance delivery failures', async () => {
@@ -301,16 +337,20 @@ describe('OperationsService', () => {
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await service.listMessages({ status: 'submit_failed' }); await service.listMessages({ status: 'submit_failed' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }], OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }],
}), }),
})); }),
);
await service.listMessages({ status: 'failed' }); await service.listMessages({ status: 'failed' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }), where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }),
})); }),
);
}); });
it('paginates message summaries without preloading detail relations', async () => { it('paginates message summaries without preloading detail relations', async () => {
@@ -323,7 +363,8 @@ describe('OperationsService', () => {
page: 2, page: 2,
pageSize: 25, pageSize: 25,
}); });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }), where: expect.objectContaining({ tenantId: 'tenant-1' }),
skip: 25, skip: 25,
take: 25, take: 25,
@@ -332,9 +373,12 @@ describe('OperationsService', () => {
id: true, id: true,
content: true, content: true,
hasDrainageContent: true, hasDrainageContent: true,
receiptStatus: true,
deliveredAt: true,
tenant: { select: { id: true, name: true } }, tenant: { select: { id: true, name: true } },
}), }),
})); }),
);
const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0]; const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0];
expect(call.select).not.toHaveProperty('submitRecords'); expect(call.select).not.toHaveProperty('submitRecords');
expect(call.select).not.toHaveProperty('receiptRecords'); expect(call.select).not.toHaveProperty('receiptRecords');
@@ -351,7 +395,14 @@ describe('OperationsService', () => {
await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' }); await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' });
expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({ expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', channelId: 'channel-1', applicationId: undefined, phoneNumber: undefined, content: undefined, receivedAt: undefined }, where: {
tenantId: 'tenant-1',
channelId: 'channel-1',
applicationId: undefined,
phoneNumber: undefined,
content: undefined,
receivedAt: undefined,
},
include: { include: {
tenant: true, tenant: true,
application: true, application: true,
@@ -376,19 +427,22 @@ describe('OperationsService', () => {
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' })); await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' }));
expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'message-1' }, where: { id: 'message-1' },
include: expect.objectContaining({ include: expect.objectContaining({
submitRecords: expect.any(Object), submitRecords: expect.any(Object),
receiptRecords: expect.any(Object), receiptRecords: expect.any(Object),
downstreamDeliveries: expect.any(Object), downstreamDeliveries: expect.any(Object),
}), }),
})); }),
);
}); });
it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => { it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsUplinkMessage.findMany.mockResolvedValue([{ prisma.smsUplinkMessage.findMany.mockResolvedValue([
{
id: 'uplink-1', id: 'uplink-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
@@ -416,7 +470,8 @@ describe('OperationsService', () => {
application: { id: 'app-1', name: '应用A' }, application: { id: 'app-1', name: '应用A' },
}, },
matchCandidates: [], matchCandidates: [],
}]); },
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' }); const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' });
@@ -432,7 +487,8 @@ describe('OperationsService', () => {
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => { it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsMessageRecord.findMany.mockResolvedValue([{ prisma.smsMessageRecord.findMany.mockResolvedValue([
{
id: 'record-1', id: 'record-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
batchTaskId: 'task-1', batchTaskId: 'task-1',
@@ -451,17 +507,31 @@ describe('OperationsService', () => {
tenant: { id: 'tenant-1', name: '企业A' }, tenant: { id: 'tenant-1', name: '企业A' },
channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 }, channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 },
submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }], submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }],
receiptRecords: [{ receiptRecords: [
id: 'receipt-1', messageId: 'MSG-1', gatewayMessageId: 'GW-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD', {
errorCode: null, errorMessage: null, deliveredAt: new Date('2026-07-21T01:00:05.000Z'), createdAt: new Date('2026-07-21T01:00:05.000Z'), id: 'receipt-1',
}], messageId: 'MSG-1',
}]); gatewayMessageId: 'GW-1',
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
errorCode: null,
errorMessage: null,
deliveredAt: new Date('2026-07-21T01:00:05.000Z'),
createdAt: new Date('2026-07-21T01:00:05.000Z'),
},
],
},
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
const [message] = await service.listClientMessages({ tenantId: 'tenant-1' }); const [message] = await service.listClientMessages({ tenantId: 'tenant-1' });
expect(message).toMatchObject({ expect(message).toMatchObject({
id: 'record-1', messageId: 'MSG-1', carrier: 'mobile', province: '上海', application: { id: 'app-1', name: '应用A' }, id: 'record-1',
messageId: 'MSG-1',
carrier: 'mobile',
province: '上海',
application: { id: 'app-1', name: '应用A' },
receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })], receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })],
}); });
expect(message).not.toHaveProperty('tenant'); expect(message).not.toHaveProperty('tenant');
@@ -474,11 +544,19 @@ describe('OperationsService', () => {
it('returns a client dashboard without gateway state or supplier channel secrets', async () => { it('returns a client dashboard without gateway state or supplier channel secrets', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsBatchTask.findMany.mockResolvedValue([{ prisma.smsBatchTask.findMany.mockResolvedValue([
id: 'task-1', taskNo: 'BATCH-1', tenantId: 'tenant-1', applicationId: 'app-1', phoneTotal: 1, status: 'finished', {
createdAt: new Date('2026-07-21T01:00:00.000Z'), application: { id: 'app-1', name: '应用A' }, id: 'task-1',
taskNo: 'BATCH-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
phoneTotal: 1,
status: 'finished',
createdAt: new Date('2026-07-21T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }], messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }],
}]); },
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' }); const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' });
@@ -506,25 +584,34 @@ describe('OperationsService', () => {
it('builds dashboard and statistics aggregates', async () => { it('builds dashboard and statistics aggregates', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([{ prisma.$queryRaw
.mockResolvedValueOnce([
{
tenantId: 'tenant-1', tenantId: 'tenant-1',
tenantName: '租户A', tenantName: '租户A',
todaySpendCents: 24000n, todaySpendCents: 24000n,
balanceCents: 1000000n, balanceCents: 1000000n,
creditCents: 50000n, creditCents: 50000n,
}]).mockResolvedValueOnce([{ },
])
.mockResolvedValueOnce([
{
segmentCount: 20n, segmentCount: 20n,
deliveredSegmentCount: 18n, deliveredSegmentCount: 18n,
billedCents: 360n, billedCents: 360n,
costCents: 216n, costCents: 216n,
}]).mockResolvedValueOnce([ },
])
.mockResolvedValueOnce([
{ hour: 9, submittedCount: 12n, successCount: 10n }, { hour: 9, submittedCount: 12n, successCount: 10n },
{ hour: 10, submittedCount: 5n, successCount: 4n }, { hour: 10, submittedCount: 5n, successCount: 4n },
]).mockResolvedValueOnce([ ])
.mockResolvedValueOnce([
{ category: 'templates', count: 3n, averageProcessingMs: 90_000n }, { category: 'templates', count: 3n, averageProcessingMs: 90_000n },
{ category: 'signatures', count: 2n, averageProcessingMs: 120_000n }, { category: 'signatures', count: 2n, averageProcessingMs: 120_000n },
]); ]);
prisma.cmppDownstreamDelivery.count = jest.fn() prisma.cmppDownstreamDelivery.count = jest
.fn()
.mockResolvedValueOnce(3) .mockResolvedValueOnce(3)
.mockResolvedValueOnce(2) .mockResolvedValueOnce(2)
.mockResolvedValueOnce(8) .mockResolvedValueOnce(8)
@@ -550,7 +637,7 @@ describe('OperationsService', () => {
}, },
today: expect.objectContaining({ today: expect.objectContaining({
returnedCents: 10, returnedCents: 10,
segmentCount: 20, segmentCount: 2,
deliveredSegmentCount: 18, deliveredSegmentCount: 18,
arrivalRate: 90, arrivalRate: 90,
billedCents: 360, billedCents: 360,
@@ -568,14 +655,18 @@ describe('OperationsService', () => {
{ category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 }, { category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 },
{ category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null }, { category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null },
], ],
enterpriseSpendRanks: [{ enterpriseSpendRanks: [
{
tenantId: 'tenant-1', tenantId: 'tenant-1',
tenantName: '租户A', tenantName: '租户A',
todaySpendCents: 24000, todaySpendCents: 24000,
balanceCents: 1000000, balanceCents: 1000000,
creditCents: 50000, creditCents: 50000,
}], },
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], ],
gatewayConnections: [
{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } },
],
downstreamDeliverySummary: expect.objectContaining({ downstreamDeliverySummary: expect.objectContaining({
pending: 3, pending: 3,
failed: 2, failed: 2,
@@ -619,10 +710,7 @@ describe('OperationsService', () => {
where: { where: {
tenantId: 'tenant-1', tenantId: 'tenant-1',
createdAt: { gte: expect.any(Date) }, createdAt: { gte: expect.any(Date) },
OR: [ OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
}, },
_sum: { amountCents: true }, _sum: { amountCents: true },
_count: { _all: true }, _count: { _all: true },
@@ -640,7 +728,8 @@ describe('OperationsService', () => {
it('returns real daily channel and signature quality for the selected Shanghai date', async () => { it('returns real daily channel and signature quality for the selected Shanghai date', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw prisma.$queryRaw
.mockResolvedValueOnce([{ .mockResolvedValueOnce([
{
channelId: 'channel-1', channelId: 'channel-1',
channelName: '通道一', channelName: '通道一',
total: 5, total: 5,
@@ -654,8 +743,10 @@ describe('OperationsService', () => {
unknownRate: 20, unknownRate: 20,
failureRate: 20, failureRate: 20,
averageArrivalMs: 1200, averageArrivalMs: 1200,
}]) },
.mockResolvedValueOnce([{ ])
.mockResolvedValueOnce([
{
id: 'signature-1:plain', id: 'signature-1:plain',
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
@@ -670,15 +761,19 @@ describe('OperationsService', () => {
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
averageArrivalMs: 1200, averageArrivalMs: 1200,
}]) },
.mockResolvedValueOnce([{ ])
.mockResolvedValueOnce([
{
total: 5, total: 5,
successCount: 3, successCount: 3,
unknownCount: 1, unknownCount: 1,
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
}]) },
.mockResolvedValueOnce([{ ])
.mockResolvedValueOnce([
{
applicationId: 'app-1', applicationId: 'app-1',
applicationName: '通知应用', applicationName: '通知应用',
tenantId: 'tenant-1', tenantId: 'tenant-1',
@@ -688,7 +783,8 @@ describe('OperationsService', () => {
unknownCount: 1, unknownCount: 1,
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
}]); },
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({ await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
@@ -701,22 +797,28 @@ describe('OperationsService', () => {
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
}, },
channels: [expect.objectContaining({ channels: [
expect.objectContaining({
channelId: 'channel-1', channelId: 'channel-1',
total: 5, total: 5,
acceptedCount: 4, acceptedCount: 4,
submitFailureCount: 1, submitFailureCount: 1,
submitFailureRate: 20, submitFailureRate: 20,
successRate: 60, successRate: 60,
})], }),
signatures: [expect.objectContaining({ ],
signatures: [
expect.objectContaining({
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
hasDrainage: false, hasDrainage: false,
acceptedCount: 4, acceptedCount: 4,
submitFailureCount: 1, submitFailureCount: 1,
})], }),
applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })], ],
applications: [
expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 }),
],
}); });
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4); expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
}); });
@@ -736,9 +838,15 @@ describe('OperationsService', () => {
}); });
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({
expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending' } }); where: { tenantId: 'tenant-1', auditStatus: 'pending' },
expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending_review' } }); });
expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: 'pending' },
});
expect(prisma.smsSendTask.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: 'pending_review' },
});
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled(); expect(prisma.$queryRaw).not.toHaveBeenCalled();
}); });
@@ -761,7 +869,8 @@ describe('OperationsService', () => {
it('returns paged registered-signature quality with channel and carrier breakdowns', async () => { it('returns paged registered-signature quality with channel and carrier breakdowns', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw prisma.$queryRaw
.mockResolvedValueOnce([{ .mockResolvedValueOnce([
{
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
tenantId: 'tenant-1', tenantId: 'tenant-1',
@@ -776,7 +885,8 @@ describe('OperationsService', () => {
successRate: 75, successRate: 75,
averageArrivalMs: 1200, averageArrivalMs: 1200,
rowCount: 12, rowCount: 12,
}]) },
])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
signatureId: 'signature-1', signatureId: 'signature-1',
@@ -827,16 +937,17 @@ describe('OperationsService', () => {
averageArrivalMs: 1800, averageArrivalMs: 1800,
}, },
]); ]);
const service = new OperationsService(prisma as never); await expect(
new OperationsQualityQueries(prisma as never).signatureQualityLive({
await expect(service.signatureQuality({
date: '2026-07-24', date: '2026-07-24',
keyword: '测试', keyword: '测试',
page: 2, page: 2,
pageSize: 5, pageSize: 5,
})).resolves.toEqual({ }),
).resolves.toEqual({
date: '2026-07-24', date: '2026-07-24',
items: [expect.objectContaining({ items: [
expect.objectContaining({
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
total: 5, total: 5,
@@ -863,7 +974,8 @@ describe('OperationsService', () => {
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }), expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }), expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
], ],
})], }),
],
total: 12, total: 12,
page: 2, page: 2,
pageSize: 5, pageSize: 5,
@@ -874,14 +986,14 @@ describe('OperationsService', () => {
it('does not query channel details when the selected date has no registered signatures', async () => { it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]); prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never); await expect(
new OperationsQualityQueries(prisma as never).signatureQualityLive({ date: '2026-07-24' }),
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({ ).resolves.toEqual({
date: '2026-07-24', date: '2026-07-24',
items: [], items: [],
total: 0, total: 0,
page: 1, page: 1,
pageSize: 10, pageSize: 25,
}); });
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
}); });
@@ -934,13 +1046,15 @@ describe('OperationsService', () => {
await service.systemLogs({ level: 'error', page: 2, pageSize: 5 }); await service.systemLogs({ level: 'error', page: 2, pageSize: 5 });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
AND: expect.objectContaining({ OR: expect.any(Array) }), AND: expect.objectContaining({ OR: expect.any(Array) }),
}), }),
skip: 5, skip: 5,
take: 5, take: 5,
})); }),
);
expect(prisma.operationLog.count).toHaveBeenCalledWith({ expect(prisma.operationLog.count).toHaveBeenCalledWith({
where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }), where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }),
}); });
@@ -952,27 +1066,31 @@ describe('OperationsService', () => {
await service.systemLogs({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' }); await service.systemLogs({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
createdAt: { createdAt: {
gte: new Date('2026-08-20T16:00:00.000Z'), gte: new Date('2026-08-20T16:00:00.000Z'),
lte: new Date('2026-08-27T15:59:59.999Z'), lte: new Date('2026-08-27T15:59:59.999Z'),
}, },
}), }),
})); }),
);
}); });
it('exports filtered operation logs with a traceable operation id', async () => { it('exports filtered operation logs with a traceable operation id', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(expect.objectContaining({ await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(
expect.objectContaining({
operationId: expect.any(String), operationId: expect.any(String),
status: 'completed', status: 'completed',
recordCount: 1, recordCount: 1,
truncated: false, truncated: false,
content: expect.stringContaining('billing.manual_recharge'), content: expect.stringContaining('billing.manual_recharge'),
})); }),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 })); expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 }));
}); });
@@ -982,8 +1100,12 @@ describe('OperationsService', () => {
const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user'); const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user');
expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) })); expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) })); expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) }),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) }),
);
expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID'); expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID');
expect(exported.content).not.toContain('amountCents'); expect(exported.content).not.toContain('amountCents');
}); });
@@ -992,11 +1114,13 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({ await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(
expect.objectContaining({
total: 1, total: 1,
page: 1, page: 1,
pageSize: 100, pageSize: 100,
})); }),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
}); });
@@ -1004,19 +1128,23 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listGatewaySubmitDeadLetters({ await expect(
service.listGatewaySubmitDeadLetters({
tenantId: 'tenant-1', tenantId: 'tenant-1',
status: 'pending', status: 'pending',
keyword: 'SUBMIT', keyword: 'SUBMIT',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual({ }),
items: [expect.objectContaining({ ).resolves.toEqual({
items: [
expect.objectContaining({
id: 'dead-1', id: 'dead-1',
status: 'pending', status: 'pending',
rawPayloadAvailable: true, rawPayloadAvailable: true,
commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } }, commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } },
})], }),
],
total: 1, total: 1,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -1045,7 +1173,8 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listReceiptAnomalies({ await expect(
service.listReceiptAnomalies({
tenantId: 'tenant-1', tenantId: 'tenant-1',
channelId: 'channel-1', channelId: 'channel-1',
status: 'pending', status: 'pending',
@@ -1053,7 +1182,9 @@ describe('OperationsService', () => {
keyword: 'MSG-1', keyword: 'MSG-1',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual(expect.objectContaining({ }),
).resolves.toEqual(
expect.objectContaining({
total: 1, total: 1,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -1063,8 +1194,10 @@ describe('OperationsService', () => {
ignored: 0, ignored: 0,
oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'), oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'),
}, },
})); }),
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(expect.objectContaining({ );
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
tenantId: 'tenant-1', tenantId: 'tenant-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -1079,16 +1212,20 @@ describe('OperationsService', () => {
channel: { select: { id: true, code: true, name: true, status: true } }, channel: { select: { id: true, code: true, name: true, status: true } },
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } }, messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
submitRecord: { select: { submitId: true, submitStatus: true } }, submitRecord: { select: { submitId: true, submitStatus: true } },
receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } }, receiptRecord: {
select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true },
}, },
})); },
}),
);
}); });
it('returns paginated downstream deliveries', async () => { it('returns paginated downstream deliveries', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listDownstreamDeliveries({ await expect(
service.listDownstreamDeliveries({
tenantId: 'tenant-1', tenantId: 'tenant-1',
deliveryType: 'receipt', deliveryType: 'receipt',
status: 'failed', status: 'failed',
@@ -1097,7 +1234,8 @@ describe('OperationsService', () => {
createdAtTo: '2026-07-15', createdAtTo: '2026-07-15',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual({ }),
).resolves.toEqual({
items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })], items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })],
total: 1, total: 1,
page: 1, page: 1,
@@ -1128,7 +1266,8 @@ describe('OperationsService', () => {
it('builds downstream delivery dashboard aggregates', async () => { it('builds downstream delivery dashboard aggregates', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.cmppDownstreamDelivery.count = jest.fn() prisma.cmppDownstreamDelivery.count = jest
.fn()
.mockResolvedValueOnce(12) .mockResolvedValueOnce(12)
.mockResolvedValueOnce(3) .mockResolvedValueOnce(3)
.mockResolvedValueOnce(0) .mockResolvedValueOnce(0)
@@ -1142,7 +1281,8 @@ describe('OperationsService', () => {
.mockResolvedValueOnce(2) .mockResolvedValueOnce(2)
.mockResolvedValueOnce(1) .mockResolvedValueOnce(1)
.mockResolvedValueOnce(0); .mockResolvedValueOnce(0);
prisma.cmppDownstreamDelivery.groupBy = jest.fn() prisma.cmppDownstreamDelivery.groupBy = jest
.fn()
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } }, { deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
{ deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } }, { deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } },
@@ -1163,11 +1303,13 @@ describe('OperationsService', () => {
]); ]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.downstreamDeliveryDashboard({ await expect(
service.downstreamDeliveryDashboard({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
deliveryType: 'all', deliveryType: 'all',
})).resolves.toEqual({ }),
).resolves.toEqual({
summary: { summary: {
total: 12, total: 12,
pending: 3, pending: 3,
@@ -1182,8 +1324,26 @@ describe('OperationsService', () => {
alertCount: 2, alertCount: 2,
}, },
typeBreakdown: [ typeBreakdown: [
{ deliveryType: 'receipt', total: 9, pending: 2, awaitingAck: 0, delivered: 6, failed: 1, unconfirmed: 0, rejected: 0 }, {
{ deliveryType: 'uplink', total: 3, pending: 1, awaitingAck: 0, delivered: 2, failed: 0, unconfirmed: 0, rejected: 0 }, deliveryType: 'receipt',
total: 9,
pending: 2,
awaitingAck: 0,
delivered: 6,
failed: 1,
unconfirmed: 0,
rejected: 0,
},
{
deliveryType: 'uplink',
total: 3,
pending: 1,
awaitingAck: 0,
delivered: 2,
failed: 0,
unconfirmed: 0,
rejected: 0,
},
], ],
retryBuckets: [ retryBuckets: [
{ label: '0次', count: 2 }, { label: '0次', count: 2 },
@@ -1191,8 +1351,28 @@ describe('OperationsService', () => {
{ label: '4次及以上', count: 0 }, { label: '4次及以上', count: 0 },
], ],
topApplications: [ topApplications: [
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 1 }, {
{ applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 }, applicationId: 'app-1',
name: '应用A',
pending: 2,
awaitingAck: 0,
failed: 1,
unconfirmed: 0,
rejected: 0,
delivered: 5,
alertCount: 1,
},
{
applicationId: 'app-2',
name: '应用B',
pending: 1,
awaitingAck: 0,
failed: 0,
unconfirmed: 0,
rejected: 0,
delivered: 3,
alertCount: 1,
},
], ],
}); });
@@ -1222,7 +1402,8 @@ describe('OperationsService', () => {
it('returns paginated downstream recovery statuses', async () => { it('returns paginated downstream recovery statuses', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.gatewayDownstreamRecoveryStatus.count = jest.fn() prisma.gatewayDownstreamRecoveryStatus.count = jest
.fn()
.mockResolvedValueOnce(1) .mockResolvedValueOnce(1)
.mockResolvedValueOnce(0) .mockResolvedValueOnce(0)
.mockResolvedValueOnce(0) .mockResolvedValueOnce(0)
@@ -1231,7 +1412,8 @@ describe('OperationsService', () => {
.mockResolvedValueOnce(1); .mockResolvedValueOnce(1);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listDownstreamRecoveryStatuses({ await expect(
service.listDownstreamRecoveryStatuses({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
state: 'waiting_connection', state: 'waiting_connection',
@@ -1241,7 +1423,8 @@ describe('OperationsService', () => {
updatedAtTo: '2026-07-08', updatedAtTo: '2026-07-08',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual({ }),
).resolves.toEqual({
items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })], items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })],
total: 1, total: 1,
page: 1, page: 1,
@@ -1253,19 +1436,19 @@ describe('OperationsService', () => {
failed: 0, failed: 0,
waitingConnection: 1, waitingConnection: 1,
backoff: 1, backoff: 1,
failureCategories: [ failureCategories: [{ category: 'client_disconnected', count: 1 }],
{ category: 'client_disconnected', count: 1 },
],
}, },
}); });
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
updatedAt: { updatedAt: {
gte: new Date('2026-07-01T16:00:00.000Z'), gte: new Date('2026-07-01T16:00:00.000Z'),
lte: new Date('2026-07-08T15:59:59.999Z'), lte: new Date('2026-07-08T15:59:59.999Z'),
}, },
}), }),
})); }),
);
}); });
it('returns downstream recovery status detail', async () => { it('returns downstream recovery status detail', async () => {
@@ -1290,18 +1473,23 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.exportDownstreamRecoveryStatuses({ await expect(
service.exportDownstreamRecoveryStatuses({
tenantId: 'tenant-1', tenantId: 'tenant-1',
state: 'waiting_connection', state: 'waiting_connection',
keyword: '100001', keyword: '100001',
updatedAtFrom: '2026-07-02', updatedAtFrom: '2026-07-02',
updatedAtTo: '2026-07-08', updatedAtTo: '2026-07-08',
})).resolves.toEqual(expect.objectContaining({ }),
).resolves.toEqual(
expect.objectContaining({
total: 1, total: 1,
fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/), fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/),
content: expect.stringContaining('100001'), content: expect.stringContaining('100001'),
})); }),
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ );
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
failureCategory: undefined, failureCategory: undefined,
updatedAt: { updatedAt: {
@@ -1309,7 +1497,8 @@ describe('OperationsService', () => {
lte: new Date('2026-07-08T15:59:59.999Z'), lte: new Date('2026-07-08T15:59:59.999Z'),
}, },
}), }),
})); }),
);
}); });
it('returns message segment audit rows', async () => { it('returns message segment audit rows', async () => {
+34 -15
View File
@@ -1,10 +1,18 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money'; import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; import {
messageWhere,
qualityBusinessDay,
returnedTransactionWhere,
downstreamAlertWindows,
stalledPendingWhere,
clientBatchTaskView,
clientAccountView,
clientRechargeView,
summarizeMessageGroups,
} from '../operations.helpers';
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline. // R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsDashboardQueries { export class OperationsDashboardQueries {
@@ -94,13 +102,15 @@ async dashboard(query: { tenantId?: string }) {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 10, take: 10,
}), }),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
tenantId: string; tenantId: string;
tenantName: string; tenantName: string;
todaySpendCents: bigint; todaySpendCents: bigint;
balanceCents: bigint; balanceCents: bigint;
creditCents: bigint; creditCents: bigint;
}>>(Prisma.sql` }>
>(Prisma.sql`
SELECT SELECT
tenant.id AS "tenantId", tenant.id AS "tenantId",
tenant.name AS "tenantName", tenant.name AS "tenantName",
@@ -118,12 +128,14 @@ async dashboard(query: { tenantId?: string }) {
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
ORDER BY "todaySpendCents" DESC, tenant.name ASC ORDER BY "todaySpendCents" DESC, tenant.name ASC
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
segmentCount: bigint; segmentCount: bigint;
deliveredSegmentCount: bigint; deliveredSegmentCount: bigint;
billedCents: bigint; billedCents: bigint;
costCents: bigint; costCents: bigint;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH segment_metrics AS ( WITH segment_metrics AS (
SELECT SELECT
COUNT(segment.id)::bigint AS "segmentCount", COUNT(segment.id)::bigint AS "segmentCount",
@@ -208,11 +220,13 @@ async dashboard(query: { tenantId?: string }) {
updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
}, },
}), }),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
hour: number; hour: number;
submittedCount: bigint; submittedCount: bigint;
successCount: bigint; successCount: bigint;
}>>(Prisma.sql` }>
>(Prisma.sql`
SELECT SELECT
EXTRACT( EXTRACT(
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai' HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
@@ -227,11 +241,13 @@ async dashboard(query: { tenantId?: string }) {
ORDER BY 1 ORDER BY 1
`), `),
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit. // Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
category: string; category: string;
count: bigint; count: bigint;
averageProcessingMs: bigint | null; averageProcessingMs: bigint | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH review_samples AS ( WITH review_samples AS (
SELECT SELECT
'enterpriseCertifications'::text AS category, 'enterpriseCertifications'::text AS category,
@@ -302,7 +318,8 @@ async dashboard(query: { tenantId?: string }) {
]); ]);
const todayTotals = summarizeMessageGroups(todayMessageGroups); const todayTotals = summarizeMessageGroups(todayMessageGroups);
const todayBusinessMetrics = todayBusinessMetricsRows[0]; const todayBusinessMetrics = todayBusinessMetricsRows[0];
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0); const supplierSegmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
const segmentCount = todayTotals.billingUnits;
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0); const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents); const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
const costCents = moneyToNumber(todayBusinessMetrics?.costCents); const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
@@ -334,7 +351,8 @@ async dashboard(query: { tenantId?: string }) {
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs), averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
}; };
}); });
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; const downstreamAlertCount =
downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
return { return {
taskCount, taskCount,
messageStatus: messageGroups, messageStatus: messageGroups,
@@ -349,7 +367,8 @@ async dashboard(query: { tenantId?: string }) {
billingUnits: todayTotals.billingUnits, billingUnits: todayTotals.billingUnits,
segmentCount, segmentCount,
deliveredSegmentCount, deliveredSegmentCount,
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0, arrivalRate:
supplierSegmentCount > 0 ? Number(((deliveredSegmentCount / supplierSegmentCount) * 100).toFixed(1)) : 0,
billedCents, billedCents,
profitCents, profitCents,
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0, profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
@@ -84,12 +84,15 @@ export class OperationsMessageQueries {
carrier: true, carrier: true,
province: true, province: true,
content: true, content: true,
originalContent: true,
hasDrainageContent: true, hasDrainageContent: true,
drainageDetection: true, drainageDetection: true,
billingUnits: true, billingUnits: true,
amountCents: true, amountCents: true,
status: true, status: true,
submitStatus: true, submitStatus: true,
receiptStatus: true,
deliveredAt: true,
queuedAt: true, queuedAt: true,
tenant: { select: { id: true, name: true } }, tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } }, application: { select: { id: true, name: true } },
@@ -108,12 +111,16 @@ export class OperationsMessageQueries {
const item = await this.prisma.smsMessageRecord.findUnique({ const item = await this.prisma.smsMessageRecord.findUnique({
where: { id }, where: { id },
include: { include: {
channelWordDecisions: { orderBy: [{ decidedAt: 'desc' }, { id: 'desc' }], take: 10 },
tenant: { select: { id: true, name: true } }, tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } }, application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } }, channel: { select: { id: true, name: true, srcId: true } },
submitRecords: { submitRecords: {
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: { select: {
id: true, id: true,
sentContent: true,
contentPolicy: true,
submitId: true, submitId: true,
channelId: true, channelId: true,
channelGroupId: true, channelGroupId: true,
+140 -60
View File
@@ -1,10 +1,10 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { SignatureAnalyticsRead } from '../../signature-analytics/analytics-read';
import { moneyToNumber } from '../../common/money'; import { analyticsDate, analyticsPage, todayKey } from '../../signature-analytics/analytics-date';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; import type { SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; import { messageWhere, qualityBusinessDay, normalizeGroupBy, positiveInteger } from '../operations.helpers';
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline. // R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsQualityQueries { export class OperationsQualityQueries {
@@ -38,7 +38,8 @@ async statistics(query: { tenantId?: string; groupBy?: string }) {
async sendQuality(date?: string) { async sendQuality(date?: string) {
const day = qualityBusinessDay(date); const day = qualityBusinessDay(date);
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([ const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
channelId: string; channelId: string;
channelName: string; channelName: string;
total: number; total: number;
@@ -52,7 +53,8 @@ async sendQuality(date?: string) {
unknownRate: number; unknownRate: number;
failureRate: number; failureRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
submit."channelId" AS channel_id, submit."channelId" AS channel_id,
@@ -131,7 +133,8 @@ async sendQuality(date?: string) {
GROUP BY channel_id GROUP BY channel_id
ORDER BY COUNT(*) DESC, channel_id ORDER BY COUNT(*) DESC, channel_id
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
id: string; id: string;
signatureId: string; signatureId: string;
signatureName: string; signatureName: string;
@@ -146,7 +149,8 @@ async sendQuality(date?: string) {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."signatureId" AS signature_id, message."signatureId" AS signature_id,
@@ -216,13 +220,15 @@ async sendQuality(date?: string) {
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
ORDER BY "successCount" DESC, total DESC, signature.name ORDER BY "successCount" DESC, total DESC, signature.name
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
total: number; total: number;
successCount: number; successCount: number;
unknownCount: number; unknownCount: number;
failureCount: number; failureCount: number;
successRate: number; successRate: number;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT message.status, message."receiptStatus" AS receipt_status SELECT message.status, message."receiptStatus" AS receipt_status
FROM "SmsMessageRecord" message FROM "SmsMessageRecord" message
@@ -251,7 +257,8 @@ async sendQuality(date?: string) {
END AS "successRate" END AS "successRate"
FROM base FROM base
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
applicationId: string; applicationId: string;
applicationName: string; applicationName: string;
tenantId: string; tenantId: string;
@@ -261,7 +268,8 @@ async sendQuality(date?: string) {
unknownCount: number; unknownCount: number;
failureCount: number; failureCount: number;
successRate: number; successRate: number;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."applicationId" AS application_id, message."applicationId" AS application_id,
@@ -315,12 +323,34 @@ async sendQuality(date?: string) {
return { date: day.key, summary, channels, signatures, drainageSignatures, applications }; return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
} }
async signatureQuality(query: SignatureQualityQuery) { async signatureQuality(query: SignatureQualityQuery) {
const date = analyticsDate(query.date);
analyticsPage(query.page, query.pageSize);
if (date !== todayKey()) return new SignatureAnalyticsRead(this.prisma).quality({ ...query, date });
return this.prisma.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const result = await new OperationsQualityQueries(tx as PrismaService).signatureQualityLive({ ...query, date });
return {
...result,
dataSource: 'live',
reportState: 'ready',
frozen: false,
sourceAsOf: new Date(),
serverBusinessDate: date,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
);
}
async signatureQualityLive(query: SignatureQualityQuery, snapshot = false) {
const day = qualityBusinessDay(query.date); const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1); const page = positiveInteger(query.page, 1);
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); const pageSize = snapshot ? 2147483647 : Math.min(100, positiveInteger(query.pageSize, 25));
const keyword = query.keyword?.trim() || null; const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null; const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<Array<{ const summaries = await this.prisma.$queryRaw<
Array<{
signatureId: string; signatureId: string;
signatureName: string; signatureName: string;
tenantId: string; tenantId: string;
@@ -334,8 +364,11 @@ async signatureQuality(query: SignatureQualityQuery) {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
rowCount: number; rowCount: number;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."signatureId" AS signature_id, message."signatureId" AS signature_id,
@@ -343,6 +376,12 @@ async signatureQuality(query: SignatureQualityQuery) {
message.status, message.status,
message."submitStatus" AS submit_status, message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status, message."receiptStatus" AS receipt_status,
CASE
WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN 'success'
WHEN message.status = 'submit_failed' OR message."submitStatus" IN ('rejected', 'timeout') THEN 'submit_failed'
WHEN message."receiptStatus" = 'undelivered' OR (message.status = 'failed' AND message."receiptStatus" IS NOT NULL AND message."receiptStatus" <> 'unknown') THEN 'failure'
ELSE 'unknown'
END AS quality_status,
CASE CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL AND message."submittedAt" IS NOT NULL
@@ -353,6 +392,15 @@ async signatureQuality(query: SignatureQualityQuery) {
WHERE message."signatureId" IS NOT NULL WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt} AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt} AND message."queuedAt" < ${day.endAt}
), dimensions AS (
SELECT signature_id FROM base
UNION
SELECT message."signatureId" FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id=submit."messageRecordId"
WHERE message."signatureId" IS NOT NULL
AND COALESCE(submit."submittedAt",submit."createdAt")>=${day.startAt}
AND COALESCE(submit."submittedAt",submit."createdAt")<${day.endAt}
AND submit."submitStatus" IN ('accepted','rejected','timeout')
) )
SELECT SELECT
signature.id AS "signatureId", signature.id AS "signatureId",
@@ -360,37 +408,21 @@ async signatureQuality(query: SignatureQualityQuery) {
tenant.id AS "tenantId", tenant.id AS "tenantId",
tenant.name AS "tenantName", tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total, COUNT(base.signature_id)::integer AS total,
COUNT(*) FILTER ( COUNT(base.signature_id) FILTER (WHERE base.quality_status <> 'submit_failed')::integer AS "acceptedCount",
WHERE COALESCE(base.status, '') <> 'submit_failed' COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'submit_failed')::integer AS "submitFailureCount",
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'success')::integer AS "successCount",
)::integer AS "acceptedCount", COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'unknown')::integer AS "unknownCount",
COUNT(*) FILTER ( COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'failure')::integer AS "failureCount",
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE CASE
WHEN COUNT(*) FILTER ( WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0 ) = 0 THEN 0
ELSE ROUND( ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0 * 100.0
/ COUNT(*) FILTER ( / COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
), ),
@@ -399,10 +431,11 @@ async signatureQuality(query: SignatureQualityQuery) {
END AS "successRate", END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs", ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount" COUNT(*) OVER()::integer AS "rowCount"
FROM base FROM dimensions
JOIN "SmsSignature" signature ON signature.id = base.signature_id JOIN "SmsSignature" signature ON signature.id = dimensions.signature_id
LEFT JOIN base ON base.signature_id=signature.id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId" JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = base.application_id LEFT JOIN "SmsApplication" application ON application.id = COALESCE(base.application_id,signature."applicationId")
WHERE ( WHERE (
${keyword}::text IS NULL ${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern} OR signature.name ILIKE ${keywordPattern}
@@ -415,9 +448,11 @@ async signatureQuality(query: SignatureQualityQuery) {
OFFSET ${(page - 1) * pageSize} OFFSET ${(page - 1) * pageSize}
`); `);
const signatureIds = summaries.map((item) => item.signatureId); const signatureIds = summaries.map((item) => item.signatureId);
const drainageBreakdowns = signatureIds.length === 0 const drainageBreakdowns =
signatureIds.length === 0
? [] ? []
: await this.prisma.$queryRaw<Array<{ : await this.prisma.$queryRaw<
Array<{
signatureId: string; signatureId: string;
channelId: string; channelId: string;
channelName: string; channelName: string;
@@ -431,7 +466,10 @@ async signatureQuality(query: SignatureQualityQuery) {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."signatureId" AS signature_id, message."signatureId" AS signature_id,
@@ -446,12 +484,12 @@ async signatureQuality(query: SignatureQualityQuery) {
submit."submitStatus" AS submit_status, submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at, receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at, failed_receipt."failedAt" AS failed_at,
COALESCE(segment_summary.segment_count, 0) AS segment_count, COALESCE(segment_summary.expected_count, 0) AS segment_count,
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE CASE
WHEN segment_summary.segment_count > 0 WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count AND segment_summary.delivered_count = segment_summary.expected_count
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0 WHEN segment_summary.segment_count = 0
@@ -463,6 +501,7 @@ async signatureQuality(query: SignatureQualityQuery) {
JOIN "SmsChannel" channel ON channel.id = submit."channelId" JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
CASE WHEN COUNT(*) > 0 THEN GREATEST(MAX(segment."segmentTotal"), message."billingUnits") ELSE 0 END::integer AS expected_count,
COUNT(*)::integer AS segment_count, COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
@@ -521,21 +560,28 @@ async signatureQuality(query: SignatureQualityQuery) {
1 1
)::double precision )::double precision
END AS "successRate", END AS "successRate",
COALESCE(SUM(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL),0)::double precision AS "arrivalMsSum",
COUNT(arrival_ms) FILTER (WHERE delivery_status = 'success')::integer AS "arrivalSamples",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified FROM classified
GROUP BY signature_id, channel_id, carrier, drainage_state GROUP BY signature_id, channel_id, carrier, drainage_state
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
`); `);
const carrierOverview = signatureIds.length === 0 const carrierOverview =
signatureIds.length === 0
? [] ? []
: await this.prisma.$queryRaw<Array<{ : await this.prisma.$queryRaw<
Array<{
signatureId: string; signatureId: string;
carrier: string; carrier: string;
businessMessageCount: number; businessMessageCount: number;
finalSuccessCount: number; finalSuccessCount: number;
finalSuccessRate: number; finalSuccessRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
SELECT SELECT
message."signatureId" AS "signatureId", message."signatureId" AS "signatureId",
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
@@ -570,6 +616,7 @@ async signatureQuality(query: SignatureQualityQuery) {
ORDER BY message."signatureId", COUNT(*) DESC, carrier ORDER BY message."signatureId", COUNT(*) DESC, carrier
`); `);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
void _rowCount;
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId); const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns); const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
return { return {
@@ -605,17 +652,23 @@ type SignatureSplitRow = {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}; };
function aggregateSignatureRows(rows: SignatureSplitRow[]) { function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const grouped = new Map<string, SignatureSplitRow[]>(); const grouped = new Map<string, SignatureSplitRow[]>();
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row])); rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
return [...grouped.values()].map((parts) => { return [...grouped.values()]
.map((parts) => {
const first = parts[0]; const first = parts[0];
const total = parts.reduce((sum, item) => sum + item.total, 0); const total = parts.reduce((sum, item) => sum + item.total, 0);
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0); const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
0,
);
return { return {
...first, ...first,
id: first.signatureId, id: first.signatureId,
@@ -626,10 +679,24 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
successCount, successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight), averageArrivalMs:
arrivalWeight === 0
? null
: Math.round(
parts.reduce(
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
0,
) / arrivalWeight,
),
}; };
}).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName)); })
.sort(
(left, right) =>
right.successCount - left.successCount ||
right.total - left.total ||
left.signatureName.localeCompare(right.signatureName),
);
} }
type DrainageBreakdownRow = { type DrainageBreakdownRow = {
@@ -646,6 +713,8 @@ type DrainageBreakdownRow = {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}; };
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) { function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
@@ -658,7 +727,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const first = parts[0]; const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0); const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
0,
);
return { return {
signatureId: first.signatureId, signatureId: first.signatureId,
channelId: first.channelId, channelId: first.channelId,
@@ -670,8 +742,16 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
successCount, successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight), averageArrivalMs:
arrivalWeight === 0
? null
: Math.round(
parts.reduce(
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
0,
) / arrivalWeight,
),
}; };
}); });
} }
@@ -0,0 +1,17 @@
import type { PrismaService } from '../../prisma/prisma.service';
import { OperationsUplinkQueries } from './uplink.queries';
describe('runtime monitor summary', () => {
it('keeps tenant/channel status counts without reading recent business records', async () => {
const byStatus = [{ status: 'delivered', _count: { _all: 3 } }];
const groupBy = jest.fn().mockResolvedValue(byStatus);
// No detail delegates: accessing any removed query fails this test.
const queries = new OperationsUplinkQueries({ smsMessageRecord: { groupBy } } as unknown as PrismaService);
expect(await queries.monitor({ tenantId: 'tenant-a', channelId: 'channel-a' })).toEqual({ byStatus });
expect(groupBy).toHaveBeenCalledWith({
by: ['status'],
where: expect.objectContaining({ tenantId: 'tenant-a', channelId: 'channel-a' }),
_count: { _all: true },
});
});
});
+51 -33
View File
@@ -1,16 +1,22 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; import { messageWhere, clientUplinkView } from '../operations.helpers';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline. // Uplink record queries and the status-only runtime summary.
export class OperationsUplinkQueries { export class OperationsUplinkQueries {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { listUplinkMessages(query: {
tenantId?: string;
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
}) {
return this.prisma.smsUplinkMessage.findMany({ return this.prisma.smsUplinkMessage.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
@@ -18,7 +24,13 @@ listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId
applicationId: query.applicationId, applicationId: query.applicationId,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
content: query.keyword ? { contains: query.keyword } : undefined, content: query.keyword ? { contains: query.keyword } : undefined,
receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined, receivedAt:
query.startTime || query.endTime
? {
gte: query.startTime ? new Date(query.startTime) : undefined,
lte: query.endTime ? new Date(query.endTime) : undefined,
}
: undefined,
}, },
include: { include: {
tenant: true, tenant: true,
@@ -39,11 +51,33 @@ listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId
take: query.pageSize ?? 500, take: query.pageSize ?? 500,
}); });
} }
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { async listClientUplinkMessages(query: {
tenantId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
}) {
const items = await this.listUplinkMessages(query); const items = await this.listUplinkMessages(query);
return items.map(clientUplinkView); return items.map(clientUplinkView);
} }
async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { async listUplinkMessagesPage(
query: {
tenantId?: string;
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
},
clientView = false,
) {
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.SmsUplinkMessageWhereInput = { const where: Prisma.SmsUplinkMessageWhereInput = {
@@ -52,10 +86,13 @@ async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; app
applicationId: query.applicationId, applicationId: query.applicationId,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
content: query.keyword ? { contains: query.keyword } : undefined, content: query.keyword ? { contains: query.keyword } : undefined,
receivedAt: query.startTime || query.endTime ? { receivedAt:
query.startTime || query.endTime
? {
gte: query.startTime ? new Date(query.startTime) : undefined, gte: query.startTime ? new Date(query.startTime) : undefined,
lte: query.endTime ? new Date(query.endTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined,
} : undefined, }
: undefined,
}; };
const [rawItems, total] = await Promise.all([ const [rawItems, total] = await Promise.all([
this.listUplinkMessages({ ...query, page, pageSize }), this.listUplinkMessages({ ...query, page, pageSize }),
@@ -70,26 +107,7 @@ async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; app
} }
async monitor(query: { tenantId?: string; channelId?: string }) { async monitor(query: { tenantId?: string; channelId?: string }) {
const where = messageWhere(query); const where = messageWhere(query);
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ const byStatus = await this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } });
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }), return { byStatus };
this.prisma.smsMessageRecord.findMany({
where,
include: { submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
take: 20,
}),
this.prisma.smsReceiptRecord.findMany({
where: { tenantId: query.tenantId, channelId: query.channelId },
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
]);
return {
byStatus,
recentMessages,
recentReceipts,
recentUplinks: recentUplinks.slice(0, 20),
};
} }
} }
@@ -235,7 +235,10 @@ export class ReportBatchGenerationService {
continue; continue;
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId) if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
continue; continue;
if (scope.batchItem.reportType === 'signature' && carriers.size && task.carrier && !carriers.has(task.carrier)) if (
carriers.size &&
(task.carrier ? !carriers.has(task.carrier) : !carriers.has('all') && !carriers.has('legacy'))
)
continue; continue;
const key = `${scope.batchItem.id}:${task.id}`; const key = `${scope.batchItem.id}:${task.id}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
@@ -664,7 +667,7 @@ export class ReportBatchGenerationService {
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}); });
const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all']; const targetCarriers = [...carriers].sort();
for (const carrier of targetCarriers) { for (const carrier of targetCarriers) {
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`; const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons]; const targetReasons = [...blockedReasons];
@@ -677,11 +680,13 @@ export class ReportBatchGenerationService {
if (missing.length) if (missing.length)
targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
} }
const existingTask = currentTasks.find( const existingTask = currentTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
(task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier),
);
if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备'); if (existingTask?.status === 'abandoned') targetReasons.push('该通道报备明细已放弃报备');
const duplicateBatchId = priorKeys.get(businessKey); const duplicateBatchId =
priorKeys.get(businessKey) ??
(selected.reportType === 'drainage'
? priorKeys.get(businessKey.replace(/:carrier:[^:]+$/, ':carrier:all'))
: undefined);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ targets.push({
id: `${channel.id}:${carrier}`, id: `${channel.id}:${carrier}`,
@@ -71,16 +71,15 @@ export class ReportChannelExportService {
: missing.length : missing.length
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
: null; : null;
const reportCarriers = const reportCarriers = item.eligibleTargets
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId) .filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom') .map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
: [null];
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[]; [];
for (const carrier of reportCarriers) { for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ const entry = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
const existingTask = await tx.channelSignatureReportTask.findFirst({
where: { where: {
signatureId: item.signature.id, signatureId: item.signature.id,
channelId, channelId,
@@ -90,28 +89,30 @@ export class ReportChannelExportService {
}, },
}); });
const task = existingTask const task = existingTask
? await this.prisma.channelSignatureReportTask.update({ ? await tx.channelSignatureReportTask.update({
where: { id: existingTask.id }, where: { id: existingTask.id },
data: { data: {
status: missingReason ? 'waiting_material' : 'exporting', status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason, reason: missingReason,
...(reportType === 'signature' ? { approvedAt: null } : {}), approvedAt: null,
}, },
}) })
: await this.prisma.channelSignatureReportTask.create({ : await tx.channelSignatureReportTask.create({
data: { data: {
tenantId: item.signature.tenantId, tenantId: item.signature.tenantId,
signatureId: item.signature.id, signatureId: item.signature.id,
channelId, channelId,
carrier, carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', approvalScope: 'carrier_specific',
reportType, reportType,
drainageItemId: item.drainageInfo?.id, drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting', status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason, reason: missingReason,
}, },
}); });
tasks.push({ task, existingTask }); return { task, existingTask };
});
tasks.push(entry);
} }
const task = tasks[0].task; const task = tasks[0].task;
if (missingReason) { if (missingReason) {
@@ -312,7 +313,7 @@ export class ReportChannelExportService {
missingFields: detail.missingFields, missingFields: detail.missingFields,
}); });
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台'; workbook.creator = '聆界短信平台';
const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] }); const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.columns = detail.fields.map((field) => ({ sheet.columns = detail.fields.map((field) => ({
header: field.exportName || field.name, header: field.exportName || field.name,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import { SignatureNameConflict } from '../sms-config/signature-uniqueness';
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts'; import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
import { import {
normalizePage, normalizePage,
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
} }
private async findImportSignature(tenantId: string, applicationId: string | undefined, name: string) {
const where = { tenantId, applicationId: applicationId ?? null, name };
return (
(await this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: { notIn: ['deleted', 'disabled'] } },
})) ??
this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: 'disabled' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
);
}
async stageSignatureRow( async stageSignatureRow(
tenantId: string, tenantId: string,
applicationId: string | undefined, applicationId: string | undefined,
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
if (!name) throw new Error('缺少短信签名'); if (!name) throw new Error('缺少短信签名');
const purpose = mappedCorePatchValue(mappings, values, 'purpose'); const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values); const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({ const existing = await this.findImportSignature(tenantId, applicationId, name);
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
return { return {
operation: existing ? 'update' : 'create', operation: existing ? 'update' : 'create',
targetId: existing?.id, targetId: existing?.id,
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else { } else {
const duplicate = await this.prisma.smsSignature.findFirst({ const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
});
if (duplicate) { if (duplicate) {
targetId = duplicate.id; targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else { } else {
try {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() }); const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id; targetId = created.id;
} catch (error) {
if (!(error instanceof SignatureNameConflict)) throw error;
// A concurrent create won. Imports continue to supplement the existing materials.
const current = await this.findImportSignature(batch.tenantId, applicationId, name);
if (!current) throw error;
targetId = current.id;
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
}
} }
} }
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
@@ -1,58 +1,85 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportPendingQueryService } from './pending-query.service'; import { ReportPendingQueryService } from './pending-query.service';
import { safeSpreadsheetText, styleHeader } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */ /** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportOfficialExportService { export class ReportOfficialExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {} constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
private readonly pending: ReportPendingQueryService,
) {}
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) { async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台'; workbook.creator = '聆界短信平台';
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] }); const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', {
const headers = reportType === 'signature' views: [{ state: 'frozen', ySplit: 1 }],
});
const headers =
reportType === 'signature'
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注'] ? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
: ['短信签名', '引流 URL 或号码', '备注', '主体证明']; : ['短信签名', '引流 URL 或号码', '备注', '主体证明'];
sheet.addRow(headers); sheet.addRow(headers);
sheet.addRow(reportType === 'signature' sheet.addRow(
reportType === 'signature'
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
: ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']); : ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片'],
);
styleHeader(sheet.getRow(1)); styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => { column.width = 24; }); sheet.columns.forEach((column) => {
column.width = 24;
});
sheet.getRow(2).height = 48; sheet.getRow(2).height = 48;
const content = Buffer.from(await workbook.xlsx.writeBuffer()); const content = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`; const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
await this.prisma.operationLog.create({ data: { await this.prisma.operationLog.create({
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material', data: {
userId: operatorId,
action: 'report_material.template_downloaded',
resource: 'report_material',
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue, detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
} }); },
});
return { fileName, content }; return { fileName, content };
} }
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) { async exportPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string },
operatorId?: string,
) {
const items = await this.pending.findPendingItems(query); const items = await this.pending.findPendingItems(query);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] }); const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']); sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
styleHeader(sheet.getRow(1)); styleHeader(sheet.getRow(1));
for (const item of items) sheet.addRow([ for (const item of items)
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name), sheet.addRow([
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt, item.reportType === 'signature' ? '签名' : '引流信息',
safeSpreadsheetText(item.tenant?.name),
safeSpreadsheetText(item.application?.name),
safeSpreadsheetText(item.name),
safeSpreadsheetText(item.detail),
item.changedAt,
]); ]);
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; }); sheet.columns.forEach((column, index) => {
column.width = index === 4 ? 42 : 22;
});
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`; const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
await this.prisma.operationLog.create({ data: { await this.prisma.operationLog.create({
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material', data: {
tenantId: query.tenantId,
userId: operatorId,
action: 'report_material.pending_export',
resource: 'report_material',
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue, detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
} }); },
});
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) }; return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
} }
} }
@@ -22,7 +22,91 @@ async function standardWorkbook() {
return Buffer.from(await workbook.xlsx.writeBuffer()); return Buffer.from(await workbook.xlsx.writeBuffer());
} }
const BROKEN_ID = 'ID_8E811861F79046DDAB7EDBD3AEE9DE89';
const BROKEN_NODE = `<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="522" name="${BROKEN_ID}"/></xdr:nvPicPr><xdr:blipFill><a:stretch/></xdr:blipFill></xdr:pic></etc:cellImage>`;
async function wpsFixture() {
return JSZip.loadAsync(await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image'));
}
async function addBrokenNode(zip: JSZip, referenced: boolean) {
const xml = await zip.file('xl/cellimages.xml')!.async('string');
zip.file('xl/cellimages.xml', xml.replace('<etc:cellImage>', `${BROKEN_NODE}<etc:cellImage>`));
if (referenced) {
const sheet = await zip.file('xl/worksheets/sheet1.xml')!.async('string');
zip.file(
'xl/worksheets/sheet1.xml',
sheet.replace(
'</sheetData>',
`<row r="3"><c r="B3"><f>_xlfn.DISPIMG("${BROKEN_ID}",1)</f></c></row></sheetData>`,
),
);
}
}
describe('WPS workbook compatibility', () => { describe('WPS workbook compatibility', () => {
describe.each([false, true])('includeImageData=%s', (includeImageData) => {
it('ignores an unreferenced damaged node without stealing the next image or shifting a blank cell', async () => {
const zip = await wpsFixture();
await addBrokenNode(zip, false);
const sheet = await zip.file('xl/worksheets/sheet1.xml')!.async('string');
zip.file('xl/worksheets/sheet1.xml', sheet.replace('<c r="A2"', '<c r="B2"/><c r="A2"'));
const loaded = await loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), {
includeImageData,
});
const images = loaded.wpsImagesBySheet.get('签名报备')!;
expect(images).toHaveLength(1);
expect(images[0]).toMatchObject({ row: 2, column: 1, extension: 'png' });
if (includeImageData) expect(images[0]).toMatchObject({ buffer: PNG });
else expect(images[0]).not.toHaveProperty('buffer');
});
it('reports the actually damaged referenced cell, not the valid adjacent image', async () => {
const zip = await wpsFixture();
await addBrokenNode(zip, true);
await expect(
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
).rejects.toThrow('工作表【签名报备】B3的WPS图片引用缺失或不唯一');
});
it('rejects duplicate image IDs even when the later node is damaged', async () => {
const zip = await wpsFixture();
const xml = await zip.file('xl/cellimages.xml')!.async('string');
const id = xml.match(/name="(ID_[A-F0-9]{32})"/)![1];
const duplicate = BROKEN_NODE.replace(BROKEN_ID, id);
zip.file('xl/cellimages.xml', xml.replace('</etc:cellImages>', `${duplicate}</etc:cellImages>`));
await expect(
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
).rejects.toThrow('工作表【签名报备】A2的WPS图片ID重复');
});
it('reports a missing media file with the exact worksheet and cell', async () => {
const zip = await wpsFixture();
for (const name of Object.keys(zip.files).filter((name) => name.startsWith('xl/media/'))) zip.remove(name);
await expect(
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
).rejects.toThrow('工作表【签名报备】A2的WPS图片文件缺失');
});
it.each(['missing', 'duplicate', 'external'])('rejects %s image relationships', async (kind) => {
const zip = await wpsFixture();
const path = 'xl/_rels/cellimages.xml.rels';
const xml = await zip.file(path)!.async('string');
const relationship = xml.match(/<Relationship\b[^>]*\/>/)![0];
zip.file(
path,
kind === 'missing'
? xml.replace(relationship, '')
: kind === 'duplicate'
? xml.replace(relationship, relationship + relationship)
: xml.replace(relationship, relationship.replace('/>', ' TargetMode="External"/>')),
);
await expect(
loadCompatibleWorkbook(await zip.generateAsync({ type: 'nodebuffer' }), { includeImageData }),
).rejects.toThrow('工作表【签名报备】A2的WPS图片引用缺失或不唯一');
});
});
it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => { it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => {
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image'); const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
const zip = await JSZip.loadAsync(converted); const zip = await JSZip.loadAsync(converted);
@@ -82,17 +82,32 @@ async function inspectWpsImages(zip: JSZip, includeImageData: boolean) {
}; };
const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels'); const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels');
const relTargets = new Map<string, string>(); const relTargets = new Map<string, string>();
const seenRelations = new Set<string>();
for (const match of relXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) { for (const match of relXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
const attrs = attributes(match[1]); const attrs = attributes(match[1]);
if (attrs.Id && attrs.Target && /\/image$/.test(attrs.Type ?? '')) if (seenRelations.has(attrs.Id)) {
relTargets.delete(attrs.Id);
continue;
}
seenRelations.add(attrs.Id);
if (attrs.Id && attrs.Target && attrs.TargetMode !== 'External' && /\/image$/.test(attrs.Type ?? ''))
relTargets.set(attrs.Id, packagePath(attrs.Target)); relTargets.set(attrs.Id, packagePath(attrs.Target));
} }
const imageTargets = new Map<string, string>(); const imageTargets = new Map<string, string>();
for (const match of cellImagesXml.matchAll( const seenImageIds = new Set<string>();
/<etc:cellImage\b[^>]*>[\s\S]*?<xdr:cNvPr\b([^>]*)\/?>(?:[\s\S]*?)<a:blip\b([^>]*)\/?>(?:[\s\S]*?)<\/etc:cellImage>/g, const duplicateImageIds = new Set<string>();
)) { // Bound every lookup to one node: a damaged node must never consume its neighbour's blip.
const id = attributes(match[1]).name; for (const match of cellImagesXml.matchAll(/<etc:cellImage\b[^>]*?(?:\/>|>([\s\S]*?)<\/etc:cellImage>)/g)) {
const target = relTargets.get(attributes(match[2])['r:embed']); const node = match[1] ?? '';
const id = attributes(node.match(/<xdr:cNvPr\b([^>]*)>/)?.[1] ?? '').name;
if (!id) continue;
if (seenImageIds.has(id)) {
duplicateImageIds.add(id);
imageTargets.delete(id);
continue;
}
seenImageIds.add(id);
const target = relTargets.get(attributes(node.match(/<a:blip\b([^>]*)>/)?.[1] ?? '')['r:embed']);
if (id && target) imageTargets.set(id, target); if (id && target) imageTargets.set(id, target);
} }
const workbookXml = await text(zip, 'xl/workbook.xml'); const workbookXml = await text(zip, 'xl/workbook.xml');
@@ -112,16 +127,21 @@ async function inspectWpsImages(zip: JSZip, includeImageData: boolean) {
if (!attrs.name || !path) continue; if (!attrs.name || !path) continue;
const sheetXml = await text(zip, path); const sheetXml = await text(zip, path);
const images: Array<EmbeddedImage | EmbeddedImageMetadata> = []; const images: Array<EmbeddedImage | EmbeddedImageMetadata> = [];
for (const cell of sheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) { for (const cell of sheetXml.matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
const formulaText = decodeXml(cell[2].match(/<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? ''); const formulaText = decodeXml((cell[2] ?? '').match(/<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? '');
if (!formulaText) continue; if (!formulaText) continue;
const formula = DISPIMG_FORMULA.exec(formulaText); const formula = DISPIMG_FORMULA.exec(formulaText);
if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`); if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`);
const target = imageTargets.get(formula[1]); const target = imageTargets.get(formula[1]);
const cellPosition = coordinates(attributes(cell[1]).r); const address = attributes(cell[1]).r ?? '';
if (!target || !cellPosition) throw new BadRequestException('WPS单元格图片关系不完整'); const cellPosition = coordinates(address);
const location = `工作表【${attrs.name}${cellPosition ? address : '未知单元格'}`;
if (duplicateImageIds.has(formula[1]))
throw new BadRequestException(`${location}的WPS图片ID重复,无法确定对应图片,请重新插入图片`);
if (!cellPosition) throw new BadRequestException(`${location}的WPS图片单元格地址无效`);
if (!target) throw new BadRequestException(`${location}的WPS图片引用缺失或不唯一,请重新插入图片或清空该单元格`);
const imageEntry = zip.file(target); const imageEntry = zip.file(target);
if (!imageEntry) throw new BadRequestException('WPS单元格图片文件缺失'); if (!imageEntry) throw new BadRequestException(`${location}的WPS图片文件缺失,请重新插入图片或清空该单元格`);
const extension = normalizeImageExtension(target.split('.').pop() ?? 'png'); const extension = normalizeImageExtension(target.split('.').pop() ?? 'png');
if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持'); if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持');
const size = Number( const size = Number(
+350 -42
View File
@@ -1,11 +1,19 @@
import { Logger } from '@nestjs/common';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
type CapturedSql = { strings: string[]; values: unknown[]; text: string };
function sqlText(query: CapturedSql) {
return query.strings.join(' ');
}
describe('ReportsService', () => { describe('ReportsService', () => {
const tx = { const tx = {
dailyReconciliationReport: { deleteMany: jest.fn() }, dailyReconciliationReport: { deleteMany: jest.fn() },
dailyProfitReport: { deleteMany: jest.fn() }, dailyProfitReport: { deleteMany: jest.fn() },
dailyQualityReport: { deleteMany: jest.fn() }, dailyQualityReport: { deleteMany: jest.fn() },
$executeRaw: jest.fn(), $executeRaw: jest.fn(),
$queryRaw: jest.fn(),
}; };
const prisma = { const prisma = {
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() }, dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
@@ -14,25 +22,62 @@ describe('ReportsService', () => {
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)), $transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
}; };
let service: ReportsService; let service: ReportsService;
const environmentKeys = [
'REPORT_DAILY_REFRESH_ENABLED',
'REPORT_REFRESH_INTERVAL_MS',
'REPORT_REFRESH_TRANSACTION_TIMEOUT_MS',
] as const;
const originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]]));
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.resetAllMocks();
for (const key of environmentKeys) delete process.env[key];
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
prisma.$transaction.mockImplementation((callback: (client: typeof tx) => unknown) => callback(tx));
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
tx.$executeRaw.mockResolvedValue(0); tx.$executeRaw.mockResolvedValue(0);
tx.$queryRaw.mockResolvedValue([{ locked: true }]);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]); prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1); prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); prisma.dailyReconciliationReport.aggregate.mockResolvedValue({
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
});
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]); prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
prisma.dailyProfitReport.count.mockResolvedValue(1); prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } }); prisma.dailyProfitReport.aggregate.mockResolvedValue({
_sum: {
submittedUnits: 11,
sentUnits: 10,
unknownUnits: 2,
successUnits: 7,
failedUnits: 1,
revenueCents: BigInt(1000),
costCents: BigInt(600),
profitCents: BigInt(400),
},
});
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]); prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1); prisma.dailyQualityReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); prisma.dailyQualityReport.aggregate.mockResolvedValue({
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
});
service = new ReportsService(prisma as never); service = new ReportsService(prisma as never);
}); });
afterEach(() => {
service.onModuleDestroy();
jest.useRealTimers();
jest.restoreAllMocks();
for (const key of environmentKeys) {
const value = originalEnvironment[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => { it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({ await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'], refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
@@ -41,14 +86,193 @@ describe('ReportsService', () => {
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4); expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4); expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4); expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.$executeRaw).toHaveBeenCalledTimes(28); expect(tx.$executeRaw).toHaveBeenCalledTimes(32);
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
});
it.each([
['2026-12-31T15:59:59.999Z', ['2026-12-27', '2026-12-28', '2026-12-29', '2026-12-30']],
['2026-12-31T16:00:00.000Z', ['2026-12-28', '2026-12-29', '2026-12-30', '2026-12-31']],
['2026-02-28T16:00:00.000Z', ['2026-02-25', '2026-02-26', '2026-02-27', '2026-02-28']],
])('uses completed Shanghai dates at boundary %s', async (now, expectedDates) => {
await expect(service.refreshRollingWindow(new Date(now))).resolves.toEqual({ refreshedDates: expectedDates });
expect(
tx.dailyReconciliationReport.deleteMany.mock.calls.map(([query]) =>
query.where.reportDate.toISOString().slice(0, 10),
),
).toEqual(expectedDates);
});
it('bounds application costs by the original message day while retaining cross-day submits', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const applicationProfit = tx.$executeRaw.mock.calls
.map(([query]) => query as CapturedSql)
.find((query) => sqlText(query).includes('WITH costs AS'))!;
const costs = applicationProfit.text.split('INSERT INTO "DailyProfitReport"')[0];
expect(costs).toMatch(/message\."queuedAt" >= \$1/);
expect(costs).toMatch(/message\."queuedAt" < \$2/);
expect(applicationProfit.values.slice(0, 2)).toEqual([
new Date('2026-07-10T16:00:00.000Z'),
new Date('2026-07-11T16:00:00.000Z'),
]);
expect(costs).toContain('submit."submitStatus" = \'accepted\'');
expect(costs).not.toContain('submit."submittedAt"');
expect(costs).not.toContain('submit."createdAt"');
expect(costs).toContain('WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count');
expect(costs).toContain('WHEN legacy_receipt.delivered THEN message."billingUnits"');
const channelProfit = tx.$executeRaw.mock.calls
.map(([query]) => sqlText(query))
.find((query) => query.includes("CONCAT('profit-channel-'"))!;
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") >=');
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") <');
});
it.each([
[undefined, 30_000],
['45000', 45_000],
['200000', 120_000],
['0', 30_000],
['-1', 30_000],
['1.5', 30_000],
['invalid', 30_000],
['Infinity', 30_000],
])('uses a bounded report transaction timeout for %s', async (configured, expected) => {
if (configured !== undefined) process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = configured;
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5_000, timeout: expected });
const timeoutQueries = tx.$executeRaw.mock.calls
.map(([query]) => query as CapturedSql)
.filter((query) => sqlText(query).includes('statement_timeout'));
expect(timeoutQueries).toHaveLength(4);
for (const query of timeoutQueries) {
expect(sqlText(query)).toContain("set_config('statement_timeout',");
expect(sqlText(query)).toContain(', true)');
expect(query.values).toEqual([`${expected}ms`]);
}
});
it('continues later dates after one transaction fails and reports partial completion', async () => {
const failure = new Error('database transaction expired');
tx.$executeRaw.mockResolvedValueOnce(0).mockRejectedValueOnce(failure);
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
'failed dates: 2026-07-11; refreshed dates: 2026-07-12, 2026-07-13, 2026-07-14',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
expect(
tx.dailyQualityReport.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10)),
).toEqual(['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14']);
expect(Logger.prototype.error).toHaveBeenCalledWith('Daily report refresh failed for 2026-07-11', failure.stack);
});
it('does not delete reports for a date whose lock is owned by another transaction', async () => {
tx.$queryRaw.mockResolvedValueOnce([{ locked: false }]);
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
'failed dates: 2026-07-11',
);
for (const model of [tx.dailyReconciliationReport, tx.dailyProfitReport, tx.dailyQualityReport]) {
expect(model.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10))).toEqual([
'2026-07-12',
'2026-07-13',
'2026-07-14',
]);
}
expect(tx.$queryRaw.mock.calls.map(([query]) => query.values[1])).toEqual([20260711, 20260712, 20260713, 20260714]);
expect(sqlText(tx.$queryRaw.mock.calls[0][0])).toContain('pg_try_advisory_xact_lock');
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
tx.dailyReconciliationReport.deleteMany.mock.invocationCallOrder[0],
);
});
it('fails safely when the transaction lock query returns no result', async () => {
tx.$queryRaw.mockResolvedValue([]);
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
'refreshed dates: none',
);
expect(tx.dailyReconciliationReport.deleteMany).not.toHaveBeenCalled();
expect(tx.dailyProfitReport.deleteMany).not.toHaveBeenCalled();
expect(tx.dailyQualityReport.deleteMany).not.toHaveBeenCalled();
});
it('retries partial failures and skips the business day only after full success', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
tx.$executeRaw.mockRejectedValueOnce(new Error('database unavailable'));
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
expect(Logger.prototype.log).not.toHaveBeenCalled();
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
expect(Logger.prototype.log).toHaveBeenCalledTimes(1);
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
jest.setSystemTime(new Date('2026-07-15T16:00:00.000Z'));
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(12);
});
it('prevents overlapping scheduled refreshes in the same service instance', async () => {
let finishRefresh!: (result: { refreshedDates: string[] }) => void;
const refresh = jest.spyOn(service, 'refreshRollingWindow').mockImplementation(
() =>
new Promise((resolve) => {
finishRefresh = resolve;
}),
);
const running = service['runScheduledRefresh']();
await service['runScheduledRefresh']();
expect(refresh).toHaveBeenCalledTimes(1);
finishRefresh({ refreshedDates: ['2026-07-14'] });
await running;
});
it('cancels startup and interval timers when destroyed before startup refresh', async () => {
jest.useFakeTimers();
const refresh = jest.spyOn(service, 'refreshRollingWindow');
service.onModuleInit();
expect(jest.getTimerCount()).toBe(2);
service.onModuleDestroy();
expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000);
expect(refresh).not.toHaveBeenCalled();
});
it('starts after 15 seconds and retries a failed scheduled run on the configured interval', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
process.env.REPORT_REFRESH_INTERVAL_MS = '60000';
const refresh = jest
.spyOn(service, 'refreshRollingWindow')
.mockRejectedValueOnce(new Error('temporarily unavailable'));
service.onModuleInit();
await jest.advanceTimersByTimeAsync(14_999);
expect(refresh).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1);
expect(refresh).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(45_000);
expect(refresh).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(60_000);
expect(refresh).toHaveBeenCalledTimes(2);
});
it('does not schedule reports when daily refresh is disabled', () => {
jest.useFakeTimers();
process.env.REPORT_DAILY_REFRESH_ENABLED = 'false';
service.onModuleInit();
expect(jest.getTimerCount()).toBe(0);
}); });
it('calculates profit cost from channel unit price times delivered fragment count', async () => { it('calculates profit cost from channel unit price times delivered fragment count', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => const firstDayQueries = tx.$executeRaw.mock.calls
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), .slice(1, 8)
); .map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
const profitQueries = firstDayQueries.slice(1, 3).join('\n'); const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('"SmsMessageSegmentAudit"'); expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
@@ -59,9 +283,9 @@ describe('ReportsService', () => {
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => { it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => const firstDayQueries = tx.$executeRaw.mock.calls
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), .slice(1, 8)
); .map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
const profitQueries = firstDayQueries.slice(1, 3).join('\n'); const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"'); expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
@@ -71,58 +295,111 @@ describe('ReportsService', () => {
}); });
it('queries reconciliation reports with server-side filters and bounded pagination', async () => { it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({ await expect(
service.listReconciliation({
dateFrom: '2026-07-01', dateFrom: '2026-07-01',
dateTo: '2026-07-14', dateTo: '2026-07-14',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
page: 2, page: 2,
pageSize: 500, pageSize: 500,
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); }),
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ ).resolves.toEqual({
items: [{ id: 'recon-1' }],
total: 1,
page: 2,
pageSize: 100,
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
});
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }), where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100, skip: 100,
take: 100, take: 100,
})); }),
);
}); });
it('keeps application and channel profit filters separate', async () => { it('keeps application and channel profit filters separate', async () => {
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' }); const result = await service.listProfit({
expect(result).toEqual(expect.objectContaining({ dimensionType: 'channel',
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
});
expect(result).toEqual(
expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }), summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
})); }),
);
expect(result.summary).not.toHaveProperty('refundCents'); expect(result.summary).not.toHaveProperty('refundCents');
expect(result.items[0]).not.toHaveProperty('refundCents'); expect(result.items[0]).not.toHaveProperty('refundCents');
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
dimensionType: 'channel', dimensionType: 'channel',
tenantId: undefined, tenantId: undefined,
applicationId: undefined, applicationId: undefined,
channelId: 'channel-1', channelId: 'channel-1',
}), }),
})); }),
);
}); });
it('returns zero full-result totals and rates when a filtered report has no rows', async () => { it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]); prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0); prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({ prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null }, _sum: {
submittedUnits: null,
sentUnits: null,
unknownUnits: null,
successUnits: null,
failedUnits: null,
revenueCents: null,
costCents: null,
profitCents: null,
},
}); });
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({ await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(
expect.objectContaining({
total: 0, total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 }, summary: {
})); submittedUnits: 0,
sentUnits: 0,
unknownUnits: 0,
successUnits: 0,
failedUnits: 0,
revenueCents: 0,
costCents: 0,
profitCents: 0,
profitRateBps: 0,
},
}),
);
}); });
it('exports income without refund columns', async () => { it('exports income without refund columns', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{ prisma.dailyProfitReport.findMany.mockResolvedValueOnce([
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业', {
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, id: 'profit-export',
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400), reportDate: new Date('2026-07-14'),
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'), dimensionName: '应用A',
}]); tenantName: '示例企业',
submittedUnits: 14,
sentUnits: 12,
unknownUnits: 1,
successUnits: 10,
failedUnits: 1,
revenueCents: BigInt(3500),
refundCents: BigInt(200),
costCents: BigInt(2100),
profitCents: BigInt(1400),
profitRateBps: 4000,
generatedAt: new Date('2026-07-15T00:00:00Z'),
},
]);
const exported = await service.exportProfit({ dimensionType: 'application' }); const exported = await service.exportProfit({ dimensionType: 'application' });
expect(exported.content).toContain('收入金额(元)'); expect(exported.content).toContain('收入金额(元)');
@@ -131,28 +408,59 @@ describe('ReportsService', () => {
}); });
it('sorts quality reports by send volume and keeps the selected dimension', async () => { it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({ await expect(
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage', service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }),
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 }, ).resolves.toEqual({
items: [{ id: 'quality-1' }],
total: 1,
page: 1,
pageSize: 20,
dimensionType: 'drainage',
summary: {
submittedUnits: 11,
sentUnits: 10,
unknownUnits: 2,
successUnits: 7,
failedUnits: 1,
successRateBps: 7000,
},
}); });
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }), where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
})); }),
);
}); });
it('exports complete filtered report data as escaped CSV instead of the current page', async () => { it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{ prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A', {
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'), id: 'recon-export',
}]); reportDate: new Date('2026-07-14'),
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' }); tenantName: '示例,企业',
applicationName: '应用A',
submittedUnits: 14,
sentUnits: 12,
unknownUnits: 1,
successUnits: 10,
failedUnits: 1,
generatedAt: new Date('2026-07-15T00:00:00Z'),
},
]);
const exported = await service.exportReconciliation({
tenantId: 'tenant-1',
dateFrom: '2026-07-01',
dateTo: '2026-07-14',
});
expect(exported.fileName).toContain('对账单-'); expect(exported.fileName).toContain('对账单-');
expect(exported.content).toContain('"示例,企业"'); expect(exported.content).toContain('"示例,企业"');
expect(exported.content).toContain('提交条数'); expect(exported.content).toContain('提交条数');
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }), where: expect.objectContaining({ tenantId: 'tenant-1' }),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
})); }),
);
}); });
}); });
+180 -26
View File
@@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service';
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000; const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000; const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
const DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS = 30_000;
const REPORT_LOCK_NAMESPACE = 0x434d5052;
export type ReportListQuery = { export type ReportListQuery = {
dateFrom?: string; dateFrom?: string;
@@ -21,6 +23,7 @@ export type ReportListQuery = {
@Injectable() @Injectable()
export class ReportsService implements OnModuleInit, OnModuleDestroy { export class ReportsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReportsService.name); private readonly logger = new Logger(ReportsService.name);
private startupTimer?: ReturnType<typeof setTimeout>;
private refreshTimer?: ReturnType<typeof setInterval>; private refreshTimer?: ReturnType<typeof setInterval>;
private refreshRunning = false; private refreshRunning = false;
private lastRefreshBusinessDate?: string; private lastRefreshBusinessDate?: string;
@@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
onModuleInit() { onModuleInit() {
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return; if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000); this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
startupTimer.unref?.(); this.startupTimer.unref?.();
this.refreshTimer = setInterval( this.refreshTimer = setInterval(
() => void this.runScheduledRefresh(), () => void this.runScheduledRefresh(),
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS), positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
@@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
} }
onModuleDestroy() { onModuleDestroy() {
if (this.startupTimer) clearTimeout(this.startupTimer);
if (this.refreshTimer) clearInterval(this.refreshTimer); if (this.refreshTimer) clearInterval(this.refreshTimer);
} }
@@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query); const { page, pageSize, skip } = pagination(query);
const where = reconciliationWhere(query); const where = reconciliationWhere(query);
const [items, total, aggregate] = await Promise.all([ const [items, total, aggregate] = await Promise.all([
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }), this.prisma.dailyReconciliationReport.findMany({
where,
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.dailyReconciliationReport.count({ where }), this.prisma.dailyReconciliationReport.count({ where }),
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }), this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]); ]);
@@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query); const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query); const { dimensionType, where } = profitWhere(query);
const [storedItems, total, aggregate] = await Promise.all([ const [storedItems, total, aggregate] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), this.prisma.dailyProfitReport.findMany({
where,
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.dailyProfitReport.count({ where }), this.prisma.dailyProfitReport.count({ where }),
this.prisma.dailyProfitReport.aggregate({ this.prisma.dailyProfitReport.aggregate({
where, where,
@@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
}), }),
]); ]);
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。 // refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item); const items = storedItems.map(({ refundCents, ...item }) => {
void refundCents;
return item;
});
const summary = { const summary = {
...volumeSummary(aggregate._sum), ...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0), revenueCents: Number(aggregate._sum.revenueCents ?? 0),
@@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query); const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = qualityWhere(query); const { dimensionType, where } = qualityWhere(query);
const [items, total, aggregate] = await Promise.all([ const [items, total, aggregate] = await Promise.all([
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), this.prisma.dailyQualityReport.findMany({
where,
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.dailyQualityReport.count({ where }), this.prisma.dailyQualityReport.count({ where }),
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }), this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]); ]);
@@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
} }
async exportReconciliation(query: ReportListQuery) { async exportReconciliation(query: ReportListQuery) {
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); const items = await this.prisma.dailyReconciliationReport.findMany({
return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)])); where: reconciliationWhere(query),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
});
return csvExport(
'对账单',
['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'],
items.map((item) => [
dateKey(item.reportDate),
item.tenantName,
item.applicationName,
item.submittedUnits,
item.sentUnits,
item.unknownUnits,
item.successUnits,
item.failedUnits,
formatCsvDate(item.generatedAt),
]),
);
} }
async exportProfit(query: ReportListQuery) { async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query); const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); const items = await this.prisma.dailyProfitReport.findMany({
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); where,
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
});
return csvExport(
`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`,
[
'发送日期',
'统计维度',
'企业',
'提交条数',
'发送条数',
'未知条数',
'成功条数',
'失败条数',
'收入金额(元)',
'成本金额(元)',
'利润(元)',
'利润率(%)',
'生成时间',
],
items.map((item) => [
dateKey(item.reportDate),
item.dimensionName,
item.tenantName ?? '',
item.submittedUnits,
item.sentUnits,
item.unknownUnits,
item.successUnits,
item.failedUnits,
moneyUnitsToFixedYuan(item.revenueCents),
moneyUnitsToFixedYuan(item.costCents),
moneyUnitsToFixedYuan(item.profitCents),
(item.profitRateBps / 100).toFixed(2),
formatCsvDate(item.generatedAt),
]),
);
} }
async exportQuality(query: ReportListQuery) { async exportQuality(query: ReportListQuery) {
const { dimensionType, where } = qualityWhere(query); const { dimensionType, where } = qualityWhere(query);
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); const items = await this.prisma.dailyQualityReport.findMany({
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); where,
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
});
return csvExport(
`发送质量报表-${dimensionType}`,
[
'发送日期',
'统计对象',
'企业',
'提交条数',
'发送条数',
'未知条数',
'成功条数',
'失败条数',
'成功率(%)',
'平均到达时长(毫秒)',
'生成时间',
],
items.map((item) => [
dateKey(item.reportDate),
item.dimensionName,
item.tenantName ?? '',
item.submittedUnits,
item.sentUnits,
item.unknownUnits,
item.successUnits,
item.failedUnits,
(item.successRateBps / 100).toFixed(2),
item.avgArrivalMs ?? '',
formatCsvDate(item.generatedAt),
]),
);
} }
async refreshRollingWindow(now = new Date()) { async refreshRollingWindow(now = new Date()) {
const days = completedBusinessDays(now, 4); const days = completedBusinessDays(now, 4);
for (const day of days) await this.refreshBusinessDay(day); const refreshedDates: string[] = [];
return { refreshedDates: days.map((day) => day.key) }; const failedDates: string[] = [];
for (const day of days) {
try {
await this.refreshBusinessDay(day);
refreshedDates.push(day.key);
} catch (error) {
failedDates.push(day.key);
this.logger.error(
`Daily report refresh failed for ${day.key}`,
error instanceof Error ? error.stack : String(error),
);
}
}
if (failedDates.length) {
throw new Error(
`Daily report refresh incomplete; failed dates: ${failedDates.join(', ')}; refreshed dates: ${refreshedDates.join(', ') || 'none'}`,
);
}
return { refreshedDates };
} }
private async runScheduledRefresh() { private async runScheduledRefresh() {
const businessDate = shanghaiDateKey(new Date()); const now = new Date();
const businessDate = shanghaiDateKey(now);
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return; if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
this.refreshRunning = true; this.refreshRunning = true;
try { try {
const result = await this.refreshRollingWindow(); const result = await this.refreshRollingWindow(now);
this.lastRefreshBusinessDate = businessDate; this.lastRefreshBusinessDate = businessDate;
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`); this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
} catch (error) { } catch (error) {
@@ -132,7 +256,17 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
} }
private async refreshBusinessDay(day: BusinessDay) { private async refreshBusinessDay(day: BusinessDay) {
await this.prisma.$transaction(async (tx) => { const timeout = Math.min(
120_000,
positiveInteger(process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS, DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS),
);
await this.prisma.$transaction(
async (tx) => {
await tx.$executeRaw(Prisma.sql`SELECT set_config('statement_timeout', ${`${timeout}ms`}, true)`);
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>(Prisma.sql`
SELECT pg_try_advisory_xact_lock(${REPORT_LOCK_NAMESPACE}::integer, ${Number(day.key.replaceAll('-', ''))}::integer) AS locked
`);
if (!lock?.locked) throw new Error(`Daily reports for ${day.key} are being refreshed by another transaction`);
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } }); await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } }); await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } }); await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
@@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
) AS delivered ) AS delivered
) legacy_receipt ON TRUE ) legacy_receipt ON TRUE
WHERE submit."submitStatus" = 'accepted' WHERE submit."submitStatus" = 'accepted'
--
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY submit."messageRecordId" GROUP BY submit."messageRecordId"
) )
INSERT INTO "DailyProfitReport" ( INSERT INTO "DailyProfitReport" (
@@ -352,23 +489,28 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
await tx.$executeRaw(qualityByChannelSql(day)); await tx.$executeRaw(qualityByChannelSql(day));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature')); await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage')); await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
}); },
{ maxWait: 5_000, timeout },
);
} }
} }
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') { function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`); const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
const dimensionId = dimensionType === 'application' const dimensionId =
dimensionType === 'application'
? Prisma.sql`application.id` ? Prisma.sql`application.id`
: dimensionType === 'signature' : dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))` ? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`; : Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
const dimensionName = dimensionType === 'application' const dimensionName =
dimensionType === 'application'
? Prisma.sql`application.name` ? Prisma.sql`application.name`
: dimensionType === 'signature' : dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.name, '未关联签名')` ? Prisma.sql`COALESCE(signature.name, '未关联签名')`
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`; : Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
const applicationJoin = dimensionType === 'application' const applicationJoin =
dimensionType === 'application'
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"` ? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`; : Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
@@ -397,7 +539,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
JOIN "Tenant" tenant ON tenant.id = message."tenantId" JOIN "Tenant" tenant ON tenant.id = message."tenantId"
${applicationJoin} ${applicationJoin}
LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId" LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId"
LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId" LEFT JOIN "SmsDrainageInfo" drainage ON ${dimensionType === 'drainage' ? Prisma.sql`(CASE WHEN message."drainageGate" IS NULL THEN drainage.id = message."drainageInfoId" ELSE EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(message."drainageGate"->'targets', '[]'::jsonb)) target WHERE target->'materialIds' ? drainage.id) END)` : Prisma.sql`drainage.id = message."drainageInfoId"`}
WHERE message."queuedAt" >= ${day.startAt} WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt} AND message."queuedAt" < ${day.endAt}
), thresholds AS ( ), thresholds AS (
@@ -548,7 +690,13 @@ const reportVolumeSumSelection = {
failedUnits: true, failedUnits: true,
} as const; } as const;
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) { function volumeSummary(sum: {
submittedUnits?: number | null;
sentUnits?: number | null;
unknownUnits?: number | null;
successUnits?: number | null;
failedUnits?: number | null;
}) {
return { return {
submittedUnits: Number(sum.submittedUnits ?? 0), submittedUnits: Number(sum.submittedUnits ?? 0),
sentUnits: Number(sum.sentUnits ?? 0), sentUnits: Number(sum.sentUnits ?? 0),
@@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number
} }
function ratioBps(numerator: number, denominator: number) { function ratioBps(numerator: number, denominator: number) {
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator); return denominator === 0 ? 0 : Math.round((numerator * 10_000) / denominator);
} }
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput { function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined }; return {
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: query.tenantId || undefined,
applicationId: query.applicationId || undefined,
};
} }
function profitWhere(query: ReportListQuery) { function profitWhere(query: ReportListQuery) {
@@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) {
function qualityWhere(query: ReportListQuery) { function qualityWhere(query: ReportListQuery) {
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']); const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application'; const dimensionType = allowedDimensions.has(String(query.dimensionType))
? String(query.dimensionType)
: 'application';
const where: Prisma.DailyQualityReportWhereInput = { const where: Prisma.DailyQualityReportWhereInput = {
dimensionType, dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo), reportDate: dateFilter(query.dateFrom, query.dateTo),
@@ -0,0 +1,41 @@
import { nightClock, nightWindow } from './night-sending-risk.service';
describe('night sending window', () => {
const config = nightClock(null);
it.each([
['2026-09-07T20:59:59+08:00', false],
['2026-09-07T21:00:00+08:00', true],
['2026-09-08T00:00:00+08:00', true],
['2026-09-08T07:59:59+08:00', true],
['2026-09-08T08:00:00+08:00', false],
])('uses Shanghai boundaries at %s', (time, active) => {
const window = nightWindow(new Date(time), config);
expect(Boolean(window)).toBe(active);
if (window) {
expect(window.windowStartedAt.toISOString()).toBe('2026-09-07T13:00:00.000Z');
expect(window.windowEndsAt.toISOString()).toBe('2026-09-08T00:00:00.000Z');
}
});
it('handles a same-day configured period', () => {
const clock = nightClock({ startTime: '01:00', endTime: '06:00' });
expect(nightWindow(new Date('2026-09-08T03:00:00+08:00'), clock)?.windowEndsAt.toISOString()).toBe(
'2026-09-07T22:00:00.000Z',
);
});
it('retains the previous clock until the current night ends', () => {
const config = {
startTime: '23:00',
endTime: '07:00',
previousTimeConfig: { startTime: '21:00', endTime: '08:00' },
timeConfigEffectiveAt: '2026-09-08T00:00:00Z',
};
expect(nightClock(config, new Date('2026-09-07T14:00:00Z')).startTime).toBe('21:00');
expect(nightClock(config, new Date('2026-09-08T00:00:00Z')).startTime).toBe('23:00');
});
it.each([
{ startTime: '25:00', endTime: '08:00' },
{ startTime: '21:00', endTime: '21:00' },
])('rejects invalid clock %j', (clock) => {
expect(() => nightWindow(new Date(), nightClock(clock))).toThrow();
});
});
@@ -0,0 +1,258 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma, RiskRule } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
export const NIGHT_RULE_CODE = 'NON_WORKING_MARKETING_BULK';
export const NIGHT_REVIEW_SOURCE = 'night_sending_bulk';
const REVIEW_WINDOW_MS = 10_000;
const DAY_MS = 86_400_000;
type NightClock = { startTime: string; endTime: string; timeZone: string };
export function nightClock(config: unknown, now = new Date()): NightClock {
const value = config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
if (
typeof value.timeConfigEffectiveAt === 'string' &&
new Date(value.timeConfigEffectiveAt) > now &&
value.previousTimeConfig
) {
return nightClock(value.previousTimeConfig, now);
}
return {
startTime: typeof value.startTime === 'string' ? value.startTime : '21:00',
endTime: typeof value.endTime === 'string' ? value.endTime : '08:00',
timeZone: 'Asia/Shanghai',
};
}
export function nightWindow(now: Date, clock: NightClock) {
const minute = (value: string) => {
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new BadRequestException('夜间时间配置无效');
const [h, m] = value.split(':').map(Number);
return h * 60 + m;
};
const start = minute(clock.startTime),
end = minute(clock.endTime);
if (start === end || !Number.isFinite(now.getTime())) throw new BadRequestException('夜间时间配置无效');
const local = new Date(now.getTime() + 8 * 3600_000);
const minutes = local.getUTCHours() * 60 + local.getUTCMinutes();
const active = start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
if (!active) return null;
const midnight = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - 8 * 3600_000;
const startDay = midnight - (start > end && minutes < end ? DAY_MS : 0);
return {
windowStartedAt: new Date(startDay + start * 60_000),
windowEndsAt: new Date(startDay + (end + (start > end ? 1440 : 0)) * 60_000),
};
}
/** Persistent gate for initial business-message dispatch, shared by every transport. */
export class NightSendingRiskService {
constructor(private readonly prisma: PrismaService) {}
async guard(messageIds: string[], now = new Date()) {
const held = new Set(messageIds);
if (!messageIds.length) return held;
const owners = await this.prisma.smsMessageRecord.findMany({
where: {
id: { in: [...new Set(messageIds)] },
status: 'queued',
tenantId: { not: null },
applicationId: { not: null },
},
select: { id: true, applicationId: true },
});
const applications = [...new Set(owners.map((message) => message.applicationId!))].sort();
for (const applicationId of applications) {
const ids = owners.filter((message) => message.applicationId === applicationId).map((message) => message.id);
for (let offset = 0; offset < ids.length; offset += 250) {
const blocked = await this.guardApplication(applicationId, ids.slice(offset, offset + 250), now);
for (const id of ids.slice(offset, offset + 250)) if (!blocked.includes(id)) held.delete(id);
}
}
return held;
}
private async guardApplication(applicationId: string, ids: string[], now: Date) {
return this.prisma.$transaction(
async (tx) => {
// Shared by all API/worker instances; lock before reading counters or decisions.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'night-sending:' + applicationId}, 0))`;
const application = await tx.smsApplication.findUniqueOrThrow({
where: { id: applicationId },
select: { tenantId: true },
});
const tenantId = application.tenantId;
const messages = await tx.smsMessageRecord.findMany({
where: { id: { in: ids }, applicationId, tenantId, status: 'queued' },
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
});
const rules = await tx.riskRule.findMany({
where: {
code: NIGHT_RULE_CODE,
status: 'active',
OR: [{ applicationId: null }, { applicationId, tenantId }],
},
orderBy: { createdAt: 'asc' },
});
const rule =
rules.find((item) => item.applicationId === applicationId) ??
rules.find((item) => item.applicationId === null);
const reservations = await tx.nightSendingReservation.findMany({ where: { messageRecordId: { in: ids } } });
const byMessage = new Map(reservations.map((item) => [item.messageRecordId, item]));
let window = await tx.nightSendingWindow.findFirst({
where: { applicationId, tenantId, windowStartedAt: { lte: now }, windowEndsAt: { gt: now } },
orderBy: { windowStartedAt: 'desc' },
});
const period = window ?? (rule ? nightWindow(now, nightClock(rule.config, now)) : null);
const held: string[] = ids.filter((id) => !messages.some((message) => message.id === id));
for (const message of messages) {
const previous = byMessage.get(message.id);
if (previous) {
if (previous.tenantId !== tenantId || previous.applicationId !== applicationId)
throw new Error('Night sending reservation owner mismatch');
if (previous.reviewTaskId) {
const task = await tx.smsSendTask.findUniqueOrThrow({ where: { id: previous.reviewTaskId } });
if (task.status !== 'approved') held.push(message.id);
}
continue;
}
// Already submitted business messages and retries never occupy a new allowance.
if (message.submitId || !rule || !period) continue;
if (!Number.isSafeInteger(rule.thresholdValue) || rule.thresholdValue < 0)
throw new Error('夜间累计阈值必须是非负整数');
if (!window) {
// Bootstrap only once per application/night, including first deployment mid-night.
// First Submit attempt is durable even when the supplier rejects or has no receipt.
const [baseline] = await tx.$queryRaw<Array<{ count: number }>>(Prisma.sql`
SELECT COUNT(*)::int AS count FROM "SmsMessageRecord" m
WHERE m."applicationId" = ${applicationId} AND m."tenantId" = ${tenantId}
AND EXISTS (SELECT 1 FROM "SmsSubmitRecord" s WHERE s."messageRecordId" = m.id
AND s."createdAt" >= ${period.windowStartedAt} AND s."createdAt" < ${period.windowEndsAt})
AND NOT EXISTS (SELECT 1 FROM "SmsSubmitRecord" s WHERE s."messageRecordId" = m.id AND s."createdAt" < ${period.windowStartedAt})
`);
window = await tx.nightSendingWindow.create({
data: {
id: `${applicationId}:${period.windowStartedAt.toISOString()}`,
tenantId,
applicationId,
windowStartedAt: period.windowStartedAt,
windowEndsAt: period.windowEndsAt,
count: baseline.count,
baselineCount: baseline.count,
},
});
}
window = await tx.nightSendingWindow.update({ where: { id: window.id }, data: { count: { increment: 1 } } });
let reviewTaskId: string | undefined;
if (window.count > rule.thresholdValue) {
reviewTaskId = await this.aggregate(tx, message, rule, window, now);
held.push(message.id);
}
await tx.nightSendingReservation.create({
data: {
messageRecordId: message.id,
tenantId,
applicationId,
windowId: window.id,
sequence: window.count,
thresholdValue: rule.thresholdValue,
reviewTaskId,
},
});
}
return held;
},
{ maxWait: 15_000, timeout: 30_000 },
);
}
private async aggregate(
tx: Prisma.TransactionClient,
message: {
id: string;
tenantId: string | null;
applicationId: string | null;
batchTaskId: string | null;
content: string;
phoneNumber: string;
},
rule: RiskRule,
night: { id: string; count: number; windowStartedAt: Date; windowEndsAt: Date },
now: Date,
) {
const contentHash = createHash('sha256').update(message.content).digest('hex');
const windowStartedAt = new Date(Math.floor(now.getTime() / REVIEW_WINDOW_MS) * REVIEW_WINDOW_MS);
const windowEndsAt = new Date(Math.min(windowStartedAt.getTime() + REVIEW_WINDOW_MS, night.windowEndsAt.getTime()));
const aggregationKey = createHash('sha256')
.update(`${NIGHT_REVIEW_SOURCE}|${night.id}|${contentHash}|${windowStartedAt.toISOString()}`)
.digest('hex');
// Serialize with review decisions. A request waiting at a window boundary must
// never append to an already reviewed aggregation.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'sms-review-aggregation:' + aggregationKey}, 0))`;
const existing = await tx.smsSendTask.findUnique({ where: { aggregationKey } });
if (existing && existing.status !== 'pending_review') throw new Error('审核聚合已关闭,请重试当前消息');
const reason = `夜间累计发送量审核命中,阈值 ${rule.thresholdValue} 条,本夜累计 ${night.count} 条;所有入口和内容合并计数`;
const uniqueIncrement =
existing &&
(await tx.smsMessageRecord.count({ where: { reviewTaskId: existing.id, phoneNumber: message.phoneNumber } }))
? 0
: 1;
const task = await tx.smsSendTask.upsert({
where: { aggregationKey },
create: {
tenantId: message.tenantId!,
applicationId: message.applicationId!,
taskNo: `NIGHT-${randomUUID()}`,
sourceType: NIGHT_REVIEW_SOURCE,
aggregationKey,
contentHash,
windowStartedAt,
windowEndsAt,
content: message.content,
phoneTotal: 1,
uniquePhoneTotal: 1,
status: 'pending_review',
riskDecision: 'manual_review',
reviewReason: reason,
variableIssues: {
nightStartedAt: night.windowStartedAt.toISOString(),
nightEndsAt: night.windowEndsAt.toISOString(),
},
riskHits: {
create: {
tenantId: message.tenantId!,
ruleId: rule.id,
ruleCode: NIGHT_RULE_CODE,
ruleName: '夜间累计发送量审核',
thresholdValue: rule.thresholdValue,
actualValue: night.count,
action: 'manual_review',
reason,
},
},
},
update: { phoneTotal: { increment: 1 }, uniquePhoneTotal: { increment: uniqueIncrement }, reviewReason: reason },
});
if (existing)
await tx.riskHitRecord.updateMany({
where: { taskId: task.id, ruleCode: NIGHT_RULE_CODE },
data: { actualValue: night.count, reason },
});
await tx.smsMessageRecord.update({
where: { id: message.id },
data: {
status: 'pending_review',
reviewTaskId: task.id,
errorCode: 'NIGHT_SENDING_REVIEW',
errorMessage: reason,
},
});
if (message.batchTaskId)
await tx.smsBatchTask.update({
where: { id: message.batchTaskId },
data: { auditStatus: 'pending_review', reviewReason: reason },
});
return task.id;
}
}

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