Compare commits
35
Commits
ebb185b22b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cacb6e8e7 | ||
|
|
28951b4fc4 | ||
|
|
001d5f2cbd | ||
|
|
b24cd7c08d | ||
|
|
c20c2246b2 | ||
|
|
1676cfe622 | ||
|
|
5e4d644788 | ||
|
|
627fa7ec97 | ||
|
|
572290308c | ||
|
|
4eb7b16d12 | ||
|
|
010ba32168 | ||
|
|
a350aca883 | ||
|
|
a0209f93bc | ||
|
|
86cb9aea36 | ||
|
|
cbc4a03325 | ||
|
|
c781313de5 | ||
|
|
18ecf8045f | ||
|
|
bcb278be29 | ||
|
|
4665079ca3 | ||
|
|
7f9abe3da0 | ||
|
|
ac6449028c | ||
|
|
97d1334423 | ||
|
|
a420d61b23 | ||
|
|
92b112cc6e | ||
|
|
40c8e279f0 | ||
|
|
04e9f467ab | ||
|
|
f0e843436c | ||
|
|
d13ca0713a | ||
|
|
8e4bc5a20e | ||
|
|
86947827cc | ||
|
|
0c3f820cc9 | ||
|
|
5bcdbb2a03 | ||
|
|
6d63eb5452 | ||
|
|
809175b544 | ||
|
|
6816f56178 |
@@ -1,4 +1,4 @@
|
|||||||
# CMPP 平台仓库开发约束
|
# 聆界短信平台仓库开发约束
|
||||||
|
|
||||||
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
|
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
|
||||||
|
|
||||||
|
|||||||
Generated
+109
-1
@@ -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
@@ -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,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();
|
||||||
+259
-7
@@ -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
|
||||||
@@ -1790,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?
|
||||||
@@ -1808,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)
|
||||||
@@ -1880,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?
|
||||||
@@ -1890,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
|
||||||
@@ -2041,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?
|
||||||
@@ -2154,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?
|
||||||
@@ -2225,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
|
||||||
@@ -2294,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?
|
||||||
@@ -2402,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())
|
||||||
@@ -2426,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
|
||||||
@@ -2785,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
@@ -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,39 +143,28 @@ 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 ||
|
||||||
? normalizeChannelRuntimeConfig(
|
data.heartbeatMissThreshold !== undefined
|
||||||
channel.config,
|
? normalizeChannelRuntimeConfig(
|
||||||
data.config,
|
channel.config,
|
||||||
data.desiredConnections,
|
data.config,
|
||||||
data.windowSize,
|
data.desiredConnections,
|
||||||
data.heartbeatIntervalSeconds,
|
data.windowSize,
|
||||||
data.heartbeatMissThreshold,
|
data.heartbeatIntervalSeconds,
|
||||||
)
|
data.heartbeatMissThreshold,
|
||||||
: undefined;
|
)
|
||||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
: undefined;
|
||||||
? undefined
|
const rateLimitPerSecond =
|
||||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
data.rateLimitPerSecond === undefined ? undefined : 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 =
|
||||||
? normalizeChannelCarriers(data.carriers, data.carrier)
|
data.carriers !== undefined || data.carrier !== undefined
|
||||||
: existingCarriers;
|
? normalizeChannelCarriers(data.carriers, data.carrier)
|
||||||
if (data.carriers !== undefined || data.carrier !== undefined) {
|
: existingCarriers;
|
||||||
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,50 +173,72 @@ 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) => {
|
||||||
where: { id: channelId },
|
const updated = await tx.smsChannel.update({
|
||||||
data: {
|
where: { id: channelId },
|
||||||
code: data.code,
|
data: {
|
||||||
name: data.name,
|
code: data.code,
|
||||||
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
|
name: data.name,
|
||||||
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
carrier:
|
||||||
sendRegion: data.sendRegion,
|
data.carriers !== undefined || data.carrier !== undefined
|
||||||
protocol: 'CMPP',
|
? legacyCarrierFromCapabilities(carriers)
|
||||||
gatewayHost: data.gatewayHost,
|
: undefined,
|
||||||
gatewayPort,
|
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
||||||
enterpriseCode: data.enterpriseCode,
|
sendRegion: data.sendRegion,
|
||||||
account: data.account,
|
protocol: 'CMPP',
|
||||||
passwordCipher: data.passwordCipher,
|
gatewayHost: data.gatewayHost,
|
||||||
srcId: data.srcId,
|
gatewayPort,
|
||||||
cmppVersion,
|
enterpriseCode: data.enterpriseCode,
|
||||||
rateLimitPerSecond,
|
account: data.account,
|
||||||
unitPrice: data.unitPrice,
|
passwordCipher: data.passwordCipher,
|
||||||
status: data.status,
|
srcId: data.srcId,
|
||||||
config: config as Prisma.InputJsonValue | undefined,
|
cmppVersion,
|
||||||
},
|
rateLimitPerSecond,
|
||||||
});
|
unitPrice: data.unitPrice,
|
||||||
await this.prisma.operationLog.create({
|
status: data.status,
|
||||||
data: {
|
config: config as Prisma.InputJsonValue | undefined,
|
||||||
action: 'sms_channel.update',
|
},
|
||||||
resource: 'sms_channel',
|
});
|
||||||
resourceId: channelId,
|
const removedGroupItems =
|
||||||
detail: {
|
data.carriers !== undefined || data.carrier !== undefined
|
||||||
before: {
|
? await tx.smsChannelGroupItem.findMany({
|
||||||
code: channel.code,
|
where: { channelId, group: { carrier: { notIn: carriers } } },
|
||||||
name: channel.name,
|
select: {
|
||||||
carrier: channel.carrier,
|
id: true,
|
||||||
carriers: channel.carriers,
|
groupId: true,
|
||||||
sendRegion: channel.sendRegion,
|
carrier: true,
|
||||||
gatewayHost: channel.gatewayHost,
|
province: true,
|
||||||
gatewayPort: channel.gatewayPort,
|
group: { select: { name: true, carrier: true } },
|
||||||
enterpriseCode: channel.enterpriseCode,
|
},
|
||||||
account: channel.account,
|
})
|
||||||
srcId: channel.srcId,
|
: [];
|
||||||
unitPrice: moneyToNumber(channel.unitPrice),
|
if (removedGroupItems.length)
|
||||||
},
|
await tx.smsChannelGroupItem.deleteMany({ where: { id: { in: removedGroupItems.map((item) => item.id) } } });
|
||||||
after: data,
|
await tx.operationLog.create({
|
||||||
} as Prisma.InputJsonValue,
|
data: {
|
||||||
},
|
action: 'sms_channel.update',
|
||||||
|
resource: 'sms_channel',
|
||||||
|
resourceId: channelId,
|
||||||
|
detail: {
|
||||||
|
before: {
|
||||||
|
code: channel.code,
|
||||||
|
name: channel.name,
|
||||||
|
carrier: channel.carrier,
|
||||||
|
carriers: channel.carriers,
|
||||||
|
sendRegion: channel.sendRegion,
|
||||||
|
gatewayHost: channel.gatewayHost,
|
||||||
|
gatewayPort: channel.gatewayPort,
|
||||||
|
enterpriseCode: channel.enterpriseCode,
|
||||||
|
account: channel.account,
|
||||||
|
srcId: channel.srcId,
|
||||||
|
unitPrice: moneyToNumber(channel.unitPrice),
|
||||||
|
},
|
||||||
|
after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
|
||||||
|
removedGroupItems,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
const updatedStatus = data.status ?? channel.status;
|
const updatedStatus = data.status ?? channel.status;
|
||||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -194,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
|
||||||
@@ -238,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,
|
||||||
@@ -270,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) => {
|
||||||
@@ -278,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 {
|
||||||
@@ -350,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;
|
||||||
@@ -438,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];
|
||||||
})
|
})
|
||||||
@@ -461,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 [
|
||||||
@@ -539,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),
|
||||||
@@ -549,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) {
|
||||||
@@ -565,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('报备运营商不在通道支持范围内');
|
||||||
}
|
}
|
||||||
@@ -575,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 =
|
||||||
@@ -591,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: {
|
||||||
@@ -626,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
ChannelReportDeliveryRow,
|
rows: ChannelReportDeliveryRow[],
|
||||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
key: keyof Pick<
|
||||||
>) {
|
ChannelReportDeliveryRow,
|
||||||
|
'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
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
protocolFieldsToJson,
|
||||||
|
protocolUint32,
|
||||||
|
protocolUint32FromDb,
|
||||||
|
protocolUint32ToDb,
|
||||||
|
parseProtocolSequence,
|
||||||
|
} from './protocol-uint32';
|
||||||
|
|
||||||
|
describe('CMPP unsigned protocol fields', () => {
|
||||||
|
it.each([0, 2147483647, 2147483648, 4294967295])(
|
||||||
|
'round trips %s without changing the JSON number contract',
|
||||||
|
(value) => {
|
||||||
|
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
|
||||||
|
),
|
||||||
|
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
|
||||||
|
expect(() => protocolUint32(value)).toThrow();
|
||||||
|
expect(() => protocolUint32ToDb(value)).toThrow();
|
||||||
|
});
|
||||||
|
it('preserves optional historical nulls and unrelated serializers', () => {
|
||||||
|
expect(protocolUint32ToDb(null)).toBeUndefined();
|
||||||
|
expect(protocolUint32FromDb(null)).toBeUndefined();
|
||||||
|
const date = new Date();
|
||||||
|
expect(
|
||||||
|
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
|
||||||
|
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
|
||||||
|
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
|
||||||
|
});
|
||||||
|
it('distinguishes text zero from missing or malformed historical sequences', () => {
|
||||||
|
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
|
||||||
|
expect(parseProtocolSequence(value)).toBeUndefined();
|
||||||
|
expect(parseProtocolSequence('0')).toBe(0);
|
||||||
|
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
|
||||||
|
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
||||||
|
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
|
||||||
|
return value == null ? undefined : BigInt(protocolUint32(value, field));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Historical Submit sequence columns are text; blanks must never become zero. */
|
||||||
|
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
|
||||||
|
if (value == null || !/^\d+$/.test(value)) return undefined;
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
|
||||||
|
export function protocolFieldsToJson(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
|
||||||
|
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, item]) => [
|
||||||
|
key,
|
||||||
|
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
|
||||||
|
? protocolUint32FromDb(item)
|
||||||
|
: protocolFieldsToJson(item),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
|
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
|
||||||
|
@Controller('admin/dictionaries/channel-sensitive-words')
|
||||||
|
export class ChannelSensitiveWordsController {
|
||||||
|
constructor(private readonly service: ChannelSensitiveWordsService) {}
|
||||||
|
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
|
||||||
|
return this.service.list(userId, query);
|
||||||
|
}
|
||||||
|
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
|
||||||
|
return this.service.save(userId, body);
|
||||||
|
}
|
||||||
|
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
|
||||||
|
return this.service.save(userId, body, id);
|
||||||
|
}
|
||||||
|
@Delete(':id') remove(
|
||||||
|
@CurrentSessionUserId() userId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { version?: unknown },
|
||||||
|
) {
|
||||||
|
return this.service.remove(userId, id, body?.version);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
|
||||||
|
describe('channel word administration', () => {
|
||||||
|
it('trims only outer whitespace and requires a version for editing', () => {
|
||||||
|
expect(validateChannelWord(valid).word).toBe('贷 款');
|
||||||
|
expect(() => validateChannelWord(valid, true)).toThrow('版本');
|
||||||
|
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
{ ...valid, word: ' ' },
|
||||||
|
{ ...valid, word: 'a'.repeat(201) },
|
||||||
|
{ ...valid, channelId: '' },
|
||||||
|
{ ...valid, status: 'deleted' },
|
||||||
|
{ ...valid, remark: 'a'.repeat(501) },
|
||||||
|
{ ...valid, operatorId: 'spoof' },
|
||||||
|
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
|
||||||
|
it('checks active platform admin permission before data access', async () => {
|
||||||
|
const prisma = {
|
||||||
|
user: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||||
|
channelSensitiveWord: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
|
||||||
|
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
|
||||||
|
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('rejects invalid pagination before querying rules', async () => {
|
||||||
|
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
|
||||||
|
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
|
||||||
|
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
|
||||||
|
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export function validateChannelWord(value: unknown, editing = false) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
|
||||||
|
throw new BadRequestException('包含不支持的字段');
|
||||||
|
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
|
||||||
|
throw new BadRequestException('请选择通道');
|
||||||
|
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
|
||||||
|
throw new BadRequestException('敏感词需为1~200个字符');
|
||||||
|
if (typeof data.status !== 'string' || !['active', 'inactive'].includes(data.status))
|
||||||
|
throw new BadRequestException('状态无效');
|
||||||
|
if (data.remark !== undefined && (typeof data.remark !== 'string' || data.remark.length > 500))
|
||||||
|
throw new BadRequestException('备注最多500个字符');
|
||||||
|
if (editing && (!Number.isSafeInteger(data.version) || Number(data.version) < 1))
|
||||||
|
throw new BadRequestException('请提供规则版本');
|
||||||
|
return {
|
||||||
|
channelId: data.channelId.trim(),
|
||||||
|
word: data.word.trim(),
|
||||||
|
status: data.status as string,
|
||||||
|
remark: (data.remark as string | undefined) ?? '',
|
||||||
|
version: editing ? Number(data.version) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChannelSensitiveWordsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
async authorize(userId?: string) {
|
||||||
|
if (
|
||||||
|
!userId ||
|
||||||
|
!(await this.prisma.user.findFirst({
|
||||||
|
where: { id: userId, status: 'active', deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } },
|
||||||
|
select: { id: true },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new ForbiddenException('无敏感词管理权限');
|
||||||
|
}
|
||||||
|
async list(userId: string | undefined, query: Record<string, string | undefined>) {
|
||||||
|
await this.authorize(userId);
|
||||||
|
const page = Number(query.page ?? 1),
|
||||||
|
pageSize = Number(query.pageSize ?? 25);
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
|
||||||
|
throw new BadRequestException('分页参数无效');
|
||||||
|
if (query.status && !['all', 'active', 'inactive'].includes(query.status))
|
||||||
|
throw new BadRequestException('状态无效');
|
||||||
|
if (query.keyword && (typeof query.keyword !== 'string' || query.keyword.length > 200))
|
||||||
|
throw new BadRequestException('搜索词过长');
|
||||||
|
if (query.channelId && typeof query.channelId !== 'string') throw new BadRequestException('通道参数无效');
|
||||||
|
const where: Prisma.ChannelSensitiveWordWhereInput = {
|
||||||
|
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||||
|
channelId: query.channelId || undefined,
|
||||||
|
word: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||||
|
};
|
||||||
|
const [items, total] = await this.prisma.$transaction([
|
||||||
|
this.prisma.channelSensitiveWord.findMany({
|
||||||
|
where,
|
||||||
|
include: { channel: { select: { id: true, name: true, status: true } } },
|
||||||
|
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.channelSensitiveWord.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
async save(userId: string | undefined, value: unknown, id?: string) {
|
||||||
|
await this.authorize(userId);
|
||||||
|
const data = validateChannelWord(value, Boolean(id));
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (
|
||||||
|
!(await tx.smsChannel.findFirst({
|
||||||
|
where: { id: data.channelId, status: { not: 'deleted' } },
|
||||||
|
select: { id: true },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw new BadRequestException('通道不存在或已删除');
|
||||||
|
const current = id
|
||||||
|
? await tx.channelSensitiveWord.findUnique({ where: { id } })
|
||||||
|
: await tx.channelSensitiveWord.findUnique({
|
||||||
|
where: { channelId_word: { channelId: data.channelId, word: data.word } },
|
||||||
|
});
|
||||||
|
if (id && (!current || current.status === 'deleted')) throw new NotFoundException('规则不存在或已删除');
|
||||||
|
if (!id && current && current.status !== 'deleted') throw new ConflictException('该通道已配置相同敏感词');
|
||||||
|
const fields = {
|
||||||
|
channelId: data.channelId,
|
||||||
|
word: data.word,
|
||||||
|
status: data.status,
|
||||||
|
remark: data.remark,
|
||||||
|
updatedBy: userId!,
|
||||||
|
};
|
||||||
|
let saved;
|
||||||
|
if (current) {
|
||||||
|
const result = await tx.channelSensitiveWord.updateMany({
|
||||||
|
where: { id: current.id, version: id ? data.version : current.version },
|
||||||
|
data: { ...fields, version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new ConflictException('规则已被修改,请刷新后重试');
|
||||||
|
saved = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id: current.id } });
|
||||||
|
} else saved = await tx.channelSensitiveWord.create({ data: { ...fields, createdBy: userId! } });
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
action:
|
||||||
|
current?.status === 'deleted'
|
||||||
|
? 'channel_sensitive_word.restore'
|
||||||
|
: id
|
||||||
|
? 'channel_sensitive_word.update'
|
||||||
|
: 'channel_sensitive_word.create',
|
||||||
|
resource: 'channel_sensitive_word',
|
||||||
|
resourceId: saved.id,
|
||||||
|
detail: JSON.parse(JSON.stringify({ before: current, after: saved })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
|
||||||
|
throw new ConflictException('该通道已配置相同敏感词');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async remove(userId: string | undefined, id: string, version: unknown) {
|
||||||
|
await this.authorize(userId);
|
||||||
|
if (!Number.isSafeInteger(version) || Number(version) < 1) throw new BadRequestException('请提供规则版本');
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const before = await tx.channelSensitiveWord.findUnique({ where: { id } });
|
||||||
|
if (!before || before.status === 'deleted') throw new NotFoundException('规则不存在或已删除');
|
||||||
|
const result = await tx.channelSensitiveWord.updateMany({
|
||||||
|
where: { id, version: Number(version) },
|
||||||
|
data: { status: 'deleted', version: { increment: 1 }, updatedBy: userId! },
|
||||||
|
});
|
||||||
|
if (!result.count) throw new ConflictException('规则已被修改,请刷新后重试');
|
||||||
|
const after = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id } });
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
action: 'channel_sensitive_word.delete',
|
||||||
|
resource: 'channel_sensitive_word',
|
||||||
|
resourceId: id,
|
||||||
|
detail: JSON.parse(JSON.stringify({ before, after })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
|
||||||
|
|
||||||
|
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
|
||||||
|
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
|
||||||
|
id: 'a',
|
||||||
|
accepted: true,
|
||||||
|
costUnitPrice: 300n,
|
||||||
|
gatewayId: 'g1',
|
||||||
|
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
|
||||||
|
});
|
||||||
|
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
|
||||||
|
attemptId: 'a',
|
||||||
|
gatewayId,
|
||||||
|
status,
|
||||||
|
at: at(day),
|
||||||
|
approximate: false,
|
||||||
|
});
|
||||||
|
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
|
||||||
|
describe('homepage business receipt projection', () => {
|
||||||
|
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
|
||||||
|
const a = attempt(Array(size).fill('delivered'), size);
|
||||||
|
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
|
||||||
|
const result = homeFact({ ...message, billingUnits: size }, [a], events);
|
||||||
|
expect(result.successDay).toBe('2026-09-17');
|
||||||
|
expect(result.revenue).toBe(String(size * 500));
|
||||||
|
});
|
||||||
|
it('includes paid successful fragments of prior failed attempts once', () => {
|
||||||
|
const earlier = attempt(['delivered', 'failed', 'failed']);
|
||||||
|
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
|
||||||
|
const result = homeFact(
|
||||||
|
message,
|
||||||
|
[earlier, final],
|
||||||
|
[
|
||||||
|
event('g1', 'delivered'),
|
||||||
|
event('g2', 'failed'),
|
||||||
|
event('g3', 'failed'),
|
||||||
|
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(result.units).toBe(3);
|
||||||
|
expect(result.revenue).toBe('1500');
|
||||||
|
expect(result.cost).toBe('1200');
|
||||||
|
});
|
||||||
|
it('accounts for all expected units when only one failure arrives', () => {
|
||||||
|
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
|
||||||
|
expect(f.units).toBe(3);
|
||||||
|
expect(f.successDay).toBeNull();
|
||||||
|
expect(f.receiptDays).toEqual(['2026-09-17']);
|
||||||
|
});
|
||||||
|
it('rejects partial success and missing parts even if message says delivered', () => {
|
||||||
|
const f = homeFact(
|
||||||
|
message,
|
||||||
|
[attempt(['delivered', 'delivered'])],
|
||||||
|
[event('g1', 'delivered'), event('g2', 'delivered')],
|
||||||
|
);
|
||||||
|
expect(f.successDay).toBeNull();
|
||||||
|
expect(f.incomplete).toBe(true);
|
||||||
|
});
|
||||||
|
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
|
||||||
|
const f = homeFact(
|
||||||
|
message,
|
||||||
|
[attempt(['delivered', 'delivered', 'delivered'])],
|
||||||
|
[
|
||||||
|
event('g1', 'delivered', 16),
|
||||||
|
event('g2', 'delivered', 16),
|
||||||
|
event('g3', 'delivered'),
|
||||||
|
event('g3', 'delivered', 18),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(f.successDay).toBe('2026-09-17');
|
||||||
|
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
|
||||||
|
expect(f.revenue).toBe('1500');
|
||||||
|
expect(f.cost).toBe('900');
|
||||||
|
});
|
||||||
|
it('does not combine different attempts into a complete message', () => {
|
||||||
|
const f = homeFact(
|
||||||
|
message,
|
||||||
|
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
|
||||||
|
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
|
||||||
|
);
|
||||||
|
expect(f.successDay).toBeNull();
|
||||||
|
});
|
||||||
|
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
|
||||||
|
const a = attempt(['delivered', 'delivered', 'delivered']);
|
||||||
|
a.segments[1].inferred = a.segments[2].inferred = true;
|
||||||
|
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
|
||||||
|
a.segments[1].inferred = false;
|
||||||
|
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
|
||||||
|
});
|
||||||
|
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
|
||||||
|
const f = homeFact(
|
||||||
|
message,
|
||||||
|
[{ ...attempt([]), gatewayId: 'g1' }],
|
||||||
|
[
|
||||||
|
{ ...event('g1', 'delivered'), approximate: true },
|
||||||
|
{ ...event('bad', 'failed'), attemptId: null },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(f.successDay).toBe('2026-09-17');
|
||||||
|
expect(f.approximate).toBe(true);
|
||||||
|
expect(f.incomplete).toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects invalid units and unsafe money without rounding', () => {
|
||||||
|
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
|
||||||
|
expect(safeMoney('12345')).toBe(12345);
|
||||||
|
expect(() => safeMoney(9007199254740992n)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { todayKey } from '../signature-analytics/analytics-date';
|
||||||
|
|
||||||
|
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
|
||||||
|
export type HomeAttempt = {
|
||||||
|
id: string;
|
||||||
|
accepted: boolean;
|
||||||
|
gatewayId: string | null;
|
||||||
|
costUnitPrice: bigint;
|
||||||
|
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
|
||||||
|
};
|
||||||
|
export type HomeFact = {
|
||||||
|
units: number;
|
||||||
|
delivered: boolean;
|
||||||
|
successDay: string | null;
|
||||||
|
successAt: string | null;
|
||||||
|
receiptDays: string[];
|
||||||
|
revenue: string;
|
||||||
|
cost: string;
|
||||||
|
approximate: boolean;
|
||||||
|
incomplete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure projection: never writes a message status or invents missing supplier receipts. */
|
||||||
|
export function homeFact(
|
||||||
|
message: { billingUnits: number; unitPrice: bigint; status: string },
|
||||||
|
attempts: HomeAttempt[],
|
||||||
|
incoming: HomeEvent[],
|
||||||
|
): HomeFact {
|
||||||
|
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
|
||||||
|
const events = new Map<string, HomeEvent>();
|
||||||
|
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
|
||||||
|
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
|
||||||
|
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
|
||||||
|
if (!events.has(key)) events.set(key, event);
|
||||||
|
}
|
||||||
|
const successes: Date[] = [];
|
||||||
|
let cost = 0n;
|
||||||
|
let incomplete = !units || incoming.some((e) => !e.attemptId);
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
if (!attempt.accepted) continue;
|
||||||
|
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
|
||||||
|
const successFor = (id: string | null) =>
|
||||||
|
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
|
||||||
|
if (!attempt.segments.length) {
|
||||||
|
const success = successFor(attempt.gatewayId);
|
||||||
|
if (success && units) {
|
||||||
|
successes.push(success.at);
|
||||||
|
cost += BigInt(units) * attempt.costUnitPrice;
|
||||||
|
} else if (receipts.length) incomplete = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
|
||||||
|
const expected = Math.max(...attempt.segments.map((s) => s.total));
|
||||||
|
const times: Date[] = [];
|
||||||
|
for (const part of parts.values()) {
|
||||||
|
// A contractual message-level receipt can account for the explicitly inferred parts only.
|
||||||
|
const received =
|
||||||
|
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
|
||||||
|
if (part.status === 'delivered' && received) {
|
||||||
|
times.push(received.at);
|
||||||
|
cost += attempt.costUnitPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const complete =
|
||||||
|
expected > 0 &&
|
||||||
|
parts.size === expected &&
|
||||||
|
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
|
||||||
|
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
|
||||||
|
if (!complete) incomplete = true;
|
||||||
|
}
|
||||||
|
const success =
|
||||||
|
message.status === 'delivered' && successes.length
|
||||||
|
? new Date(Math.min(...successes.map((s) => s.getTime())))
|
||||||
|
: null;
|
||||||
|
if (message.status === 'delivered' && !success) incomplete = true;
|
||||||
|
const effective = [...events.values()].filter((e) => !success || e.at <= success);
|
||||||
|
return {
|
||||||
|
units,
|
||||||
|
delivered: message.status === 'delivered',
|
||||||
|
successDay: success ? todayKey(success) : null,
|
||||||
|
successAt: success?.toISOString() ?? null,
|
||||||
|
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
|
||||||
|
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
|
||||||
|
cost: success ? cost.toString() : '0',
|
||||||
|
approximate: effective.some((e) => e.approximate),
|
||||||
|
incomplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeMoney(value: bigint | string | number) {
|
||||||
|
const integer = BigInt(value);
|
||||||
|
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
|
||||||
|
throw new Error('金额超过安全展示范围');
|
||||||
|
return Number(integer);
|
||||||
|
}
|
||||||
|
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
|
||||||
|
import { sourceFacts } from './home-source';
|
||||||
|
import { pruneHomeVersions } from './home-retention';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private timer?: NodeJS.Timeout;
|
||||||
|
private readonly logger = new Logger(HomeProjection.name);
|
||||||
|
constructor(private readonly db: PrismaService) {}
|
||||||
|
onModuleInit() {
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === 'test' ||
|
||||||
|
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
|
||||||
|
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.timer = setInterval(() => void this.run(), 10_000);
|
||||||
|
this.timer.unref();
|
||||||
|
void this.run();
|
||||||
|
}
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
private async run() {
|
||||||
|
try {
|
||||||
|
await this.tick();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
|
||||||
|
await this.db.homeProjectionState
|
||||||
|
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async tick(now = new Date()) {
|
||||||
|
return this.db.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const [lock] = await tx.$queryRaw<
|
||||||
|
Array<{ locked: boolean }>
|
||||||
|
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
|
||||||
|
if (!lock.locked) return { busy: true };
|
||||||
|
const date = todayKey(now),
|
||||||
|
first = addDays(date, -3);
|
||||||
|
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
|
||||||
|
if (state.seededDay !== date) {
|
||||||
|
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
|
||||||
|
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
|
||||||
|
}
|
||||||
|
const work = await tx.$queryRaw<
|
||||||
|
Array<{ messageRecordId: string }>
|
||||||
|
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
|
||||||
|
const ids = work.map((w) => w.messageRecordId),
|
||||||
|
version = state.version + 1;
|
||||||
|
const sources = await sourceFacts(tx, ids);
|
||||||
|
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
|
||||||
|
for (const id of ids) {
|
||||||
|
const next = sources.find((s) => s.message.id === id);
|
||||||
|
const old = prior.find((p) => p.messageRecordId === id);
|
||||||
|
const day = next ? todayKey(next.message.queuedAt) : '';
|
||||||
|
const payload = next && day >= first && day <= date ? next.fact : null;
|
||||||
|
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
|
||||||
|
if (old)
|
||||||
|
await tx.homeMessageFact.update({
|
||||||
|
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
|
||||||
|
data: { toVersion: version },
|
||||||
|
});
|
||||||
|
if (payload)
|
||||||
|
await tx.homeMessageFact.create({
|
||||||
|
data: {
|
||||||
|
messageRecordId: id,
|
||||||
|
fromVersion: version,
|
||||||
|
queuedDay: day,
|
||||||
|
payload: payload as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
|
||||||
|
const remaining = await tx.homeProjectionDirty.count();
|
||||||
|
await tx.homeProjectionState.update({
|
||||||
|
where: { id: 'home' },
|
||||||
|
data: {
|
||||||
|
version,
|
||||||
|
seededDay: date,
|
||||||
|
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
|
||||||
|
updatedAt: now,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
|
||||||
|
return { remaining, version };
|
||||||
|
},
|
||||||
|
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
|
||||||
|
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
|
||||||
|
import { percentage, safeMoney } from './home-fact';
|
||||||
|
|
||||||
|
type MetricRow = {
|
||||||
|
queuedDay: string;
|
||||||
|
total: bigint;
|
||||||
|
delivered: bigint;
|
||||||
|
units: bigint;
|
||||||
|
successUnits: bigint;
|
||||||
|
successMessages: bigint;
|
||||||
|
revenue: bigint;
|
||||||
|
cost: bigint;
|
||||||
|
approximate: bigint;
|
||||||
|
incomplete: bigint;
|
||||||
|
};
|
||||||
|
export function metrics(row?: MetricRow) {
|
||||||
|
const total = Number(row?.total ?? 0),
|
||||||
|
delivered = Number(row?.delivered ?? 0);
|
||||||
|
const units = Number(row?.units ?? 0),
|
||||||
|
success = Number(row?.successUnits ?? 0);
|
||||||
|
const revenue = safeMoney(row?.revenue ?? 0n),
|
||||||
|
cost = safeMoney(row?.cost ?? 0n);
|
||||||
|
return {
|
||||||
|
sent: total,
|
||||||
|
delivered: Number(row?.successMessages ?? 0),
|
||||||
|
successRate: percentage(delivered, total),
|
||||||
|
receiptUnits: units,
|
||||||
|
successUnits: success,
|
||||||
|
receiptSuccessRate: percentage(success, units),
|
||||||
|
revenueCents: revenue,
|
||||||
|
profitCents: revenue - cost,
|
||||||
|
profitRate: percentage(revenue - cost, revenue),
|
||||||
|
approximate: Number(row?.approximate ?? 0),
|
||||||
|
incomplete: Number(row?.incomplete ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
|
||||||
|
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
|
||||||
|
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
|
||||||
|
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
|
||||||
|
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
|
||||||
|
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
|
||||||
|
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
|
||||||
|
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
|
||||||
|
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
|
||||||
|
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
|
||||||
|
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
|
||||||
|
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
|
||||||
|
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
|
||||||
|
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
|
||||||
|
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
|
||||||
|
}
|
||||||
|
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
|
||||||
|
const start = startOfDay(date),
|
||||||
|
end = startOfDay(addDays(date, 1));
|
||||||
|
const rows = await tx.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
todaySpendCents: bigint;
|
||||||
|
todayReturnedCents: bigint;
|
||||||
|
balanceCents: bigint;
|
||||||
|
creditCents: bigint;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
|
||||||
|
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
|
||||||
|
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
|
||||||
|
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
|
||||||
|
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
|
||||||
|
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
|
||||||
|
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
|
||||||
|
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
todaySpendCents: safeMoney(r.todaySpendCents),
|
||||||
|
todayReturnedCents: safeMoney(r.todayReturnedCents),
|
||||||
|
balanceCents: safeMoney(r.balanceCents),
|
||||||
|
creditCents: safeMoney(r.creditCents),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
export async function operationStatus(tx: Prisma.TransactionClient) {
|
||||||
|
const window = downstreamAlertWindows();
|
||||||
|
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
|
||||||
|
await Promise.all([
|
||||||
|
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
|
||||||
|
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
|
||||||
|
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
|
||||||
|
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
|
||||||
|
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
|
||||||
|
tx.smsBatchTask.count(),
|
||||||
|
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
|
||||||
|
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
|
||||||
|
tx.cmppDownstreamDelivery.count({
|
||||||
|
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
taskCount,
|
||||||
|
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
|
||||||
|
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
|
||||||
|
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
|
||||||
|
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
|
||||||
|
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
|
||||||
|
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
|
||||||
|
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
|
||||||
|
GROUP BY hour ORDER BY hour`);
|
||||||
|
const byHour = new Map(rows.map((row) => [row.hour, row]));
|
||||||
|
return Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
hour,
|
||||||
|
label: String(hour).padStart(2, '0') + ':00',
|
||||||
|
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
|
||||||
|
successCount: Number(byHour.get(hour)?.successCount ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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)`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
const chunks: Buffer[] = [];
|
{
|
||||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
hostname: '127.0.0.1',
|
||||||
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
port,
|
||||||
});
|
path,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
response.once('end', () =>
|
||||||
|
resolve({
|
||||||
|
status: response.statusCode ?? 0,
|
||||||
|
body: Buffer.concat(chunks).toString('utf8'),
|
||||||
|
requestId: response.headers['x-request-id'] as string | undefined,
|
||||||
|
contentType: response.headers['content-type'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
request.once('error', reject);
|
request.once('error', reject);
|
||||||
request.end(body);
|
request.end(body);
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-13
@@ -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(
|
||||||
limit: IMPORT_JSON_BODY_LIMIT,
|
'/api/client/send/imports',
|
||||||
verify(request, _response, buffer) {
|
express.json({
|
||||||
request.rawBody = buffer;
|
limit: IMPORT_JSON_BODY_LIMIT,
|
||||||
},
|
verify(request, _response, buffer) {
|
||||||
}));
|
(request as typeof request & { rawBody?: Buffer }).rawBody = buffer;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
|
app.useBodyParser('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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
|
alerts: [
|
||||||
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
|
{
|
||||||
state: 'firing',
|
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
|
||||||
activeAt: '2026-08-14T03:00:00.000Z',
|
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
|
||||||
value: '88.2',
|
state: 'firing',
|
||||||
}] });
|
activeAt: '2026-08-14T03:00:00.000Z',
|
||||||
|
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({
|
||||||
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
|
result: [
|
||||||
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
|
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
|
||||||
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
|
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
|
||||||
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
|
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
|
||||||
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
|
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
|
||||||
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
|
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
|
||||||
] });
|
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (query.includes('cmpp:service_.*')) {
|
if (query.includes('cmpp:service_.*')) {
|
||||||
return success({ result: [
|
return success({
|
||||||
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
|
result: [
|
||||||
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
|
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
|
||||||
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
|
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
|
||||||
] });
|
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (query.includes('timestamp(node_uname_info)')) {
|
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: root, values: [[1765000000, '91']] },
|
{
|
||||||
] });
|
metric: data,
|
||||||
|
values: [
|
||||||
|
[1765000000, '82'],
|
||||||
|
[1765000060, 'NaN'],
|
||||||
|
[1765000120, '83.5'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ metric: root, values: [[1765000000, '91']] },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (query.startsWith('node_filesystem_size_bytes')) return success({ result: [
|
if (query.startsWith('node_filesystem_size_bytes'))
|
||||||
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
|
return success({
|
||||||
...['/var/root-bind', '/'].map((mountpoint) => ({ metric: { ...root, mountpoint }, value: [1765000120, '200'] })),
|
result: [
|
||||||
] });
|
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
|
||||||
if (query === FILESYSTEM_USAGE_PERCENT) return success({ result: [
|
...['/var/root-bind', '/'].map((mountpoint) => ({
|
||||||
{ metric: data, value: [1765000120, '83.5'] }, { metric: root, value: [1765000120, '91'] },
|
metric: { ...root, mountpoint },
|
||||||
] });
|
value: [1765000120, '200'],
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (query === FILESYSTEM_USAGE_PERCENT)
|
||||||
|
return success({
|
||||||
|
result: [
|
||||||
|
{ metric: data, value: [1765000120, '83.5'] },
|
||||||
|
{ metric: root, value: [1765000120, '91'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
expect(query).toContain('min by (instance, device, fstype)');
|
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: [
|
{
|
||||||
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
|
key: 'api',
|
||||||
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
|
name: 'API服务',
|
||||||
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
|
metrics: [
|
||||||
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
|
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
|
||||||
] },
|
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
|
||||||
{ key: 'gateway', name: 'Gateway服务', metrics: [
|
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
|
||||||
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
|
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
|
||||||
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
|
],
|
||||||
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
|
},
|
||||||
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
|
{
|
||||||
] },
|
key: 'gateway',
|
||||||
{ key: 'postgresql', name: 'PostgreSQL', metrics: [
|
name: 'Gateway服务',
|
||||||
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
|
metrics: [
|
||||||
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
|
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
|
||||||
] },
|
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
|
||||||
{ key: 'redis', name: 'Redis', metrics: [
|
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
|
||||||
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
|
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
|
||||||
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
|
],
|
||||||
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
|
},
|
||||||
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
|
{
|
||||||
] },
|
key: 'postgresql',
|
||||||
{ key: 'minio', name: 'MinIO', metrics: [
|
name: 'PostgreSQL',
|
||||||
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
|
metrics: [
|
||||||
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
|
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
|
||||||
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
|
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
|
||||||
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
|
],
|
||||||
] },
|
},
|
||||||
{ key: 'nginx', name: 'Nginx', metrics: [
|
{
|
||||||
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
|
key: 'redis',
|
||||||
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
|
name: 'Redis',
|
||||||
] },
|
metrics: [
|
||||||
|
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
|
||||||
|
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
|
||||||
|
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
|
||||||
|
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minio',
|
||||||
|
name: 'MinIO',
|
||||||
|
metrics: [
|
||||||
|
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
|
||||||
|
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
|
||||||
|
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
|
||||||
|
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nginx',
|
||||||
|
name: 'Nginx',
|
||||||
|
metrics: [
|
||||||
|
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
|
||||||
|
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
|
||||||
|
],
|
||||||
|
},
|
||||||
] as const;
|
] 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,13 +310,22 @@ 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 {
|
||||||
[read] = await this.prisma.$transaction([
|
[read] = await this.prisma.$transaction([
|
||||||
@@ -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([
|
||||||
log(),
|
this.prisma.infrastructureAlertRead.update({
|
||||||
]);
|
where: { fingerprint_userId: { fingerprint, userId } },
|
||||||
|
data: { activeAt, readAt },
|
||||||
|
}),
|
||||||
|
log(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() };
|
return {
|
||||||
|
fingerprint,
|
||||||
|
activeAt: read.activeAt.toISOString(),
|
||||||
|
acknowledged: true,
|
||||||
|
acknowledgedAt: read.readAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async alertHistory(from?: string, to?: string, rawPage?: string) {
|
||||||
|
const range = alertHistoryRange(from, to);
|
||||||
|
const page = rawPage === undefined ? 1 : Number(rawPage);
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1) throw new BadRequestException('告警页码无效');
|
||||||
|
const history = new Map<string, AlertHistoryItem>();
|
||||||
|
try {
|
||||||
|
// Daily raw range vectors retain short events that a coarse query_range step would miss.
|
||||||
|
for (let start = range.start; start < range.end; start += 86400) {
|
||||||
|
const end = Math.min(start + 86400, range.end);
|
||||||
|
const response = await this.getJson<PrometheusQueryResponse>('/api/v1/query', {
|
||||||
|
query: `ALERTS_FOR_STATE[${Math.ceil(end - start)}s]`,
|
||||||
|
time: String(end),
|
||||||
|
});
|
||||||
|
mergeAlertHistory(history, response.data?.result ?? [], range.start, range.end);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('历史告警查询失败,请稍后重试');
|
||||||
|
}
|
||||||
|
const items = [...history.values()].sort(
|
||||||
|
(a, b) => b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: items.slice((page - 1) * 25, page * 25),
|
||||||
|
total: items.length,
|
||||||
|
page,
|
||||||
|
pageSize: 25,
|
||||||
|
startDate: range.startDate,
|
||||||
|
endDate: range.endDate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseRange(value?: string): InfrastructureMonitoringRange {
|
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]
|
||||||
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
|
.map(([id, items]) => {
|
||||||
const metric = items[0].metric;
|
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
|
||||||
return {
|
const metric = items[0].metric;
|
||||||
id, instance: metric.instance ?? '', device: metric.device, filesystem: metric.fstype ?? '',
|
return {
|
||||||
mountpoint: mountpoints[0], mountpoints,
|
id,
|
||||||
// Never sum aliases. Max/min also tolerate slight sampling differences.
|
instance: metric.instance ?? '',
|
||||||
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
|
device: metric.device,
|
||||||
availableBytes: available.get(id) ?? null, usagePercent: usage.get(id) ?? null,
|
filesystem: metric.fstype ?? '',
|
||||||
};
|
mountpoint: mountpoints[0],
|
||||||
})
|
mountpoints,
|
||||||
.sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint)));
|
// Never sum aliases. Max/min also tolerate slight sampling differences.
|
||||||
|
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
|
||||||
|
availableBytes: available.get(id) ?? null,
|
||||||
|
usagePercent: usage.get(id) ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.instance.localeCompare(right.instance) ||
|
||||||
|
(left.mountpoint === '/'
|
||||||
|
? -1
|
||||||
|
: right.mountpoint === '/'
|
||||||
|
? 1
|
||||||
|
: left.mountpoint.localeCompare(right.mountpoint)),
|
||||||
|
);
|
||||||
const rootDisk = disks.find((disk) => disk.mountpoints.includes('/'));
|
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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+19
-10
@@ -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,
|
||||||
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
|
new DocumentBuilder()
|
||||||
.setVersion('1.0.0')
|
.setTitle('聆界短信平台 HTTP 客户接口')
|
||||||
.build(), { include: [OpenApiModule] });
|
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
|
||||||
clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')));
|
.setVersion('1.0.0')
|
||||||
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
.build(),
|
||||||
|
{ include: [OpenApiModule] },
|
||||||
|
);
|
||||||
|
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/.
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>`;
|
||||||
|
}
|
||||||
@@ -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(/\/+$/, '') ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('<script>');
|
||||||
|
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":"【示例签名】您的验证码是123456,5分钟内有效。","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())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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末尾无LF,POST追加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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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(':');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,13 +60,11 @@ 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()
|
bodyHash: 'same',
|
||||||
.mockResolvedValue({
|
status: 'completed',
|
||||||
bodyHash: 'same',
|
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
|
||||||
status: 'completed',
|
}),
|
||||||
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sendChain = { createHttpBatchTask: jest.fn() };
|
const sendChain = { createHttpBatchTask: jest.fn() };
|
||||||
@@ -97,14 +95,12 @@ 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()
|
bodyHash: 'same',
|
||||||
.mockResolvedValue({
|
status: 'failed',
|
||||||
bodyHash: 'same',
|
httpStatus: 422,
|
||||||
status: 'failed',
|
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
|
||||||
httpStatus: 422,
|
}),
|
||||||
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
|
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
|
||||||
@@ -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(
|
||||||
auth() as never,
|
service.sendMessage(
|
||||||
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
|
auth() as never,
|
||||||
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
|
{ mobile: '18821203795', content: '示例', clientMessageId: 'client-1' },
|
||||||
);
|
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
|
||||||
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
|
),
|
||||||
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
|
).resolves.toEqual(response);
|
||||||
);
|
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()
|
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
|
||||||
.mockResolvedValue({
|
}),
|
||||||
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
|
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
|
||||||
|
$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,15 +204,13 @@ 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()
|
id: 'app-1',
|
||||||
.mockResolvedValue({
|
name: '应用A',
|
||||||
id: 'app-1',
|
interfaceEnabled: true,
|
||||||
name: '应用A',
|
httpConfig: null,
|
||||||
interfaceEnabled: true,
|
httpIpAllowlist: [],
|
||||||
httpConfig: null,
|
}),
|
||||||
httpIpAllowlist: [],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
|
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
|
||||||
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
|
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
|
||||||
@@ -280,15 +265,13 @@ 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()
|
id: 'app-1',
|
||||||
.mockResolvedValue({
|
name: '应用A',
|
||||||
id: 'app-1',
|
interfaceEnabled: true,
|
||||||
name: '应用A',
|
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
|
||||||
interfaceEnabled: true,
|
httpIpAllowlist: [],
|
||||||
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
|
}),
|
||||||
httpIpAllowlist: [],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
|
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,30 +505,49 @@ 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(
|
||||||
tenantId: string;
|
data: {
|
||||||
applicationId?: string | null;
|
tenantId: string;
|
||||||
messageRecordId?: string | null;
|
applicationId?: string | null;
|
||||||
messageId?: string | null;
|
messageRecordId?: string | null;
|
||||||
uplinkMessageId?: string | null;
|
messageId?: string | null;
|
||||||
eventType: 'receipt' | 'uplink';
|
uplinkMessageId?: string | null;
|
||||||
payload: Record<string, unknown>;
|
eventType: 'receipt' | 'uplink';
|
||||||
}) {
|
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,30 +557,34 @@ 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) => {
|
||||||
where: { eventId },
|
const event = await tx.httpWebhookEvent.upsert({
|
||||||
update: {},
|
where: { eventId },
|
||||||
create: {
|
update: {},
|
||||||
eventId,
|
create: {
|
||||||
tenantId: data.tenantId,
|
eventId,
|
||||||
applicationId: data.applicationId,
|
tenantId: data.tenantId,
|
||||||
eventType: data.eventType,
|
applicationId: data.applicationId!,
|
||||||
messageRecordId: data.messageRecordId,
|
eventType: data.eventType,
|
||||||
messageId: data.messageId,
|
messageRecordId: data.messageRecordId,
|
||||||
uplinkMessageId: data.uplinkMessageId,
|
messageId: data.messageId,
|
||||||
payload: data.payload as Prisma.InputJsonValue,
|
uplinkMessageId: data.uplinkMessageId,
|
||||||
},
|
payload: data.payload as Prisma.InputJsonValue,
|
||||||
});
|
},
|
||||||
const delivery = await this.prisma.httpWebhookDelivery.upsert({
|
});
|
||||||
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
|
return tx.httpWebhookDelivery.upsert({
|
||||||
update: {},
|
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
|
||||||
create: { eventId: event.id, endpointId: endpoint.id },
|
update: {},
|
||||||
});
|
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 {
|
|
||||||
httpStatus: error.getStatus(),
|
|
||||||
code: String(object.code ?? 'SEND_REJECTED'),
|
|
||||||
responseBody: {
|
|
||||||
code: String(object.code ?? 'SEND_REJECTED'),
|
|
||||||
message: Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage),
|
|
||||||
} as Prisma.InputJsonValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
httpStatus: 500,
|
httpStatus: failure.status,
|
||||||
code: 'INTERNAL_ERROR',
|
code: failure.code,
|
||||||
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
|
responseBody: { code: failure.code, message: failure.message } 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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
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 =
|
||||||
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
query.status === 'unknown'
|
||||||
: query.status === 'failed'
|
? { status: { in: ['submitted', 'unknown'] } }
|
||||||
? { status: 'failed', submitStatus: 'accepted' }
|
: query.status === 'submit_failed'
|
||||||
: query.status
|
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||||
? { status: query.status }
|
: query.status === 'failed'
|
||||||
: {};
|
? { status: 'failed', submitStatus: 'accepted' }
|
||||||
|
: query.status
|
||||||
|
? { status: query.status }
|
||||||
|
: {};
|
||||||
return {
|
return {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
applicationId: query.applicationId,
|
applicationId: query.applicationId,
|
||||||
@@ -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: {
|
? {
|
||||||
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
|
queuedAt: {
|
||||||
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
|
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
|
||||||
},
|
...(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 } },
|
? [
|
||||||
{ gatewayInstanceId: { contains: query.keyword } },
|
{ account: { contains: query.keyword } },
|
||||||
{ lastError: { contains: query.keyword } },
|
{ gatewayInstanceId: { contains: query.keyword } },
|
||||||
{ lastSkipReason: { contains: query.keyword } },
|
{ lastError: { contains: query.keyword } },
|
||||||
{ tenant: { name: { contains: query.keyword } } },
|
{ lastSkipReason: { contains: query.keyword } },
|
||||||
{ application: { name: { contains: query.keyword } } },
|
{ tenant: { name: { contains: query.keyword } } },
|
||||||
] : undefined,
|
{ application: { name: { contains: query.keyword } } },
|
||||||
|
]
|
||||||
|
: 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,13 +528,14 @@ 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 =
|
||||||
? 'error'
|
result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
|
||||||
: log.action.includes('warning') || log.action.includes('risk')
|
? 'error'
|
||||||
? 'warning'
|
: log.action.includes('warning') || log.action.includes('risk')
|
||||||
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
|
? 'warning'
|
||||||
? 'success'
|
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
|
||||||
: 'info';
|
? 'success'
|
||||||
|
: 'info';
|
||||||
return {
|
return {
|
||||||
id: log.id,
|
id: log.id,
|
||||||
time: log.createdAt,
|
time: log.createdAt,
|
||||||
@@ -482,22 +552,32 @@ 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 }
|
||||||
id: channel.id,
|
: null,
|
||||||
code: channel.code,
|
channel: channel
|
||||||
name: channel.name,
|
? {
|
||||||
status: channel.status,
|
id: channel.id,
|
||||||
carrier: channel.carrier,
|
code: channel.code,
|
||||||
sendRegion: channel.sendRegion,
|
name: channel.name,
|
||||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
status: channel.status,
|
||||||
} : null,
|
carrier: channel.carrier,
|
||||||
|
sendRegion: channel.sendRegion,
|
||||||
|
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||||
|
}
|
||||||
|
: 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);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OperationsQualityQueries } from './queries/quality.queries';
|
||||||
import { OperationsService } from './operations.service';
|
import { OperationsService } from './operations.service';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
@@ -372,6 +373,8 @@ 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 } },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -634,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,
|
||||||
@@ -934,10 +937,8 @@ describe('OperationsService', () => {
|
|||||||
averageArrivalMs: 1800,
|
averageArrivalMs: 1800,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const service = new OperationsService(prisma as never);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.signatureQuality({
|
new OperationsQualityQueries(prisma as never).signatureQualityLive({
|
||||||
date: '2026-07-24',
|
date: '2026-07-24',
|
||||||
keyword: '测试',
|
keyword: '测试',
|
||||||
page: 2,
|
page: 2,
|
||||||
@@ -985,9 +986,9 @@ 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,
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
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 {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async dashboard(query: { tenantId?: string }) {
|
async dashboard(query: { tenantId?: string }) {
|
||||||
const businessDay = qualityBusinessDay();
|
const businessDay = qualityBusinessDay();
|
||||||
const sinceToday = businessDay.startAt;
|
const sinceToday = businessDay.startAt;
|
||||||
const downstreamAlertWindow = downstreamAlertWindows();
|
const downstreamAlertWindow = downstreamAlertWindows();
|
||||||
@@ -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<
|
||||||
tenantId: string;
|
Array<{
|
||||||
tenantName: string;
|
tenantId: string;
|
||||||
todaySpendCents: bigint;
|
tenantName: string;
|
||||||
balanceCents: bigint;
|
todaySpendCents: bigint;
|
||||||
creditCents: bigint;
|
balanceCents: bigint;
|
||||||
}>>(Prisma.sql`
|
creditCents: bigint;
|
||||||
|
}>
|
||||||
|
>(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<
|
||||||
segmentCount: bigint;
|
Array<{
|
||||||
deliveredSegmentCount: bigint;
|
segmentCount: bigint;
|
||||||
billedCents: bigint;
|
deliveredSegmentCount: bigint;
|
||||||
costCents: bigint;
|
billedCents: bigint;
|
||||||
}>>(Prisma.sql`
|
costCents: bigint;
|
||||||
|
}>
|
||||||
|
>(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<
|
||||||
hour: number;
|
Array<{
|
||||||
submittedCount: bigint;
|
hour: number;
|
||||||
successCount: bigint;
|
submittedCount: bigint;
|
||||||
}>>(Prisma.sql`
|
successCount: bigint;
|
||||||
|
}>
|
||||||
|
>(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<
|
||||||
category: string;
|
Array<{
|
||||||
count: bigint;
|
category: string;
|
||||||
averageProcessingMs: bigint | null;
|
count: bigint;
|
||||||
}>>(Prisma.sql`
|
averageProcessingMs: bigint | null;
|
||||||
|
}>
|
||||||
|
>(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,
|
||||||
@@ -383,7 +402,7 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
recentRecharges,
|
recentRecharges,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async clientDashboard(query: { tenantId?: string }) {
|
async clientDashboard(query: { tenantId?: string }) {
|
||||||
const tenantId = query.tenantId;
|
const tenantId = query.tenantId;
|
||||||
const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([
|
const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([
|
||||||
this.dashboard(query),
|
this.dashboard(query),
|
||||||
@@ -432,7 +451,7 @@ async clientDashboard(query: { tenantId?: string }) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
pendingAudits(tenantId?: string) {
|
pendingAudits(tenantId?: string) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { SignatureAnalyticsRead } from '../../signature-analytics/analytics-read';
|
||||||
|
import { analyticsDate, analyticsPage, todayKey } from '../../signature-analytics/analytics-date';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { SignatureQualityQuery } from '../operations.contracts';
|
import type { SignatureQualityQuery } from '../operations.contracts';
|
||||||
@@ -321,9 +323,30 @@ export class OperationsQualityQueries {
|
|||||||
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(100, positiveInteger(query.pageSize, 25));
|
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<
|
const summaries = await this.prisma.$queryRaw<
|
||||||
@@ -341,6 +364,8 @@ export class OperationsQualityQueries {
|
|||||||
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`
|
||||||
@@ -351,6 +376,12 @@ export class OperationsQualityQueries {
|
|||||||
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
|
||||||
@@ -361,6 +392,15 @@ export class OperationsQualityQueries {
|
|||||||
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",
|
||||||
@@ -368,37 +408,21 @@ export class OperationsQualityQueries {
|
|||||||
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')
|
||||||
),
|
),
|
||||||
@@ -407,10 +431,11 @@ export class OperationsQualityQueries {
|
|||||||
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}
|
||||||
@@ -441,6 +466,8 @@ export class OperationsQualityQueries {
|
|||||||
failureCount: number;
|
failureCount: number;
|
||||||
successRate: number;
|
successRate: number;
|
||||||
averageArrivalMs: number | null;
|
averageArrivalMs: number | null;
|
||||||
|
arrivalMsSum?: number;
|
||||||
|
arrivalSamples?: number;
|
||||||
}>
|
}>
|
||||||
>(Prisma.sql`
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
@@ -457,12 +484,12 @@ export class OperationsQualityQueries {
|
|||||||
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
|
||||||
@@ -474,6 +501,7 @@ export class OperationsQualityQueries {
|
|||||||
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,
|
||||||
@@ -532,6 +560,8 @@ export class OperationsQualityQueries {
|
|||||||
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
|
||||||
@@ -548,6 +578,8 @@ export class OperationsQualityQueries {
|
|||||||
finalSuccessCount: number;
|
finalSuccessCount: number;
|
||||||
finalSuccessRate: number;
|
finalSuccessRate: number;
|
||||||
averageArrivalMs: number | null;
|
averageArrivalMs: number | null;
|
||||||
|
arrivalMsSum?: number;
|
||||||
|
arrivalSamples?: number;
|
||||||
}>
|
}>
|
||||||
>(Prisma.sql`
|
>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -620,6 +652,8 @@ 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[]) {
|
||||||
@@ -632,7 +666,7 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
|
|||||||
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(
|
const arrivalWeight = parts.reduce(
|
||||||
(sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount),
|
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -650,7 +684,10 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
|
|||||||
arrivalWeight === 0
|
arrivalWeight === 0
|
||||||
? null
|
? null
|
||||||
: Math.round(
|
: Math.round(
|
||||||
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
|
parts.reduce(
|
||||||
|
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
|
||||||
|
0,
|
||||||
|
) / arrivalWeight,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -676,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[]) {
|
||||||
@@ -688,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,
|
||||||
@@ -705,7 +747,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
|
|||||||
arrivalWeight === 0
|
arrivalWeight === 0
|
||||||
? null
|
? null
|
||||||
: Math.round(
|
: Math.round(
|
||||||
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
|
parts.reduce(
|
||||||
|
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
|
||||||
|
0,
|
||||||
|
) / arrivalWeight,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,47 +71,48 @@ 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'
|
.filter((target) => target.channelId === channelId)
|
||||||
? item.eligibleTargets
|
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
|
||||||
.filter((target) => target.channelId === channelId)
|
|
||||||
.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) => {
|
||||||
where: {
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
|
||||||
signatureId: item.signature.id,
|
const existingTask = await tx.channelSignatureReportTask.findFirst({
|
||||||
channelId,
|
where: {
|
||||||
carrier,
|
signatureId: item.signature.id,
|
||||||
reportType,
|
channelId,
|
||||||
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
|
carrier,
|
||||||
},
|
reportType,
|
||||||
|
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const task = existingTask
|
||||||
|
? await tx.channelSignatureReportTask.update({
|
||||||
|
where: { id: existingTask.id },
|
||||||
|
data: {
|
||||||
|
status: missingReason ? 'waiting_material' : 'exporting',
|
||||||
|
reason: missingReason,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await tx.channelSignatureReportTask.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.signature.tenantId,
|
||||||
|
signatureId: item.signature.id,
|
||||||
|
channelId,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
reportType,
|
||||||
|
drainageItemId: item.drainageInfo?.id,
|
||||||
|
status: missingReason ? 'waiting_material' : 'exporting',
|
||||||
|
reason: missingReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { task, existingTask };
|
||||||
});
|
});
|
||||||
const task = existingTask
|
tasks.push(entry);
|
||||||
? await this.prisma.channelSignatureReportTask.update({
|
|
||||||
where: { id: existingTask.id },
|
|
||||||
data: {
|
|
||||||
status: missingReason ? 'waiting_material' : 'exporting',
|
|
||||||
reason: missingReason,
|
|
||||||
...(reportType === 'signature' ? { approvedAt: null } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: await this.prisma.channelSignatureReportTask.create({
|
|
||||||
data: {
|
|
||||||
tenantId: item.signature.tenantId,
|
|
||||||
signatureId: item.signature.id,
|
|
||||||
channelId,
|
|
||||||
carrier,
|
|
||||||
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
|
|
||||||
reportType,
|
|
||||||
drainageItemId: item.drainageInfo?.id,
|
|
||||||
status: missingReason ? 'waiting_material' : 'exporting',
|
|
||||||
reason: missingReason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tasks.push({ task, existingTask });
|
|
||||||
}
|
}
|
||||||
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 {
|
||||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
|
try {
|
||||||
targetId = created.id;
|
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
|
||||||
|
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));
|
);
|
||||||
sheet.columns.forEach((column) => { column.width = 24; });
|
styleHeader(sheet.getRow(1));
|
||||||
sheet.getRow(2).height = 48;
|
sheet.columns.forEach((column) => {
|
||||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
column.width = 24;
|
||||||
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
|
});
|
||||||
await this.prisma.operationLog.create({ data: {
|
sheet.getRow(2).height = 48;
|
||||||
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
|
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
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(
|
||||||
const items = await this.pending.findPendingItems(query);
|
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string },
|
||||||
const workbook = new ExcelJS.Workbook();
|
operatorId?: string,
|
||||||
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
|
) {
|
||||||
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
|
const items = await this.pending.findPendingItems(query);
|
||||||
styleHeader(sheet.getRow(1));
|
const workbook = new ExcelJS.Workbook();
|
||||||
for (const item of items) sheet.addRow([
|
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||||
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
|
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
|
||||||
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
|
styleHeader(sheet.getRow(1));
|
||||||
|
for (const item of items)
|
||||||
|
sheet.addRow([
|
||||||
|
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) => {
|
||||||
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
column.width = index === 4 ? 42 : 22;
|
||||||
await this.prisma.operationLog.create({ data: {
|
});
|
||||||
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
|
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
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()) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,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 (
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { HttpException, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma, SmsAttemptCompletionWork } from '@prisma/client';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { completionContext, CompletionRouteRequired } from './completion-context';
|
||||||
|
import type { RoutedChannel } from './send-chain.contracts';
|
||||||
|
import { countCompletion, observeCompletion } from './completion-metrics';
|
||||||
|
|
||||||
|
export type CompletionEventKind = 'receipt' | 'submit' | 'segment' | 'timeout' | 'rejection';
|
||||||
|
export class AttemptCompletion {
|
||||||
|
private readonly logger = new Logger(AttemptCompletion.name);
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
private running = false;
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly execute: (kind: CompletionEventKind, payload: Prisma.JsonValue) => Promise<unknown>,
|
||||||
|
private readonly waitForRoute: (route: RoutedChannel) => Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.timer = setInterval(() => void this.scan(), 5_000);
|
||||||
|
this.timer.unref();
|
||||||
|
void this.scan();
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(
|
||||||
|
messageRecordId: string,
|
||||||
|
sourceSubmitRecordId: string | undefined,
|
||||||
|
kind: CompletionEventKind,
|
||||||
|
payload: unknown,
|
||||||
|
identity?: string,
|
||||||
|
) {
|
||||||
|
const json = JSON.parse(JSON.stringify(payload)) as Prisma.InputJsonValue;
|
||||||
|
const workKey = sourceSubmitRecordId ? `attempt:${sourceSubmitRecordId}` : `message:${messageRecordId}`;
|
||||||
|
const eventKey = createHash('sha256')
|
||||||
|
.update(`${workKey}:${kind}:${identity ?? JSON.stringify(json)}`)
|
||||||
|
.digest('hex');
|
||||||
|
const work = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const message = await tx.smsMessageRecord.findUniqueOrThrow({
|
||||||
|
where: { id: messageRecordId },
|
||||||
|
select: { tenantId: true },
|
||||||
|
});
|
||||||
|
if (sourceSubmitRecordId) {
|
||||||
|
const source = await tx.smsSubmitRecord.findUniqueOrThrow({ where: { id: sourceSubmitRecordId } });
|
||||||
|
if (source.messageRecordId !== messageRecordId || source.tenantId !== message.tenantId)
|
||||||
|
throw new Error('completion_source_mismatch');
|
||||||
|
}
|
||||||
|
await tx.smsAttemptCompletionWork.createMany({
|
||||||
|
data: [{ workKey, messageRecordId, sourceSubmitRecordId, tenantId: message.tenantId }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const current = await tx.smsAttemptCompletionWork.findUniqueOrThrow({ where: { workKey } });
|
||||||
|
if (current.messageRecordId !== messageRecordId) throw new Error('completion_work_mismatch');
|
||||||
|
await tx.$queryRaw`SELECT id FROM "SmsAttemptCompletionWork" WHERE id=${current.id} FOR UPDATE`;
|
||||||
|
const inserted = await tx.smsCompletionEvent.createMany({
|
||||||
|
data: [{ workId: current.id, eventKey, kind, payload: json }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
if (inserted.count)
|
||||||
|
await tx.$executeRaw`
|
||||||
|
UPDATE "SmsAttemptCompletionWork" SET revision=revision+1,
|
||||||
|
state=CASE WHEN state IN ('processing', 'needs_review') THEN state ELSE 'pending' END,
|
||||||
|
"nextAttemptAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') WHERE id=${current.id}`;
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
await this.process(work.id);
|
||||||
|
return this.prisma.smsMessageRecord.findUnique({ where: { id: messageRecordId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan() {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
observeCompletion(
|
||||||
|
await this.prisma.$queryRaw<Array<{ state: string; count: number; age: number }>>`
|
||||||
|
SELECT w.state, COUNT(*)::int AS count,
|
||||||
|
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - MIN(COALESCE(e."createdAt", w."updatedAt")))::float AS age
|
||||||
|
FROM "SmsAttemptCompletionWork" w
|
||||||
|
LEFT JOIN LATERAL (SELECT MIN("createdAt") AS "createdAt" FROM "SmsCompletionEvent" WHERE "workId"=w.id AND "processedAt" IS NULL) e ON true
|
||||||
|
WHERE w.state IN ('pending','processing','retry_wait','needs_review') GROUP BY w.state`,
|
||||||
|
);
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
SELECT id FROM "SmsAttemptCompletionWork"
|
||||||
|
WHERE (state IN ('pending','retry_wait') AND "nextAttemptAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||||
|
OR (state='processing' AND "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||||
|
ORDER BY "nextAttemptAt", id LIMIT 32`;
|
||||||
|
for (const row of rows) await this.process(row.id);
|
||||||
|
} catch {
|
||||||
|
this.logger.error('completion_scan_failed');
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(id: string) {
|
||||||
|
const owner = randomUUID();
|
||||||
|
const claims = await this.prisma.$queryRaw<SmsAttemptCompletionWork[]>`
|
||||||
|
UPDATE "SmsAttemptCompletionWork" SET state='processing', "leaseOwner"=${owner},
|
||||||
|
"leaseUntil"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')+INTERVAL '60 seconds', "fenceVersion"="fenceVersion"+1,
|
||||||
|
attempts=attempts+1, "updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||||
|
WHERE id=${id} AND ((state IN ('pending','retry_wait') AND "nextAttemptAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||||
|
OR (state='processing' AND "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))) RETURNING *`;
|
||||||
|
const claim = claims[0];
|
||||||
|
if (!claim) {
|
||||||
|
countCompletion('not_claimed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
countCompletion('claimed');
|
||||||
|
if (claim.attempts > 1) countCompletion('recovered');
|
||||||
|
const renew = setInterval(() => {
|
||||||
|
void this.prisma
|
||||||
|
.$executeRaw`UPDATE "SmsAttemptCompletionWork" SET "leaseUntil"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')+INTERVAL '60 seconds'
|
||||||
|
WHERE id=${id} AND state='processing' AND "leaseOwner"=${owner} AND "fenceVersion"=${claim.fenceVersion}
|
||||||
|
AND "leaseUntil">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`.catch(() =>
|
||||||
|
this.logger.warn('completion_lease_renew_failed'),
|
||||||
|
);
|
||||||
|
}, 20_000);
|
||||||
|
renew.unref();
|
||||||
|
let route: RoutedChannel | undefined;
|
||||||
|
let routePlanned = false;
|
||||||
|
let planRevision: number | undefined;
|
||||||
|
try {
|
||||||
|
for (let pass = 0; pass < 64; pass++) {
|
||||||
|
try {
|
||||||
|
const done = await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const rows = await tx.$queryRaw<SmsAttemptCompletionWork[]>`
|
||||||
|
SELECT * FROM "SmsAttemptCompletionWork" WHERE id=${id} AND state='processing'
|
||||||
|
AND "leaseOwner"=${owner} AND "fenceVersion"=${claim.fenceVersion} AND "leaseUntil">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FOR UPDATE`;
|
||||||
|
const work = rows[0];
|
||||||
|
if (!work) {
|
||||||
|
countCompletion('fence_rejected');
|
||||||
|
throw new Error('completion_fence_rejected');
|
||||||
|
}
|
||||||
|
await tx.$queryRaw`SELECT id FROM "SmsMessageRecord" WHERE id=${work.messageRecordId} FOR UPDATE`;
|
||||||
|
if (planRevision !== work.revision) {
|
||||||
|
route = undefined;
|
||||||
|
routePlanned = false;
|
||||||
|
}
|
||||||
|
planRevision = work.revision;
|
||||||
|
const event = await tx.smsCompletionEvent.findFirst({
|
||||||
|
where: { workId: id, processedAt: null },
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
if (event) {
|
||||||
|
await completionContext.run({ tx, messageRecordId: work.messageRecordId, route, routePlanned }, () =>
|
||||||
|
this.execute(event.kind as CompletionEventKind, event.payload),
|
||||||
|
);
|
||||||
|
await tx.smsCompletionEvent.update({ where: { id: event.id }, data: { processedAt: new Date() } });
|
||||||
|
}
|
||||||
|
const remaining = await tx.smsCompletionEvent.count({ where: { workId: id, processedAt: null } });
|
||||||
|
const message = await tx.smsMessageRecord.findUniqueOrThrow({ where: { id: work.messageRecordId } });
|
||||||
|
const retry = work.sourceSubmitRecordId
|
||||||
|
? await tx.smsSubmitRecord.findUnique({
|
||||||
|
where: { retryOfSubmitRecordId: work.sourceSubmitRecordId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
await tx.smsAttemptCompletionWork.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
processedRevision: work.revision - remaining,
|
||||||
|
decision: message.status,
|
||||||
|
retrySubmitRecordId: retry?.id,
|
||||||
|
state: remaining ? 'processing' : 'idle',
|
||||||
|
lastError: null,
|
||||||
|
...(!remaining ? { leaseOwner: null, leaseUntil: null, attempts: 0 } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return remaining === 0;
|
||||||
|
},
|
||||||
|
{ timeout: 20_000, maxWait: 5_000 },
|
||||||
|
);
|
||||||
|
route = undefined;
|
||||||
|
routePlanned = false;
|
||||||
|
countCompletion('event_committed');
|
||||||
|
if (done) return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CompletionRouteRequired)) throw error;
|
||||||
|
// The probe transaction rolls back. Routing and rate limiting occur without locks.
|
||||||
|
try {
|
||||||
|
route = await error.select();
|
||||||
|
await this.waitForRoute(route);
|
||||||
|
} catch (selectionError) {
|
||||||
|
if (!(selectionError instanceof HttpException) || selectionError.getStatus() >= 500) throw selectionError;
|
||||||
|
route = undefined;
|
||||||
|
}
|
||||||
|
routePlanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('completion_batch_budget_exhausted');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError
|
||||||
|
? error.code
|
||||||
|
: error instanceof Error && error.message.startsWith('completion_')
|
||||||
|
? error.message
|
||||||
|
: 'completion_processing_failed';
|
||||||
|
const exhausted = claim.attempts >= 12;
|
||||||
|
countCompletion(exhausted ? 'needs_review' : 'retry_wait');
|
||||||
|
await this.prisma.smsAttemptCompletionWork.updateMany({
|
||||||
|
where: { id, leaseOwner: owner, fenceVersion: claim.fenceVersion, state: 'processing' },
|
||||||
|
data: {
|
||||||
|
state: exhausted ? 'needs_review' : 'retry_wait',
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
lastError: code,
|
||||||
|
nextAttemptAt: new Date(Date.now() + Math.min(300_000, 1000 * 2 ** Math.min(claim.attempts, 8))),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.error(`${exhausted ? 'completion_needs_review' : 'completion_retry_wait'}:${code}`);
|
||||||
|
} finally {
|
||||||
|
clearInterval(renew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
const channel = {
|
||||||
|
status: 'active',
|
||||||
|
carrier: 'all',
|
||||||
|
carriers: ['mobile', 'unicom', 'telecom'],
|
||||||
|
sendRegion: '全国',
|
||||||
|
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||||
|
};
|
||||||
|
const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel }));
|
||||||
|
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
|
||||||
|
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
|
||||||
|
describe('channel sensitive routing snapshot', () => {
|
||||||
|
it('checks rewritten content separately for each candidate', () => {
|
||||||
|
const snapshot = new ChannelWordSnapshot([{ ...rule, word: '拒收请回复R' }]);
|
||||||
|
expect(
|
||||||
|
snapshot.select('m', '正文拒收请回复R', items, options, (id) => (id === 'a' ? '正文' : '正文拒收请回复R'))
|
||||||
|
.selected?.channelId,
|
||||||
|
).toBe('a');
|
||||||
|
expect(snapshot.select('n', '正文', items, options, () => '正文拒收请回复R').selected?.channelId).toBe('b');
|
||||||
|
});
|
||||||
|
it('removes only matching eligible channels before original priority selection', () => {
|
||||||
|
const snapshot = new ChannelWordSnapshot([rule]);
|
||||||
|
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
|
||||||
|
expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a');
|
||||||
|
expect(snapshot.decisions[0].snapshot).toEqual(
|
||||||
|
expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('keeps original exclusions and distinguishes no original route from all hit', () => {
|
||||||
|
const snapshot = new ChannelWordSnapshot([rule]);
|
||||||
|
expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true);
|
||||||
|
expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false);
|
||||||
|
expect(
|
||||||
|
snapshot.select(
|
||||||
|
'm',
|
||||||
|
'贷款',
|
||||||
|
items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })),
|
||||||
|
options,
|
||||||
|
).rejected,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('allows national fallback when matching province channel is excluded', () => {
|
||||||
|
const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]];
|
||||||
|
expect(
|
||||||
|
new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected
|
||||||
|
?.channelId,
|
||||||
|
).toBe('b');
|
||||||
|
});
|
||||||
|
it('matches original complete content, case sensitively without removing separators', () => {
|
||||||
|
const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]);
|
||||||
|
expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1);
|
||||||
|
for (const value of ['ab贷款', 'Ab贷款', 'Ab贷-款']) expect(snapshot.hits(value)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it('caps sample words but never truncates excluded channels or hit counts', () => {
|
||||||
|
const rules = Array.from({ length: 21 }, (_, index) => ({ ...rule, id: String(index) }));
|
||||||
|
const snapshot = new ChannelWordSnapshot([...rules, { ...rule, channelId: 'b' }]);
|
||||||
|
expect(snapshot.hits('贷款')[0]).toEqual(expect.objectContaining({ count: 21, samples: expect.any(Array) }));
|
||||||
|
expect(snapshot.hits('贷款')[0].samples).toHaveLength(20);
|
||||||
|
expect(snapshot.select('m', '贷款', items, options).rejected).toBe(true);
|
||||||
|
});
|
||||||
|
it('reads rules once per batch and persists all decisions in one idempotent write', async () => {
|
||||||
|
const prisma = {
|
||||||
|
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([rule]) },
|
||||||
|
smsChannelSensitiveDecision: { createMany: jest.fn() },
|
||||||
|
};
|
||||||
|
const snapshot = await loadChannelWords(prisma as unknown as PrismaService, ['a', 'b', 'a']);
|
||||||
|
for (let index = 0; index < 100; index++) snapshot.select(String(index), '贷款', items, options);
|
||||||
|
expect(snapshot.hits('贷款')).toBe(snapshot.hits('贷款'));
|
||||||
|
await snapshot.persist(prisma as unknown as PrismaService);
|
||||||
|
expect(prisma.channelSensitiveWord.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.smsChannelSensitiveDecision.createMany).toHaveBeenCalledWith({
|
||||||
|
data: expect.any(Array),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
expect(snapshot.decisions).toHaveLength(100);
|
||||||
|
expect(new Set(snapshot.decisions.map((value) => value.routeAttemptId)).size).toBe(100);
|
||||||
|
});
|
||||||
|
it('fails closed for technical read and persistence errors, not as a word hit', async () => {
|
||||||
|
const prisma = {
|
||||||
|
channelSensitiveWord: { findMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
|
||||||
|
smsChannelSensitiveDecision: { createMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
|
||||||
|
};
|
||||||
|
await expect(loadChannelWords(prisma as unknown as PrismaService, ['a'])).rejects.toMatchObject({ status: 503 });
|
||||||
|
const snapshot = new ChannelWordSnapshot([rule]);
|
||||||
|
snapshot.select('m', '贷款', items, options);
|
||||||
|
await expect(snapshot.persist(prisma as unknown as PrismaService)).rejects.toMatchObject({ status: 503 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ChannelSensitiveWord, Prisma } from '@prisma/client';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { selectChannelCandidate } from './send-chain.helpers';
|
||||||
|
|
||||||
|
export const CHANNEL_WORD_NO_ROUTE = 'CHANNEL_SENSITIVE_WORD_NO_ROUTE';
|
||||||
|
export class ChannelWordRejection extends BadRequestException {
|
||||||
|
readonly reasonCode = CHANNEL_WORD_NO_ROUTE;
|
||||||
|
constructor() {
|
||||||
|
super('可用通道均命中通道敏感词');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type Rule = Pick<ChannelSensitiveWord, 'id' | 'channelId' | 'word' | 'version'>;
|
||||||
|
type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> };
|
||||||
|
export class ChannelWordSnapshot {
|
||||||
|
private readonly matches = new Map<string, Hit[]>();
|
||||||
|
readonly decisions: Prisma.SmsChannelSensitiveDecisionCreateManyInput[] = [];
|
||||||
|
constructor(
|
||||||
|
private readonly rules: Rule[],
|
||||||
|
readonly readAt = new Date().toISOString(),
|
||||||
|
) {}
|
||||||
|
hits(content: string): Hit[] {
|
||||||
|
const cached = this.matches.get(content);
|
||||||
|
if (cached) return cached;
|
||||||
|
const matched = new Map<string, Hit>();
|
||||||
|
for (const rule of this.rules) {
|
||||||
|
if (!rule.word || !content.includes(rule.word)) continue;
|
||||||
|
const hit = matched.get(rule.channelId) ?? { channelId: rule.channelId, count: 0, samples: [] };
|
||||||
|
hit.count++;
|
||||||
|
if (hit.samples.length < 20) hit.samples.push({ id: rule.id, word: rule.word, version: rule.version });
|
||||||
|
matched.set(rule.channelId, hit);
|
||||||
|
}
|
||||||
|
const result = [...matched.values()];
|
||||||
|
this.matches.set(content, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
select<T extends Parameters<typeof selectChannelCandidate>[0][number]>(
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
items: T[],
|
||||||
|
options: Parameters<typeof selectChannelCandidate>[1],
|
||||||
|
contentForChannel?: (channelId: string) => string,
|
||||||
|
) {
|
||||||
|
const candidates = items.filter((item) => selectChannelCandidate([item], options));
|
||||||
|
const candidateIds = new Set(candidates.map((item) => item.channelId));
|
||||||
|
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
|
||||||
|
const hits = (
|
||||||
|
contentForChannel
|
||||||
|
? [...candidateIds].flatMap((id) => this.hits(contentForChannel(id)).filter((hit) => hit.channelId === id))
|
||||||
|
: this.hits(content).filter((hit) => candidateIds.has(hit.channelId))
|
||||||
|
).map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
|
||||||
|
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
|
||||||
|
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
|
||||||
|
const rejected = !selected && candidates.length > 0 && hits.length > 0;
|
||||||
|
const routeAttemptId = randomUUID();
|
||||||
|
this.decisions.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
messageRecordId: messageId,
|
||||||
|
routeAttemptId,
|
||||||
|
snapshot: {
|
||||||
|
readAt: this.readAt,
|
||||||
|
stage: 'route',
|
||||||
|
contentHash: createHash('sha256').update(content).digest('hex'),
|
||||||
|
...(contentForChannel
|
||||||
|
? {
|
||||||
|
candidateContentHashes: Object.fromEntries(
|
||||||
|
[...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).digest('hex')]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
candidateChannelIds: [...candidateIds],
|
||||||
|
excludedChannelIds: hits.map((hit) => hit.channelId),
|
||||||
|
hits,
|
||||||
|
selectedChannelId: selected?.channelId ?? null,
|
||||||
|
reason: rejected ? '可用通道均命中通道敏感词' : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { selected, rejected };
|
||||||
|
}
|
||||||
|
async persist(prisma: PrismaService) {
|
||||||
|
if (!this.decisions.length) return;
|
||||||
|
try {
|
||||||
|
await prisma.smsChannelSensitiveDecision.createMany({ data: this.decisions, skipDuplicates: true });
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('通道敏感词选路记录保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function loadChannelWords(prisma: PrismaService, channelIds: string[]) {
|
||||||
|
try {
|
||||||
|
const rules = channelIds.length
|
||||||
|
? await prisma.channelSensitiveWord.findMany({
|
||||||
|
where: { channelId: { in: [...new Set(channelIds)] }, status: 'active' },
|
||||||
|
select: { id: true, channelId: true, word: true, version: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
return new ChannelWordSnapshot(rules);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('通道敏感词读取失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type { RoutedChannel } from './send-chain.contracts';
|
||||||
|
|
||||||
|
export type CompletionContext = {
|
||||||
|
tx: Prisma.TransactionClient;
|
||||||
|
messageRecordId: string;
|
||||||
|
route?: RoutedChannel;
|
||||||
|
routePlanned?: boolean;
|
||||||
|
};
|
||||||
|
export const completionContext = new AsyncLocalStorage<CompletionContext>();
|
||||||
|
|
||||||
|
// Only send-chain collaborators use this adapter. Nested billing/outbox transactions
|
||||||
|
// join the explicitly established completion transaction, never start a second one.
|
||||||
|
export function completionDatabase(prisma: PrismaService): PrismaService {
|
||||||
|
return new Proxy(prisma, {
|
||||||
|
get(target, key) {
|
||||||
|
const tx = completionContext.getStore()?.tx;
|
||||||
|
if (tx && key === '$transaction') {
|
||||||
|
return (operation: ((client: Prisma.TransactionClient) => unknown) | Promise<unknown>[]) =>
|
||||||
|
typeof operation === 'function' ? operation(tx) : Promise.all(operation);
|
||||||
|
}
|
||||||
|
const owner = tx && key in tx ? tx : target;
|
||||||
|
const value = Reflect.get(owner, key);
|
||||||
|
return typeof value === 'function' ? value.bind(owner) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompletionRouteRequired extends Error {
|
||||||
|
constructor(readonly select: () => Promise<RoutedChannel>) {
|
||||||
|
super('completion_route_required');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
type CompletionMetric =
|
||||||
|
'claimed' | 'not_claimed' | 'recovered' | 'fence_rejected' | 'event_committed' | 'retry_wait' | 'needs_review';
|
||||||
|
const counts = new Map<CompletionMetric, number>();
|
||||||
|
let snapshot: Array<{ state: string; count: number; age: number }> = [];
|
||||||
|
const states = ['pending', 'processing', 'retry_wait', 'needs_review'];
|
||||||
|
export function countCompletion(event: CompletionMetric) {
|
||||||
|
counts.set(event, (counts.get(event) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
export function observeCompletion(rows: typeof snapshot) {
|
||||||
|
snapshot = rows;
|
||||||
|
}
|
||||||
|
export function renderCompletionMetrics() {
|
||||||
|
return [
|
||||||
|
'# TYPE cmpp_completion_events_total counter',
|
||||||
|
...[...counts].map(([event, value]) => `cmpp_completion_events_total{event="${event}"} ${value}`),
|
||||||
|
'# TYPE cmpp_completion_work gauge',
|
||||||
|
'# TYPE cmpp_completion_oldest_seconds gauge',
|
||||||
|
...states.flatMap((state) => {
|
||||||
|
const row = snapshot.find((item) => item.state === state);
|
||||||
|
return [
|
||||||
|
`cmpp_completion_work{state="${state}"} ${Number(row?.count ?? 0)}`,
|
||||||
|
`cmpp_completion_oldest_seconds{state="${state}"} ${Math.max(0, Number(row?.age ?? 0))}`,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseProtocolSequence } from '../common/protocol-uint32';
|
||||||
import type { PrismaService } from '../prisma/prisma.service';
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
export type FinalReceiptMessage = {
|
export type FinalReceiptMessage = {
|
||||||
@@ -44,29 +45,33 @@ async function resolveClientReceiptTargets(
|
|||||||
});
|
});
|
||||||
if (group?.segments.length) {
|
if (group?.segments.length) {
|
||||||
return group.segments.flatMap((segment) => {
|
return group.segments.flatMap((segment) => {
|
||||||
const submitSequenceId = Number(segment.sequenceId);
|
const submitSequenceId = parseProtocolSequence(segment.sequenceId);
|
||||||
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
|
if (submitSequenceId === undefined) return [];
|
||||||
return [{
|
return [
|
||||||
segmentIndex: segment.segmentIndex,
|
{
|
||||||
segmentTotal: group.segmentTotal,
|
segmentIndex: segment.segmentIndex,
|
||||||
submitSequenceId,
|
segmentTotal: group.segmentTotal,
|
||||||
submitGroupMessageId: group.messageId,
|
submitSequenceId,
|
||||||
registeredDelivery: segment.registeredDelivery,
|
submitGroupMessageId: group.messageId,
|
||||||
}];
|
registeredDelivery: segment.registeredDelivery,
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitSequenceId = Number(message.cmppSubmitSequenceId);
|
const submitSequenceId = parseProtocolSequence(message.cmppSubmitSequenceId);
|
||||||
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
|
if (submitSequenceId === undefined) return [];
|
||||||
return [{
|
return [
|
||||||
segmentIndex: 1,
|
{
|
||||||
segmentTotal: 1,
|
segmentIndex: 1,
|
||||||
submitSequenceId,
|
segmentTotal: 1,
|
||||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
|
submitSequenceId,
|
||||||
// Null means a historical CMPP record created before this field existed.
|
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
|
||||||
registeredDelivery: message.cmppRegisteredDelivery !== false,
|
// Null means a historical CMPP record created before this field existed.
|
||||||
}];
|
registeredDelivery: message.cmppRegisteredDelivery !== false,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +109,7 @@ export async function queueFinalReceiptDeliveries(
|
|||||||
propagateHttpQueueError: data.propagateHttpQueueError,
|
propagateHttpQueueError: data.propagateHttpQueueError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const targets = (await resolveClientReceiptTargets(prisma, message))
|
const targets = (await resolveClientReceiptTargets(prisma, message)).filter((target) => target.registeredDelivery);
|
||||||
.filter((target) => target.registeredDelivery);
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const isSingleFragment = target.segmentTotal === 1;
|
const isSingleFragment = target.segmentTotal === 1;
|
||||||
await queue({
|
await queue({
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
assessDrainage,
|
||||||
|
drainageHost,
|
||||||
|
drainageTargets,
|
||||||
|
materialMatches,
|
||||||
|
normalizeDrainagePhone,
|
||||||
|
type DrainageTarget,
|
||||||
|
type DrainageMaterial,
|
||||||
|
} from './drainage-authorization';
|
||||||
|
import { detectDrainageContentWithRules } from './drainage-content-detection';
|
||||||
|
|
||||||
|
const target = (value: string): DrainageTarget => ({
|
||||||
|
key: value,
|
||||||
|
category: 'url',
|
||||||
|
value: drainageHost(value)!,
|
||||||
|
text: value,
|
||||||
|
start: 0,
|
||||||
|
end: value.length,
|
||||||
|
});
|
||||||
|
const material = (id: string, url: string, channels: string[], auditStatus = 'approved'): DrainageMaterial => ({
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
auditStatus,
|
||||||
|
materialVersion: 1,
|
||||||
|
reportTasks: channels.map((channelId) => ({
|
||||||
|
id: `${id}-${channelId}`,
|
||||||
|
channelId,
|
||||||
|
status: 'approved',
|
||||||
|
carrier: 'mobile',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
describe('drainage authorization', () => {
|
||||||
|
it.each(['详情:https://qa0915.example.com', '详情:https://qa0915.example.com'])(
|
||||||
|
'keeps prose separators outside explicit URLs: %s',
|
||||||
|
(content) => {
|
||||||
|
const text = 'https://qa0915.example.com';
|
||||||
|
const start = content.indexOf(text);
|
||||||
|
const targets = drainageTargets(content, [
|
||||||
|
{
|
||||||
|
ruleId: 'url',
|
||||||
|
ruleCode: 'URL',
|
||||||
|
ruleName: 'URL',
|
||||||
|
category: 'url',
|
||||||
|
text,
|
||||||
|
normalizedText: text,
|
||||||
|
start,
|
||||||
|
end: start + text.length,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(targets).toHaveLength(1);
|
||||||
|
expect(targets[0].text).toBe(text);
|
||||||
|
expect(materialMatches(targets[0], text)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test.each([
|
||||||
|
'lisglo.cn',
|
||||||
|
'sms.lisglo.cn',
|
||||||
|
'a.sms.lisglo.cn/path?x=1',
|
||||||
|
'https://SMS.LISGLO.CN:8443/other?a=2#fragment',
|
||||||
|
])('allows registered host and subdomains %s', (url) => {
|
||||||
|
expect(materialMatches(target(url), 'lisglo.cn')).toBe(true);
|
||||||
|
});
|
||||||
|
test.each(['lisglo.cn.evil.com', 'evillisglo.cn', 'evil.com/?next=lisglo.cn', 'https://lisglo.cn@evil.com/'])(
|
||||||
|
'rejects fake containing URL %s',
|
||||||
|
(url) => {
|
||||||
|
expect(materialMatches(target(url), 'lisglo.cn')).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test.each(['cn', 'com', 'com.cn', 'co.uk'])('does not authorize public suffix %s', (host) =>
|
||||||
|
expect(drainageHost(host)).toBeNull(),
|
||||||
|
);
|
||||||
|
it('does not broaden child registration to siblings or parent', () => {
|
||||||
|
expect(materialMatches(target('lisglo.cn'), 'sms.lisglo.cn')).toBe(false);
|
||||||
|
expect(materialMatches(target('other.lisglo.cn'), 'sms.lisglo.cn')).toBe(false);
|
||||||
|
expect(materialMatches(target('a.sms.lisglo.cn'), 'sms.lisglo.cn')).toBe(true);
|
||||||
|
});
|
||||||
|
it('preserves existing path-restricted authorization', () => {
|
||||||
|
expect(materialMatches(target('lisglo.cn/app/1'), 'https://lisglo.cn/app')).toBe(true);
|
||||||
|
expect(materialMatches(target('lisglo.cn/other'), 'https://lisglo.cn/app')).toBe(false);
|
||||||
|
expect(materialMatches(target('lisglo.cn/application'), 'https://lisglo.cn/app')).toBe(false);
|
||||||
|
});
|
||||||
|
it('normalizes phone separators without changing original text or dropping area code', () => {
|
||||||
|
const original = '021-77882277';
|
||||||
|
expect(normalizeDrainagePhone(original)).toBe('02177882277');
|
||||||
|
expect(original).toBe('021-77882277');
|
||||||
|
expect(materialMatches({ ...target('lisglo.cn'), category: 'landline', value: '02177882277' }, '77882277')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('requires every target and intersects channels, unions alternatives for one target', () => {
|
||||||
|
const targets = [target('a.lisglo.cn'), target('example.com')];
|
||||||
|
const rows = [
|
||||||
|
material('a', 'lisglo.cn', ['1']),
|
||||||
|
material('a2', 'a.lisglo.cn', ['2']),
|
||||||
|
material('b', 'example.com', ['2', '3']),
|
||||||
|
];
|
||||||
|
expect(assessDrainage(targets, rows, 'mobile').allowedChannelIds).toEqual(['2']);
|
||||||
|
expect(assessDrainage(targets, rows.slice(0, 2), 'mobile').reasonCode).toBe('DRAINAGE_NOT_REGISTERED');
|
||||||
|
expect(assessDrainage(targets, rows, 'telecom').reasonCode).toBe('DRAINAGE_CHANNEL_NOT_APPROVED');
|
||||||
|
});
|
||||||
|
it('requires approval and never borrows frozen or rejected channel reports', () => {
|
||||||
|
expect(
|
||||||
|
assessDrainage([target('lisglo.cn')], [material('a', 'lisglo.cn', ['1'], 'pending')], 'mobile').reasonCode,
|
||||||
|
).toBe('DRAINAGE_NOT_APPROVED');
|
||||||
|
const row = material('a', 'lisglo.cn', ['1']);
|
||||||
|
row.reportTasks[0].status = 'waiting_review';
|
||||||
|
expect(assessDrainage([target('lisglo.cn')], [row], 'mobile').reasonCode).toBe('DRAINAGE_CHANNEL_NOT_APPROVED');
|
||||||
|
});
|
||||||
|
it('extends truncated detector tokens to complete hostile URL before checking', () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
id: 'url',
|
||||||
|
code: 'URL',
|
||||||
|
name: 'url',
|
||||||
|
category: 'url',
|
||||||
|
priority: 1,
|
||||||
|
version: 1,
|
||||||
|
flags: 'giu',
|
||||||
|
pattern: '[a-z]+\\.[a-z]+',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const content of ['lisglo.cn.evil.com', 'https://lisglo.cn@evil.com/path?next=lisglo.cn']) {
|
||||||
|
const detected = detectDrainageContentWithRules(content, rules);
|
||||||
|
const targets = drainageTargets(content, (detected.drainageDetection as any).matches);
|
||||||
|
expect(targets.length).toBeGreaterThan(0);
|
||||||
|
expect(targets.every((item) => !materialMatches(item, 'lisglo.cn'))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('does not exclude a URL with userInfo as an email', () => {
|
||||||
|
const content = 'https://lisglo.cn@evil.com/path';
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
id: 'url',
|
||||||
|
code: 'URL',
|
||||||
|
name: 'url',
|
||||||
|
category: 'url',
|
||||||
|
priority: 1,
|
||||||
|
version: 1,
|
||||||
|
flags: 'giu',
|
||||||
|
pattern: 'https?://[^\\s]+',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const detected = detectDrainageContentWithRules(content, rules);
|
||||||
|
const targets = drainageTargets(
|
||||||
|
content,
|
||||||
|
(detected.drainageDetection as { matches: import('./drainage-content-detection').DrainageDetectionMatch[] })
|
||||||
|
.matches,
|
||||||
|
);
|
||||||
|
expect(targets).toHaveLength(1);
|
||||||
|
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drainage carrier override', () => {
|
||||||
|
it('uses explicit rejection over legacy approval and keeps other carriers independent', () => {
|
||||||
|
const row = material('m', 'example.com', []);
|
||||||
|
row.reportTasks = [
|
||||||
|
{ id: 'legacy', channelId: 'c', carrier: null, status: 'approved' },
|
||||||
|
{ id: 'mobile', channelId: 'c', carrier: 'mobile', status: 'failed' },
|
||||||
|
{ id: 'unicom', channelId: 'c', carrier: 'unicom', status: 'approved' },
|
||||||
|
];
|
||||||
|
expect(assessDrainage([target('example.com')], [row], 'mobile').allowedChannelIds).toEqual([]);
|
||||||
|
expect(assessDrainage([target('example.com')], [row], 'unicom').allowedChannelIds).toEqual(['c']);
|
||||||
|
expect(assessDrainage([target('example.com')], [row], 'telecom').allowedChannelIds).toEqual(['c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { parse } from 'tldts';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { detectDrainageContent, normalizeContent, type DrainageDetectionMatch } from './drainage-content-detection';
|
||||||
|
|
||||||
|
export const DRAINAGE_POLICY_VERSION = 'domain-boundary-v1';
|
||||||
|
export class DrainageRejection extends BadRequestException {
|
||||||
|
constructor(
|
||||||
|
public readonly reasonCode: string,
|
||||||
|
reason: string,
|
||||||
|
) {
|
||||||
|
super({ code: reasonCode, message: reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type DrainageTarget = { key: string; category: string; value: string; text: string; start: number; end: number };
|
||||||
|
export type DrainageMaterial = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
auditStatus: string;
|
||||||
|
materialVersion: number;
|
||||||
|
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>;
|
||||||
|
};
|
||||||
|
export type DrainageAssessment = {
|
||||||
|
version: string;
|
||||||
|
evaluatedAt: string;
|
||||||
|
targets: Array<DrainageTarget & { materialIds: string[] }>;
|
||||||
|
materials: DrainageMaterial[];
|
||||||
|
allowedChannelIds: string[] | null;
|
||||||
|
reasonCode: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function drainageHost(raw: string) {
|
||||||
|
const value = raw
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
|
||||||
|
.replace(/[。。]/g, '.')
|
||||||
|
.trim();
|
||||||
|
try {
|
||||||
|
const parsed = new URL(/^[a-z]+:\/\//i.test(value) ? value : `https://${value}`);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||||
|
const host = parsed.hostname.toLowerCase().replace(/\.$/, '');
|
||||||
|
if (isIP(host)) return host;
|
||||||
|
const domain = parse(host, { allowPrivateDomains: true });
|
||||||
|
return domain.domain && domain.isIcann !== false ? host : domain.domain && domain.isPrivate ? host : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDrainagePhone(value: string) {
|
||||||
|
return normalizeContent(value, 'landline').text.replace(/[()]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materialMatches(target: DrainageTarget, raw: string) {
|
||||||
|
if (target.category !== 'url') return normalizeDrainagePhone(raw) === target.value;
|
||||||
|
const host = drainageHost(raw);
|
||||||
|
if (!host || !(target.value === host || (!isIP(host) && target.value.endsWith(`.${host}`)))) return false;
|
||||||
|
// Existing path-specific material does not silently authorize unrelated paths.
|
||||||
|
const normalizedRaw = normalizeContent(raw, 'url').text.trim();
|
||||||
|
const registered = new URL(/^[a-z]+:\/\//i.test(normalizedRaw) ? normalizedRaw : `https://${normalizedRaw}`);
|
||||||
|
if (registered.pathname !== '/' || registered.search) {
|
||||||
|
const candidate = new URL(/^[a-z]+:\/\//i.test(target.text) ? target.text : `https://${target.text}`);
|
||||||
|
return (
|
||||||
|
(candidate.pathname === registered.pathname ||
|
||||||
|
candidate.pathname.startsWith(`${registered.pathname.replace(/\/$/, '')}/`)) &&
|
||||||
|
(!registered.search || candidate.search === registered.search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drainageTargets(content: string, matches: DrainageDetectionMatch[]) {
|
||||||
|
const urls: DrainageTarget[] = [];
|
||||||
|
const normalized = normalizeContent(content, 'url');
|
||||||
|
for (const match of matches.filter((item) => item.category === 'url')) {
|
||||||
|
let start = normalized.sourceStarts.findIndex((offset) => offset >= match.start);
|
||||||
|
let end = normalized.sourceEnds.findIndex((offset) => offset >= match.end) + 1;
|
||||||
|
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
|
||||||
|
// Extend the entire URL token, including suffix labels, userInfo and query.
|
||||||
|
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
|
||||||
|
while (start > 0 && token.test(normalized.text[start - 1])) {
|
||||||
|
// A prose colon before an explicit scheme is a separator, including normalized Chinese colons.
|
||||||
|
if (normalized.text[start - 1] === ':' && /^https?:\/\//i.test(normalized.text.slice(start, end))) break;
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
while (end < normalized.text.length && token.test(normalized.text[end])) end++;
|
||||||
|
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
|
||||||
|
const value = drainageHost(text);
|
||||||
|
if (!value) throw new DrainageRejection('DRAINAGE_INVALID', '引流URL格式无效或不是可登记域名');
|
||||||
|
urls.push({
|
||||||
|
key: `url:${value}:${text}`,
|
||||||
|
category: 'url',
|
||||||
|
value,
|
||||||
|
text,
|
||||||
|
start: normalized.sourceStarts[start],
|
||||||
|
end: normalized.sourceEnds[end - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const targets = [...urls];
|
||||||
|
for (const match of matches.filter((item) => item.category !== 'url')) {
|
||||||
|
if (urls.some((url) => match.start < url.end && match.end > url.start)) continue;
|
||||||
|
if (!['mobile', 'landline'].includes(match.category))
|
||||||
|
throw new ServiceUnavailableException('引流识别类型尚未支持发送校验');
|
||||||
|
const value = normalizeDrainagePhone(match.normalizedText);
|
||||||
|
targets.push({
|
||||||
|
key: `phone:${value}`,
|
||||||
|
category: match.category,
|
||||||
|
value,
|
||||||
|
text: match.text,
|
||||||
|
start: match.start,
|
||||||
|
end: match.end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...new Map(targets.map((target) => [target.key, target])).values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessDrainage(
|
||||||
|
targets: DrainageTarget[],
|
||||||
|
materials: DrainageMaterial[],
|
||||||
|
carrier?: string,
|
||||||
|
): DrainageAssessment {
|
||||||
|
let allowed: Set<string> | null = null;
|
||||||
|
const assessment: DrainageAssessment = {
|
||||||
|
version: DRAINAGE_POLICY_VERSION,
|
||||||
|
evaluatedAt: new Date().toISOString(),
|
||||||
|
targets: [],
|
||||||
|
materials: [],
|
||||||
|
allowedChannelIds: null,
|
||||||
|
reasonCode: null,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
for (const target of targets) {
|
||||||
|
const matching = materials.filter((item) => item.auditStatus !== 'deleted' && materialMatches(target, item.url));
|
||||||
|
const approved = matching.filter((item) => item.auditStatus === 'approved');
|
||||||
|
assessment.targets.push({ ...target, materialIds: approved.map((item) => item.id) });
|
||||||
|
const code = !matching.length ? 'DRAINAGE_NOT_REGISTERED' : !approved.length ? 'DRAINAGE_NOT_APPROVED' : null;
|
||||||
|
if (code && !assessment.reasonCode) {
|
||||||
|
assessment.reasonCode = code;
|
||||||
|
assessment.reason = `引流信息“${target.text.slice(0, 160)}”${!matching.length ? '未在当前签名下添加' : '尚未审核通过'}`;
|
||||||
|
}
|
||||||
|
const channels = new Set(
|
||||||
|
approved.flatMap((item) =>
|
||||||
|
[...new Set(item.reportTasks.map((task) => task.channelId))]
|
||||||
|
.map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier))
|
||||||
|
.filter((task): task is NonNullable<typeof task> => task?.status === 'approved')
|
||||||
|
.map((task) => task.channelId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
allowed =
|
||||||
|
allowed === null
|
||||||
|
? channels
|
||||||
|
: new Set<string>(Array.from(allowed as Set<string>).filter((id: string) => channels.has(id)));
|
||||||
|
}
|
||||||
|
const used = new Set(assessment.targets.flatMap((target) => target.materialIds));
|
||||||
|
assessment.materials = materials
|
||||||
|
.filter((item) => used.has(item.id))
|
||||||
|
.map(({ id, url, auditStatus, materialVersion, reportTasks }) => ({
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
auditStatus,
|
||||||
|
materialVersion,
|
||||||
|
reportTasks,
|
||||||
|
}));
|
||||||
|
assessment.allowedChannelIds = allowed === null ? null : [...allowed];
|
||||||
|
if (targets.length && allowed?.size === 0 && !assessment.reasonCode) {
|
||||||
|
assessment.reasonCode = 'DRAINAGE_CHANNEL_NOT_APPROVED';
|
||||||
|
assessment.reason = '当前签名下的全部引流信息没有共同报备通过的通道';
|
||||||
|
}
|
||||||
|
return assessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateMessageDrainage(
|
||||||
|
prisma: PrismaService,
|
||||||
|
message: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
applicationId?: string | null;
|
||||||
|
signatureId?: string | null;
|
||||||
|
},
|
||||||
|
carrier?: string,
|
||||||
|
materials?: DrainageMaterial[],
|
||||||
|
fresh = false,
|
||||||
|
) {
|
||||||
|
let detected;
|
||||||
|
try {
|
||||||
|
detected = await detectDrainageContent(prisma, message.content, fresh);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ServiceUnavailableException('引流检测暂不可用', { cause: error });
|
||||||
|
}
|
||||||
|
const detection = detected.drainageDetection as unknown as {
|
||||||
|
matches: DrainageDetectionMatch[];
|
||||||
|
truncated: boolean;
|
||||||
|
ruleCount: number;
|
||||||
|
};
|
||||||
|
if (detection.truncated || detection.ruleCount === 0)
|
||||||
|
throw new ServiceUnavailableException('引流检测不完整,暂不能发送');
|
||||||
|
let targets: DrainageTarget[] = [];
|
||||||
|
let invalid: DrainageRejection | undefined;
|
||||||
|
try {
|
||||||
|
targets = drainageTargets(message.content, detection.matches);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof DrainageRejection)) throw error;
|
||||||
|
invalid = error;
|
||||||
|
}
|
||||||
|
const rows =
|
||||||
|
materials ??
|
||||||
|
(targets.length && message.signatureId
|
||||||
|
? await prisma.smsDrainageInfo.findMany({
|
||||||
|
where: {
|
||||||
|
signatureId: message.signatureId,
|
||||||
|
tenantId: message.tenantId ?? '',
|
||||||
|
applicationId: message.applicationId ?? '',
|
||||||
|
auditStatus: { not: 'deleted' },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reportTasks: {
|
||||||
|
where: { reportType: 'drainage', signatureId: message.signatureId, tenantId: message.tenantId ?? '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: []);
|
||||||
|
const assessment = assessDrainage(targets, rows, carrier);
|
||||||
|
if (invalid) {
|
||||||
|
assessment.reasonCode = invalid.reasonCode;
|
||||||
|
assessment.reason = invalid.message;
|
||||||
|
assessment.allowedChannelIds = [];
|
||||||
|
}
|
||||||
|
// Append-only evidence survives later approval changes and subsequent routing attempts.
|
||||||
|
await prisma.smsDrainageDecision.create({
|
||||||
|
data: { messageRecordId: message.id, snapshot: JSON.parse(JSON.stringify(assessment)) },
|
||||||
|
});
|
||||||
|
await prisma.smsMessageRecord.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: {
|
||||||
|
...detected,
|
||||||
|
drainageGate: JSON.parse(JSON.stringify(assessment)),
|
||||||
|
drainageInfoId:
|
||||||
|
assessment.targets.length === 1 && assessment.targets[0].materialIds.length === 1
|
||||||
|
? assessment.targets[0].materialIds[0]
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (assessment.reasonCode) throw new DrainageRejection(assessment.reasonCode, assessment.reason!);
|
||||||
|
return assessment;
|
||||||
|
}
|
||||||
@@ -53,7 +53,8 @@ export function invalidateDrainageDetectionRuleCache() {
|
|||||||
|
|
||||||
export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
|
export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
|
||||||
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空');
|
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空');
|
||||||
if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
|
if (pattern.length > MAX_PATTERN_LENGTH)
|
||||||
|
throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
|
||||||
if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
|
if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
|
||||||
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复');
|
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复');
|
||||||
}
|
}
|
||||||
@@ -69,18 +70,20 @@ export function validateDrainageDetectionPattern(pattern: string, flags = 'giu')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
|
export function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
|
||||||
let text = '';
|
let text = '';
|
||||||
const sourceStarts: number[] = [];
|
const sourceStarts: number[] = [];
|
||||||
const sourceEnds: number[] = [];
|
const sourceEnds: number[] = [];
|
||||||
let sourceIndex = 0;
|
let sourceIndex = 0;
|
||||||
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
|
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
|
||||||
const sourceEnd = sourceIndex + sourceChar.length;
|
const sourceEnd = sourceIndex + sourceChar.length;
|
||||||
let normalized = sourceChar.normalize('NFKC')
|
let normalized = sourceChar
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
|
||||||
.replace(/[.。]/g, '.')
|
.replace(/[.。]/g, '.')
|
||||||
.replace(/[:﹕]/g, ':')
|
.replace(/[:﹕]/g, ':')
|
||||||
.replace(/[/]/g, '/')
|
.replace(/[/]/g, '/')
|
||||||
.replace(/[()]/g, (char) => char === '(' ? '(' : ')')
|
.replace(/[()]/g, (char) => (char === '(' ? '(' : ')'))
|
||||||
.replace(/[+]/g, '+');
|
.replace(/[+]/g, '+');
|
||||||
if (category === 'url') {
|
if (category === 'url') {
|
||||||
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
|
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
|
||||||
@@ -116,8 +119,11 @@ function sourceRange(normalized: NormalizedContent, start: number, end: number)
|
|||||||
|
|
||||||
function emailRanges(normalized: NormalizedContent) {
|
function emailRanges(normalized: NormalizedContent) {
|
||||||
const ranges: Array<{ start: number; end: number }> = [];
|
const ranges: Array<{ start: number; end: number }> = [];
|
||||||
const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
|
const email =
|
||||||
|
/[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
|
||||||
for (const match of normalized.text.matchAll(email)) {
|
for (const match of normalized.text.matchAll(email)) {
|
||||||
|
const tokenStart = normalized.text.lastIndexOf(' ', match.index) + 1;
|
||||||
|
if (/https?:\/\/\S*$/i.test(normalized.text.slice(tokenStart, match.index + match[0].length))) continue;
|
||||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||||
}
|
}
|
||||||
return ranges;
|
return ranges;
|
||||||
@@ -135,7 +141,9 @@ export function detectDrainageContentWithRules(
|
|||||||
const matches: DrainageDetectionMatch[] = [];
|
const matches: DrainageDetectionMatch[] = [];
|
||||||
const normalizedByCategory = new Map<string, NormalizedContent>();
|
const normalizedByCategory = new Map<string, NormalizedContent>();
|
||||||
const emailNormalized = normalizeContent(content, 'email');
|
const emailNormalized = normalizeContent(content, 'email');
|
||||||
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
|
const originalEmailRanges = emailRanges(emailNormalized).map((range) =>
|
||||||
|
sourceRange(emailNormalized, range.start, range.end),
|
||||||
|
);
|
||||||
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
|
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
|
||||||
validateDrainageDetectionPattern(rule.pattern, rule.flags);
|
validateDrainageDetectionPattern(rule.pattern, rule.flags);
|
||||||
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
|
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
|
||||||
@@ -160,7 +168,12 @@ export function detectDrainageContentWithRules(
|
|||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
};
|
};
|
||||||
if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) {
|
if (
|
||||||
|
!matches.some(
|
||||||
|
(item) =>
|
||||||
|
item.category === candidate.category && item.start === candidate.start && item.end === candidate.end,
|
||||||
|
)
|
||||||
|
) {
|
||||||
matches.push(candidate);
|
matches.push(candidate);
|
||||||
}
|
}
|
||||||
if (matches.length >= MAX_MATCHES) break;
|
if (matches.length >= MAX_MATCHES) break;
|
||||||
@@ -186,8 +199,8 @@ export function detectDrainageContentWithRules(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activeRules(prisma: PrismaService) {
|
async function activeRules(prisma: PrismaService, fresh = false) {
|
||||||
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
|
if (!fresh && cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
|
||||||
const rules = await prisma.drainageDetectionRule.findMany({
|
const rules = await prisma.drainageDetectionRule.findMany({
|
||||||
where: { status: 'active' },
|
where: { status: 'active' },
|
||||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||||
@@ -206,6 +219,6 @@ async function activeRules(prisma: PrismaService) {
|
|||||||
return rules;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function detectDrainageContent(prisma: PrismaService, content: string) {
|
export async function detectDrainageContent(prisma: PrismaService, content: string, fresh = false) {
|
||||||
return detectDrainageContentWithRules(content, await activeRules(prisma));
|
return detectDrainageContentWithRules(content, await activeRules(prisma, fresh));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { SendChainService } from './send-chain.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DrainageReceiptRecoveryService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
private running = false;
|
||||||
|
private readonly logger = new Logger(DrainageReceiptRecoveryService.name);
|
||||||
|
constructor(private readonly sendChain: SendChainService) {}
|
||||||
|
onModuleInit() {
|
||||||
|
if (['api', 'callback', 'outbox'].includes(process.env.CMPP_PROCESS_ROLE ?? 'all')) return;
|
||||||
|
this.timer = setInterval(() => void this.scan(), 10000);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
async scan() {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
await this.sendChain.recoverDrainageFailureReceipts();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`选路拦截回执恢复失败: ${String(error)}`);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { DrainageSubmitGuardController } from './drainage-submit-guard.controller';
|
||||||
|
|
||||||
|
describe('channel test final guard', () => {
|
||||||
|
const content = '无签名正文 https://example.test 4001234567'.repeat(5);
|
||||||
|
function setup(overrides = {}) {
|
||||||
|
const tx = {
|
||||||
|
smsSubmitRecord: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
channelId: 'channel',
|
||||||
|
submitId: 'submit',
|
||||||
|
resultProcessedAt: null,
|
||||||
|
messageRecord: { content, tenantId: null, batchTaskId: null, status: 'submit_queued', ...overrides },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
drainageDetectionRule: {
|
||||||
|
findMany: jest.fn(() => {
|
||||||
|
throw new Error('must not detect channel tests');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const db = { $transaction: (operation: (value: unknown) => unknown) => operation(tx) };
|
||||||
|
return { controller: new DrainageSubmitGuardController(db as never), tx };
|
||||||
|
}
|
||||||
|
const local = { socket: { remoteAddress: '127.0.0.1' } };
|
||||||
|
const input = {
|
||||||
|
submitId: 'submit',
|
||||||
|
channelId: 'channel',
|
||||||
|
contentHash: createHash('sha256').update(content).digest('hex'),
|
||||||
|
};
|
||||||
|
it('allows unsigned long channel tests containing drainage information without detecting content', async () => {
|
||||||
|
const { controller, tx } = setup();
|
||||||
|
await expect(controller.authorize(local, input)).resolves.toEqual({ allowed: true });
|
||||||
|
expect(tx.drainageDetectionRule.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('still rejects a modified body or a finished submit', async () => {
|
||||||
|
await expect(setup().controller.authorize(local, { ...input, contentHash: 'a'.repeat(64) })).resolves.toMatchObject(
|
||||||
|
{ allowed: false },
|
||||||
|
);
|
||||||
|
await expect(setup({ status: 'delivered' }).controller.authorize(local, input)).resolves.toMatchObject({
|
||||||
|
allowed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('does not grant the exemption to a customer message without its required signature', async () => {
|
||||||
|
await expect(
|
||||||
|
setup({ tenantId: 'tenant', applicationId: 'app', batchTaskId: 'task' }).controller.authorize(local, input),
|
||||||
|
).resolves.toMatchObject({ allowed: false });
|
||||||
|
});
|
||||||
|
it('rejects external callers before looking up a submit', async () => {
|
||||||
|
const { controller, tx } = setup();
|
||||||
|
await expect(controller.authorize({ socket: { remoteAddress: '10.0.0.2' } }, input)).rejects.toThrow();
|
||||||
|
expect(tx.smsSubmitRecord.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user