27 Commits
Author SHA1 Message Date
hectorzhao 3cacb6e8e7 fix: 修复模板唯一性及六项运营页面问题
CSS quality / css-quality (push) Canceled after 0s
2026-09-21 19:28:50 +08:00
hectorzhao 28951b4fc4 fix: 修复未报备签名统计的中文编码兼容性 2026-09-21 15:39:45 +08:00
hectorzhao 001d5f2cbd fix: 修复 CMPP 协议字段容量与版本兼容性
CSS quality / css-quality (push) Has been cancelled
2026-09-20 15:49:37 +08:00
hectorzhao b24cd7c08d feat: 增加模板通道拒收策略并修复运营页面 2026-09-20 15:09:57 +08:00
hectorzhao c20c2246b2 fix: 固化长短信回执终态并隔离发送尝试归属 2026-09-18 13:20:03 +08:00
hectorzhao 1676cfe622 fix: 限制有效签名名称唯一并保留批量导入补资料 2026-09-17 16:49:58 +08:00
hectorzhao 5e4d644788 feat: 重构首页回执营业统计并增加企业返还金额
CSS quality / css-quality (push) Has been cancelled
2026-09-17 13:11:35 +08:00
hectorzhao 627fa7ec97 fix: 固定跨午夜日报刷新基准并补充验收记录 2026-09-17 11:14:21 +08:00
hectorzhao 572290308c fix: 修复上行归属并实现签名质量日报优化 2026-09-17 11:12:58 +08:00
hectorzhao 4eb7b16d12 docs: record test deployment and long-message completion acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-16 19:22:16 +08:00
hectorzhao 010ba32168 fix: register supplier response waiter before reader can consume reply
CSS quality / css-quality (push) Has been cancelled
2026-09-16 19:08:42 +08:00
hectorzhao a350aca883 fix: coordinate SMS completion and improve operations diagnostics
CSS quality / css-quality (push) Has been cancelled
2026-09-16 18:27:29 +08:00
hectorzhao a0209f93bc chore: rename product to 聆界短信平台 2026-09-16 11:31:45 +08:00
hectorzhao 86cb9aea36 docs: record HTTP integration acceptance and test deployment
CSS quality / css-quality (push) Has been cancelled
2026-09-15 17:42:28 +08:00
hectorzhao cbc4a03325 fix: exclude prose colons from drainage URL boundaries
CSS quality / css-quality (push) Has been cancelled
2026-09-15 16:52:30 +08:00
hectorzhao c781313de5 feat: add HTTP signature tools and fix independent HTTP send validation
CSS quality / css-quality (push) Has been cancelled
2026-09-15 15:58:29 +08:00
hectorzhao 18ecf8045f feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled
2026-09-15 14:58:31 +08:00
hectorzhao bcb278be29 docs: record client fixes test deployment and acceptance boundaries
CSS quality / css-quality (push) Has been cancelled
2026-09-14 23:29:37 +08:00
hectorzhao 4665079ca3 fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled
2026-09-14 22:53:34 +08:00
hectorzhao 7f9abe3da0 fix: enforce drainage uniqueness and carrier-specific reporting 2026-09-14 18:12:38 +08:00
hectorzhao ac6449028c docs: close HTTP API real simulator acceptance
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:53:22 +08:00
hectorzhao 97d1334423 fix: preserve fractional timestamp input compatibility
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:20:30 +08:00
hectorzhao a420d61b23 fix: validate HTTP dates IPv6 URLs and parser errors
CSS quality / css-quality (push) Has been cancelled
2026-09-14 15:15:58 +08:00
hectorzhao 92b112cc6e fix: honor Node all-address DNS lookup for HTTP webhooks
CSS quality / css-quality (push) Has been cancelled
2026-09-14 14:03:00 +08:00
hectorzhao 40c8e279f0 docs: close HTTP API test deployment with verification evidence
CSS quality / css-quality (push) Has been cancelled
2026-09-14 13:20:37 +08:00
hectorzhao 04e9f467ab docs: record HTTP remediation validation and test release handoff
CSS quality / css-quality (push) Has been cancelled
2026-09-14 13:02:16 +08:00
hectorzhao f0e843436c feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled
2026-09-14 12:48:10 +08:00
269 changed files with 23693 additions and 4587 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# CMPP 平台仓库开发约束 # 聆界短信平台仓库开发约束
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。 本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
+89
View File
@@ -29,6 +29,7 @@
"tldts": "^7.4.12" "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",
@@ -2012,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",
@@ -2101,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",
@@ -2108,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",
@@ -2173,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",
@@ -2183,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",
+1
View File
@@ -38,6 +38,7 @@
"tldts": "^7.4.12" "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,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();
+219 -7
View File
@@ -686,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
@@ -773,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?
@@ -848,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
@@ -1826,6 +1832,7 @@ model SmsDrainageDecision {
} }
model SmsMessageRecord { model SmsMessageRecord {
originalContent String?
channelWordDecisions SmsChannelSensitiveDecision[] channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false) channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[] monitorFacts SendingMonitorFact[]
@@ -1919,6 +1926,8 @@ model CmppSubmitSession {
} }
model SmsSubmitRecord { model SmsSubmitRecord {
sentContent String?
contentPolicy Json?
drainageGate Json? drainageGate Json?
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
@@ -1930,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
@@ -2081,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?
@@ -2194,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?
@@ -2265,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
@@ -2334,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?
@@ -2442,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())
@@ -2466,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
@@ -2825,3 +2834,206 @@ model SendingMonitorTargetVersion {
updatedBy String updatedBy String
@@id([channelId,version]) @@id([channelId,version])
} }
model OpenApiDispatchOutbox {
id String @id @default(cuid())
requestId String @unique
batchTaskId String @unique
status String @default("pending")
leaseToken String?
leaseUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, leaseUntil])
}
model InfrastructureAlertCollection {
id String @id
observedAt DateTime
}
model InfrastructureAlertEvent {
id String @id @default(cuid())
fingerprint String
activeAt DateTime
payload Json
lastObservedAt DateTime
recoveredAt DateTime?
clearedAt DateTime?
clearedBy String?
createdAt DateTime @default(now())
@@unique([fingerprint, activeAt])
@@index([clearedAt, activeAt])
}
model SmsAttemptCompletionWork {
id String @id @default(cuid())
workKey String @unique
tenantId String?
messageRecordId String
sourceSubmitRecordId String? @unique
revision Int @default(0)
processedRevision Int @default(0)
state String @default("pending")
leaseOwner String?
leaseUntil DateTime?
fenceVersion Int @default(0)
attempts Int @default(0)
nextAttemptAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
decision String?
retrySubmitRecordId String?
lastError String?
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
updatedAt DateTime @updatedAt
events SmsCompletionEvent[]
@@index([state, nextAttemptAt])
@@index([state, leaseUntil])
@@index([messageRecordId])
}
model SmsCompletionEvent {
id String @id @default(cuid())
eventKey String @unique
workId String
kind String
payload Json
processedAt DateTime?
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
work SmsAttemptCompletionWork @relation(fields: [workId], references: [id], onDelete: Restrict)
@@index([workId, processedAt, createdAt])
}
model SignatureAnalyticsGeneration {
id String @id
businessDate DateTime @db.Date
sourceAsOf DateTime
days SignatureAnalyticsDay[]
quality SignatureQualityDaily[]
activity SignatureActivityDaily[]
unreported UnreportedSignatureDaily[]
@@unique([id, businessDate])
}
model SignatureAnalyticsDay {
businessDate DateTime @id @db.Date
publishedGenerationId String?
publishedGeneration SignatureAnalyticsGeneration? @relation(fields: [publishedGenerationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generatedAt DateTime?
sourceAsOf DateTime?
refreshFor DateTime? @db.Date
state String @default("missing")
error String?
provenance String @default("daily")
schemaVersion Int @default(1)
rowCounts Json?
updatedAt DateTime @updatedAt
}
model SignatureAnalyticsRun {
id String @id @default(cuid())
scope String
businessDate DateTime @db.Date
refreshFor DateTime @db.Date
generationId String
state String @default("pending")
owner String?
fence Int @default(0)
leaseUntil DateTime?
attempt Int @default(0)
nextAttemptAt DateTime @default(now())
checkpoint Json?
error String?
startedAt DateTime?
finishedAt DateTime?
@@unique([scope, businessDate])
@@index([state, nextAttemptAt])
}
model SignatureQualityDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
signatureId String
signatureName String
tenantId String
tenantName String
applicationNames String
total Int
payload Json
@@id([generationId, signatureId])
@@index([businessDate, generationId, total])
}
model SignatureActivityDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
dimensionKey String
dimensionType String
signatureId String
channelKey String
carrier String
tenantId String
applicationId String?
signatureName String
tenantName String
applicationName String
channelName String
approvedAt DateTime?
submittedAttempts Int
acceptedBusinessCount Int
deliveredBusinessCount Int
applicability String
@@id([generationId, dimensionKey])
@@index([businessDate, generationId, dimensionType, acceptedBusinessCount])
}
model UnreportedSignatureDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
dimensionKey String
tenantId String
applicationId String
signatureName String
tenantName String
applicationName String
messageCount Int
@@id([generationId, dimensionKey])
@@index([businessDate, generationId, messageCount])
}
model HomeProjectionState {
id String @id
version Int @default(0)
seededDay String?
initialized Boolean @default(false)
lastError String?
updatedAt DateTime @default(now())
}
model HomeProjectionDirty {
messageRecordId String @id
enqueuedAt DateTime @default(now())
@@index([enqueuedAt])
}
model HomeMessageFact {
messageRecordId String
fromVersion Int
toVersion Int?
queuedDay String
payload Json
@@id([messageRecordId, fromVersion])
@@index([queuedDay, toVersion])
@@index([toVersion])
}
model HomeSnapshot {
id String @id
userId String
businessDate String
version Int
createdAt DateTime @default(now())
expiresAt DateTime
summary Json
@@index([expiresAt])
}
+12 -1
View File
@@ -1,3 +1,7 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { HomeModule } from './home-dashboard/home.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
@@ -57,13 +61,20 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
InfrastructureMonitoringModule, InfrastructureMonitoringModule,
OpenApiModule, OpenApiModule,
SignatureRetirementModule, SignatureRetirementModule,
SignatureAnalyticsModule,
HomeModule,
SecurityDetectionModule, SecurityDetectionModule,
MetricsModule, MetricsModule,
ReportNotificationsModule, ReportNotificationsModule,
SendingMonitorModule, SendingMonitorModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
RequestContextMiddleware,
SessionValidationMiddleware,
ManualOperationAuditMiddleware,
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
@@ -0,0 +1,53 @@
import { ChannelConfigurationService } from './channel-configuration.service';
import { selectChannelCandidate } from '../send-chain/send-chain.helpers';
describe('carrier capability reduction', () => {
it('removes incompatible group members in a transaction without reconnecting', async () => {
const channel = {
id: 'c',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
status: 'active',
config: {},
};
const prisma = {
$transaction: jest.fn(),
smsChannel: {
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })),
},
operationLog: { create: jest.fn() },
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([{ id: 'member', group: { name: 'existing', carrier: 'telecom' } }]),
deleteMany: jest.fn(),
},
};
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
carriers: ['mobile', 'unicom'],
});
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
);
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['member'] } } });
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
const candidate = {
channelId: 'c',
carrier: 'telecom',
channel: {
...channel,
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
},
};
expect(
selectChannelCandidate([candidate], {
carrier: 'telecom',
excludedChannelIds: new Set(),
approvedChannelIds: new Set(['c']),
}),
).toBeUndefined();
});
});
@@ -1,17 +1,26 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service'; import { ChannelConnectionService } from './channel-connection.service';
import type { ChangeChannelStatusDto, CreateChannelDto, UpdateChannelDto } from './channels.contracts';
import {
channelConnectionSettingsChanged,
currentShanghaiDayRange,
legacyCarrierFromCapabilities,
normalizeBusinessCarrier,
normalizeChannelCarriers,
normalizeChannelRateLimit,
normalizeChannelRuntimeConfig,
normalizeCmppVersion,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */ /** R5 channel domain service composed behind ChannelsService. */
export class ChannelConfigurationService { export class ChannelConfigurationService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
listChannels() { listChannels() {
return this.prisma.smsChannel.findMany({ return this.prisma.smsChannel.findMany({
@@ -20,7 +29,13 @@ export class ChannelConfigurationService {
}); });
} }
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { async listChannelsPage(query: {
keyword?: string;
carrier?: string;
status?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1)); const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsChannelWhereInput = { const where: Prisma.SmsChannelWhereInput = {
@@ -44,12 +59,18 @@ export class ChannelConfigurationService {
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)])); const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。 // 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
const pageIds = candidates const pageIds = candidates
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) .sort(
|| left.name.localeCompare(right.name, 'zh-CN') (left, right) =>
|| left.id.localeCompare(right.id)) (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) ||
left.name.localeCompare(right.name, 'zh-CN') ||
left.id.localeCompare(right.id),
)
.slice((page - 1) * pageSize, page * pageSize) .slice((page - 1) * pageSize, page * pageSize)
.map((channel) => channel.id); .map((channel) => channel.id);
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } }); const pageItems = await this.prisma.smsChannel.findMany({
where: { id: { in: pageIds } },
include: { connectionStates: true },
});
const itemById = new Map(pageItems.map((item) => [item.id, item])); const itemById = new Map(pageItems.map((item) => [item.id, item]));
const items = pageIds.flatMap((id) => { const items = pageIds.flatMap((id) => {
const item = itemById.get(id); const item = itemById.get(id);
@@ -122,11 +143,12 @@ export class ChannelConfigurationService {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
} }
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion); const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined const config =
|| data.desiredConnections !== undefined data.config !== undefined ||
|| data.windowSize !== undefined data.desiredConnections !== undefined ||
|| data.heartbeatIntervalSeconds !== undefined data.windowSize !== undefined ||
|| data.heartbeatMissThreshold !== undefined data.heartbeatIntervalSeconds !== undefined ||
data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig( ? normalizeChannelRuntimeConfig(
channel.config, channel.config,
data.config, data.config,
@@ -136,25 +158,13 @@ export class ChannelConfigurationService {
data.heartbeatMissThreshold, data.heartbeatMissThreshold,
) )
: undefined; : undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined const rateLimitPerSecond =
? undefined data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond);
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier); const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
const carriers = data.carriers !== undefined || data.carrier !== undefined const carriers =
data.carriers !== undefined || data.carrier !== undefined
? normalizeChannelCarriers(data.carriers, data.carrier) ? normalizeChannelCarriers(data.carriers, data.carrier)
: existingCarriers; : existingCarriers;
if (data.carriers !== undefined || data.carrier !== undefined) {
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
if (removed.length) {
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
include: { group: true },
});
if (blockingGroups.length) {
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
}
}
}
const connectionConfigChanged = channelConnectionSettingsChanged(channel, { const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost, gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort, gatewayPort: gatewayPort ?? channel.gatewayPort,
@@ -163,12 +173,16 @@ export class ChannelConfigurationService {
cmppVersion: cmppVersion ?? channel.cmppVersion, cmppVersion: cmppVersion ?? channel.cmppVersion,
config: config ?? channel.config, config: config ?? channel.config,
}); });
const updated = await this.prisma.smsChannel.update({ const updated = await this.prisma.$transaction(async (tx) => {
const updated = await tx.smsChannel.update({
where: { id: channelId }, where: { id: channelId },
data: { data: {
code: data.code, code: data.code,
name: data.name, name: data.name,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined, carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined, carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion, sendRegion: data.sendRegion,
protocol: 'CMPP', protocol: 'CMPP',
@@ -185,7 +199,22 @@ export class ChannelConfigurationService {
config: config as Prisma.InputJsonValue | undefined, config: config as Prisma.InputJsonValue | undefined,
}, },
}); });
await this.prisma.operationLog.create({ const removedGroupItems =
data.carriers !== undefined || data.carrier !== undefined
? await tx.smsChannelGroupItem.findMany({
where: { channelId, group: { carrier: { notIn: carriers } } },
select: {
id: true,
groupId: true,
carrier: true,
province: true,
group: { select: { name: true, carrier: true } },
},
})
: [];
if (removedGroupItems.length)
await tx.smsChannelGroupItem.deleteMany({ where: { id: { in: removedGroupItems.map((item) => item.id) } } });
await tx.operationLog.create({
data: { data: {
action: 'sms_channel.update', action: 'sms_channel.update',
resource: 'sms_channel', resource: 'sms_channel',
@@ -204,10 +233,13 @@ export class ChannelConfigurationService {
srcId: channel.srcId, srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice), unitPrice: moneyToNumber(channel.unitPrice),
}, },
after: data, after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
removedGroupItems,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
}, },
}); });
return updated;
});
const updatedStatus = data.status ?? channel.status; const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) { if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
await this.connection.requestChannelConnection(updated, 'channel_updated'); await this.connection.requestChannelConnection(updated, 'channel_updated');
+55 -15
View File
@@ -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';
@@ -362,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;
@@ -450,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];
}) })
@@ -473,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 [
@@ -551,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),
@@ -561,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) {
@@ -577,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('报备运营商不在通道支持范围内');
} }
@@ -587,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 =
@@ -603,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: {
@@ -638,6 +677,7 @@ export class ChannelReportingService {
reportType, reportType,
drainageItemId: item.drainageItemId!, drainageItemId: item.drainageItemId!,
channelId: item.channelId, channelId: item.channelId,
carrier,
status: item.status, status: item.status,
}); });
} }
+16 -12
View File
@@ -1,18 +1,24 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; import type { TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; import {
normalizeTestPhones,
normalizeTestContent,
normalizeGatewayConnectionStatus,
calculateBillingUnits,
buildChannelTestSubmitCommand,
} from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service'; import { ChannelConnectionService } from './channel-connection.service';
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
/** R5 channel domain service composed behind ChannelsService. */ /** R5 channel domain service composed behind ChannelsService. */
export class ChannelTestService { export class ChannelTestService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {} constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
async testChannel(channelId: string, data: TestChannelDto = {}) { async testChannel(channelId: string, data: TestChannelDto = {}) {
const phoneNumbers = normalizeTestPhones(data); const phoneNumbers = normalizeTestPhones(data);
@@ -27,8 +33,8 @@ export class ChannelTestService {
if (channel.status !== 'active') { if (channel.status !== 'active') {
throw new BadRequestException('通道未启用,不能发送测试短信'); throw new BadRequestException('通道未启用,不能发送测试短信');
} }
const connectedState = channel.connectionStates.find((state) => const connectedState = channel.connectionStates.find(
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0, (state) => normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
); );
if (!connectedState) { if (!connectedState) {
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送'); throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
@@ -36,7 +42,6 @@ export class ChannelTestService {
const createdAt = new Date(); const createdAt = new Date();
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`; const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const drainageDetection = await detectDrainageContent(this.prisma, content);
const results = []; const results = [];
for (const [index, phoneNumber] of phoneNumbers.entries()) { for (const [index, phoneNumber] of phoneNumbers.entries()) {
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`; const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
@@ -51,7 +56,6 @@ export class ChannelTestService {
messageId, messageId,
phoneNumber, phoneNumber,
content, content,
...drainageDetection,
billingUnits: calculateBillingUnits(content), billingUnits: calculateBillingUnits(content),
unitPrice: 0, unitPrice: 0,
amountCents: 0, amountCents: 0,
+8
View File
@@ -246,6 +246,10 @@ export class ChannelsController {
@Query('status') status?: string, @Query('status') status?: string,
@Query('reportType') reportType?: string, @Query('reportType') reportType?: string,
@Query('keyword') keyword?: string, @Query('keyword') keyword?: string,
@Query('enterpriseKeyword') enterpriseKeyword?: string,
@Query('applicationKeyword') applicationKeyword?: string,
@Query('channelKeyword') channelKeyword?: string,
@Query('objectKeyword') objectKeyword?: string,
@Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string, @Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string, @Query('page') page?: string,
@@ -260,6 +264,10 @@ export class ChannelsController {
status, status,
reportType, reportType,
keyword, keyword,
enterpriseKeyword,
applicationKeyword,
channelKeyword,
objectKeyword,
createdAtFrom, createdAtFrom,
createdAtTo, createdAtTo,
page: Number(page), page: Number(page),
+13 -3
View File
@@ -72,11 +72,14 @@ function createPrismaMock() {
}, },
], ],
}; };
const channelUpdate = jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data }));
const operationLogCreate = jest.fn();
return { return {
$queryRaw: jest.fn().mockResolvedValue([]), $queryRaw: jest.fn().mockResolvedValue([]),
$transaction: jest.fn((callback) => $transaction: jest.fn((callback) =>
callback({ callback({
smsChannel: { smsChannel: {
update: channelUpdate,
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
}, },
smsChannelGroup: { smsChannelGroup: {
@@ -86,6 +89,7 @@ function createPrismaMock() {
.mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }), .mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
}, },
smsChannelGroupItem: { smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([]),
deleteMany: jest.fn(), deleteMany: jest.fn(),
createMany: jest.fn(), createMany: jest.fn(),
}, },
@@ -98,7 +102,7 @@ function createPrismaMock() {
createMany: jest.fn(), createMany: jest.fn(),
}, },
operationLog: { operationLog: {
create: jest.fn(), create: operationLogCreate,
}, },
}), }),
), ),
@@ -106,7 +110,7 @@ function createPrismaMock() {
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue(channel), findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })), update: channelUpdate,
}, },
channelHealthMetric: { findMany: jest.fn() }, channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: { smsChannelGroup: {
@@ -228,7 +232,7 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }),
}, },
operationLog: { operationLog: {
create: jest.fn(), create: operationLogCreate,
findMany: jest.fn().mockResolvedValue([ findMany: jest.fn().mockResolvedValue([
{ {
id: 'log-1', id: 'log-1',
@@ -370,6 +374,7 @@ describe('ChannelsService', () => {
updatedAt: new Date(), updatedAt: new Date(),
}; };
const tx = { const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
channelReportField: { channelReportField: {
findMany: jest findMany: jest
.fn() .fn()
@@ -519,6 +524,7 @@ describe('ChannelsService', () => {
async (sourceEntry) => { async (sourceEntry) => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const tx = { const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: { smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
@@ -601,6 +607,7 @@ describe('ChannelsService', () => {
it('uses the enterprise-signature save time when creating an approved carrier task', async () => { it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const tx = { const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: { smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
@@ -655,6 +662,7 @@ describe('ChannelsService', () => {
it('changes a drainage report task without overwriting the signature report summary', async () => { it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const tx = { const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: { smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(), update: jest.fn(),
@@ -699,6 +707,7 @@ describe('ChannelsService', () => {
reportType: 'drainage', reportType: 'drainage',
drainageItemId: 'drain-1', drainageItemId: 'drain-1',
channelId: 'channel-1', channelId: 'channel-1',
carrier: null,
status: 'approved', status: 'approved',
}, },
]); ]);
@@ -1259,6 +1268,7 @@ describe('ChannelsService', () => {
const transactionCallback = prisma.$transaction.mock.calls[0][0]; const transactionCallback = prisma.$transaction.mock.calls[0][0];
const tx = { const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() }, smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
smsChannelGroup: { smsChannelGroup: {
update: jest.fn(), update: jest.fn(),
+4
View File
@@ -202,6 +202,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
status?: string; status?: string;
reportType?: string; reportType?: string;
keyword?: string; keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string; createdAtFrom?: string;
createdAtTo?: string; createdAtTo?: string;
page?: number; page?: number;
+13
View File
@@ -0,0 +1,13 @@
/** A carrier-specific decision overrides a legacy channel decision, including rejection. */
export function selectDrainageReportTask<
T extends {
channelId: string;
carrier?: string | null;
approvalScope?: string;
},
>(tasks: T[], channelId: string, carrier?: string) {
return (
(carrier ? tasks.find((task) => task.channelId === channelId && task.carrier === carrier) : undefined) ??
tasks.find((task) => task.channelId === channelId && !task.carrier && task.approvalScope !== 'carrier_specific')
);
}
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map } from 'rxjs/operators';
import { protocolFieldsToJson } from './protocol-uint32';
@Injectable()
export class ProtocolFieldsInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(map(protocolFieldsToJson));
}
}
+40
View File
@@ -0,0 +1,40 @@
import {
protocolFieldsToJson,
protocolUint32,
protocolUint32FromDb,
protocolUint32ToDb,
parseProtocolSequence,
} from './protocol-uint32';
describe('CMPP unsigned protocol fields', () => {
it.each([0, 2147483647, 2147483648, 4294967295])(
'round trips %s without changing the JSON number contract',
(value) => {
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
expect(
JSON.parse(
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
),
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
},
);
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
expect(() => protocolUint32(value)).toThrow();
expect(() => protocolUint32ToDb(value)).toThrow();
});
it('preserves optional historical nulls and unrelated serializers', () => {
expect(protocolUint32ToDb(null)).toBeUndefined();
expect(protocolUint32FromDb(null)).toBeUndefined();
const date = new Date();
expect(
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
});
it('distinguishes text zero from missing or malformed historical sequences', () => {
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
expect(parseProtocolSequence(value)).toBeUndefined();
expect(parseProtocolSequence('0')).toBe(0);
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
}
return value;
}
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
return value == null ? undefined : BigInt(protocolUint32(value, field));
}
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
if (value == null) return undefined;
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
}
/** Historical Submit sequence columns are text; blanks must never become zero. */
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
if (value == null || !/^\d+$/.test(value)) return undefined;
const number = Number(value);
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
}
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
export function protocolFieldsToJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
? protocolUint32FromDb(item)
: protocolFieldsToJson(item),
]),
);
}
+3
View File
@@ -1,3 +1,5 @@
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProtocolFieldsInterceptor } from './common/protocol-fields.interceptor';
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller'; 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';
@@ -21,6 +23,7 @@ import { SendChainService } from './send-chain/send-chain.service';
], ],
controllers: [DrainageSubmitGuardController, GatewayCallbackController], controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [ providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
BillingService, BillingService,
RiskReviewService, RiskReviewService,
PhoneFrequencyService, PhoneFrequencyService,
+108
View File
@@ -0,0 +1,108 @@
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
id: 'a',
accepted: true,
costUnitPrice: 300n,
gatewayId: 'g1',
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
});
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
attemptId: 'a',
gatewayId,
status,
at: at(day),
approximate: false,
});
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
describe('homepage business receipt projection', () => {
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
const a = attempt(Array(size).fill('delivered'), size);
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
const result = homeFact({ ...message, billingUnits: size }, [a], events);
expect(result.successDay).toBe('2026-09-17');
expect(result.revenue).toBe(String(size * 500));
});
it('includes paid successful fragments of prior failed attempts once', () => {
const earlier = attempt(['delivered', 'failed', 'failed']);
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
const result = homeFact(
message,
[earlier, final],
[
event('g1', 'delivered'),
event('g2', 'failed'),
event('g3', 'failed'),
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
],
);
expect(result.units).toBe(3);
expect(result.revenue).toBe('1500');
expect(result.cost).toBe('1200');
});
it('accounts for all expected units when only one failure arrives', () => {
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
expect(f.units).toBe(3);
expect(f.successDay).toBeNull();
expect(f.receiptDays).toEqual(['2026-09-17']);
});
it('rejects partial success and missing parts even if message says delivered', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered'])],
[event('g1', 'delivered'), event('g2', 'delivered')],
);
expect(f.successDay).toBeNull();
expect(f.incomplete).toBe(true);
});
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered', 'delivered'])],
[
event('g1', 'delivered', 16),
event('g2', 'delivered', 16),
event('g3', 'delivered'),
event('g3', 'delivered', 18),
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
expect(f.revenue).toBe('1500');
expect(f.cost).toBe('900');
});
it('does not combine different attempts into a complete message', () => {
const f = homeFact(
message,
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
);
expect(f.successDay).toBeNull();
});
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
const a = attempt(['delivered', 'delivered', 'delivered']);
a.segments[1].inferred = a.segments[2].inferred = true;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
a.segments[1].inferred = false;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
});
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
const f = homeFact(
message,
[{ ...attempt([]), gatewayId: 'g1' }],
[
{ ...event('g1', 'delivered'), approximate: true },
{ ...event('bad', 'failed'), attemptId: null },
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.approximate).toBe(true);
expect(f.incomplete).toBe(true);
});
it('rejects invalid units and unsafe money without rounding', () => {
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
expect(safeMoney('12345')).toBe(12345);
expect(() => safeMoney(9007199254740992n)).toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { todayKey } from '../signature-analytics/analytics-date';
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
export type HomeAttempt = {
id: string;
accepted: boolean;
gatewayId: string | null;
costUnitPrice: bigint;
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
};
export type HomeFact = {
units: number;
delivered: boolean;
successDay: string | null;
successAt: string | null;
receiptDays: string[];
revenue: string;
cost: string;
approximate: boolean;
incomplete: boolean;
};
/** Pure projection: never writes a message status or invents missing supplier receipts. */
export function homeFact(
message: { billingUnits: number; unitPrice: bigint; status: string },
attempts: HomeAttempt[],
incoming: HomeEvent[],
): HomeFact {
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
const events = new Map<string, HomeEvent>();
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
if (!events.has(key)) events.set(key, event);
}
const successes: Date[] = [];
let cost = 0n;
let incomplete = !units || incoming.some((e) => !e.attemptId);
for (const attempt of attempts) {
if (!attempt.accepted) continue;
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
const successFor = (id: string | null) =>
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
if (!attempt.segments.length) {
const success = successFor(attempt.gatewayId);
if (success && units) {
successes.push(success.at);
cost += BigInt(units) * attempt.costUnitPrice;
} else if (receipts.length) incomplete = true;
continue;
}
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
const expected = Math.max(...attempt.segments.map((s) => s.total));
const times: Date[] = [];
for (const part of parts.values()) {
// A contractual message-level receipt can account for the explicitly inferred parts only.
const received =
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
if (part.status === 'delivered' && received) {
times.push(received.at);
cost += attempt.costUnitPrice;
}
}
const complete =
expected > 0 &&
parts.size === expected &&
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
if (!complete) incomplete = true;
}
const success =
message.status === 'delivered' && successes.length
? new Date(Math.min(...successes.map((s) => s.getTime())))
: null;
if (message.status === 'delivered' && !success) incomplete = true;
const effective = [...events.values()].filter((e) => !success || e.at <= success);
return {
units,
delivered: message.status === 'delivered',
successDay: success ? todayKey(success) : null,
successAt: success?.toISOString() ?? null,
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
cost: success ? cost.toString() : '0',
approximate: effective.some((e) => e.approximate),
incomplete,
};
}
export function safeMoney(value: bigint | string | number) {
const integer = BigInt(value);
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
throw new Error('金额超过安全展示范围');
return Number(integer);
}
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
+97
View File
@@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { sourceFacts } from './home-source';
import { pruneHomeVersions } from './home-retention';
@Injectable()
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
private timer?: NodeJS.Timeout;
private readonly logger = new Logger(HomeProjection.name);
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (
process.env.NODE_ENV === 'test' ||
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
)
return;
this.timer = setInterval(() => void this.run(), 10_000);
this.timer.unref();
void this.run();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async run() {
try {
await this.tick();
} catch (error) {
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
await this.db.homeProjectionState
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
.catch(() => undefined);
}
}
async tick(now = new Date()) {
return this.db.$transaction(
async (tx) => {
const [lock] = await tx.$queryRaw<
Array<{ locked: boolean }>
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
if (!lock.locked) return { busy: true };
const date = todayKey(now),
first = addDays(date, -3);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (state.seededDay !== date) {
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
}
const work = await tx.$queryRaw<
Array<{ messageRecordId: string }>
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
const ids = work.map((w) => w.messageRecordId),
version = state.version + 1;
const sources = await sourceFacts(tx, ids);
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
for (const id of ids) {
const next = sources.find((s) => s.message.id === id);
const old = prior.find((p) => p.messageRecordId === id);
const day = next ? todayKey(next.message.queuedAt) : '';
const payload = next && day >= first && day <= date ? next.fact : null;
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
if (old)
await tx.homeMessageFact.update({
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
data: { toVersion: version },
});
if (payload)
await tx.homeMessageFact.create({
data: {
messageRecordId: id,
fromVersion: version,
queuedDay: day,
payload: payload as unknown as Prisma.InputJsonValue,
},
});
}
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
const remaining = await tx.homeProjectionDirty.count();
await tx.homeProjectionState.update({
where: { id: 'home' },
data: {
version,
seededDay: date,
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
updatedAt: now,
lastError: null,
},
});
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
return { remaining, version };
},
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import { Prisma } from '@prisma/client';
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
import { percentage, safeMoney } from './home-fact';
type MetricRow = {
queuedDay: string;
total: bigint;
delivered: bigint;
units: bigint;
successUnits: bigint;
successMessages: bigint;
revenue: bigint;
cost: bigint;
approximate: bigint;
incomplete: bigint;
};
export function metrics(row?: MetricRow) {
const total = Number(row?.total ?? 0),
delivered = Number(row?.delivered ?? 0);
const units = Number(row?.units ?? 0),
success = Number(row?.successUnits ?? 0);
const revenue = safeMoney(row?.revenue ?? 0n),
cost = safeMoney(row?.cost ?? 0n);
return {
sent: total,
delivered: Number(row?.successMessages ?? 0),
successRate: percentage(delivered, total),
receiptUnits: units,
successUnits: success,
receiptSuccessRate: percentage(success, units),
revenueCents: revenue,
profitCents: revenue - cost,
profitRate: percentage(revenue - cost, revenue),
approximate: Number(row?.approximate ?? 0),
incomplete: Number(row?.incomplete ?? 0),
};
}
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
}
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const rows = await tx.$queryRaw<
Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
todayReturnedCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>
>`
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
return rows.map((r) => ({
...r,
todaySpendCents: safeMoney(r.todaySpendCents),
todayReturnedCents: safeMoney(r.todayReturnedCents),
balanceCents: safeMoney(r.balanceCents),
creditCents: safeMoney(r.creditCents),
}));
}
export async function operationStatus(tx: Prisma.TransactionClient) {
const window = downstreamAlertWindows();
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
await Promise.all([
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
tx.smsBatchTask.count(),
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
tx.cmppDownstreamDelivery.count({
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
}),
]);
return {
taskCount,
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
};
}
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
GROUP BY hour ORDER BY hour`);
const byHour = new Map(rows.map((row) => [row.hour, row]));
return Array.from({ length: 24 }, (_, hour) => ({
hour,
label: String(hour).padStart(2, '0') + ':00',
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
successCount: Number(byHour.get(hour)?.successCount ?? 0),
}));
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
/** Only disposable dashboard projections; never source SMS, receipt or accounting records. */
export async function pruneHomeVersions(tx: Prisma.TransactionClient, now: Date, firstDay: string, version: number) {
const active = await tx.homeSnapshot.aggregate({ where: { expiresAt: { gt: now } }, _min: { version: true } });
const minimum = active._min.version ?? version;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "toVersion"<=${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "queuedDay"<${firstDay}
AND "fromVersion"<${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeSnapshot" WHERE id IN (SELECT id FROM "HomeSnapshot"
WHERE "expiresAt"<${new Date(now.getTime() - 86400000)} LIMIT 1000)`;
}
+83
View File
@@ -0,0 +1,83 @@
import { Prisma } from '@prisma/client';
import { homeFact, type HomeEvent } from './home-fact';
export async function sourceFacts(tx: Prisma.TransactionClient, ids: string[]) {
const [messages, inbox, legacy] = await Promise.all([
tx.smsMessageRecord.findMany({
where: { id: { in: ids } },
include: { submitRecords: true, segmentAudits: true },
}),
tx.upstreamReceiptInbox.findMany({ where: { matchedMessageRecordId: { in: ids }, status: 'matched' } }),
tx.smsReceiptRecord.findMany({ where: { messageRecordId: { in: ids } } }),
]);
const ownedReceiptKeys = new Set(
(
await tx.upstreamReceiptInbox.findMany({
where: { receiptKey: { in: legacy.map((r) => r.receiptKey) } },
select: { receiptKey: true },
})
).map((r) => r.receiptKey),
);
return messages.map((message) => {
const parts = (id: string, submitId: string) =>
message.segmentAudits.filter((p) => p.submitRecordId === id || (!p.submitRecordId && p.submitId === submitId));
const candidates = (gatewayId: string, channelId: string | null) =>
message.submitRecords.filter(
(s) =>
s.channelId === channelId &&
(s.gatewayMessageId === gatewayId || parts(s.id, s.submitId).some((p) => p.gatewayMessageId === gatewayId)),
);
const events: HomeEvent[] = inbox
.filter((i) => i.matchedMessageRecordId === message.id)
.map((i) => {
const direct = message.submitRecords.find((s) => s.id === i.matchedSubmitRecordId);
const possible = candidates(i.gatewayMessageId, i.matchedChannelId ?? i.incomingChannelId);
return {
attemptId: direct?.id ?? (possible.length === 1 ? possible[0].id : null),
gatewayId: i.gatewayMessageId,
status: i.receiptStatus,
at: i.gatewayReceivedAt ?? i.receivedAt,
approximate: !i.gatewayReceivedAt,
};
});
for (const r of legacy.filter((r) => r.messageRecordId === message.id)) {
// An Inbox event (including unresolved/re-associated events) is not a legacy fallback.
if (ownedReceiptKeys.has(r.receiptKey)) continue;
const possible = candidates(r.gatewayMessageId, r.channelId);
const attemptId = possible.length === 1 ? possible[0].id : null;
if (
events.some(
(e) => e.attemptId === attemptId && e.gatewayId === r.gatewayMessageId && e.status === r.receiptStatus,
)
)
continue;
events.push({
attemptId,
gatewayId: r.gatewayMessageId,
status: r.receiptStatus,
at: r.createdAt,
approximate: true,
});
}
return {
message,
fact: homeFact(
message,
message.submitRecords.map((s) => ({
id: s.id,
accepted: s.submitStatus === 'accepted',
gatewayId: s.gatewayMessageId,
costUnitPrice: s.costUnitPrice,
segments: parts(s.id, s.submitId).map((p) => ({
index: p.segmentIndex,
total: p.segmentTotal,
gatewayId: p.gatewayMessageId,
status: p.receiptStatus,
inferred: p.compensationType === 'supplier_message_level_receipt',
})),
})),
events,
),
};
});
}
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Module, Query, Req } from '@nestjs/common';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { PrismaModule } from '../prisma/prisma.module';
import { HomeProjection } from './home-projection';
import { HomeService } from './home.service';
@Controller('admin/operations/home')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Get('summary')
async summary(@Req() req: SessionRequest) {
return this.home.summary(await this.home.authorize(req));
}
@Get('receipt-breakdown')
async receipts(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'receipt');
}
@Get('revenue-breakdown')
async revenue(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'revenue');
}
}
@Module({ imports: [PrismaModule], controllers: [HomeController], providers: [HomeService, HomeProjection] })
export class HomeModule {}
+118
View File
@@ -0,0 +1,118 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
@Injectable()
export class HomeService {
constructor(private readonly db: PrismaService) {}
async authorize(req: SessionRequest) {
const user =
req.authSession?.portal === 'admin' &&
req.sessionUserId &&
(await this.db.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!user) throw new ForbiddenException('无运营首页查看权限');
return user.id;
}
async summary(userId: string, now = new Date()) {
return this.db.$transaction(
async (tx) => {
// Read committed after this lock: no token may reference an already pruned version.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(17100917)`;
const date = todayKey(now);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (!state.initialized || state.seededDay !== date)
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
aggregateHome(tx, date, state.version),
enterpriseRanks(tx, date),
operationStatus(tx),
tx.homeProjectionDirty.count(),
tx.upstreamReceiptInbox.count({
where: {
status: { not: 'matched' },
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
},
}),
hourlySendTrend(tx, date),
]);
const values = metrics(rows[0]);
const summary = {
businessDate: date,
asOf: now.toISOString(),
dataThrough: state.updatedAt.toISOString(),
definitionVersion: 1,
processing:
pending > 0 ||
unresolved > 0 ||
Boolean(state.lastError) ||
now.getTime() - state.updatedAt.getTime() > 60_000,
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
today: values,
enterpriseSpendRanks: ranks,
hourlySendTrend: hourlyTrend,
...status,
};
const snapshot = await tx.homeSnapshot.create({
data: {
id: randomUUID(),
userId,
businessDate: date,
version: state.version,
createdAt: now,
expiresAt: new Date(now.getTime() + 15 * 60_000),
summary,
},
});
return { ...summary, snapshotToken: snapshot.id };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30_000 },
);
}
async breakdown(userId: string, token: string | undefined, kind: 'receipt' | 'revenue', now = new Date()) {
if (!token || !/^[0-9a-f-]{36}$/i.test(token)) throw new BadRequestException('统计快照参数无效');
return this.db.$transaction(
async (tx) => {
const snapshot = await tx.homeSnapshot.findUnique({ where: { id: token } });
if (!snapshot || snapshot.userId !== userId) throw new ForbiddenException('统计快照不可访问');
if (snapshot.expiresAt <= now || snapshot.businessDate !== todayKey(now))
throw new ConflictException('统计快照已过期,请刷新首页后重试');
const rows = await aggregateHome(tx, snapshot.businessDate, snapshot.version, true);
return {
snapshotToken: token,
businessDate: snapshot.businessDate,
items: Array.from({ length: 4 }, (_, offset) => {
const submitDate = addDays(snapshot.businessDate, -offset),
value = metrics(rows.find((r) => r.queuedDay === submitDate));
return kind === 'receipt'
? { submitDate, total: value.receiptUnits, success: value.successUnits, rate: value.receiptSuccessRate }
: {
submitDate,
revenueCents: value.revenueCents,
profitCents: value.profitCents,
rate: value.profitRate,
};
}),
};
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+46 -17
View File
@@ -1,12 +1,7 @@
import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits'; import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits';
const express = require('express') as () => { import express from 'express';
use(...args: unknown[]): void; import * as http from 'node:http';
post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void;
listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null };
};
const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown };
const http = require('node:http') as typeof import('node:http');
describe('configureHttpBodyParsers', () => { describe('configureHttpBodyParsers', () => {
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => { it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
@@ -17,7 +12,7 @@ describe('configureHttpBodyParsers', () => {
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb'); expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb'); expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
expect(use).toHaveBeenCalledTimes(1); expect(use).toHaveBeenCalledTimes(2);
expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function)); expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function));
expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' }); expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' });
expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true }); expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true });
@@ -28,12 +23,16 @@ describe('configureHttpBodyParsers', () => {
configureHttpBodyParsers({ configureHttpBodyParsers({
use: serverApp.use.bind(serverApp), use: serverApp.use.bind(serverApp),
useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) { useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) {
serverApp.use(type === 'json' serverApp.use(
? expressModule.json({ limit: options.limit }) type === 'json'
: expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true })); ? express.json({ limit: options.limit })
: express.urlencoded({ limit: options.limit, extended: options.extended ?? true }),
);
}, },
} as never); } as never);
serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 })); serverApp.post('/api/client/send/imports/preview', (request, response) =>
response.json({ size: (request as typeof request & { rawBody?: Buffer }).rawBody?.length ?? 0 }),
);
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true })); serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => { const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
@@ -47,19 +46,49 @@ describe('configureHttpBodyParsers', () => {
expect(importResponse.status).toBe(200); expect(importResponse.status).toBe(200);
expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) }); expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) });
await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 }); await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 });
const oversized = await postJSON(address.port, '/api/openapi/v1/sms/messages', body);
expect(oversized.status).toBe(413);
expect(JSON.parse(oversized.body)).toMatchObject({ code: 'PAYLOAD_TOO_LARGE', status: 413 });
for (const malformed of ['{"private-marker":', '"private-marker"']) {
const invalid = await postJSON(address.port, '/api/openapi/v1/sms/messages', malformed);
expect(invalid.status).toBe(400);
expect(JSON.parse(invalid.body)).toMatchObject({ code: 'PARAMETER_INVALID', requestId: invalid.requestId });
expect(invalid.requestId).toMatch(/^req_/);
expect(invalid.contentType).toContain('application/problem+json');
expect(invalid.body).not.toContain('private-marker');
}
const ordinary = await postJSON(address.port, '/api/ordinary', '{');
expect(ordinary.status).toBe(400);
expect(ordinary.requestId).toBeUndefined();
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
} }
}); });
}); });
function postJSON(port: number, path: string, body: string) { function postJSON(port: number, path: string, body: string) {
return new Promise<{ status: number; body: string }>((resolve, reject) => { return new Promise<{ status: number; body: string; requestId?: string; contentType?: string }>((resolve, reject) => {
const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => { const request = http.request(
{
hostname: '127.0.0.1',
port,
path,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
},
(response) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk)); response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); response.once('end', () =>
}); resolve({
status: response.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8'),
requestId: response.headers['x-request-id'] as string | undefined,
contentType: response.headers['content-type'],
}),
);
},
);
request.once('error', reject); request.once('error', reject);
request.end(body); request.end(body);
}); });
+9 -10
View File
@@ -1,11 +1,6 @@
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { openApiBodyErrorMiddleware } from './open-api/open-api-body-error.middleware';
const express = require('express') as { import express from 'express';
json(options: {
limit: string;
verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void;
}): (...args: unknown[]) => unknown;
};
export const DEFAULT_JSON_BODY_LIMIT = '2mb'; export const DEFAULT_JSON_BODY_LIMIT = '2mb';
export const IMPORT_JSON_BODY_LIMIT = '25mb'; export const IMPORT_JSON_BODY_LIMIT = '25mb';
@@ -14,12 +9,16 @@ export function configureHttpBodyParsers(app: NestExpressApplication) {
// Import preview/confirmation temporarily carries the source CSV/TSV in // Import preview/confirmation temporarily carries the source CSV/TSV in
// JSON. Give only these endpoints the larger boundary; keeping ordinary // JSON. Give only these endpoints the larger boundary; keeping ordinary
// JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint. // JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint.
app.use('/api/client/send/imports', express.json({ app.use(
'/api/client/send/imports',
express.json({
limit: IMPORT_JSON_BODY_LIMIT, limit: IMPORT_JSON_BODY_LIMIT,
verify(request, _response, buffer) { verify(request, _response, buffer) {
request.rawBody = buffer; (request as typeof request & { rawBody?: Buffer }).rawBody = buffer;
}, },
})); }),
);
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT }); app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true }); app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
app.use('/api/openapi/v1/sms', openApiBodyErrorMiddleware);
} }
@@ -37,6 +37,15 @@ export class InfrastructureMonitoringController {
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() { alertThresholds() {
return this.settings.get(); return this.settings.get();
@@ -1,8 +1,15 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics'; import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { retainedAlerts } from './persistent-alerts';
jest.mock('./persistent-alerts', () => ({
retainAlerts: jest.fn(async (_prisma, alerts) => alerts),
retainedAlerts: jest.fn(),
clearRetainedAlert: jest.fn(),
}));
function success(data: unknown) { function success(data: unknown) {
return { return {
@@ -14,7 +21,12 @@ function success(data: unknown) {
describe('InfrastructureMonitoringService', () => { describe('InfrastructureMonitoringService', () => {
const prisma = { const prisma = {
infrastructureAlertRead: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), findUniqueOrThrow: jest.fn() }, infrastructureAlertRead: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
findUniqueOrThrow: jest.fn(),
},
operationLog: { create: jest.fn() }, operationLog: { create: jest.fn() },
$transaction: jest.fn(), $transaction: jest.fn(),
}; };
@@ -34,9 +46,27 @@ describe('InfrastructureMonitoringService', () => {
}); });
it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => { it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => {
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }), prisma as never)).toThrow('must not contain credentials'); expect(
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }), prisma as never)).toThrow('must use HTTPS'); () =>
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }), prisma as never)).not.toThrow(); new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }),
prisma as never,
),
).toThrow('must not contain credentials');
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }),
prisma as never,
),
).toThrow('must use HTTPS');
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }),
prisma as never,
),
).not.toThrow();
}); });
it('loads real Prometheus vectors, ranges, services and active alerts', async () => { it('loads real Prometheus vectors, ranges, services and active alerts', async () => {
@@ -45,34 +75,52 @@ describe('InfrastructureMonitoringService', () => {
const url = new URL(String(input)); const url = new URL(String(input));
requestedUrls.push(url); requestedUrls.push(url);
if (url.pathname.endsWith('/alerts')) { if (url.pathname.endsWith('/alerts')) {
return success({ alerts: [{ return success({
alerts: [
{
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' }, labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
annotations: { summary: 'CPU持续偏高', threshold: '85%' }, annotations: { summary: 'CPU持续偏高', threshold: '85%' },
state: 'firing', state: 'firing',
activeAt: '2026-08-14T03:00:00.000Z', activeAt: '2026-08-14T03:00:00.000Z',
value: '88.2', value: '88.2',
}] }); },
],
});
} }
const query = url.searchParams.get('query') ?? ''; const query = url.searchParams.get('query') ?? '';
if (url.pathname.endsWith('/query_range')) { if (url.pathname.endsWith('/query_range')) {
return success({ result: [{ metric: {}, values: [[1_765_000_000, '12.5'], [1_765_000_060, '14.5']] }] }); return success({
result: [
{
metric: {},
values: [
[1_765_000_000, '12.5'],
[1_765_000_060, '14.5'],
],
},
],
});
} }
if (query.includes('node_systemd_unit_state')) { if (query.includes('node_systemd_unit_state')) {
return success({ result: [ return success({
result: [
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] }, { metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
] }); ],
});
} }
if (query.includes('cmpp:service_.*')) { if (query.includes('cmpp:service_.*')) {
return success({ result: [ return success({
result: [
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] }, { metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] }, { metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] }, { metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
] }); ],
});
} }
if (query.includes('timestamp(node_uname_info)')) { if (query.includes('timestamp(node_uname_info)')) {
return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] }); return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] });
@@ -87,14 +135,27 @@ describe('InfrastructureMonitoringService', () => {
expect(result.metrics.cpuUsagePercent).toBe(25); expect(result.metrics.cpuUsagePercent).toBe(25);
expect(result.trends.cpuUsagePercent).toHaveLength(2); expect(result.trends.cpuUsagePercent).toHaveLength(2);
expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 }); expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 });
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({ unit: 'redis-server.service', status: 'healthy' }); expect(result.services.find((item) => item.key === 'redis')).toMatchObject({
unit: 'redis-server.service',
status: 'healthy',
});
expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true }); expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true });
expect(result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')?.value).toBe(3); expect(
result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')
?.value,
).toBe(3);
expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' }); expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' });
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5); expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5);
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range')).every((url) => url.searchParams.get('step') === '60')).toBe(true); expect(
expect(requestedUrls.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))?.searchParams.get('query')) requestedUrls
.toContain('cmpp-api\\\\.service'); .filter((url) => url.pathname.endsWith('/query_range'))
.every((url) => url.searchParams.get('step') === '60'),
).toBe(true);
expect(
requestedUrls
.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))
?.searchParams.get('query'),
).toContain('cmpp-api\\\\.service');
}); });
it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => { it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => {
@@ -124,12 +185,36 @@ describe('InfrastructureMonitoringService', () => {
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] }); if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
if (!query.includes('node_filesystem_')) return success({ result: [] }); if (!query.includes('node_filesystem_')) return success({ result: [] });
expect(query).not.toContain('mountpoint="/"'); expect(query).not.toContain('mountpoint="/"');
if (url.pathname.endsWith('/query_range')) return success({ result: [...metrics].reverse().map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })) }); if (url.pathname.endsWith('/query_range'))
return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, query === FILESYSTEM_USAGE_PERCENT ? (metric.mountpoint === '/' ? '91' : '12') : query.includes('avail') ? '9' : '100'] })) }); return success({
result: [...metrics]
.reverse()
.map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })),
});
return success({
result: metrics.map((metric) => ({
metric,
value: [
1765000060,
query === FILESYSTEM_USAGE_PERCENT
? metric.mountpoint === '/'
? '91'
: '12'
: query.includes('avail')
? '9'
: '100',
],
})),
});
}); });
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h'); const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']); expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']);
expect(result.disks[0]).toMatchObject({ usagePercent: 91, totalBytes: 100, availableBytes: 9, trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }] }); expect(result.disks[0]).toMatchObject({
usagePercent: 91,
totalBytes: 100,
availableBytes: 9,
trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }],
});
expect(result.disks[2].trend[0].value).toBe(12); expect(result.disks[2].trend[0].value).toBe(12);
expect(result.metrics.diskUsagePercent).toBe(91); expect(result.metrics.diskUsagePercent).toBe(91);
expect(result.trends.diskUsagePercent[0].value).toBe(91); expect(result.trends.diskUsagePercent[0].value).toBe(91);
@@ -146,20 +231,44 @@ describe('InfrastructureMonitoringService', () => {
if (!query.includes('node_filesystem_')) return success({ result: [] }); if (!query.includes('node_filesystem_')) return success({ result: [] });
if (url.pathname.endsWith('/query_range')) { if (url.pathname.endsWith('/query_range')) {
expect(query).toBe(FILESYSTEM_USAGE_PERCENT); expect(query).toBe(FILESYSTEM_USAGE_PERCENT);
return success({ result: [ return success({
{ metric: data, values: [[1765000000, '82'], [1765000060, 'NaN'], [1765000120, '83.5']] }, result: [
{
metric: data,
values: [
[1765000000, '82'],
[1765000060, 'NaN'],
[1765000120, '83.5'],
],
},
{ metric: root, values: [[1765000000, '91']] }, { metric: root, values: [[1765000000, '91']] },
] }); ],
});
} }
if (query.startsWith('node_filesystem_size_bytes')) return success({ result: [ if (query.startsWith('node_filesystem_size_bytes'))
return success({
result: [
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })), ...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
...['/var/root-bind', '/'].map((mountpoint) => ({ metric: { ...root, mountpoint }, value: [1765000120, '200'] })), ...['/var/root-bind', '/'].map((mountpoint) => ({
] }); metric: { ...root, mountpoint },
if (query === FILESYSTEM_USAGE_PERCENT) return success({ result: [ value: [1765000120, '200'],
{ metric: data, value: [1765000120, '83.5'] }, { metric: root, value: [1765000120, '91'] }, })),
] }); ],
});
if (query === FILESYSTEM_USAGE_PERCENT)
return success({
result: [
{ metric: data, value: [1765000120, '83.5'] },
{ metric: root, value: [1765000120, '91'] },
],
});
expect(query).toContain('min by (instance, device, fstype)'); expect(query).toContain('min by (instance, device, fstype)');
return success({ result: [{ metric: data, value: [1765000120, '16.5'] }, { metric: root, value: [1765000120, '18'] }] }); return success({
result: [
{ metric: data, value: [1765000120, '16.5'] },
{ metric: root, value: [1765000120, '18'] },
],
});
}); });
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never); const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
const result = await service.overview('1h'); const result = await service.overview('1h');
@@ -169,7 +278,13 @@ describe('InfrastructureMonitoringService', () => {
expect(result.metrics.diskUsagePercent).toBe(91); expect(result.metrics.diskUsagePercent).toBe(91);
expect(result.trends.diskUsagePercent[0].value).toBe(91); expect(result.trends.diskUsagePercent[0].value).toBe(91);
const disk = result.disks[1]; const disk = result.disks[1];
expect(disk).toMatchObject({ id: filesystemIdentity(data), mountpoint: '/data', totalBytes: 100, availableBytes: 16.5, usagePercent: 83.5 }); expect(disk).toMatchObject({
id: filesystemIdentity(data),
mountpoint: '/data',
totalBytes: 100,
availableBytes: 16.5,
usagePercent: 83.5,
});
expect(disk.mountpoints).toEqual(['/data', '/var/lib/minio', '/var/lib/pgsql', '/var/lib/redis']); expect(disk.mountpoints).toEqual(['/data', '/var/lib/minio', '/var/lib/pgsql', '/var/lib/redis']);
expect(disk.trend.map((point) => point.value)).toEqual([82, 83.5]); expect(disk.trend.map((point) => point.value)).toEqual([82, 83.5]);
mounts.reverse(); mounts.reverse();
@@ -198,33 +313,77 @@ describe('InfrastructureMonitoringService', () => {
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h'); const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
expect(result.disks).toHaveLength(4); expect(result.disks).toHaveLength(4);
expect(new Set(result.disks.map((disk) => disk.id)).size).toBe(4); expect(new Set(result.disks.map((disk) => disk.id)).size).toBe(4);
expect(result.disks.every((disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0)).toBe(true); expect(
result.disks.every(
(disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0,
),
).toBe(true);
expect(result.metrics.diskUsagePercent).toBeNull(); expect(result.metrics.diskUsagePercent).toBeNull();
expect(result.trends.diskUsagePercent).toEqual([]); expect(result.trends.diskUsagePercent).toEqual([]);
}); });
it('excludes only the current alert occurrence after the current administrator marks it read', async () => { it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' }; const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' };
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24); const fingerprint = createHash('sha256')
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' }] })); .update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))))
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') }]); .digest('hex')
.slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(
success({
alerts: [
{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' },
],
}),
);
prisma.infrastructureAlertRead.findMany.mockResolvedValue([
{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') },
]);
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never); const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 }); await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 });
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') }]); prisma.infrastructureAlertRead.findMany.mockResolvedValue([
{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') },
]);
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 }); await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 });
}); });
it('upserts an idempotent per-user read record only for a currently active occurrence', async () => { it('upserts an idempotent per-user read record only for a currently active occurrence', async () => {
const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' }; const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' };
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24); const fingerprint = createHash('sha256')
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' }] })); .update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))))
prisma.$transaction.mockResolvedValue([{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') }, {}]); .digest('hex')
.slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(
success({
alerts: [
{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' },
],
}),
);
prisma.$transaction.mockResolvedValue([
{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') },
{},
]);
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never); const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({ fingerprint, acknowledged: true }); jest.mocked(retainedAlerts).mockResolvedValue([
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) })); {
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow('已结束或已重新触发'); fingerprint,
startedAt: '2026-08-16T02:00:00.000Z',
name: 'QaCritical',
severity: 'critical',
} as never,
]);
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({
fingerprint,
acknowledged: true,
});
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) }),
);
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow(
'已结束或已重新触发',
);
}); });
}); });
@@ -1,3 +1,4 @@
import { retainAlerts, retainedAlerts, clearRetainedAlert } from './persistent-alerts';
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@@ -202,6 +203,8 @@ 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( constructor(
config: ConfigService, config: ConfigService,
@@ -211,6 +214,35 @@ export class InfrastructureMonitoringService {
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();
@@ -220,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';
@@ -250,7 +282,7 @@ export class InfrastructureMonitoringService {
alerts, alerts,
}; };
} catch (error) { } catch (error) {
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断 // 客户端保留最后成功快照;采集失败不推断告警恢复
this.logger.warn( this.logger.warn(
`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`, `Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
); );
@@ -260,10 +292,7 @@ export class InfrastructureMonitoringService {
async notificationSummary(userId?: string) { async notificationSummary(userId?: string) {
try { try {
const alerts = await this.attachReadState( const alerts = await this.attachReadState(await this.loadRetainedAlerts(), userId);
this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')),
userId,
);
const unreadAlerts = alerts.filter((item) => !item.acknowledged); const unreadAlerts = alerts.filter((item) => !item.acknowledged);
return { return {
count: unreadAlerts.length, count: unreadAlerts.length,
@@ -281,7 +310,7 @@ 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( const current = activeAlerts.find(
(item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime(), (item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime(),
); );
@@ -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(),
};
});
}
+17 -8
View File
@@ -26,20 +26,26 @@ async function bootstrap() {
configureHttpBodyParsers(app); configureHttpBodyParsers(app);
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle('CMPP Platform API') .setTitle('聆界短信平台 API')
.setDescription('First-version CMPP SMS platform API') .setDescription('聆界短信平台 API')
.setVersion('0.1.0') .setVersion('0.1.0')
.build(); .build();
const document = SwaggerModule.createDocument(app, swaggerConfig); const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
const clientDocument = SwaggerModule.createDocument(app, new DocumentBuilder() const clientDocument = SwaggerModule.createDocument(
.setTitle('CMPP短信平台 HTTP 客户接口') app,
new DocumentBuilder()
.setTitle('聆界短信平台 HTTP 客户接口')
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口') .setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
.setVersion('1.0.0') .setVersion('1.0.0')
.build(), { include: [OpenApiModule] }); .build(),
clientDocument.paths = Object.fromEntries(Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/'))); { include: [OpenApiModule] },
SwaggerModule.setup('api/client-docs', app, clientDocument); );
clientDocument.paths = Object.fromEntries(
Object.entries(clientDocument.paths).filter(([path]) => path.startsWith('/api/openapi/v1/')),
);
SwaggerModule.setup('api/client-docs', app, clientDocument, { ui: false });
const port = Number(process.env.API_PORT ?? 3000); const port = Number(process.env.API_PORT ?? 3000);
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。 // 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
@@ -55,7 +61,10 @@ async function bootstrap() {
response.writeHead(404).end(); response.writeHead(404).end();
return; return;
} }
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' }); response.writeHead(200, {
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(metrics.render()); response.end(metrics.render());
}); });
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/. // Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
+2
View File
@@ -1,3 +1,4 @@
import { renderCompletionMetrics } from '../send-chain/completion-metrics';
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { monitorEventLoopDelay } from 'node:perf_hooks'; import { monitorEventLoopDelay } from 'node:perf_hooks';
@@ -314,6 +315,7 @@ export class MetricsService implements OnModuleDestroy {
lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope })); lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope }));
} }
this.eventLoopDelay.reset(); this.eventLoopDelay.reset();
lines.push(...renderCompletionMetrics());
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
} }
+182
View File
@@ -0,0 +1,182 @@
.http-developer-docs {
margin: 0;
color: #1f2937;
background: #f6f7f9;
font:
14px/1.6 system-ui,
sans-serif;
}
.http-developer-docs * {
box-sizing: border-box;
}
.http-developer-docs .http-doc-header {
padding: 24px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
display: flex;
gap: 20px;
justify-content: space-between;
align-items: center;
}
.http-developer-docs h1 {
font-size: 24px;
margin: 8px 0;
}
.http-developer-docs h2 {
font-size: 20px;
margin: 0 0 16px;
}
.http-developer-docs h3 {
font-size: 16px;
margin: 20px 0 12px;
}
.http-developer-docs p {
overflow-wrap: anywhere;
}
.http-developer-docs a {
color: #2563eb;
text-decoration: none;
overflow-wrap: anywhere;
}
.http-developer-docs a:hover {
text-decoration: underline;
}
.http-developer-docs .http-doc-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.http-developer-docs .http-doc-layout {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
}
.http-developer-docs nav {
padding: 20px;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
}
.http-developer-docs nav a {
display: block;
padding: 7px 0;
font-size: 13px;
}
.http-developer-docs nav label {
display: block;
margin-top: 20px;
}
.http-developer-docs input {
width: 100%;
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
.http-developer-docs main {
min-width: 0;
}
.http-developer-docs section {
display: block;
border-bottom: 1px solid #e5e7eb;
scroll-margin-top: 20px;
}
.http-developer-docs section[hidden] {
display: none;
}
.http-developer-docs .http-doc-body {
padding: 24px;
background: #fff;
min-width: 0;
}
.http-developer-docs .http-doc-sample {
margin-bottom: 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.http-developer-docs .http-doc-sample-bar {
padding: 10px;
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #6b7280;
}
.http-developer-docs button {
padding: 5px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #1f2937;
background: #fff;
cursor: pointer;
flex-shrink: 0;
}
.http-developer-docs button:focus-visible,
.http-developer-docs a:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.http-developer-docs pre {
margin: 0;
padding: 16px;
overflow: auto;
max-height: 560px;
font-size: 13px;
background: #f4f6f8;
}
.http-developer-docs code {
font-family: ui-monospace, monospace;
overflow-wrap: anywhere;
}
.http-developer-docs .http-doc-table {
overflow-x: auto;
}
.http-developer-docs table {
border-collapse: collapse;
min-width: 100%;
}
.http-developer-docs td {
padding: 9px;
border: 1px solid #e5e7eb;
min-width: 100px;
overflow-wrap: anywhere;
}
.http-developer-docs tr:first-child {
font-weight: 600;
background: #f4f6f8;
}
.http-developer-docs .http-doc-copy-status {
position: fixed;
bottom: 12px;
right: 12px;
max-width: 80vw;
background: #fff;
border-radius: 6px;
padding: 8px;
box-shadow: 0 2px 12px #0002;
}
.http-developer-docs .http-doc-copy-status:empty {
display: none;
}
@media (width <= 700px) {
.http-developer-docs .http-doc-header {
padding: 16px;
display: block;
}
.http-developer-docs .http-doc-layout {
grid-template-columns: minmax(0, 1fr);
}
.http-developer-docs nav {
position: static;
max-height: none;
padding: 16px;
}
.http-developer-docs .http-doc-body {
padding: 16px;
}
}
+31
View File
@@ -0,0 +1,31 @@
/* global document, navigator, window, Event */
const copyStatus = document.getElementById('copy-status');
document.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-copy]');
if (!button) return;
const content = document.getElementById(button.dataset.copy);
try {
if (!navigator.clipboard) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(content.textContent);
copyStatus.textContent = '已复制示例;未执行任何请求。';
} catch {
const range = document.createRange(); range.selectNodeContents(content);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
copyStatus.textContent = '自动复制不可用,已选中示例,请手动复制。';
}
});
const search = document.getElementById('doc-search');
search.addEventListener('input', () => {
const term = search.value.trim().toLowerCase(); let visible = 0;
document.querySelectorAll('[data-doc-section]').forEach((section) => {
section.hidden = !!term && !section.textContent.toLowerCase().includes(term);
if (!section.hidden) visible++;
});
document.getElementById('no-results').hidden = visible !== 0;
document.getElementById('search-status').textContent = term ? `${visible} 个章节匹配` : '';
});
document.querySelector('nav').addEventListener('click', (event) => {
if (!event.target.closest('a')) return;
search.value = ''; search.dispatchEvent(new Event('input'));
});
if (window.innerWidth <= 700) document.querySelector('nav details').open = false;
+67
View File
@@ -0,0 +1,67 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
export function httpDocVersion(markdown: string) {
const metadata = markdown.split(/\r?\n/).find((line) => line.startsWith('**接口版本:')) ?? '';
const version = /接口版本:([a-zA-Z0-9.-]+)/.exec(metadata)?.[1];
const revision = /\b\d{4}-\d{2}-\d{2}\b/.exec(metadata)?.[0];
if (!version || !revision) throw new Error('HTTP document metadata is missing');
return version + ' / ' + revision;
}
export function readHttpGuide() {
return readFileSync(resolve(__dirname, '../../../../docs/client-http-api-guide.md'), 'utf8');
}
function asset(name: string) {
return readFileSync(resolve(__dirname, '../../../src/open-api/docs', name), 'utf8');
}
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!);
}
function inline(value: string): string {
// Escape first; raw HTML can never execute. Only HTTP(S) and local anchors become links.
return escapeHtml(value).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, url: string) => /^(https?:\/\/|#)/i.test(url) ? `<a href="${url}" rel="noreferrer">${label}</a>` : label);
}
export function renderHttpGuide(markdown: string, origin: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const sections: Array<{ id: string; title: string; body: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[] };
sections.push(current);
let code: string[] | null = null;
let language = '';
let sampleTitle = '示例';
let table = false;
let sampleCount = 0;
const closeTable = () => { if (table) { current.body.push('</tbody></table></div>'); table = false; } };
for (const line of lines) {
if (code) {
if (/^```/.test(line)) {
current.body.push(`<div class="http-doc-sample"><div class="http-doc-sample-bar"><span>${escapeHtml(sampleTitle)} · ${escapeHtml(language || '示例')} · 仅供阅读,不执行请求</span><button type="button" data-copy="sample-${++sampleCount}">复制代码</button></div><pre id="sample-${sampleCount}" tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre></div>`);
code = null;
} else code.push(line);
continue;
}
if (/^```/.test(line)) { closeTable(); code = []; language = line.slice(3).trim(); continue; }
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) {
closeTable();
sampleTitle = heading[2];
if (heading[1].length === 2) {
current = { id: 'section-' + sections.length, title: heading[2], body: [] };
sections.push(current);
} else if (heading[1].length > 2) current.body.push(`<h3>${inline(heading[2])}</h3>`);
continue;
}
if (/^\s*\|/.test(line)) {
if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
if (!table) { current.body.push('<div class="http-doc-table"><table><tbody>'); table = true; }
current.body.push('<tr>' + line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => `<td>${inline(cell.trim())}</td>`).join('') + '</tr>');
continue;
}
closeTable();
const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1');
if (caption.endsWith('') && caption.length < 70) sampleTitle = caption.replace(/$/, '');
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
}
closeTable();
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信平台 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信平台 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
}
+100 -15
View File
@@ -1,19 +1,61 @@
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common'; import {
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; CanActivate,
ExecutionContext,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
OnModuleDestroy,
Optional,
UnauthorizedException,
} from '@nestjs/common';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import IORedis from 'ioredis'; import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto'; import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types'; import type { OpenApiRequestLike } from './open-api.types';
import { openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service'; import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable() @Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy { export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis; private redis?: IORedis;
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {} constructor(
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
request.openApiRequestId = 'req_' + randomUUID();
context
.switchToHttp()
.getResponse<{ setHeader: (name: string, value: string) => void }>()
.setHeader('X-Request-Id', request.openApiRequestId);
const startedAt = Date.now();
try {
return await this.authenticate(context);
} catch (error) {
const failure = publicOpenApiFailure(error);
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'authentication',
status: 'failed',
requestId: request.openApiRequestId,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
detail: { method: request.method, path: '/api/openapi/v1/sms' },
});
throw error;
}
}
private async authenticate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>(); const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const accessKey = header(request, 'x-app-key'); const accessKey = header(request, 'x-app-key');
const timestampText = header(request, 'x-timestamp'); const timestampText = header(request, 'x-timestamp');
@@ -40,26 +82,45 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' }); throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
} }
const timestamp = Number(timestampText); const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) { if (
!Number.isFinite(timestamp) ||
Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000
) {
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED'); await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' }); throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
} }
const sourceIp = requestIp(request); const sourceIp = requestIp(request);
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) { if (
credential.application.httpIpAllowlist.length > 0 &&
(!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))
) {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' }); throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
} }
const path = (request.originalUrl ?? request.url ?? '').split('?')[0]; const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex'); const expected = openApiSignature(
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n'); decryptSecret(credential.secretEncrypted),
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex'); request.method,
path,
timestampText,
nonce,
request.rawBody,
);
const expectedBuffer = Buffer.from(expected, 'hex'); const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0); const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
? Buffer.from(suppliedSignature, 'hex')
: Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) { if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID'); await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' }); throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
} }
const redis = this.getRedis(); const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX'); const nonceAccepted = await redis.set(
`openapi:nonce:${credential.id}:${nonce}`,
'1',
'EX',
config.timestampToleranceSeconds * 2,
'NX',
);
if (nonceAccepted !== 'OK') { if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED'); await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' }); throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
@@ -78,22 +139,41 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
accessKey, accessKey,
sourceIp, sourceIp,
}; };
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } }); await this.prisma.httpApiCredential.update({
where: { id: credential.id },
data: { lastUsedAt: new Date(), lastUsedIp: sourceIp },
});
return true; return true;
} }
onModuleDestroy() { this.redis?.disconnect(); } onModuleDestroy() {
this.redis?.disconnect();
}
private getRedis() { private getRedis() {
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 }); this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis; return this.redis;
} }
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) { private async recordFailure(
ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt',
request: OpenApiRequestLike,
account: string | undefined,
resultCode: string,
) {
const sourceIp = requestIp(request); const sourceIp = requestIp(request);
if (!sourceIp) return; if (!sourceIp) return;
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。 // 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined); await this.security
.recordEvent({
ruleCode,
sourceIp,
account,
resultCode,
protocol: 'http',
path: (request.originalUrl ?? request.url ?? '').split('?')[0],
})
.catch(() => undefined);
} }
} }
@@ -105,7 +185,12 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) { function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim(); const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, ''); const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean)); const trustedProxies = new Set(
(process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, ''); return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
} }
@@ -0,0 +1,21 @@
import { randomUUID } from 'node:crypto';
import { sendOpenApiProblem, type OpenApiProblemResponse } from './open-api.protocol';
/** Mounted after parsers, before routes; never expose parser errors containing raw input. */
export function openApiBodyErrorMiddleware(
error: unknown,
request: { openApiRequestId?: string },
response: OpenApiProblemResponse,
next: (error: unknown) => void,
) {
const type = error && typeof error === 'object' && 'type' in error ? error.type : undefined;
const failures: Record<string, { status: number; code: string; message: string }> = {
'entity.parse.failed': { status: 400, code: 'PARAMETER_INVALID', message: '请求体必须为有效的JSON对象' },
'entity.too.large': { status: 413, code: 'PAYLOAD_TOO_LARGE', message: '请求体超过大小限制' },
'charset.unsupported': { status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: '请求体字符集不受支持' },
'encoding.unsupported': { status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: '请求体编码不受支持' },
};
const failure = typeof type === 'string' && Object.hasOwn(failures, type) ? failures[type] : undefined;
if (!failure) return next(error);
sendOpenApiProblem(response, (request.openApiRequestId ??= `req_${randomUUID()}`), failure);
}
@@ -0,0 +1,66 @@
import { PrismaService } from '../prisma/prisma.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
@Module({
controllers: [OpenApiController],
providers: [
{ provide: PrismaService, useValue: {} },
{ provide: SecurityDetectionService, useValue: {} },
{ provide: OpenApiService, useValue: {} },
{ provide: OpenApiAuthGuard, useValue: {} },
{ provide: OpenApiTraceInterceptor, useValue: {} },
],
})
class ContractModule {}
describe('generated public OpenAPI contract', () => {
it('describes exactly four operations, seven query fields, nullable IDs and both callbacks', async () => {
const app = await NestFactory.create(ContractModule, { logger: false, abortOnError: false });
try {
app.setGlobalPrefix('api');
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder().setTitle('contract').setVersion('v1').build(),
);
expect(
Object.values(document.paths).reduce(
(count, path) => count + Object.keys(path).filter((key) => ['get', 'post'].includes(key)).length,
0,
),
).toBe(4);
const post = document.paths['/api/openapi/v1/sms/messages'].post!;
const headers = post.parameters as Array<{ name: string; in: string; required?: boolean }>;
expect(headers.filter((field) => field.name.toLowerCase() === 'idempotency-key')).toHaveLength(1);
expect(headers.some((field) => field.name.toLowerCase() === 'user-agent' && field.required)).toBe(false);
const query = document.paths['/api/openapi/v1/sms/uplinks'].get!.parameters as Array<{
name: string;
in: string;
}>;
expect(
query
.filter((field) => field.in === 'query')
.map((field) => field.name)
.sort(),
).toEqual(['accessNumber', 'cursor', 'endTime', 'keyword', 'limit', 'mobile', 'startTime']);
for (const path of Object.values(document.paths)) {
if (path.get) expect(path.get.responses['200']).toHaveProperty('content.application/json.schema');
}
expect(document.components!.schemas!.OpenApiSendMessageResponseDto).toMatchObject({
properties: { clientMessageId: { type: 'string', nullable: true } },
});
expect(document.components!.schemas).toHaveProperty('OpenApiReceiptEventDto');
expect(document.components!.schemas).toHaveProperty('OpenApiUplinkEventDto');
const detail = JSON.stringify(document.components!.schemas!.OpenApiUplinkDetailDto);
expect(detail).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,33 @@
import { Controller, Get, Header, Query, Res } from '@nestjs/common';
import { httpDocVersion, readHttpGuide, renderHttpGuide } from './docs/reader';
@Controller('client-docs')
export class OpenApiDocsController {
@Get()
@Header('Cache-Control', 'no-cache')
getGuide(
@Query('format') format: string | undefined,
@Res()
response: {
type: (value: string) => void;
setHeader: (name: string, value: string) => void;
send: (value: string) => void;
},
) {
const markdown = readHttpGuide();
response.setHeader('X-Document-Version', httpDocVersion(markdown));
response.setHeader('X-Content-Type-Options', 'nosniff');
if (format === 'md') {
response.type('text/markdown; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="client-http-api-guide.md"');
response.send(markdown);
return;
}
response.type('text/html; charset=utf-8');
response.setHeader(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
response.send(renderHttpGuide(markdown, process.env.HTTP_API_PUBLIC_ORIGIN?.replace(/\/+$/, '') ?? ''));
}
}
+29 -13
View File
@@ -1,20 +1,36 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { publicOpenApiFailure, sendOpenApiProblem } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Catch() @Catch()
export class OpenApiExceptionFilter implements ExceptionFilter { export class OpenApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(OpenApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<{ status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } } }>(); const http = host.switchToHttp();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const request = http.getRequest<OpenApiRequestLike>();
const value = exception instanceof HttpException ? exception.getResponse() : {}; const response = http.getResponse<{
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {}; setHeader: (name: string, value: string) => void;
const rawMessage = object.message ?? (exception instanceof Error ? exception.message : 'Internal server error'); status: (code: number) => { type: (value: string) => { send: (body: unknown) => void } };
const detail = Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage); }>();
response.status(status).type('application/problem+json').send({ const requestId = (request.openApiRequestId ??= `req_${randomUUID()}`);
type: `https://cmpp-platform.local/problems/${String(object.code ?? 'REQUEST_FAILED').toLowerCase()}`, const failure = publicOpenApiFailure(exception);
title: String(object.error ?? HttpStatus[status] ?? 'Request failed'), // Dependency messages may include SQL values or credentials; keep safe correlation only.
status, if (failure.status >= 500)
code: String(object.code ?? 'REQUEST_FAILED'), this.logger.error({
detail, requestId,
code: failure.code,
errorType: exception instanceof Error ? exception.name : 'UnknownError',
stack:
exception instanceof Error
? exception.stack
?.split('\n')
.filter((line) => /^\s*at /.test(line))
.slice(0, 8)
.join('\n')
: undefined,
}); });
sendOpenApiProblem(response, requestId, failure);
} }
} }
@@ -0,0 +1,79 @@
import { BadRequestException } from '@nestjs/common';
import * as dns from 'node:dns/promises';
import { parseOpenApiDate } from './open-api.protocol';
import { OpenApiService, resolveWebhookTarget } from './open-api.service';
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }));
describe('public HTTP input boundaries', () => {
it.each([
'2026-02-30',
'2025-02-29T00:00:00Z',
'2026-04-31T00:00:00+08:00',
'2026-01-01T24:00:00Z',
'2026-01-01T12:60:00Z',
'2026-01-01T00:00:00+24:00',
'2026-01-01T00:00:00',
'09/14/2026',
'',
'2026-00-01',
'2026-01-00',
])('rejects invalid ISO calendar/time %s', (value) => {
expect(() => parseOpenApiDate(value)).toThrow(BadRequestException);
});
it.each([
['2024-02-29', '2024-02-29T00:00:00.000Z'],
['2026-09-14T08:00:00+08:00', '2026-09-14T00:00:00.000Z'],
['2026-09-14T00:00Z', '2026-09-14T00:00:00.000Z'],
['2000-02-29T00:00:00.123Z', '2000-02-29T00:00:00.123Z'],
['2026-09-14T00:00:00.123456789Z', '2026-09-14T00:00:00.123Z'],
])('preserves valid calendar dates/timezones %s', (value, expected) => {
expect(parseOpenApiDate(value).toISOString()).toBe(expected);
});
it('rejects an impossible cursor date before calling PostgreSQL', async () => {
const prisma = { smsUplinkMessage: { findMany: jest.fn() } };
const service = new OpenApiService(prisma as never, {} as never);
const cursor = Buffer.from(JSON.stringify(['2026-02-30T00:00:00Z', 'id'])).toString('base64url');
await expect(
service.listUplinks({ config: { uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 } } as never, {
cursor,
}),
).rejects.toMatchObject({ response: { code: 'CURSOR_INVALID' } });
expect(prisma.smsUplinkMessage.findMany).not.toHaveBeenCalled();
});
it.each([
'::1',
'::',
'fd00::1',
'fe80::1',
'::ffff:127.0.0.1',
'::ffff:7f00:1',
'::ffff:192.168.1.1',
'0:0:0:0:0:ffff:0a00:0001',
])('rejects private IPv6 literal %s without DNS', async (address) => {
const lookup = jest.mocked(dns.lookup);
try {
await expect(resolveWebhookTarget(`https://[${address}]/hook`, true)).rejects.toThrow(BadRequestException);
expect(lookup).not.toHaveBeenCalled();
} finally {
lookup.mockReset();
}
});
it('preserves public IPv6 addresses for TLS URLs and fixed-address connection', async () => {
await expect(resolveWebhookTarget('https://[2606:4700:4700::1111]/hook', true)).resolves.toMatchObject({
address: '2606:4700:4700::1111',
family: 6,
});
});
it('returns a controlled 400 on DNS failure without exposing resolver diagnostics', async () => {
const lookup = jest.mocked(dns.lookup).mockRejectedValueOnce(new Error('ENOTFOUND internal-resolver-detail'));
try {
await expect(resolveWebhookTarget('https://unavailable.invalid/hook', true)).rejects.toMatchObject({
status: 400,
message: 'Webhook域名未解析到可用地址',
});
} finally {
lookup.mockReset();
}
});
});
@@ -0,0 +1,202 @@
import { BadRequestException, HttpException } from '@nestjs/common';
import { Job } from 'bullmq';
import { createHash, createHmac } from 'node:crypto';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { OpenApiService } from './open-api.service';
import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { renderHttpGuide } from './docs/reader';
const auth = {
application: { id: 'own-app', tenantId: 'own-tenant' },
config: { sendEnabled: true, uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 },
};
describe('HTTP API remediation boundaries', () => {
it.each([
{ mobile: 'abc' },
{ mobile: '1' },
{ mobile: '' },
{ mobile: '138001380001' },
{ accessNumber: '<script>' },
{ accessNumber: '' },
{ accessNumber: '1'.repeat(22) },
])('rejects malformed uplink number filters before querying: %o', async (query) => {
const findMany = jest.fn();
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
await expect(service.listUplinks(auth as never, query)).rejects.toMatchObject({ status: 400 });
expect(findMany).not.toHaveBeenCalled();
});
it('accepts numeric uplink filter boundaries without changing exact matches', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, undefined as never);
await service.listUplinks(auth as never, { mobile: '13800138000', accessNumber: '1'.repeat(21) });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ phoneNumber: '13800138000', destId: '1'.repeat(21) }),
}),
);
});
it('matches the published fixed GET signature vector', () => {
expect(
openApiSignature(
'doc-example-secret',
'GET',
'/api/openapi/v1/sms/uplinks',
'1789344000',
'550e8400-e29b-41d4-a716-446655440000',
undefined,
),
).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
});
it('preserves internal idempotency hashes but signs exact POST UTF8 bytes', () => {
expect(openApiBodyHash(undefined, undefined)).toBe(
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
);
const raw = Buffer.from('{ "content": "中文\\n正文" }');
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw.toString('utf8')].join('\n');
expect(
openApiSignature('offline-secret', 'post', '/api/openapi/v1/sms/messages?ignored=1', '123', 'nonce-0001', raw),
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
for (const separator of ['\r\n', '\\n'])
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw),
);
});
it('uses stable colon-free job IDs accepted by the actual BullMQ validator', () => {
const validate = (jobId: string) =>
(Job.prototype as unknown as { validateOptions: (data: unknown) => void }).validateOptions.call(
{ opts: { jobId } },
{ data: '{}' },
);
expect(() => validate('delivery:2')).toThrow('Custom Id cannot contain :');
expect(() => validate(webhookJobId('delivery:legacy', 2))).not.toThrow();
expect(webhookJobId('delivery:legacy', 2)).toBe(webhookJobId('delivery:legacy', 2));
expect(webhookJobId('delivery:legacy', 2)).not.toBe(webhookJobId('delivery:legacy', 3));
});
it.each([new Error('postgres://private:secret@host/secret'), new HttpException('private-secret', 503)])(
'does not expose dependency failures',
(error) => {
expect(publicOpenApiFailure(error)).toEqual({
status: 500,
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
},
);
it('keeps first and replayed failure bodies consistent', () => {
const first = publicOpenApiFailure(new Error('private'));
const replay = publicOpenApiFailure(new HttpException({ code: first.code, message: first.message }, first.status));
expect(replay).toEqual(first);
expect(publicOpenApiFailure(new BadRequestException({ code: 'LIMIT_INVALID', message: 'bad limit' })).code).toBe(
'LIMIT_INVALID',
);
});
it('returns a safe request ID with the problem response', () => {
const send = jest.fn();
const setHeader = jest.fn();
const response = { setHeader, status: jest.fn(() => ({ type: () => ({ send }) })) };
new OpenApiExceptionFilter().catch(new Error('secret'), {
switchToHttp: () => ({ getRequest: () => ({ openApiRequestId: 'req-test' }), getResponse: () => response }),
} as never);
expect(setHeader).toHaveBeenCalledWith('X-Request-Id', 'req-test');
expect(send).toHaveBeenCalledWith(expect.objectContaining({ code: 'INTERNAL_ERROR', requestId: 'req-test' }));
expect(JSON.stringify(send.mock.calls)).not.toContain('secret');
});
it.each([1, {}, [], 'x'.repeat(129)])(
'rejects invalid clientMessageId before persistence',
async (clientMessageId) => {
const service = new OpenApiService({} as never, {} as never);
await expect(
service.sendMessage(auth as never, { mobile: '13800138000', content: '示例', clientMessageId } as never, {
idempotencyKey: 'offline-0001',
bodyHash: 'hash',
}),
).rejects.toMatchObject({ status: 400 });
},
);
it.each(['1.5', '0', '-1', 'NaN', 'Infinity', '', '9999999999999999999'])(
'rejects invalid limit %s before Prisma',
async (limit) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { limit })).rejects.toMatchObject({ status: 400 });
},
);
it.each([
'not-base64!',
Buffer.from(JSON.stringify(['2026-09-14', {}])).toString('base64url'),
Buffer.from(JSON.stringify(['bad-date', 'row'])).toString('base64url'),
])('rejects malformed cursor', async (cursor) => {
const service = new OpenApiService({} as never, {} as never);
await expect(service.listUplinks(auth as never, { cursor })).rejects.toMatchObject({ status: 400 });
});
it('projects only public detail fields and the authenticated tenant/application', async () => {
const findFirst = jest.fn().mockResolvedValue({ id: 'uplink' });
const service = new OpenApiService({ smsUplinkMessage: { findFirst } } as never, {} as never);
await service.getUplink(auth as never, 'uplink');
expect(findFirst).toHaveBeenCalledWith({
where: { id: 'uplink', applicationId: 'own-app', tenantId: 'own-tenant', matchStatus: 'matched' },
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
});
expect(JSON.stringify(findFirst.mock.calls)).not.toMatch(/channelId|gatewayMessageId|eventId|matchReason/);
});
it('retains application page-size clipping and a real empty response', async () => {
const findMany = jest.fn().mockResolvedValue([]);
const service = new OpenApiService({ smsUplinkMessage: { findMany } } as never, {} as never);
await expect(service.listUplinks(auth as never, { limit: '1000' })).resolves.toEqual({
items: [],
nextCursor: null,
});
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 101 }));
});
it('never rebuilds an interrupted or uncertain request', async () => {
const send = jest.fn();
const service = new OpenApiService(
{
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'hash', status: 'requires_review' }) },
} as never,
{ createHttpBatchTask: send } as never,
);
await expect(
service.sendMessage(
auth as never,
{ mobile: '13800138000', content: '示例' },
{ idempotencyKey: 'offline-0001', bodyHash: 'hash' },
),
).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REQUEST_REQUIRES_REVIEW' }) });
expect(send).not.toHaveBeenCalled();
});
it('keeps named code examples beside their source paragraphs', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n**签名原文:**\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
'',
);
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
expect(html).toContain('签名原文 · text');
expect(html).not.toContain('data-show-sample');
expect(html).not.toContain('<aside');
expect(html).not.toMatch(/>示例 \d+</);
});
it('renders escaped MD and code, without executable document HTML or unsafe links', () => {
const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 接入\n<script>alert(1)</script>\n[bad](javascript:alert)\n```html\n<img src=x onerror=alert(1)>\n```',
'https://example.test',
);
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('href="javascript:');
expect(html).not.toContain('<img src=x');
expect(html).toContain('data-copy="sample-1"');
});
});
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { createHash, createHmac } from 'node:crypto';
import { openApiSignature } from './open-api.protocol';
describe('raw-body request signing contract', () => {
const path = '/api/openapi/v1/sms/messages';
const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db';
const body = Buffer.from(
'{"mobile":"13800138000","content":"【示例签名】您的验证码是1234565分钟内有效。","clientMessageId":"doc-example-20260914-0001"}',
);
const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL';
const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw);
it('matches the independently computed published POST vector', () => {
expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124');
});
it('does not accept legacy body digests or changed body bytes', () => {
const legacy = createHmac('sha256', secret)
.update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n'))
.digest('hex');
expect(sign(body)).not.toBe(legacy);
for (const changed of [
Buffer.concat([body, Buffer.from('\n')]),
Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)),
Buffer.from(body.toString().replace('123456', '654321')),
]) {
expect(sign(changed)).not.toBe(sign(body));
}
});
it('rejects missing POST raw bytes instead of reconstructing JSON', () => {
expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体');
});
it('rejects nonempty GET bodies and distinguishes a trailing LF', () => {
const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce];
const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]);
expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex'));
expect(actual).not.toBe(
createHmac('sha256', secret)
.update(fields.join('\n') + '\n')
.digest('hex'),
);
expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文');
});
it('executes both handbook examples and verifies every complete request packet', () => {
const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace(
/\r\n/g,
'\n',
);
expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例');
expect(guide).not.toContain('### 2.4');
const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
expect(scripts).toHaveLength(2);
for (const script of scripts) {
const outputs: string[] = [];
runInNewContext(script[1], {
Buffer,
require: () => ({ createHmac }),
console: { log: (value: string) => outputs.push(value) },
});
expect(outputs).toHaveLength(1);
expect(guide).toContain(outputs[0]);
}
const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)];
expect(packets).toHaveLength(5);
for (const [, packet] of packets) {
const split = packet.indexOf('\n\n');
const headers = packet.slice(0, split);
const [method, url] = headers.split('\n')[0].split(' ');
const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1];
const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined;
if (raw) expect(raw.length).toBe(Number(header('Content-Length')));
expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe(
header('X-Signature'),
);
}
});
});
@@ -0,0 +1,28 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Optional } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { publicOpenApiFailure } from './open-api.protocol';
import type { OpenApiRequestLike } from './open-api.types';
@Injectable()
export class OpenApiTraceInterceptor implements NestInterceptor {
constructor(@Optional() private readonly logs?: ProtocolLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const startedAt = Date.now();
const record = (error?: unknown) =>
this.logs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: request.method === 'GET' ? 'query_request' : 'send_request',
status: error ? 'failed' : 'success',
requestId: request.openApiRequestId,
tenantId: request.openApiAuth?.application.tenantId,
applicationId: request.openApiAuth?.application.id,
resultCode: error ? publicOpenApiFailure(error).code : 'OK',
durationMs: Date.now() - startedAt,
detail: { method: request.method, operation: context.getHandler().name },
});
return next.handle().pipe(tap({ next: () => record(), error: (error: unknown) => record(error) }));
}
}
@@ -0,0 +1,42 @@
import { createServer, get, type RequestOptions } from 'node:http';
import type { AddressInfo } from 'node:net';
import { pinnedWebhookLookup } from './open-api.protocol';
describe('webhook pinned DNS lookup', () => {
it('supports single-address and all-address callbacks without resolving another address', () => {
const callback = jest.fn();
const lookup = pinnedWebhookLookup('203.0.113.10', 4);
lookup('ignored.example', {}, callback);
expect(callback).toHaveBeenLastCalledWith(null, '203.0.113.10', 4);
lookup('ignored.example', { all: true }, callback);
expect(callback).toHaveBeenLastCalledWith(null, [{ address: '203.0.113.10', family: 4 }]);
pinnedWebhookLookup('2001:db8::10', 6)('ignored.example', { all: true }, callback);
expect(callback).toHaveBeenLastCalledWith(null, [{ address: '2001:db8::10', family: 6 }]);
});
it('delivers through the real Node HTTP connector when automatic family selection requests all addresses', async () => {
const server = createServer((_request, response) => response.end('received'));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const port = (server.address() as AddressInfo).port;
const options: RequestOptions & { autoSelectFamily: boolean } = {
lookup: pinnedWebhookLookup('127.0.0.1', 4),
autoSelectFamily: true,
agent: false,
};
const body = await new Promise<string>((resolve, reject) => {
const request = get(`http://webhook.invalid:${port}/`, options, (response) => {
let received = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => (received += chunk));
response.on('end', () => resolve(received));
});
request.setTimeout(3000, () => request.destroy(new Error('test HTTP timeout')));
request.on('error', reject);
});
expect(body).toBe('received');
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
});
+84 -7
View File
@@ -1,19 +1,61 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common'; import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import { ApiBody, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import {
import { createHash } from 'node:crypto'; Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
BadRequestException,
} from '@nestjs/common';
import { ApiExtraModels, ApiBody, ApiHeader, ApiQuery, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { openApiBodyHash } from './open-api.protocol';
import { OpenApiAuthGuard } from './open-api-auth.guard'; import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiService } from './open-api.service'; import { OpenApiService } from './open-api.service';
import type { OpenApiRequestLike } from './open-api.types'; import type { OpenApiRequestLike } from './open-api.types';
import { OpenApiExceptionFilter } from './open-api-exception.filter'; import { OpenApiExceptionFilter } from './open-api-exception.filter';
import { OpenApiSendMessageDto, OpenApiSendMessageResponseDto } from './open-api.dto'; import {
OpenApiSendMessageDto,
OpenApiSendMessageResponseDto,
OpenApiMessageDto,
OpenApiUplinksDto,
OpenApiUplinkDetailDto,
OpenApiProblemDto,
OpenApiReceiptEventDto,
OpenApiUplinkEventDto,
} from './open-api.dto';
@ApiExtraModels(OpenApiReceiptEventDto, OpenApiUplinkEventDto)
@ApiTags('client-open-api-v1') @ApiTags('client-open-api-v1')
@ApiHeader({ name: 'X-App-Key', required: true }) @ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', required: true }) @ApiHeader({ name: 'X-Timestamp', required: true })
@ApiHeader({ name: 'X-Nonce', required: true }) @ApiHeader({ name: 'X-Nonce', required: true })
@ApiHeader({ name: 'X-Signature', required: true }) @ApiHeader({
name: 'X-Signature',
required: true,
description:
'HMAC-SHA256小写十六进制。方法、路径(不含query)、时间戳、nonce以LF分隔;GET末尾无LFPOST追加LF及原始UTF-8正文,不计算正文摘要。',
})
@ApiResponse({ status: 400, type: OpenApiProblemDto })
@ApiResponse({ status: 401, type: OpenApiProblemDto })
@ApiResponse({ status: 403, type: OpenApiProblemDto })
@ApiResponse({ status: 404, type: OpenApiProblemDto })
@ApiResponse({ status: 409, type: OpenApiProblemDto })
@ApiResponse({ status: 413, type: OpenApiProblemDto })
@ApiResponse({ status: 415, type: OpenApiProblemDto })
@ApiResponse({ status: 422, type: OpenApiProblemDto })
@ApiResponse({ status: 429, type: OpenApiProblemDto })
@ApiResponse({ status: 500, type: OpenApiProblemDto })
@UseGuards(OpenApiAuthGuard) @UseGuards(OpenApiAuthGuard)
@UseFilters(OpenApiExceptionFilter) @UseFilters(OpenApiExceptionFilter)
@UseInterceptors(OpenApiTraceInterceptor)
@Controller('openapi/v1/sms') @Controller('openapi/v1/sms')
export class OpenApiController { export class OpenApiController {
constructor(private readonly service: OpenApiService) {} constructor(private readonly service: OpenApiService) {}
@@ -22,27 +64,62 @@ export class OpenApiController {
@HttpCode(202) @HttpCode(202)
@ApiHeader({ name: 'Idempotency-Key', required: true }) @ApiHeader({ name: 'Idempotency-Key', required: true })
@ApiOperation({ summary: '发送单条短信' }) @ApiOperation({ summary: '发送单条短信' })
@UsePipes(
new ValidationPipe({
transform: true,
exceptionFactory: () => new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' }),
}),
)
@ApiBody({ type: OpenApiSendMessageDto }) @ApiBody({ type: OpenApiSendMessageDto })
@ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto }) @ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto })
sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) { sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto) {
return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent }); return this.service.sendMessage(request.openApiAuth!, body, {
idempotencyKey: scalarHeader(request, 'idempotency-key'),
bodyHash: openApiBodyHash(request.rawBody, body),
userAgent: scalarHeader(request, 'user-agent'),
});
} }
@ApiResponse({ status: 200, type: OpenApiMessageDto })
@Get('messages/:messageId') @Get('messages/:messageId')
@ApiOperation({ summary: '查询短信状态' }) @ApiOperation({ summary: '查询短信状态' })
getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) { getMessage(@Req() request: OpenApiRequestLike, @Param('messageId') messageId: string) {
return this.service.getMessage(request.openApiAuth!, messageId); return this.service.getMessage(request.openApiAuth!, messageId);
} }
@ApiResponse({ status: 200, type: OpenApiUplinksDto })
@ApiQuery({ name: 'startTime', required: false, type: String, description: 'ISO8601时间,默认endTime前24小时' })
@ApiQuery({
name: 'endTime',
required: false,
type: String,
description: 'ISO8601时间,默认当前时间;翻页固定时间范围',
})
@ApiQuery({ name: 'mobile', required: false, type: String })
@ApiQuery({ name: 'accessNumber', required: false, type: String })
@ApiQuery({ name: 'keyword', required: false, type: String })
@ApiQuery({
name: 'limit',
required: false,
schema: { type: 'integer', minimum: 1, default: 50 },
description: '按当前应用maxPageSize裁剪',
})
@ApiQuery({ name: 'cursor', required: false, type: String })
@Get('uplinks') @Get('uplinks')
@ApiOperation({ summary: '游标分页查询上行短信' }) @ApiOperation({ summary: '游标分页查询上行短信' })
listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) { listUplinks(@Req() request: OpenApiRequestLike, @Query() query: Record<string, string | undefined>) {
return this.service.listUplinks(request.openApiAuth!, query); return this.service.listUplinks(request.openApiAuth!, query);
} }
@ApiResponse({ status: 200, type: OpenApiUplinkDetailDto })
@Get('uplinks/:uplinkId') @Get('uplinks/:uplinkId')
@ApiOperation({ summary: '查询上行短信详情' }) @ApiOperation({ summary: '查询上行短信详情' })
getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) { getUplink(@Req() request: OpenApiRequestLike, @Param('uplinkId') uplinkId: string) {
return this.service.getUplink(request.openApiAuth!, uplinkId); return this.service.getUplink(request.openApiAuth!, uplinkId);
} }
} }
function scalarHeader(request: OpenApiRequestLike, name: string) {
const value = request.headers[name];
return Array.isArray(value) ? value[0] : value;
}
+89 -2
View File
@@ -1,16 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, Matches, IsNotEmpty } from 'class-validator';
export class OpenApiSendMessageDto { export class OpenApiSendMessageDto {
@ApiProperty({ example: '13800138000', description: '中国大陆手机号' }) @ApiProperty({ example: '13800138000', description: '中国大陆手机号' })
@IsString()
@Matches(/^1\d{10}$/)
mobile!: string; mobile!: string;
@ApiProperty({ @ApiProperty({
example: '【示例签名】您的验证码是123456,5分钟内有效。', example: '【示例签名】您的验证码是123456,5分钟内有效。',
description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID', description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID',
}) })
@IsString()
@IsNotEmpty()
content!: string; content!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', maxLength: 128 }) @ApiPropertyOptional({ type: String, nullable: true, example: 'order-20260720-0001', maxLength: 128 })
@IsOptional()
@IsString()
@MaxLength(128)
clientMessageId?: string; clientMessageId?: string;
} }
@@ -24,7 +32,7 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' }) @ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' })
messageId!: string; messageId!: string;
@ApiPropertyOptional({ example: 'order-20260720-0001', nullable: true }) @ApiProperty({ type: String, example: 'order-20260720-0001', nullable: true })
clientMessageId!: string | null; clientMessageId!: string | null;
@ApiProperty({ example: 'queued' }) @ApiProperty({ example: 'queued' })
@@ -33,3 +41,82 @@ export class OpenApiSendMessageResponseDto {
@ApiProperty({ example: '2026-07-20T08:00:00.000Z' }) @ApiProperty({ example: '2026-07-20T08:00:00.000Z' })
acceptedAt!: string; acceptedAt!: string;
} }
export class OpenApiProblemDto {
@ApiProperty() type!: string;
@ApiProperty() title!: string;
@ApiProperty() status!: number;
@ApiProperty() code!: string;
@ApiProperty() detail!: string;
@ApiProperty() requestId!: string;
}
export class OpenApiMessageDto {
@ApiProperty() messageId!: string;
@ApiProperty({ type: String, nullable: true }) clientMessageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() status!: string;
@ApiProperty() submitStatus!: string;
@ApiProperty() receiptStatus!: string;
@ApiProperty({ type: String, nullable: true }) errorCode!: string | null;
@ApiProperty({ type: String, nullable: true }) errorMessage!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) queuedAt!: string;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) submittedAt!: string | null;
@ApiProperty({ type: String, format: 'date-time', nullable: true }) deliveredAt!: string | null;
@ApiProperty({ type: String, format: 'date-time' }) updatedAt!: string;
}
export class OpenApiUplinkDto {
@ApiProperty() id!: string;
@ApiProperty({ type: String, nullable: true }) messageId!: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
}
export class OpenApiUplinkDetailDto extends OpenApiUplinkDto {
@ApiProperty() tenantId!: string;
@ApiProperty() applicationId!: string;
}
export class OpenApiUplinksDto {
@ApiProperty({ type: [OpenApiUplinkDto] }) items!: OpenApiUplinkDto[];
@ApiProperty({ type: String, nullable: true }) nextCursor!: string | null;
}
export class OpenApiReceiptDataDto {
@ApiProperty() messageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) gatewayMessageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() receiptStatus!: string;
@ApiPropertyOptional({ type: String, nullable: true }) rawStatus?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorCode?: string | null;
@ApiPropertyOptional({ type: String, nullable: true }) errorMessage?: string | null;
@ApiPropertyOptional({ type: String, format: 'date-time', nullable: true }) deliveredAt?: string | null;
}
export class OpenApiUplinkDataDto {
@ApiProperty() applicationId!: string;
@ApiProperty() uplinkMessageId!: string;
@ApiPropertyOptional({ type: String, nullable: true }) messageId?: string | null;
@ApiProperty() phoneNumber!: string;
@ApiProperty() destId!: string;
@ApiProperty() content!: string;
@ApiProperty({ type: String, format: 'date-time' }) receivedAt!: string;
@ApiPropertyOptional() manualClaim?: boolean;
}
export class OpenApiReceiptEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['receipt'] }) eventType!: 'receipt';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiReceiptDataDto }) data!: OpenApiReceiptDataDto;
}
export class OpenApiUplinkEventDto {
@ApiProperty() eventId!: string;
@ApiProperty({ enum: ['uplink'] }) eventType!: 'uplink';
@ApiProperty({ type: String, format: 'date-time' }) occurredAt!: string;
@ApiProperty({ type: OpenApiUplinkDataDto }) data!: OpenApiUplinkDataDto;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { OpenApiTraceInterceptor } from './open-api-trace.interceptor';
import { OpenApiDocsController } from './open-api-docs.controller';
import { forwardRef, Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { SendChainModule } from '../send-chain/send-chain.module'; import { SendChainModule } from '../send-chain/send-chain.module';
@@ -10,8 +12,8 @@ import { SecurityDetectionModule } from '../security-detection/security-detectio
@Module({ @Module({
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule], imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController], controllers: [OpenApiDocsController, OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard], providers: [OpenApiTraceInterceptor, OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService], exports: [OpenApiService],
}) })
export class OpenApiModule {} export class OpenApiModule {}
+113
View File
@@ -0,0 +1,113 @@
import { createHash, createHmac } from 'node:crypto';
import { BadRequestException, HttpException } from '@nestjs/common';
import type { LookupFunction } from 'node:net';
/** Keep the validated address pinned while honoring Node's all-address lookup contract. */
export function pinnedWebhookLookup(address: string, family: number): LookupFunction {
return (_hostname, options, callback) => {
if (options.all) callback(null, [{ address, family }]);
else callback(null, address, family);
};
}
/** Validate calendar components before Date can normalize an impossible day. */
export function parseOpenApiDate(value: string): Date {
const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2}))?$/.exec(
value,
);
if (parts) {
const year = Number(parts[1]);
const month = Number(parts[2]);
const day = Number(parts[3]);
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const zone = parts[8];
const validZone = !zone || zone === 'Z' || (Number(zone.slice(1, 3)) < 24 && Number(zone.slice(4)) < 60);
const date = new Date(value);
if (
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= days[month - 1] &&
Number(parts[4] ?? 0) < 24 &&
Number(parts[5] ?? 0) < 60 &&
Number(parts[6] ?? 0) < 60 &&
validZone &&
Number.isFinite(date.getTime())
)
return date;
}
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '时间必须为有效的ISO8601日期或带时区时间' });
}
export type OpenApiProblemResponse = {
setHeader(name: string, value: string): void;
status(code: number): { type(value: string): { send(body: unknown): void } };
};
export function sendOpenApiProblem(
response: OpenApiProblemResponse,
requestId: string,
failure: { status: number; code: string; message: string },
) {
response.setHeader('X-Request-Id', requestId);
response
.status(failure.status)
.type('application/problem+json')
.send({
type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`,
title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed',
status: failure.status,
code: failure.code,
detail: failure.message,
requestId,
});
}
/** Internal idempotency fingerprint; this digest is not part of request authentication. */
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
return createHash('sha256')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
.digest('hex');
}
export function openApiSignature(
secret: string,
method: string,
path: string,
timestamp: string,
nonce: string,
rawBody?: Buffer,
) {
const verb = method.toUpperCase();
if (verb === 'GET' && rawBody?.length) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'GET请求不得携带正文' });
}
if (verb !== 'GET' && !rawBody) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '缺少原始请求体' });
}
const signature = createHmac('sha256', secret).update(
[verb, path.split('?')[0], timestamp, nonce].join('\n'),
'utf8',
);
if (verb !== 'GET') signature.update('\n').update(rawBody!);
return signature.digest('hex');
}
export function publicOpenApiFailure(error: unknown) {
if (!(error instanceof HttpException) || error.getStatus() >= 500) {
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
}
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const message = object.message ?? value;
return {
status: error.getStatus(),
code: String(object.code ?? 'REQUEST_FAILED'),
message: Array.isArray(message) ? message.join('') : String(message),
};
}
export function webhookJobId(deliveryId: string, attemptNo: number) {
return `webhook-${createHash('sha256').update(deliveryId).digest('hex')}-${attemptNo}`;
}
@@ -0,0 +1,70 @@
import { OpenApiRecovery } from './open-api.recovery';
describe('OpenApiRecovery', () => {
function setup() {
const prisma = {
openApiDispatchOutbox: {
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsBatchTask: { findUnique: jest.fn().mockResolvedValue({ status: 'queued' }) },
httpWebhookDelivery: { findMany: jest.fn().mockResolvedValue([]) },
};
const send = { enqueueBatchTask: jest.fn().mockResolvedValue({}) };
const queue = { getJob: jest.fn().mockResolvedValue(undefined), add: jest.fn().mockResolvedValue({}) };
return { prisma, send, queue, recovery: new OpenApiRecovery(prisma as never, send as never, queue as never) };
}
it('keeps publication failures durable without marking them dispatched', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
send.enqueueBatchTask.mockRejectedValue(new Error('controlled failure'));
await recovery.tick();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'pending' }),
data: expect.objectContaining({ leaseToken: expect.any(String), leaseUntil: expect.any(Date) }),
}),
);
});
it('does not dispatch a batch whose lease was taken by another instance', async () => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.openApiDispatchOutbox.updateMany.mockResolvedValue({ count: 0 });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
});
it.each(['canceled', 'sending', 'finished', 'rejected', 'pending_review'])(
'does not enqueue non-eligible batch %s',
async (status) => {
const { prisma, send, recovery } = setup();
prisma.openApiDispatchOutbox.findMany.mockResolvedValue([{ id: 'outbox', batchTaskId: 'batch' }]);
prisma.smsBatchTask.findUnique.mockResolvedValue({ status });
await recovery.tick();
expect(send.enqueueBatchTask).not.toHaveBeenCalled();
expect(prisma.openApiDispatchOutbox.updateMany).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'closed' }) }),
);
},
);
it('scans only versioned webhook deliveries and preserves active Redis jobs', async () => {
const { prisma, queue, recovery } = setup();
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'active' });
await recovery.tick();
expect(queue.add).not.toHaveBeenCalled();
expect(prisma.httpWebhookDelivery.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ recoveryVersion: 1 }), take: 50 }),
);
});
it('recovers a failed Redis job only when its durable PG row still needs delivery', async () => {
const { prisma, queue, recovery } = setup();
const remove = jest.fn().mockResolvedValue(undefined);
prisma.httpWebhookDelivery.findMany.mockResolvedValue([{ id: 'delivery', attemptCount: 1 }]);
queue.getJob.mockResolvedValue({ getState: async () => 'failed', remove });
await recovery.tick();
expect(remove).toHaveBeenCalledTimes(1);
expect(queue.add).toHaveBeenCalledTimes(1);
expect(queue.add.mock.calls[0][2].jobId).not.toContain(':');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { PrismaService } from '../prisma/prisma.service';
import type { SendChainService } from '../send-chain/send-chain.service';
import type { Queue } from 'bullmq';
import { webhookJobId } from './open-api.protocol';
/** Only versioned/new durable work is eligible; never infer or replay historical work. */
export class OpenApiRecovery {
private readonly logger = new Logger(OpenApiRecovery.name);
private pending?: Promise<void>;
private stopped = false;
constructor(
private readonly prisma: PrismaService,
private readonly sendChain: SendChainService,
private readonly queue: Queue<{ deliveryId: string }>,
) {}
tick(): Promise<void> {
if (this.stopped) return Promise.resolve();
if (this.pending) return this.pending;
this.pending = this.run().finally(() => {
this.pending = undefined;
});
return this.pending;
}
async close() {
this.stopped = true;
await this.pending;
}
private async run() {
try {
await this.dispatchMessages();
await this.dispatchWebhooks();
} catch (error) {
this.logger.error({
code: 'OPENAPI_RECOVERY_FAILED',
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
private async dispatchMessages() {
const now = new Date();
const rows = await this.prisma.openApiDispatchOutbox.findMany({
where: { status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
orderBy: { createdAt: 'asc' },
take: 50,
});
for (const row of rows) {
if (this.stopped) return;
const leaseToken = randomUUID();
const claimed = await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, status: 'pending', OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
data: { leaseToken, leaseUntil: new Date(Date.now() + 120_000) },
});
if (!claimed.count) continue;
try {
const task = await this.prisma.smsBatchTask.findUnique({
where: { id: row.batchTaskId },
select: { status: true },
});
if (!task || !['ready', 'queued'].includes(task.status)) {
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'closed', leaseToken: null, leaseUntil: null },
});
continue;
}
await this.sendChain.enqueueBatchTask(row.batchTaskId);
await this.prisma.openApiDispatchOutbox.updateMany({
where: { id: row.id, leaseToken },
data: { status: 'dispatched', leaseToken: null, leaseUntil: null },
});
} catch (error) {
this.logger.error({
code: 'OPENAPI_DISPATCH_PENDING',
outboxId: row.id,
errorType: error instanceof Error ? error.name : 'UnknownError',
});
}
}
}
private async dispatchWebhooks() {
const now = new Date();
const rows = await this.prisma.httpWebhookDelivery.findMany({
where: {
recoveryVersion: 1,
status: { in: ['pending', 'retrying', 'delivering'] },
AND: [
{ OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }] },
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
],
},
orderBy: { nextRetryAt: 'asc' },
take: 50,
});
for (const row of rows) {
const jobId = webhookJobId(row.id, row.attemptCount + 1);
const job = await this.queue.getJob(jobId);
if (job) {
const state = await job.getState();
if (!['failed', 'completed'].includes(state)) continue;
// Failed/finished jobs are no longer executing; DB state is the durable authority.
await job.remove();
}
await this.queue.add('deliver', { deliveryId: row.id }, { jobId, removeOnComplete: 1000, removeOnFail: 1000 });
}
}
}
+21 -38
View File
@@ -60,9 +60,7 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => { it('replays a completed request for the same idempotency key and body', async () => {
const prisma = { const prisma = {
openApiRequest: { openApiRequest: {
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
bodyHash: 'same', bodyHash: 'same',
status: 'completed', status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' }, responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
@@ -97,9 +95,7 @@ describe('OpenApiService', () => {
it('replays the same persisted business rejection', async () => { it('replays the same persisted business rejection', async () => {
const prisma = { const prisma = {
openApiRequest: { openApiRequest: {
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
bodyHash: 'same', bodyHash: 'same',
status: 'failed', status: 'failed',
httpStatus: 422, httpStatus: 422,
@@ -117,37 +113,29 @@ describe('OpenApiService', () => {
).rejects.toMatchObject({ status: 422 }); ).rejects.toMatchObject({ status: 422 });
}); });
it('uses the real send chain and persists the accepted response', async () => { it('returns only the response snapshot committed by the send chain', async () => {
const response = { code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' };
const prisma = { const prisma = {
openApiRequest: { openApiRequest: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValue({ status: 'completed', responseBody: response }),
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
update: jest.fn().mockResolvedValue({}), update: jest.fn(),
}, },
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
}; };
const sendChain = { const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({}) };
createHttpBatchTask: jest
.fn()
.mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
};
const service = new OpenApiService(prisma as never, sendChain as never); const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage( await expect(
service.sendMessage(
auth() as never, auth() as never,
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { mobile: '18821203795', content: '示例', clientMessageId: 'client-1' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
); ),
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith( ).resolves.toEqual(response);
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }), expect(prisma.openApiRequest.update).not.toHaveBeenCalled();
);
expect(result).toEqual(
expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
);
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
}),
);
}); });
it('persists a 422 result when the real send chain rejects the business request', async () => { it('persists a 422 result when the real send chain rejects the business request', async () => {
@@ -180,13 +168,12 @@ describe('OpenApiService', () => {
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => { it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = { const prisma = {
smsApplication: { smsApplication: {
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' }, httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
}), }),
}, },
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) }, httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
$transaction: jest.fn().mockImplementation(async (callback) => callback(prisma)),
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) }, httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) }, httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
}; };
@@ -209,7 +196,7 @@ describe('OpenApiService', () => {
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2); expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith( expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' }, create: { eventId: 'event-row-1', endpointId: 'endpoint-1', recoveryVersion: 1 },
}), }),
); );
}); });
@@ -217,9 +204,7 @@ describe('OpenApiService', () => {
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => { it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = { const prisma = {
smsApplication: { smsApplication: {
findFirst: jest findFirst: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
id: 'app-1', id: 'app-1',
name: '应用A', name: '应用A',
interfaceEnabled: true, interfaceEnabled: true,
@@ -280,9 +265,7 @@ describe('OpenApiService', () => {
it('rejects an already expired credential before writing a secret', async () => { it('rejects an already expired credential before writing a secret', async () => {
const prisma = { const prisma = {
smsApplication: { smsApplication: {
findFirst: jest findFirst: jest.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
id: 'app-1', id: 'app-1',
name: '应用A', name: '应用A',
interfaceEnabled: true, interfaceEnabled: true,
+207 -158
View File
@@ -1,3 +1,5 @@
import { OpenApiRecovery } from './open-api.recovery';
import { HTTP_REQUEST_CONTEXT } from '../send-chain/send-chain.contracts';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -14,7 +16,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; import { createHmac, randomBytes, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { request as httpRequest } from 'node:http'; import { request as httpRequest } from 'node:http';
@@ -24,8 +26,18 @@ import { SendChainService } from '../send-chain/send-chain.service';
import { decryptSecret, encryptSecret } from './open-api.crypto'; import { decryptSecret, encryptSecret } from './open-api.crypto';
import type { OpenApiAuthContext } from './open-api.types'; import type { OpenApiAuthContext } from './open-api.types';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { parseOpenApiDate, pinnedWebhookLookup, publicOpenApiFailure, webhookJobId } from './open-api.protocol';
import { automaticDeliveryMode } from './delivery-mode'; import { automaticDeliveryMode } from './delivery-mode';
export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport');
export type OpenApiWebhookTransport = (
url: string,
body: string,
headers: Record<string, string>,
timeoutMs: number,
requireHttps: boolean,
) => Promise<{ status: number; body: string }>;
const WEBHOOK_QUEUE = 'http-webhook-delivery'; const WEBHOOK_QUEUE = 'http-webhook-delivery';
const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400]; const RETRY_DELAYS_SECONDS = [0, 60, 300, 900, 3600, 21600, 86400];
@@ -58,11 +70,14 @@ export type HttpConfigInput = {
export class OpenApiService implements OnModuleInit, OnModuleDestroy { export class OpenApiService implements OnModuleInit, OnModuleDestroy {
private queue?: Queue<{ deliveryId: string }>; private queue?: Queue<{ deliveryId: string }>;
private worker?: Worker<{ deliveryId: string }>; private worker?: Worker<{ deliveryId: string }>;
private recovery?: OpenApiRecovery;
private recoveryTimer?: ReturnType<typeof setInterval>;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService, @Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService,
@Optional() private readonly protocolLogs?: ProtocolLogsService, @Optional() private readonly protocolLogs?: ProtocolLogsService,
@Optional() @Inject(OPEN_API_WEBHOOK_TRANSPORT) private readonly webhookTransport?: OpenApiWebhookTransport,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -72,6 +87,9 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity // Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints. // cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return; if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.recovery = new OpenApiRecovery(this.prisma, this.sendChain, this.queue);
this.recoveryTimer = setInterval(() => void this.recovery?.tick(), 15_000);
this.recoveryTimer.unref?.();
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
connection, connection,
concurrency: 10, concurrency: 10,
@@ -79,6 +97,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
} }
async onModuleDestroy() { async onModuleDestroy() {
if (this.recoveryTimer) clearInterval(this.recoveryTimer);
await this.recovery?.close();
await this.worker?.close(); await this.worker?.close();
await this.queue?.close(); await this.queue?.close();
} }
@@ -262,10 +282,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
) { ) {
if (!auth.config.sendEnabled) if (!auth.config.sendEnabled)
throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' }); throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim(); if (
typeof input.mobile !== 'string' ||
typeof input.content !== 'string' ||
(input.clientMessageId != null &&
(typeof input.clientMessageId !== 'string' || Array.from(input.clientMessageId).length > 128))
) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '请求字段类型或长度非法' });
}
const mobile = input.mobile.trim();
const content = String(input.content ?? ''); const content = String(input.content ?? '');
if (!/^1[3-9]\d{9}$/.test(mobile)) if (!/^1\d{10}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' }); if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim(); const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
@@ -283,8 +310,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致', message: '同一Idempotency-Key对应的请求内容不一致',
}); });
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody; if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) if (['failed', 'requires_review'].includes(existing.status) && existing.responseBody && existing.httpStatus)
throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus); throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
if (
existing.status === 'requires_review' ||
(existing.createdAt && Date.now() - existing.createdAt.getTime() > 600_000)
)
throw new ConflictException({
code: 'REQUEST_REQUIRES_REVIEW',
message: '请求结果待核对,请提供requestId联系支持,勿更换幂等键重发',
requestId: existing.requestId,
});
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
} }
if (input.clientMessageId) { if (input.clientMessageId) {
@@ -326,14 +362,15 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
message: '同一Idempotency-Key对应的请求内容不一致', message: '同一Idempotency-Key对应的请求内容不一致',
}); });
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody; if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) if (raced && ['failed', 'requires_review'].includes(raced.status) && raced.responseBody && raced.httpStatus)
throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus); throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' }); throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
} }
throw error; throw error;
} }
try { try {
const task = await this.sendChain.createHttpBatchTask({ await this.sendChain.createHttpBatchTask({
[HTTP_REQUEST_CONTEXT]: { id: request.id, requestId },
tenantId: auth.application.tenantId, tenantId: auth.application.tenantId,
applicationId: auth.application.id, applicationId: auth.application.id,
content, content,
@@ -342,50 +379,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
userAgent: meta.userAgent, userAgent: meta.userAgent,
clientMessageId: input.clientMessageId, clientMessageId: input.clientMessageId,
}); });
const message = task.messages?.[0]; const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (task.status === 'rejected' || message?.status === 'rejected') { if (frozen?.status === 'completed' && frozen.responseBody) {
throw new UnprocessableEntityException({ void this.recovery?.tick();
code: 'SEND_REJECTED', return frozen.responseBody;
message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
});
} }
const response = { if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
code: 'ACCEPTED', throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
requestId, throw new Error('HTTP acceptance snapshot was not committed');
messageId: message?.messageId,
clientMessageId: input.clientMessageId ?? null,
status: message?.status ?? task.status,
acceptedAt: new Date().toISOString(),
};
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'completed',
httpStatus: 202,
businessCode: 'ACCEPTED',
responseBody: response,
messageRecordId: message?.id,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'send_request',
status: 'accepted',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
messageId: message?.messageId,
requestId,
phone: mobile,
resultCode: 'ACCEPTED',
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
detail: { clientMessageId: input.clientMessageId },
});
return response;
} catch (error) { } catch (error) {
const frozen = await this.prisma.openApiRequest.findUnique({ where: { id: request.id } });
if (frozen?.status === 'completed' && frozen.responseBody) return frozen.responseBody;
if (frozen?.status === 'failed' && frozen.responseBody && frozen.httpStatus)
throw new HttpException(frozen.responseBody as Record<string, unknown>, frozen.httpStatus);
let outwardError = error; let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) { if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse(); const response = error.getResponse();
@@ -399,7 +405,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.prisma.openApiRequest.update({ await this.prisma.openApiRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { data: {
status: 'failed', status: failure.httpStatus >= 500 ? 'requires_review' : 'failed',
httpStatus: failure.httpStatus, httpStatus: failure.httpStatus,
businessCode: failure.code, businessCode: failure.code,
responseBody: failure.responseBody, responseBody: failure.responseBody,
@@ -407,20 +413,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
completedAt: new Date(), completedAt: new Date(),
}, },
}); });
this.protocolLogs?.record({
protocol: 'http', throw new HttpException(failure.responseBody as Record<string, unknown>, failure.httpStatus);
direction: 'client_to_platform',
eventType: 'send_request',
status: 'failed',
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
requestId,
phone: mobile,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
payloadBytes: Buffer.byteLength(content, 'utf8'),
});
throw outwardError;
} }
} }
@@ -451,8 +445,17 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) { async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
if (!auth.config.uplinkQueryEnabled) if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const endTime = query.endTime ? new Date(query.endTime) : new Date(); for (const value of Object.values(query)) {
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000); if (value !== undefined && typeof value !== 'string')
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' });
}
if (query.mobile !== undefined && !/^1\d{10}$/.test(query.mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: 'mobile必须为1开头的11位手机号' });
if (query.accessNumber !== undefined && !/^\d{1,21}$/.test(query.accessNumber))
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'accessNumber必须为1至21位数字接入号' });
const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date();
const startTime =
query.startTime !== undefined ? parseOpenApiDate(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' }); throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000)
@@ -460,7 +463,12 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
code: 'TIME_RANGE_TOO_LARGE', code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}`, message: `单次查询不能超过${auth.config.maxQueryRangeDays}`,
}); });
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize); if (
query.limit !== undefined &&
(!/^\d+$/.test(query.limit) || !Number.isSafeInteger(Number(query.limit)) || Number(query.limit) < 1)
)
throw new BadRequestException({ code: 'LIMIT_INVALID', message: 'limit必须为正整数' });
const limit = Math.min(Number(query.limit ?? 50), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor); const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({ const rows = await this.prisma.smsUplinkMessage.findMany({
where: { where: {
@@ -482,8 +490,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: true, phoneNumber: true,
destId: true, destId: true,
content: true, content: true,
matchStatus: true,
matchReason: true,
receivedAt: true, receivedAt: true,
}, },
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }], orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
@@ -499,13 +505,29 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
if (!auth.config.uplinkQueryEnabled) if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' }); throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({ const row = await this.prisma.smsUplinkMessage.findFirst({
where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' }, where: {
id: uplinkId,
applicationId: auth.application.id,
tenantId: auth.application.tenantId,
matchStatus: 'matched',
},
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
receivedAt: true,
tenantId: true,
applicationId: true,
},
}); });
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' }); if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row; return row;
} }
async queueWebhookEvent(data: { async queueWebhookEvent(
data: {
tenantId: string; tenantId: string;
applicationId?: string | null; applicationId?: string | null;
messageRecordId?: string | null; messageRecordId?: string | null;
@@ -513,16 +535,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
uplinkMessageId?: string | null; uplinkMessageId?: string | null;
eventType: 'receipt' | 'uplink'; eventType: 'receipt' | 'uplink';
payload: Record<string, unknown>; payload: Record<string, unknown>;
}) { },
transaction?: Prisma.TransactionClient,
) {
const db = transaction ?? this.prisma;
if (!data.applicationId) return null; if (!data.applicationId) return null;
const application = await this.prisma.smsApplication.findUnique({ const application = await db.smsApplication.findUnique({
where: { id: data.applicationId }, where: { id: data.applicationId },
include: { httpConfig: true }, include: { httpConfig: true },
}); });
const config = application?.httpConfig; const config = application?.httpConfig;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled; const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
if (!config?.enabled || !enabled) return null; if (!config?.enabled || !enabled) return null;
const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ const endpoint = await db.httpWebhookEndpoint.findUnique({
where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } }, where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } },
}); });
if (!endpoint || endpoint.status !== 'active') return null; if (!endpoint || endpoint.status !== 'active') return null;
@@ -532,13 +557,14 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
: data.eventType === 'uplink' && data.uplinkMessageId : data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}` ? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`; : `evt_${randomUUID()}`;
const event = await this.prisma.httpWebhookEvent.upsert({ const persist = async (tx: Prisma.TransactionClient) => {
const event = await tx.httpWebhookEvent.upsert({
where: { eventId }, where: { eventId },
update: {}, update: {},
create: { create: {
eventId, eventId,
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId!,
eventType: data.eventType, eventType: data.eventType,
messageRecordId: data.messageRecordId, messageRecordId: data.messageRecordId,
messageId: data.messageId, messageId: data.messageId,
@@ -546,16 +572,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
payload: data.payload as Prisma.InputJsonValue, payload: data.payload as Prisma.InputJsonValue,
}, },
}); });
const delivery = await this.prisma.httpWebhookDelivery.upsert({ return tx.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } }, where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
update: {}, update: {},
create: { eventId: event.id, endpointId: endpoint.id }, create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 },
}); });
if (delivery.status === 'delivered') return delivery; };
const delivery = transaction ? await persist(transaction) : await this.prisma.$transaction(persist);
if (transaction) return delivery;
if (delivery.status !== 'pending' || delivery.recoveryVersion !== 1) return delivery;
await this.queue?.add( await this.queue?.add(
'deliver', 'deliver',
{ deliveryId: delivery.id }, { deliveryId: delivery.id },
{ jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 }, { jobId: webhookJobId(delivery.id, 1), removeOnComplete: 1000, removeOnFail: 1000 },
); );
return delivery; return delivery;
} }
@@ -603,14 +632,27 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId, event: { applicationId } }, where: { id: deliveryId, event: { applicationId } },
}); });
if (!delivery) throw new NotFoundException('Webhook投递记录不存在'); if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
await this.prisma.httpWebhookDelivery.update({ const reset = await this.prisma.httpWebhookDelivery.updateMany({
where: { id: delivery.id }, where: {
data: { status: 'pending', nextRetryAt: null, lastError: null }, id: delivery.id,
status: { in: ['pending', 'retrying', 'failed'] },
attemptCount: delivery.attemptCount,
OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
},
data: {
status: 'pending',
nextRetryAt: null,
lastError: null,
recoveryVersion: 1,
leaseToken: null,
leaseUntil: null,
},
}); });
if (!reset.count) throw new ConflictException('回调正在投递或已成功,不能重投');
await this.queue?.add( await this.queue?.add(
'deliver', 'deliver',
{ deliveryId }, { deliveryId },
{ jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 }, { jobId: webhookJobId(deliveryId, Date.now()), removeOnComplete: 1000, removeOnFail: 1000 },
); );
return { id: deliveryId, status: 'pending' }; return { id: deliveryId, status: 'pending' };
} }
@@ -620,11 +662,32 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
where: { id: deliveryId }, where: { id: deliveryId },
include: { event: true, endpoint: true }, include: { event: true, endpoint: true },
}); });
if (!delivery || delivery.status === 'delivered') return; if (!delivery || !['pending', 'retrying', 'delivering'].includes(delivery.status)) return;
if (delivery.nextRetryAt && delivery.nextRetryAt.getTime() > Date.now()) return;
const config = await this.prisma.smsApplicationHttpConfig.findUnique({ const config = await this.prisma.smsApplicationHttpConfig.findUnique({
where: { applicationId: delivery.event.applicationId }, where: { applicationId: delivery.event.applicationId },
}); });
if (!config) return; if (
!config?.enabled ||
delivery.endpoint.status !== 'active' ||
!(delivery.event.eventType === 'receipt' ? config.receiptWebhookEnabled : config.uplinkWebhookEnabled)
)
return;
const leaseToken = randomUUID();
const claimed = await this.prisma.httpWebhookDelivery.updateMany({
where: {
id: deliveryId,
status: delivery.status,
attemptCount: delivery.attemptCount,
OR: [{ leaseUntil: null }, { leaseUntil: { lt: new Date() } }],
},
data: {
status: 'delivering',
leaseToken,
leaseUntil: new Date(Date.now() + config.webhookTimeoutSeconds * 1000 + 60_000),
},
});
if (!claimed.count) return;
const attemptNo = delivery.attemptCount + 1; const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000)); const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({ const body = JSON.stringify({
@@ -641,7 +704,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let responseSummary: string | undefined; let responseSummary: string | undefined;
let errorMessage: string | undefined; let errorMessage: string | undefined;
try { try {
const response = await postWebhook( const response = await (this.webhookTransport ?? postWebhook)(
delivery.endpoint.url, delivery.endpoint.url,
body, body,
{ {
@@ -665,22 +728,6 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
responseStatus === 408 || responseStatus === 408 ||
responseStatus === 429 || responseStatus === 429 ||
(responseStatus !== undefined && responseStatus >= 500); (responseStatus !== undefined && responseStatus >= 500);
await this.prisma.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
this.protocolLogs?.record({ this.protocolLogs?.record({
protocol: 'http', protocol: 'http',
direction: 'platform_to_client', direction: 'platform_to_client',
@@ -696,56 +743,53 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
retryCount: attemptNo - 1, retryCount: attemptNo - 1,
detail: { deliveryId, attemptNo, error: errorMessage }, detail: { deliveryId, attemptNo, error: errorMessage },
}); });
if (success) {
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'delivered',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: null,
deliveredAt: new Date(),
nextRetryAt: null,
},
});
return;
}
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length); const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) { const willRetry = !success && config.webhookRetryEnabled && retryable && attemptNo < maxAttempts;
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!; const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000); const nextRetryAt = willRetry ? new Date(Date.now() + delaySeconds * 1000) : null;
await this.prisma.httpWebhookDelivery.update({ await this.prisma.$transaction(async (tx) => {
where: { id: deliveryId }, const updated = await tx.httpWebhookDelivery.updateMany({
where: { id: deliveryId, leaseToken },
data: { data: {
status: 'retrying', status: success ? 'delivered' : willRetry ? 'retrying' : 'failed',
attemptCount: attemptNo, attemptCount: attemptNo,
lastHttpStatus: responseStatus, lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`, lastError: success ? null : (errorMessage ?? 'HTTP ' + responseStatus),
deliveredAt: success ? new Date() : null,
nextRetryAt, nextRetryAt,
leaseToken: null,
leaseUntil: null,
}, },
}); });
if (!updated.count) return;
await tx.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
});
if (willRetry)
await this.queue?.add( await this.queue?.add(
'deliver', 'deliver',
{ deliveryId }, { deliveryId },
{ {
jobId: `${deliveryId}:${attemptNo + 1}`, jobId: webhookJobId(deliveryId, attemptNo + 1),
delay: delaySeconds * 1000, delay: delaySeconds * 1000,
removeOnComplete: 1000, removeOnComplete: 1000,
removeOnFail: 1000, removeOnFail: 1000,
}, },
); );
return;
}
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt: null,
},
});
} }
private async requireApplication(applicationId: string, tenantId?: string) { private async requireApplication(applicationId: string, tenantId?: string) {
@@ -783,23 +827,11 @@ function httpApiPublicOrigin() {
} }
function normalizeOpenApiFailure(error: unknown) { function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) { const failure = publicOpenApiFailure(error);
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const rawMessage = object.message ?? error.message;
return { return {
httpStatus: error.getStatus(), httpStatus: failure.status,
code: String(object.code ?? 'SEND_REJECTED'), code: failure.code,
responseBody: { responseBody: { code: failure.code, message: failure.message } as Prisma.InputJsonValue,
code: String(object.code ?? 'SEND_REJECTED'),
message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage),
} as Prisma.InputJsonValue,
};
}
return {
httpStatus: 500,
code: 'INTERNAL_ERROR',
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
}; };
} }
@@ -880,7 +912,7 @@ async function validateWebhookUrl(value: string, requireHttps: boolean) {
return (await resolveWebhookTarget(value, requireHttps)).url.toString(); return (await resolveWebhookTarget(value, requireHttps)).url.toString();
} }
async function resolveWebhookTarget(value: string, requireHttps: boolean) { export async function resolveWebhookTarget(value: string, requireHttps: boolean) {
let url: URL; let url: URL;
try { try {
url = new URL(String(value ?? '').trim()); url = new URL(String(value ?? '').trim());
@@ -890,7 +922,13 @@ async function resolveWebhookTarget(value: string, requireHttps: boolean) {
if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS'); if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS');
if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS'); if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码'); if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码');
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }); const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
let addresses: Array<{ address: string }>;
try {
addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true });
} catch {
throw new BadRequestException('Webhook域名未解析到可用地址');
}
if (addresses.some(({ address }) => isPrivateAddress(address))) if (addresses.some(({ address }) => isPrivateAddress(address)))
throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址'); throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
const selected = addresses[0]; const selected = addresses[0];
@@ -913,7 +951,7 @@ async function postWebhook(
{ {
method: 'POST', method: 'POST',
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) }, headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family), lookup: pinnedWebhookLookup(target.address, target.family),
}, },
(response) => { (response) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -937,7 +975,14 @@ async function postWebhook(
} }
function isPrivateAddress(address: string) { function isPrivateAddress(address: string) {
const normalized = address.replace(/^::ffff:/, ''); const canonical = isIP(address) === 6 ? new URL(`http://[${address}]`).hostname.slice(1, -1) : address;
const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/i.exec(canonical);
if (mapped) {
const high = parseInt(mapped[1], 16),
low = parseInt(mapped[2], 16);
return isPrivateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
}
const normalized = canonical.toLowerCase();
if ( if (
normalized === '::1' || normalized === '::1' ||
normalized === '::' || normalized === '::' ||
@@ -968,8 +1013,12 @@ function encodeCursor(receivedAt: Date, id: string) {
function decodeCursor(value?: string) { function decodeCursor(value?: string) {
if (!value) return null; if (!value) return null;
try { try {
const [date, id] = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as [string, string]; if (value.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error();
const receivedAt = new Date(date); const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string')
throw new Error();
const [date, id] = parsed;
const receivedAt = parseOpenApiDate(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error(); if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id }; return { receivedAt, id };
} catch { } catch {
+1
View File
@@ -17,4 +17,5 @@ export type OpenApiRequestLike = {
headers: Record<string, string | string[] | undefined>; headers: Record<string, string | string[] | undefined>;
socket?: { remoteAddress?: string }; socket?: { remoteAddress?: string };
openApiAuth?: OpenApiAuthContext; openApiAuth?: OpenApiAuthContext;
openApiRequestId?: string;
}; };
@@ -178,8 +178,8 @@ export class AdminOperationsController {
return this.operations.signatureQuality({ return this.operations.signatureQuality({
date, date,
keyword, keyword,
page: Number(page), page: page === undefined ? 1 : Number(page),
pageSize: Number(pageSize), pageSize: pageSize === undefined ? 25 : Number(pageSize),
}); });
} }
+4 -1
View File
@@ -10,7 +10,9 @@ import type {
// 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 = const statusWhere =
query.status === 'submit_failed' query.status === 'unknown'
? { status: { in: ['submitted', 'unknown'] } }
: query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed' : query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' } ? { status: 'failed', submitStatus: 'accepted' }
@@ -268,6 +270,7 @@ 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 drainageGate: message.drainageGate
? { ? {
version: message.drainageGate.version, version: message.drainageGate.version,
@@ -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 } },
}), }),
}), }),
@@ -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,
@@ -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 } },
@@ -113,8 +116,11 @@ export class OperationsMessageQueries {
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,
+80 -35
View File
@@ -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,16 +71,15 @@ export class ReportChannelExportService {
: missing.length : missing.length
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
: null; : null;
const reportCarriers = const reportCarriers = item.eligibleTargets
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId) .filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom') .map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
: [null];
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[]; [];
for (const carrier of reportCarriers) { for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ const entry = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${item.signature.id}, 910))`;
const existingTask = await tx.channelSignatureReportTask.findFirst({
where: { where: {
signatureId: item.signature.id, signatureId: item.signature.id,
channelId, channelId,
@@ -90,28 +89,30 @@ export class ReportChannelExportService {
}, },
}); });
const task = existingTask const task = existingTask
? await this.prisma.channelSignatureReportTask.update({ ? await tx.channelSignatureReportTask.update({
where: { id: existingTask.id }, where: { id: existingTask.id },
data: { data: {
status: missingReason ? 'waiting_material' : 'exporting', status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason, reason: missingReason,
...(reportType === 'signature' ? { approvedAt: null } : {}), approvedAt: null,
}, },
}) })
: await this.prisma.channelSignatureReportTask.create({ : await tx.channelSignatureReportTask.create({
data: { data: {
tenantId: item.signature.tenantId, tenantId: item.signature.tenantId,
signatureId: item.signature.id, signatureId: item.signature.id,
channelId, channelId,
carrier, carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', approvalScope: 'carrier_specific',
reportType, reportType,
drainageItemId: item.drainageInfo?.id, drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting', status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason, reason: missingReason,
}, },
}); });
tasks.push({ task, existingTask }); return { task, existingTask };
});
tasks.push(entry);
} }
const task = tasks[0].task; const task = tasks[0].task;
if (missingReason) { if (missingReason) {
@@ -312,7 +313,7 @@ export class ReportChannelExportService {
missingFields: detail.missingFields, missingFields: detail.missingFields,
}); });
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台'; workbook.creator = '聆界短信平台';
const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] }); const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.columns = detail.fields.map((field) => ({ sheet.columns = detail.fields.map((field) => ({
header: field.exportName || field.name, header: field.exportName || field.name,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import { SignatureNameConflict } from '../sms-config/signature-uniqueness';
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts'; import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
import { import {
normalizePage, normalizePage,
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures }; return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
} }
private async findImportSignature(tenantId: string, applicationId: string | undefined, name: string) {
const where = { tenantId, applicationId: applicationId ?? null, name };
return (
(await this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: { notIn: ['deleted', 'disabled'] } },
})) ??
this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: 'disabled' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
);
}
async stageSignatureRow( async stageSignatureRow(
tenantId: string, tenantId: string,
applicationId: string | undefined, applicationId: string | undefined,
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
if (!name) throw new Error('缺少短信签名'); if (!name) throw new Error('缺少短信签名');
const purpose = mappedCorePatchValue(mappings, values, 'purpose'); const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values); const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({ const existing = await this.findImportSignature(tenantId, applicationId, name);
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
return { return {
operation: existing ? 'update' : 'create', operation: existing ? 'update' : 'create',
targetId: existing?.id, targetId: existing?.id,
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改'); if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else { } else {
const duplicate = await this.prisma.smsSignature.findFirst({ const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
});
if (duplicate) { if (duplicate) {
targetId = duplicate.id; targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId); await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else { } else {
try {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() }); const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id; targetId = created.id;
} catch (error) {
if (!(error instanceof SignatureNameConflict)) throw error;
// A concurrent create won. Imports continue to supplement the existing materials.
const current = await this.findImportSignature(batch.tenantId, applicationId, name);
if (!current) throw error;
targetId = current.id;
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
}
} }
} }
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` }); await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
@@ -1,58 +1,85 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportPendingQueryService } from './pending-query.service'; import { ReportPendingQueryService } from './pending-query.service';
import { safeSpreadsheetText, styleHeader } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */ /** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportOfficialExportService { export class ReportOfficialExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {} constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
private readonly pending: ReportPendingQueryService,
) {}
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) { async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台'; workbook.creator = '聆界短信平台';
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] }); const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', {
const headers = reportType === 'signature' views: [{ state: 'frozen', ySplit: 1 }],
});
const headers =
reportType === 'signature'
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注'] ? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
: ['短信签名', '引流 URL 或号码', '备注', '主体证明']; : ['短信签名', '引流 URL 或号码', '备注', '主体证明'];
sheet.addRow(headers); sheet.addRow(headers);
sheet.addRow(reportType === 'signature' sheet.addRow(
reportType === 'signature'
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
: ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']); : ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片'],
);
styleHeader(sheet.getRow(1)); styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => { column.width = 24; }); sheet.columns.forEach((column) => {
column.width = 24;
});
sheet.getRow(2).height = 48; sheet.getRow(2).height = 48;
const content = Buffer.from(await workbook.xlsx.writeBuffer()); const content = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`; const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
await this.prisma.operationLog.create({ data: { await this.prisma.operationLog.create({
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material', data: {
userId: operatorId,
action: 'report_material.template_downloaded',
resource: 'report_material',
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue, detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
} }); },
});
return { fileName, content }; return { fileName, content };
} }
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) { async exportPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string },
operatorId?: string,
) {
const items = await this.pending.findPendingItems(query); const items = await this.pending.findPendingItems(query);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] }); const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']); sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
styleHeader(sheet.getRow(1)); styleHeader(sheet.getRow(1));
for (const item of items) sheet.addRow([ for (const item of items)
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name), sheet.addRow([
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt, item.reportType === 'signature' ? '签名' : '引流信息',
safeSpreadsheetText(item.tenant?.name),
safeSpreadsheetText(item.application?.name),
safeSpreadsheetText(item.name),
safeSpreadsheetText(item.detail),
item.changedAt,
]); ]);
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; }); sheet.columns.forEach((column, index) => {
column.width = index === 4 ? 42 : 22;
});
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`; const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
await this.prisma.operationLog.create({ data: { await this.prisma.operationLog.create({
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material', data: {
tenantId: query.tenantId,
userId: operatorId,
action: 'report_material.pending_export',
resource: 'report_material',
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue, detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
} }); },
});
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) }; return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
} }
} }
+219
View File
@@ -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);
}
}
}
@@ -11,6 +11,14 @@ const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobil
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) }; const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 }; const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => { 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', () => { it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]); const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b'); expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
@@ -40,13 +40,16 @@ export class ChannelWordSnapshot {
content: string, content: string,
items: T[], items: T[],
options: Parameters<typeof selectChannelCandidate>[1], options: Parameters<typeof selectChannelCandidate>[1],
contentForChannel?: (channelId: string) => string,
) { ) {
const candidates = items.filter((item) => selectChannelCandidate([item], options)); const candidates = items.filter((item) => selectChannelCandidate([item], options));
const candidateIds = new Set(candidates.map((item) => item.channelId)); 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 names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
const hits = this.hits(content) const hits = (
.filter((hit) => candidateIds.has(hit.channelId)) contentForChannel
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId })); ? [...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 excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded }); const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
const rejected = !selected && candidates.length > 0 && hits.length > 0; const rejected = !selected && candidates.length > 0 && hits.length > 0;
@@ -59,6 +62,13 @@ export class ChannelWordSnapshot {
readAt: this.readAt, readAt: this.readAt,
stage: 'route', stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'), contentHash: createHash('sha256').update(content).digest('hex'),
...(contentForChannel
? {
candidateContentHashes: Object.fromEntries(
[...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).digest('hex')]),
),
}
: {}),
candidateChannelIds: [...candidateIds], candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId), excludedChannelIds: hits.map((hit) => hit.channelId),
hits, hits,
+35
View File
@@ -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');
}
}
+26
View File
@@ -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, segmentIndex: segment.segmentIndex,
segmentTotal: group.segmentTotal, segmentTotal: group.segmentTotal,
submitSequenceId, submitSequenceId,
submitGroupMessageId: group.messageId, submitGroupMessageId: group.messageId,
registeredDelivery: segment.registeredDelivery, 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, segmentIndex: 1,
segmentTotal: 1, segmentTotal: 1,
submitSequenceId, submitSequenceId,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId, submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
// Null means a historical CMPP record created before this field existed. // Null means a historical CMPP record created before this field existed.
registeredDelivery: message.cmppRegisteredDelivery !== false, 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({
@@ -30,6 +30,28 @@ const material = (id: string, url: string, channels: string[], auditStatus = 'ap
})), })),
}); });
describe('drainage authorization', () => { 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([ test.each([
'lisglo.cn', 'lisglo.cn',
'sms.lisglo.cn', 'sms.lisglo.cn',
@@ -128,3 +150,17 @@ describe('drainage authorization', () => {
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false); 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']);
});
});
+10 -4
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { parse } from 'tldts'; import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
@@ -19,7 +20,7 @@ export type DrainageMaterial = {
url: string; url: string;
auditStatus: string; auditStatus: string;
materialVersion: number; materialVersion: number;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>; reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>;
}; };
export type DrainageAssessment = { export type DrainageAssessment = {
version: string; version: string;
@@ -80,7 +81,11 @@ export function drainageTargets(content: string, matches: DrainageDetectionMatch
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效'); if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
// Extend the entire URL token, including suffix labels, userInfo and query. // Extend the entire URL token, including suffix labels, userInfo and query.
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i; const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
while (start > 0 && token.test(normalized.text[start - 1])) start--; 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++; while (end < normalized.text.length && token.test(normalized.text[end])) end++;
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, ''); const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
const value = drainageHost(text); const value = drainageHost(text);
@@ -138,8 +143,9 @@ export function assessDrainage(
} }
const channels = new Set( const channels = new Set(
approved.flatMap((item) => approved.flatMap((item) =>
item.reportTasks [...new Set(item.reportTasks.map((task) => task.channelId))]
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier)) .map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier))
.filter((task): task is NonNullable<typeof task> => task?.status === 'approved')
.map((task) => task.channelId), .map((task) => task.channelId),
), ),
); );
@@ -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();
});
});
@@ -41,10 +41,12 @@ export class DrainageSubmitGuardController {
if ( if (
!submit || !submit ||
submit.channelId !== body.channelId || submit.channelId !== body.channelId ||
createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash createHash('sha256')
.update(submit.sentContent ?? submit.messageRecord.content)
.digest('hex') !== body.contentHash
) )
return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' }; return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' };
let message = submit.messageRecord; let message = { ...submit.messageRecord, content: submit.sentContent ?? submit.messageRecord.content };
if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) { if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) {
const template = await tx.smsTemplate.findFirst({ const template = await tx.smsTemplate.findFirst({
where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId }, where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId },
@@ -58,19 +60,9 @@ export class DrainageSubmitGuardController {
) )
return { allowed: false, code: 'DRN', reason: '提交或消息已终结,不得重复发送' }; return { allowed: false, code: 'DRN', reason: '提交或消息已终结,不得重复发送' };
if (!message.tenantId && !message.batchTaskId) { if (!message.tenantId && !message.batchTaskId) {
try { // Operations channel tests bypass signature/drainage policy only after
await evaluateMessageDrainage( // verifying the durable submit, channel, content and unfinished state.
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
return { allowed: true }; return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
} }
if (!message.tenantId || !message.applicationId || !message.signatureId) if (!message.tenantId || !message.applicationId || !message.signatureId)
return { allowed: false, code: 'DRN', reason: '提交消息未关联企业应用和签名' }; return { allowed: false, code: 'DRN', reason: '提交消息未关联企业应用和签名' };
+61
View File
@@ -0,0 +1,61 @@
import { SendBatchEntryService } from './send-batch-entry.service';
import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
function fixture() {
const application = {
id: 'app',
tenantId: 'tenant',
status: 'active',
interfaceEnabled: false,
httpConfig: { enabled: true, sendEnabled: true },
};
const prisma = {
tenant: { findUnique: jest.fn().mockResolvedValue({ status: 'active', certificationStatus: 'approved' }) },
smsApplication: { findUnique: jest.fn().mockImplementation(async () => application) },
};
const facade = { validateSendResources: jest.fn().mockRejectedValue(new Error('stop after validation')) };
const service = new SendBatchEntryService(
prisma as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
facade as never,
undefined as never,
);
return { application, service, facade };
}
describe('independent HTTP send gate', () => {
it('accepts HTTP-only applications and still rejects ordinary sends with CMPP disabled', async () => {
const { service } = fixture();
await expect(
service.validateSendResources('tenant', 'app', undefined, { httpRequest: true }),
).resolves.toBeUndefined();
await expect(service.validateSendResources('tenant', 'app')).rejects.toThrow('短信应用接口未开通');
});
it.each(['enabled', 'sendEnabled'] as const)('rejects disabled HTTP %s even if CMPP is enabled', async (key) => {
const { service, application } = fixture();
application.interfaceEnabled = true;
application.httpConfig[key] = false;
await expect(service.validateSendResources('tenant', 'app', undefined, { httpRequest: true })).rejects.toThrow(
'HTTP发送未开通',
);
});
it('uses only the internal request symbol, not a caller supplied sourceType', async () => {
const { service, facade } = fixture();
const data = {
tenantId: 'tenant',
applicationId: 'app',
content: 'test',
phones: ['13800138000'],
sourceType: 'api' as const,
};
await expect(service.createBatchTask(data)).rejects.toThrow('stop after validation');
expect(facade.validateSendResources).toHaveBeenLastCalledWith('tenant', 'app', undefined, { httpRequest: false });
await expect(
service.createBatchTask({ ...data, [HTTP_REQUEST_CONTEXT]: { id: 'req', requestId: 'req_id' } }),
).rejects.toThrow('stop after validation');
expect(facade.validateSendResources).toHaveBeenLastCalledWith('tenant', 'app', undefined, { httpRequest: true });
});
});
@@ -0,0 +1,56 @@
import { Prisma } from '@prisma/client';
import { SendReceiptService } from './send-receipt.service';
describe('concurrent protocol receipt intake', () => {
function fixture() {
const duplicate = new Prisma.PrismaClientKnownRequestError('duplicate receiptKey', {
code: 'P2002',
clientVersion: 'test',
});
const prisma = {
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel' }) },
upstreamReceiptInbox: {
upsert: jest.fn().mockRejectedValue(duplicate),
findUnique: jest.fn().mockResolvedValue({ id: 'durable', status: 'matched' }),
},
};
return {
prisma,
duplicate,
service: new SendReceiptService(prisma as never, {} as never, undefined, {} as never, {}),
};
}
const event = {
channelId: 'channel',
sequenceId: 4294967295,
gatewayMessageId: '18446744073709551615',
receiptStatus: 'delivered' as const,
rawStatus: 'DELIVRD',
};
it('acknowledges only the same durable receipt after a unique-key race', async () => {
const { prisma, service } = fixture();
await expect(service.intakeReceipt(event)).resolves.toEqual({
accepted: true,
inboxId: 'durable',
status: 'matched',
});
expect(prisma.upstreamReceiptInbox.findUnique.mock.calls[0][0].where).toEqual(
prisma.upstreamReceiptInbox.upsert.mock.calls[0][0].where,
);
});
it('does not hide a missing conflicting row or an unrelated database outage', async () => {
const { prisma, duplicate, service } = fixture();
prisma.upstreamReceiptInbox.findUnique.mockResolvedValue(null);
await expect(service.intakeReceipt(event)).rejects.toBe(duplicate);
const outage = new Error('database unavailable');
prisma.upstreamReceiptInbox.upsert.mockRejectedValue(outage);
await expect(service.intakeReceipt(event)).rejects.toBe(outage);
});
it('rejects invalid sequence before looking up or writing any business records', async () => {
const { prisma, service } = fixture();
await expect(service.intakeReceipt({ ...event, sequenceId: -1 })).rejects.toThrow();
expect(prisma.smsChannel.findUnique).not.toHaveBeenCalled();
expect(prisma.upstreamReceiptInbox.upsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,109 @@
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
account: 'supplier',
gatewayHost: 'localhost',
gatewayPort: 7890,
protocol: 'CMPP',
cmppVersion: '3.0',
};
const message = { id: 'm', messageId: 'MSG', phoneNumber: '13800138000', tenantId: 'tenant' };
const source = (id = 's', channelId = 'c') => ({
id,
submitId: id,
channelId,
tenantId: 'tenant',
messageRecordId: 'm',
messageRecord: message,
channel,
});
const fragment = (submit = source()) => ({
messageRecordId: 'm',
messageRecord: message,
channelId: submit.channelId,
channel,
submitId: submit.submitId,
submitRecord: submit,
});
const event = {
messageId: 'MSG',
channelId: 'c',
gatewayMessageId: 'GW',
phoneNumber: message.phoneNumber,
receiptStatus: 'delivered' as const,
rawStatus: 'DELIVRD',
};
function fixture(submits: unknown[] = [], fragments: unknown[] = []) {
return {
smsMessageRecord: { findUnique: jest.fn().mockResolvedValue(message) },
smsSubmitRecord: {
findMany: jest.fn().mockResolvedValue(submits),
findUnique: jest.fn().mockResolvedValue(source()),
},
smsMessageSegmentAudit: { findMany: jest.fn().mockResolvedValue(fragments) },
smsChannel: { findUnique: jest.fn().mockResolvedValue(channel) },
};
}
const resolve = (db: ReturnType<typeof fixture>, data = event) =>
resolveReceiptAttempt(db as unknown as PrismaService, data);
describe('receipt attempt identity', () => {
it('deduplicates primary and fragment evidence for one attempt', async () => {
expect((await resolve(fixture([source()], [fragment()]))).submitRecordId).toBe('s');
});
it('rejects a primary ID colliding with another attempt fragment on the same channel', async () => {
await expect(resolve(fixture([source()], [fragment(source('other'))]))).rejects.toThrow('提交尝试关联');
});
it('selects the incoming channel, irrespective of newest candidate order', async () => {
expect((await resolve(fixture([source('new', 'other'), source()]))).submitRecordId).toBe('s');
});
it('rejects two same-channel submit candidates', async () => {
await expect(resolve(fixture([source(), source('other')]))).rejects.toThrow('提交尝试关联');
});
it('accepts one other connection of the same supplier', async () => {
expect((await resolve(fixture([], [fragment(source('s', 'other'))]))).channelId).toBe('other');
});
it('rejects ambiguous connections of the same supplier', async () => {
await expect(resolve(fixture([], [fragment(source('a', 'a')), fragment(source('b', 'b'))]))).rejects.toThrow(
'提交尝试关联',
);
});
it('does not silently accept changed supplier credentials captured by Inbox', async () => {
const db = fixture([source()]);
await expect(
resolveReceiptAttempt(db as unknown as PrismaService, event, { ...channel, account: 'old-supplier' }),
).rejects.toThrow('提交尝试关联');
});
it('rejects an exact business ID with a different destination', async () => {
await expect(resolve(fixture([source()]), { ...event, phoneNumber: '13900139000' })).rejects.toThrow(
'提交尝试关联',
);
});
it('rejects malformed cross-tenant fragment relations', async () => {
await expect(resolve(fixture([], [fragment({ ...source(), tenantId: 'other' })]))).rejects.toThrow('提交尝试关联');
});
it('recovers a legacy fragment relation from its globally unique submit ID', async () => {
const db = fixture([], [{ ...fragment(), submitRecord: null }]);
expect((await resolve(db)).submitRecordId).toBe('s');
expect(db.smsSubmitRecord.findUnique).toHaveBeenCalledWith({ where: { submitId: 's' } });
});
it('rejects a truncated candidate set instead of pretending it is unique', async () => {
await expect(resolve(fixture(Array.from({ length: 101 }, () => source())))).rejects.toThrow('提交尝试关联');
});
it('limits submit-response-loss recovery to one timed-out submit in 72 hours', async () => {
const db = fixture();
db.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([source()]);
expect((await resolve(db)).submitRecordId).toBe('s');
expect(db.smsSubmitRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({
submitStatus: 'timeout',
gatewayMessageId: null,
channelId: 'c',
messageRecordId: 'm',
}),
take: 2,
}),
);
});
});
@@ -0,0 +1,125 @@
import { NotFoundException } from '@nestjs/common';
import { SmsMessageRecord } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { GatewayReceiptEventDto } from './send-chain.contracts';
import { isSameUpstreamEndpointIdentity } from './send-chain.helpers';
type UpstreamIdentity = {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
};
type Candidate = {
message: SmsMessageRecord;
messageId: string;
submitRecordId: string;
submitId: string;
channelId: string;
channel: UpstreamIdentity | null;
};
const unmatched = () => new NotFoundException('回执缺少唯一且可信的提交尝试关联');
/** A supplier Msg_Id is not globally unique. Combine submit and fragment evidence
* before accepting a candidate; a fragment can collide with another attempt's
* primary Msg_Id, including on the same logical channel. */
export async function resolveReceiptAttempt(
db: PrismaService,
data: GatewayReceiptEventDto,
identity?: UpstreamIdentity,
) {
if (!data.gatewayMessageId) throw unmatched();
const exact = data.messageId ? await db.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) : null;
const phone = data.phoneNumber?.trim();
if (exact && phone && exact.phoneNumber !== phone) throw unmatched();
const scope = exact
? { messageRecordId: exact.id }
: phone
? { messageRecord: { phoneNumber: phone } }
: { channelId: data.channelId };
const [submits, segments] = await Promise.all([
db.smsSubmitRecord.findMany({
where: { ...scope, gatewayMessageId: data.gatewayMessageId },
include: { messageRecord: true, channel: true },
take: 101,
}),
db.smsMessageSegmentAudit.findMany({
where: { ...scope, gatewayMessageId: data.gatewayMessageId },
include: { messageRecord: true, submitRecord: true, channel: true },
take: 101,
}),
]);
// A truncated set must never look unique after filtering.
if (submits.length > 100 || segments.length > 100) throw unmatched();
const candidates = new Map<string, Candidate>();
for (const submit of submits)
candidates.set(submit.id, {
message: submit.messageRecord,
messageId: submit.messageRecord.messageId,
submitRecordId: submit.id,
submitId: submit.submitId,
channelId: submit.channelId,
channel: submit.channel,
});
for (const segment of segments) {
const submit =
segment.submitRecord ?? (await db.smsSubmitRecord.findUnique({ where: { submitId: segment.submitId } }));
if (
!submit ||
submit.messageRecordId !== segment.messageRecordId ||
submit.channelId !== segment.channelId ||
submit.tenantId !== segment.messageRecord.tenantId
)
throw unmatched();
candidates.set(submit.id, {
message: segment.messageRecord,
messageId: segment.messageRecord.messageId,
submitRecordId: submit.id,
submitId: submit.submitId,
channelId: submit.channelId,
channel: segment.channel,
});
}
const all = [...candidates.values()];
const direct = all.filter((c) => c.channelId === data.channelId);
if (direct.length > 1) throw unmatched();
if (direct.length === 1) {
// Inbox supplies the identity captured at intake; changed channel credentials
// cannot silently reassign an older supplier's receipt.
if (identity && (!direct[0].channel || !isSameUpstreamEndpointIdentity(identity, direct[0].channel)))
throw unmatched();
return direct[0];
}
const incoming = identity ?? (await db.smsChannel.findUnique({ where: { id: data.channelId } }));
if (!incoming) throw unmatched();
const shared = all.filter((c) => c.channel && isSameUpstreamEndpointIdentity(incoming, c.channel));
if (shared.length > 1) throw unmatched();
if (shared.length === 1 && (exact || phone)) return shared[0];
if (!phone) throw unmatched();
// Preserve the existing narrowly bounded recovery of one timed-out submission
// whose provider identity was not recorded before its first receipt arrived.
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const legacy = await db.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: null,
submitStatus: 'timeout',
...(exact ? { messageRecordId: exact.id } : {}),
submittedAt: { gte: new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000), lte: deliveredAt },
messageRecord: { phoneNumber: phone },
},
include: { messageRecord: true, channel: true },
take: 2,
});
if (legacy.length !== 1 || (identity && !isSameUpstreamEndpointIdentity(identity, legacy[0].channel)))
throw unmatched();
const source = legacy[0];
return {
message: source.messageRecord,
messageId: source.messageRecord.messageId,
submitRecordId: source.id,
submitId: source.submitId,
channelId: source.channelId,
};
}
+23 -14
View File
@@ -1,16 +1,10 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money'; import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service'; import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/** /**
* R10 accounting implementation. * R10 accounting implementation.
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
@@ -41,16 +35,19 @@ export class SendAccountingService {
const unitPrice = moneyToNumber(message.unitPrice); const unitPrice = moneyToNumber(message.unitPrice);
const billingUnits = message.billingUnits ?? 0; const billingUnits = message.billingUnits ?? 0;
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') { if (exists && ['charged', 'refunded'].includes(exists.billingStatus)) {
return; return;
} }
const transaction = amountCents > 0 ? await this.billing.settleFrozenCharge({ const transaction =
amountCents > 0
? await this.billing.settleFrozenCharge({
tenantId: message.tenantId, tenantId: message.tenantId,
amountCents, amountCents,
taskId: message.batchTaskId, taskId: message.batchTaskId,
messageId: message.messageId, messageId: message.messageId,
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
}) : null; })
: null;
const data = { const data = {
tenantId: message.tenantId, tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined, applicationId: message.applicationId ?? undefined,
@@ -72,14 +69,22 @@ export class SendAccountingService {
} }
async releaseMessageReservation( async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string, remark: string,
) { ) {
const amountCents = moneyToNumber(message.amountCents); const amountCents = moneyToNumber(message.amountCents);
if (amountCents <= 0) { if (amountCents <= 0) {
return; return;
} }
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); const charged = await this.prisma.smsBillingRecord.findFirst({
where: { messageId: message.messageId, billingStatus: 'charged' },
});
if (charged) { if (charged) {
return; return;
} }
@@ -107,11 +112,15 @@ export class SendAccountingService {
if (amountCents <= 0) { if (amountCents <= 0) {
return; return;
} }
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); const refunded = await this.prisma.smsBillingRecord.findFirst({
where: { messageId: message.messageId, billingStatus: 'refunded' },
});
if (refunded) { if (refunded) {
return; return;
} }
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); const charged = await this.prisma.smsBillingRecord.findFirst({
where: { messageId: message.messageId, billingStatus: 'charged' },
});
if (!charged) { if (!charged) {
return; return;
} }
+147 -49
View File
@@ -1,20 +1,41 @@
import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { HTTP_REQUEST_CONTEXT } from './send-chain.contracts';
import {
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { randomUUID } from 'node:crypto';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money'; import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service'; import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service'; import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; import type {
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; CreateBatchTaskDto,
CreateHttpBatchTaskDto,
ImportPreviewDto,
ConfirmImportDto,
QueuePriority,
} from './send-chain.contracts';
import {
statusFromRisk,
parseSchedule,
parseImportRows,
normalizeQueuePriority,
matchTemplateContent,
shanghaiDateKey,
} from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection'; import { detectDrainageContent } from './drainage-content-detection';
import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; import type {
SendResourceValidationOptions,
SendSubmissionCallbacks,
SendSubmissionService,
} from './send-submission.service';
/** /**
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam. * R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -33,7 +54,13 @@ export class SendBatchEntryService {
) {} ) {}
private releaseMessageReservation( private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string, remark: string,
) { ) {
return this.callbacks.releaseMessageReservation(message, remark); return this.callbacks.releaseMessageReservation(message, remark);
@@ -56,15 +83,22 @@ export class SendBatchEntryService {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
} }
async createBatchTask(data: CreateBatchTaskDto) {
async createBatchTask(data: CreateBatchTaskDto) { const httpRequest = data[HTTP_REQUEST_CONTEXT];
const phones = [...new Set(data.phones ?? [])]; const phones = [...new Set(data.phones ?? [])];
const schedule = parseSchedule(data); const schedule = parseSchedule(data);
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId); await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId, {
httpRequest: Boolean(httpRequest),
});
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones); const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone)); let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([ const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), this.facade.resolveTemplateMessageClassification(
data.tenantId,
data.applicationId,
data.templateId,
data.content,
),
this.facade.resolveUnitPrice(data.tenantId, data.applicationId), this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
this.facade.resolveQueuePriority(data.tenantId, data.applicationId), this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId), this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
@@ -97,9 +131,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
} }
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone)); sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0; frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
frequencyBatchReason = frequencyRejectedAll frequencyBatchReason = frequencyRejectedAll ? [...frequencyRejections.values()][0]?.reason : undefined;
? [...frequencyRejections.values()][0]?.reason
: undefined;
} }
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) { if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
await this.prisma.smsSendTask.update({ await this.prisma.smsSendTask.update({
@@ -140,6 +172,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
} }
const task = await this.prisma.smsBatchTask.create({ const task = await this.prisma.smsBatchTask.create({
data: { data: {
...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
templateId: data.templateId, templateId: data.templateId,
@@ -150,7 +183,12 @@ async createBatchTask(data: CreateBatchTaskDto) {
phoneTotal: phones.length, phoneTotal: phones.length,
status: batchStatus, status: batchStatus,
riskTaskId: risk.task?.id, riskTaskId: risk.task?.id,
auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved', auditStatus:
frequencyRejectedAll || risk.status === 'rejected'
? 'rejected'
: risk.status === 'pending_review'
? 'pending'
: 'approved',
reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null, reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null,
rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null, rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null,
progressTotal: phones.length, progressTotal: phones.length,
@@ -185,7 +223,8 @@ async createBatchTask(data: CreateBatchTaskDto) {
}, },
}); });
if (phones.length > 0) { if (phones.length > 0) {
await this.prisma.smsMessageRecord.createMany({ const persistMessages = async (tx: Prisma.TransactionClient) => {
await tx.smsMessageRecord.createMany({
data: phones.map((phone) => { data: phones.map((phone) => {
const rejection = phoneRejections.get(phone); const rejection = phoneRejections.get(phone);
const status = rejection const status = rejection
@@ -203,7 +242,8 @@ async createBatchTask(data: CreateBatchTaskDto) {
signatureId: messageClassification.signatureId, signatureId: messageClassification.signatureId,
drainageInfoId: messageClassification.drainageInfoId, drainageInfoId: messageClassification.drainageInfoId,
reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined, reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
messageId: `MSG-${randomUUID()}`, ...(httpRequest ? { id: `http-${httpRequest.id}` } : {}),
messageId: httpRequest ? `MSG-http-${httpRequest.id}` : `MSG-${randomUUID()}`,
clientMessageId: data.clientMessageId, clientMessageId: data.clientMessageId,
phoneNumber: phone, phoneNumber: phone,
content: data.content, content: data.content,
@@ -217,12 +257,46 @@ async createBatchTask(data: CreateBatchTaskDto) {
status, status,
submitStatus: rejection ? 'rejected' : undefined, submitStatus: rejection ? 'rejected' : undefined,
errorCode: rejection?.code, errorCode: rejection?.code,
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined), errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? (risk.reason ?? undefined) : undefined),
}; };
}), }),
}); });
if (httpRequest) {
const rejected = batchStatus === 'rejected';
const responseBody = rejected
? { code: 'SEND_REJECTED', message: task.rejectReason ?? '短信未通过业务校验' }
: {
code: 'ACCEPTED',
requestId: httpRequest.requestId,
messageId: 'MSG-http-' + httpRequest.id,
clientMessageId: data.clientMessageId ?? null,
status: phoneRejections.has(phones[0])
? 'submit_failed'
: batchStatus === 'ready'
? 'queued'
: batchStatus,
acceptedAt: new Date().toISOString(),
};
const frozen = await tx.openApiRequest.updateMany({
where: { id: httpRequest.id, status: 'processing' },
data: {
status: rejected ? 'failed' : 'completed',
httpStatus: rejected ? 422 : 202,
businessCode: responseBody.code,
responseBody,
messageRecordId: 'http-' + httpRequest.id,
completedAt: new Date(),
},
});
if (frozen.count !== 1) throw new ConflictException('HTTP request is no longer processing');
if (batchStatus === 'ready' && sendablePhones.length > 0)
await tx.openApiDispatchOutbox.create({ data: { requestId: httpRequest.id, batchTaskId: task.id } });
} }
if (batchStatus === 'ready' && sendablePhones.length > 0) { };
if (httpRequest) await this.prisma.$transaction(persistMessages);
else await persistMessages(this.prisma);
}
if (!httpRequest && batchStatus === 'ready' && sendablePhones.length > 0) {
await this.facade.enqueueBatchTask(task.id); await this.facade.enqueueBatchTask(task.id);
} else if (batchStatus === 'failed') { } else if (batchStatus === 'failed') {
await this.facade.refreshTaskProgress(task.id); await this.facade.refreshTaskProgress(task.id);
@@ -230,7 +304,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client'); return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
} }
async createHttpBatchTask(data: CreateHttpBatchTaskDto) { async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
if (!data.applicationId) { if (!data.applicationId) {
throw new BadRequestException('公开 HTTP 发送必须关联企业应用'); throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
} }
@@ -250,7 +324,7 @@ async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
}); });
} }
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({ const task = await this.prisma.smsBatchTask.findFirst({
where: { id: taskId, tenantId, sourceType }, where: { id: taskId, tenantId, sourceType },
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } }, include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
@@ -261,7 +335,7 @@ async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return task; return task;
} }
async previewImport(data: ImportPreviewDto) { async previewImport(data: ImportPreviewDto) {
const sizeBytes = Buffer.byteLength(data.content, 'utf8'); const sizeBytes = Buffer.byteLength(data.content, 'utf8');
if (sizeBytes > 20 * 1024 * 1024) { if (sizeBytes > 20 * 1024 * 1024) {
throw new BadRequestException('导入文件不能超过 20MB'); throw new BadRequestException('导入文件不能超过 20MB');
@@ -270,10 +344,12 @@ async previewImport(data: ImportPreviewDto) {
const phones: string[] = []; const phones: string[] = [];
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = []; const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
const requiredVariables = data.requiredVariables ?? []; const requiredVariables = data.requiredVariables ?? [];
const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({ const enterpriseBlacklist = data.applicationId
? await this.prisma.enterpriseBlacklist.findMany({
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' }, where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
select: { phoneNumber: true }, select: { phoneNumber: true },
}) : []; })
: [];
const globalBlacklist = await this.prisma.globalBlacklist.findMany({ const globalBlacklist = await this.prisma.globalBlacklist.findMany({
where: { status: 'active' }, where: { status: 'active' },
select: { phoneNumber: true }, select: { phoneNumber: true },
@@ -299,7 +375,11 @@ async previewImport(data: ImportPreviewDto) {
} }
const missingVariables = requiredVariables.filter((name) => !row.variables[name]); const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
if (missingVariables.length > 0) { if (missingVariables.length > 0) {
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` }); errors.push({
rowNumber: row.rowNumber,
phoneNumber: row.phoneNumber,
reason: `变量列缺失:${missingVariables.join(',')}`,
});
continue; continue;
} }
seen.add(row.phoneNumber); seen.add(row.phoneNumber);
@@ -316,7 +396,7 @@ async previewImport(data: ImportPreviewDto) {
}; };
} }
async confirmImport(data: ConfirmImportDto) { async confirmImport(data: ConfirmImportDto) {
const preview = await this.facade.previewImport({ const preview = await this.facade.previewImport({
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
@@ -329,7 +409,7 @@ async confirmImport(data: ConfirmImportDto) {
return this.facade.createBatchTask({ ...data, phones: preview.phones }); return this.facade.createBatchTask({ ...data, phones: preview.phones });
} }
async resolveUnitPrice(tenantId: string, applicationId?: string) { async resolveUnitPrice(tenantId: string, applicationId?: string) {
if (!applicationId) { if (!applicationId) {
return 0; return 0;
} }
@@ -343,7 +423,7 @@ async resolveUnitPrice(tenantId: string, applicationId?: string) {
return moneyToNumber(application.customerUnitPrice); return moneyToNumber(application.customerUnitPrice);
} }
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> { async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
if (!applicationId) { if (!applicationId) {
return 'normal'; return 'normal';
} }
@@ -357,7 +437,7 @@ async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<Qu
return normalizeQueuePriority(application.queuePriority); return normalizeQueuePriority(application.queuePriority);
} }
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
if (!applicationId) { if (!applicationId) {
return { clientSrcId: null, applicationExtension: null }; return { clientSrcId: null, applicationExtension: null };
} }
@@ -374,7 +454,7 @@ async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
}; };
} }
async resolveTemplateMessageClassification( async resolveTemplateMessageClassification(
tenantId: string, tenantId: string,
applicationId: string | undefined, applicationId: string | undefined,
templateId: string | undefined, templateId: string | undefined,
@@ -385,8 +465,13 @@ async resolveTemplateMessageClassification(
where: { id: templateId }, where: { id: templateId },
include: { signature: true }, include: { signature: true },
}); });
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId if (
|| template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { !template ||
template.tenantId !== tenantId ||
template.applicationId !== applicationId ||
template.auditStatus !== 'approved' ||
template.signature?.auditStatus !== 'approved'
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
} }
const variables = matchTemplateContent(template.content, content); const variables = matchTemplateContent(template.content, content);
@@ -431,7 +516,7 @@ async resolveTemplateMessageClassification(
}; };
} }
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
const rejected = new Map<string, { code: string; reason: string }>(); const rejected = new Map<string, { code: string; reason: string }>();
for (const phone of phones) { for (const phone of phones) {
if (!/^1\d{10}$/.test(phone)) { if (!/^1\d{10}$/.test(phone)) {
@@ -469,7 +554,7 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
return rejected; return rejected;
} }
async validateSendResources( async validateSendResources(
tenantId: string, tenantId: string,
applicationId?: string, applicationId?: string,
templateId?: string, templateId?: string,
@@ -485,11 +570,17 @@ async validateSendResources(
if (!applicationId) { if (!applicationId) {
return; return;
} }
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: { httpConfig: true },
});
if (!application || application.tenantId !== tenantId || application.status !== 'active') { if (!application || application.tenantId !== tenantId || application.status !== 'active') {
throw new BadRequestException('短信应用不存在或已停用'); throw new BadRequestException('短信应用不存在或已停用');
} }
if (!application.interfaceEnabled) { if (options.httpRequest && (!application.httpConfig?.enabled || !application.httpConfig.sendEnabled)) {
throw new BadRequestException('短信应用HTTP发送未开通,不能发送短信');
}
if (!options.httpRequest && !application.interfaceEnabled) {
throw new BadRequestException('短信应用接口未开通,不能发送短信'); throw new BadRequestException('短信应用接口未开通,不能发送短信');
} }
if (!templateId) { if (!templateId) {
@@ -499,12 +590,14 @@ async validateSendResources(
where: { id: templateId }, where: { id: templateId },
include: { signature: true }, include: { signature: true },
}); });
const templateBelongsToApplication = template const templateBelongsToApplication =
&& template.tenantId === tenantId template && template.tenantId === tenantId && template.applicationId === applicationId;
&& template.applicationId === applicationId;
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务, // 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。 // 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) { if (
!templateBelongsToApplication ||
(!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')
) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
} }
if (!template.signature || template.signature.auditStatus !== 'approved') { if (!template.signature || template.signature.auditStatus !== 'approved') {
@@ -512,20 +605,23 @@ async validateSendResources(
} }
} }
async reserveDailySendQuota(applicationId: string, requestedCount: number) { async reserveDailySendQuota(applicationId: string, requestedCount: number) {
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount); const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
if (!result.reserved) { if (!result.reserved) {
throw new HttpException({ throw new HttpException(
{
code: 'DAILY_SEND_LIMIT_EXCEEDED', code: 'DAILY_SEND_LIMIT_EXCEEDED',
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`, message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
dailyLimit: result.dailyLimit, dailyLimit: result.dailyLimit,
requestedCount, requestedCount,
}, HttpStatus.TOO_MANY_REQUESTS); },
HttpStatus.TOO_MANY_REQUESTS,
);
} }
return result; return result;
} }
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) { async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
if (!Number.isInteger(requestedCount) || requestedCount <= 0) { if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
throw new BadRequestException('发送号码数量必须为正整数'); throw new BadRequestException('发送号码数量必须为正整数');
} }
@@ -570,11 +666,13 @@ async tryReserveDailySendQuota(applicationId: string, requestedCount: number, re
if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) { if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
throw new ConflictException('日发送配额幂等键已用于另一笔预留'); throw new ConflictException('日发送配额幂等键已用于另一笔预留');
} }
return [{ return [
{
tenantId: existing.tenantId, tenantId: existing.tenantId,
dailyLimit: existing.dailyLimit, dailyLimit: existing.dailyLimit,
usedCount: existing.usedCount, usedCount: existing.usedCount,
}]; },
];
} }
const reservedRows = await reserve(tx); const reservedRows = await reserve(tx);
if (reservedRows.length > 0) { if (reservedRows.length > 0) {
@@ -1,6 +1,9 @@
// R8 contract-only declarations. Runtime behavior remains in SendChainService. // R8 contract-only declarations. Runtime behavior remains in SendChainService.
export const HTTP_REQUEST_CONTEXT: unique symbol = Symbol('http-request-context');
export interface CreateBatchTaskDto { export interface CreateBatchTaskDto {
[HTTP_REQUEST_CONTEXT]?: { id: string; requestId: string };
tenantId: string; tenantId: string;
applicationId?: string; applicationId?: string;
templateId?: string; templateId?: string;
@@ -255,6 +258,7 @@ export interface SendJob {
export type QueuePriority = 'normal' | 'priority'; export type QueuePriority = 'normal' | 'priority';
export type RoutedChannel = { export type RoutedChannel = {
contentPolicy?: import('./template-optout-policy').ContentPolicyDecision;
channel: { channel: {
id: string; id: string;
code: string; code: string;
+72 -37
View File
@@ -1,6 +1,16 @@
import { parseProtocolSequence } from '../common/protocol-uint32';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { CreateBatchTaskDto, GatewayControlDeliveryResult, GatewayDownstreamRecoveryStatusDto, GatewayDownstreamSentDto, GatewayInboundAuthDto, GatewayReceiptEventDto, GatewaySubmitResultDto, QueuePriority } from './send-chain.contracts'; import type {
CreateBatchTaskDto,
GatewayControlDeliveryResult,
GatewayDownstreamRecoveryStatusDto,
GatewayDownstreamSentDto,
GatewayInboundAuthDto,
GatewayReceiptEventDto,
GatewaySubmitResultDto,
QueuePriority,
} from './send-chain.contracts';
// R8 pure policies and deterministic key/status helpers. No database, queue or network access. // R8 pure policies and deterministic key/status helpers. No database, queue or network access.
@@ -70,6 +80,7 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */ /** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) { export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
void _drainage; // Kept in the signature for existing callers.
return undefined; return undefined;
} }
@@ -171,7 +182,7 @@ export function parseImportRows(content: string, delimiter?: ',' | '\t') {
return dataLines.map((line, index) => { return dataLines.map((line, index) => {
const cells = splitImportLine(line, firstDelimiter); const cells = splitImportLine(line, firstDelimiter);
const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = { const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = {
rowNumber: (hasHeader ? index + 2 : index + 1), rowNumber: hasHeader ? index + 2 : index + 1,
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0], phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
variables: {}, variables: {},
}; };
@@ -194,7 +205,9 @@ export function cellByHeader(headers: string[], cells: string[], candidates: str
} }
export function normalizeCarrier(carrier?: string | null) { export function normalizeCarrier(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';
@@ -226,14 +239,20 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
return fallback; return fallback;
} }
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string, channelCarriers?: string[] | null) { export function isCarrierCompatible(
channelCarrier: string | null | undefined,
targetCarrier: string,
channelCarriers?: string[] | null,
) {
if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier)); if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier));
const normalized = normalizeCarrier(channelCarrier); const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier; return normalized === 'all' || normalized === targetCarrier;
} }
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 matchTemplateContent(templateContent: string, actualContent: string) { export function matchTemplateContent(templateContent: string, actualContent: string) {
@@ -281,7 +300,10 @@ export function isNationalChannel(item: { province?: string | null; channel: { s
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国'; return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
} }
export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) { export function isProvinceChannel(
item: { province?: string | null; channel: { sendRegion?: string | null } },
province?: string | null,
) {
if (!province) { if (!province) {
return false; return false;
} }
@@ -307,11 +329,13 @@ export function validateInboundApplicationSrcId(
} }
const fillPrefix = application.cmppAccessNumberFillEnabled const fillPrefix = application.cmppAccessNumberFillEnabled
? application.cmppAccessNumberFillPrefix?.trim() ?? '' ? (application.cmppAccessNumberFillPrefix?.trim() ?? '')
: ''; : '';
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`; const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
if (!submittedSrcId || submittedSrcId !== expectedSrcId) { if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`); throw new BadRequestException(
`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`,
);
} }
return submittedSrcId; return submittedSrcId;
} }
@@ -330,9 +354,7 @@ export function positiveInteger(value: string | undefined, fallback: number) {
} }
export function parseOptionalSequenceId(value: string | null | undefined) { export function parseOptionalSequenceId(value: string | null | undefined) {
if (!value) return undefined; return parseProtocolSequence(value);
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
} }
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] { export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
@@ -344,13 +366,17 @@ export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['r
} }
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) { export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
return createHash('sha256').update([ return createHash('sha256')
.update(
[
data.id, data.id,
data.connectionId ?? '', data.connectionId ?? '',
data.sequenceId ?? '', data.sequenceId ?? '',
data.messageId ?? '', data.messageId ?? '',
data.sequenceId ? '' : data.sentAt ?? '', data.sequenceId ? '' : (data.sentAt ?? ''),
].join('\u0000')).digest('hex'); ].join('\u0000'),
)
.digest('hex');
} }
export function shanghaiDateKey(now = new Date()) { export function shanghaiDateKey(now = new Date()) {
@@ -378,12 +404,14 @@ export function bullmqConnection() {
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) { export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
if (data.authSource && data.timestamp !== undefined) { if (data.authSource && data.timestamp !== undefined) {
const expected = createHash('md5') const expected = createHash('md5')
.update(Buffer.concat([ .update(
Buffer.concat([
Buffer.from(octetString(data.account, 6), 'binary'), Buffer.from(octetString(data.account, 6), 'binary'),
Buffer.alloc(9), Buffer.alloc(9),
Buffer.from(secretHash), Buffer.from(secretHash),
Buffer.from(String(data.timestamp).padStart(10, '0')), Buffer.from(String(data.timestamp).padStart(10, '0')),
])) ]),
)
.digest('base64'); .digest('base64');
return expected === data.authSource; return expected === data.authSource;
} }
@@ -410,8 +438,9 @@ export function hasRecoveryAuditStateChanged(
if (!previous) { if (!previous) {
return true; return true;
} }
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'] return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'].some(
.some((key) => (previous[key] ?? null) !== (current[key] ?? null)); (key) => (previous[key] ?? null) !== (current[key] ?? null),
);
} }
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
@@ -463,7 +492,8 @@ export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
if (channel.status !== 'active') { if (channel.status !== 'active') {
return false; return false;
} }
return (channel.connectionStates ?? []).some((connection) => return (channel.connectionStates ?? []).some(
(connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected', connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
); );
} }
@@ -483,11 +513,12 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
routingKey?: string; routingKey?: string;
}, },
) { ) {
const eligible = items.filter((item) => const eligible = items.filter(
!options.excludedChannelIds.has(item.channelId) (item) =>
&& options.approvedChannelIds.has(item.channelId) !options.excludedChannelIds.has(item.channelId) &&
&& normalizeCarrier(item.carrier) === options.carrier options.approvedChannelIds.has(item.channelId) &&
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers), normalizeCarrier(item.carrier) === options.carrier &&
isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
); );
const provinceCandidates = options.forceNational const provinceCandidates = options.forceNational
? [] ? []
@@ -537,11 +568,8 @@ export function aggregateReceiptSegmentState(
deliveredAt: Date, deliveredAt: Date,
) { ) {
if (audits.length === 0) { if (audits.length === 0) {
const status = data.receiptStatus === 'delivered' const status =
? 'delivered' data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
: data.receiptStatus === 'unknown'
? 'unknown'
: 'failed';
return { return {
terminal: true, terminal: true,
segmentTotal: 1, segmentTotal: 1,
@@ -576,7 +604,8 @@ export function aggregateReceiptSegmentState(
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered'); const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
if (delivered.length >= segmentTotal) { if (delivered.length >= segmentTotal) {
const latest = delivered.reduce((current, audit) => const latest = delivered.reduce((current, audit) =>
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current); (audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current,
);
return { return {
terminal: true, terminal: true,
segmentTotal, segmentTotal,
@@ -617,20 +646,26 @@ export function isSameUpstreamEndpointIdentity(
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) { ) {
return left.account.trim() === right.account.trim() return (
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() left.account.trim() === right.account.trim() &&
&& left.gatewayPort === right.gatewayPort left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() &&
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() left.gatewayPort === right.gatewayPort &&
&& left.cmppVersion.trim() === right.cmppVersion.trim(); left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() &&
left.cmppVersion.trim() === right.cmppVersion.trim()
);
} }
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) { export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
return createHash('sha256').update([ return createHash('sha256')
.update(
[
channelId, channelId,
data.gatewayMessageId, data.gatewayMessageId,
data.phoneNumber?.trim() ?? '', data.phoneNumber?.trim() ?? '',
data.receiptStatus, data.receiptStatus,
data.rawStatus.trim(), data.rawStatus.trim(),
data.errorCode ?? '', data.errorCode ?? '',
].join('\u0000')).digest('hex'); ].join('\u0000'),
)
.digest('hex');
} }
+97 -25
View File
@@ -298,6 +298,10 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }), update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
}, },
cmppDownstreamDelivery: { cmppDownstreamDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'delivery-1', messageRecordId: 'record-1', applicationId: 'app-1' }),
create: jest create: jest
.fn() .fn()
.mockImplementation(({ data }) => .mockImplementation(({ data }) =>
@@ -462,6 +466,34 @@ function createPrismaMock() {
return prisma; return prisma;
} }
// Supply relational receipt evidence separately from aggregate result fixtures.
async function receiptEvidence(
prisma: ReturnType<typeof createPrismaMock>,
overrides: Record<string, unknown> = {},
attempt: Record<string, unknown> = {},
) {
const message = { ...(await prisma.smsMessageRecord.findUnique()), ...overrides };
const prior = await prisma.smsSubmitRecord.findFirst();
const source = {
...prior,
messageRecordId: message.id,
tenantId: message.tenantId,
channelId: overrides.channelId ?? message.channelId,
submitId: message.submitId ?? prior.submitId,
messageRecord: message,
channel: await prisma.smsChannel.findUnique(),
...attempt,
};
prisma.smsSubmitRecord.findMany.mockResolvedValue([source]);
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) =>
Promise.resolve(where.retryOfSubmitRecordId ? null : source),
);
const aggregate = prisma.smsMessageSegmentAudit.findMany;
prisma.smsMessageSegmentAudit.findMany = jest
.fn()
.mockImplementation((args) => (args.include?.messageRecord ? Promise.resolve([]) : aggregate(args)));
}
function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }) { function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }) {
const billing = { const billing = {
estimateSmsCost: jest.fn().mockReturnValue({ estimateSmsCost: jest.fn().mockReturnValue({
@@ -1694,6 +1726,7 @@ describe('SendChainService', () => {
applicationId: 'app-1', applicationId: 'app-1',
eventType: 'receipt', eventType: 'receipt',
}), }),
undefined,
); );
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
}); });
@@ -3254,10 +3287,10 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'submit-1' }, where: { id: 'submit-1' },
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }), data: expect.objectContaining({ sequenceId: 7n, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
}); });
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } },
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
}); });
expect(billing.settleFrozenCharge).toHaveBeenCalledWith( expect(billing.settleFrozenCharge).toHaveBeenCalledWith(
@@ -3542,6 +3575,7 @@ describe('SendChainService', () => {
expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }), expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }),
); );
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -3596,6 +3630,7 @@ describe('SendChainService', () => {
}, },
}); });
await receiptEvidence(prisma, { ...(await prisma.smsMessageRecord.findFirst()), submitId: 'SUB-1' });
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -3631,6 +3666,10 @@ describe('SendChainService', () => {
unitPrice: 3, unitPrice: 3,
}); });
await receiptEvidence(prisma, await prisma.smsMessageRecord.findFirst(), {
channelId: 'channel-old',
submitId: 'SUB-OLD',
});
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-old', channelId: 'channel-old',
@@ -3656,12 +3695,11 @@ describe('SendChainService', () => {
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => { it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ {
id: 'submit-timeout-1', id: 'submit-timeout-1',
submitId: 'SUB-1',
messageRecordId: 'record-1',
channelId: 'channel-1', channelId: 'channel-1',
gatewayMessageId: null, gatewayMessageId: null,
submitStatus: 'timeout', submitStatus: 'timeout',
@@ -3698,7 +3736,7 @@ describe('SendChainService', () => {
}, },
data: { data: {
gatewayMessageId: 'GW-RECOVERED-1', gatewayMessageId: 'GW-RECOVERED-1',
sequenceId: 7, sequenceId: 7n,
}, },
}); });
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
@@ -3716,6 +3754,8 @@ describe('SendChainService', () => {
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([ prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([
{ {
id: 'submit-channel-b', id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b', channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID', gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: { messageRecord: {
@@ -3732,6 +3772,12 @@ describe('SendChainService', () => {
}, },
]); ]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b',
});
await service.handleReceipt({ await service.handleReceipt({
messageId: 'receipt-SHARED-UPSTREAM-ID', messageId: 'receipt-SHARED-UPSTREAM-ID',
channelId: 'channel-b', channelId: 'channel-b',
@@ -3745,7 +3791,6 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith( expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID', gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: { phoneNumber: '15601992925' }, messageRecord: { phoneNumber: '15601992925' },
}), }),
@@ -3783,7 +3828,14 @@ describe('SendChainService', () => {
submitRecordId: 'submit-original', submitRecordId: 'submit-original',
channelId: 'channel-original', channelId: 'channel-original',
gatewayMessageId: '736070230367350788', gatewayMessageId: '736070230367350788',
submitRecord: { id: 'submit-original', submitId: 'SUB-LONG-1' }, messageRecordId: 'record-long',
submitRecord: {
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
tenantId: 'tenant-1',
},
channel: { channel: {
id: 'channel-original', id: 'channel-original',
account: 'C59748', account: 'C59748',
@@ -3809,6 +3861,12 @@ describe('SendChainService', () => {
]) ])
.mockResolvedValueOnce([]); .mockResolvedValueOnce([]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
});
await service.handleReceipt({ await service.handleReceipt({
messageId: 'receipt-736070230367350788', messageId: 'receipt-736070230367350788',
channelId: 'channel-copy', channelId: 'channel-copy',
@@ -3846,7 +3904,13 @@ describe('SendChainService', () => {
submitId: 'SUB-ORIGINAL', submitId: 'SUB-ORIGINAL',
channelId: 'channel-original', channelId: 'channel-original',
gatewayMessageId: 'SHARED-ID', gatewayMessageId: 'SHARED-ID',
submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' }, messageRecordId: 'record-original',
submitRecord: {
id: 'submit-original',
submitId: 'SUB-ORIGINAL',
messageRecordId: 'record-original',
channelId: 'channel-original',
},
channel: { channel: {
id: 'channel-original', id: 'channel-original',
account: 'C59748', account: 'C59748',
@@ -3872,7 +3936,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered', receiptStatus: 'delivered',
rawStatus: 'DELIVRD', rawStatus: 'DELIVRD',
}), }),
).rejects.toThrow('SMS message record not found'); ).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
}); });
@@ -3920,6 +3984,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
]); ]);
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-LONG-1', messageId: 'MSG-LONG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4009,6 +4074,7 @@ describe('SendChainService', () => {
}, },
]); ]);
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-MESSAGE-LEVEL', messageId: 'MSG-MESSAGE-LEVEL',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4073,6 +4139,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() }, { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
]); ]);
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-CONFLICT', messageId: 'MSG-CONFLICT',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4233,6 +4300,7 @@ describe('SendChainService', () => {
}, },
}); });
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-LONG-FAIL', messageId: 'MSG-LONG-FAIL',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4274,6 +4342,7 @@ describe('SendChainService', () => {
rawStatus: 'DELIVRD', rawStatus: 'DELIVRD',
deliveredAt: '2026-07-01T10:01:00.000Z', deliveredAt: '2026-07-01T10:01:00.000Z',
}; };
await receiptEvidence(prisma);
await service.handleReceipt(receipt); await service.handleReceipt(receipt);
await service.handleReceipt(receipt); await service.handleReceipt(receipt);
@@ -4284,10 +4353,7 @@ describe('SendChainService', () => {
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => { it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null); prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ {
id: 'submit-timeout-1', id: 'submit-timeout-1',
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' }, messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
@@ -4307,7 +4373,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered', receiptStatus: 'delivered',
rawStatus: 'DELIVRD', rawStatus: 'DELIVRD',
}), }),
).rejects.toThrow('SMS message record not found'); ).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
}); });
@@ -4419,6 +4485,7 @@ describe('SendChainService', () => {
it('records receipts and uplink messages from gateway events', async () => { it('records receipts and uplink messages from gateway events', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
await receiptEvidence(prisma);
await service.handleReceipt({ await service.handleReceipt({
messageId: 'MSG-1', messageId: 'MSG-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -4454,6 +4521,7 @@ describe('SendChainService', () => {
it('records ambiguous uplink match candidates for shared access numbers', async () => { it('records ambiguous uplink match candidates for shared access numbers', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]); prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]);
prisma.smsApplication.findMany.mockResolvedValue([ prisma.smsApplication.findMany.mockResolvedValue([
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }, { id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
@@ -4474,7 +4542,7 @@ describe('SendChainService', () => {
tenantId: undefined, tenantId: undefined,
applicationId: undefined, applicationId: undefined,
matchStatus: 'ambiguous', matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用', matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
}), }),
}); });
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({ expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
@@ -4516,8 +4584,9 @@ describe('SendChainService', () => {
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' }, where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
data: { status: 'rejected' }, data: { status: 'rejected' },
}); });
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ expect(prisma.cmppDownstreamDelivery.createMany).toHaveBeenCalledWith({
data: expect.objectContaining({ data: [
expect.objectContaining({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
messageRecordId: 'record-1', messageRecordId: 'record-1',
@@ -4525,6 +4594,8 @@ describe('SendChainService', () => {
deliveryType: 'uplink', deliveryType: 'uplink',
status: 'pending', status: 'pending',
}), }),
],
skipDuplicates: true,
}); });
}); });
@@ -4782,7 +4853,7 @@ describe('SendChainService', () => {
}, },
create: expect.objectContaining({ create: expect.objectContaining({
segmentTotal: 3, segmentTotal: 3,
sequenceId: 71, sequenceId: 71n,
gatewayMessageId: 'GW-SEG-1', gatewayMessageId: 'GW-SEG-1',
submitStatus: 'accepted', submitStatus: 'accepted',
}), }),
@@ -4791,7 +4862,7 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith( expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { id: 'submit-1', gatewayMessageId: null }, where: { id: 'submit-1', gatewayMessageId: null },
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }), data: expect.objectContaining({ sequenceId: 71n, gatewayMessageId: 'GW-SEG-1' }),
}), }),
); );
}); });
@@ -4891,7 +4962,7 @@ describe('SendChainService', () => {
expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith( expect(prisma.smsMessageRecord.updateMany).toHaveBeenNthCalledWith(
1, 1,
expect.objectContaining({ expect.objectContaining({
where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown', 'timeout'] } },
data: expect.objectContaining({ status: 'submitted' }), data: expect.objectContaining({ status: 'submitted' }),
}), }),
); );
@@ -5189,7 +5260,7 @@ describe('SendChainService', () => {
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ data: expect.objectContaining({
status: 'delivered', status: 'delivered',
ackResult: 0, ackResult: 0n,
deliveredAt: new Date('2026-07-14T03:40:18.060Z'), deliveredAt: new Date('2026-07-14T03:40:18.060Z'),
}), }),
}), }),
@@ -5199,7 +5270,7 @@ describe('SendChainService', () => {
update: expect.objectContaining({ update: expect.objectContaining({
status: 'acknowledged', status: 'acknowledged',
acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'), acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'),
ackResult: 0, ackResult: 0n,
}), }),
}), }),
); );
@@ -5219,7 +5290,7 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith( expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }), data: expect.objectContaining({ ackResult: 0n, ackMessageId: '0' }),
}), }),
); );
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith( expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
@@ -5567,6 +5638,7 @@ describe('SendChainService', () => {
errorCode: 'RECEIPT_TIMEOUT', errorCode: 'RECEIPT_TIMEOUT',
}), }),
}), }),
undefined,
); );
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
+101 -1
View File
@@ -1,3 +1,5 @@
import { AttemptCompletion } from './attempt-completion';
import { completionContext, completionDatabase, CompletionRouteRequired } from './completion-context';
import { import {
BadRequestException, BadRequestException,
forwardRef, forwardRef,
@@ -84,6 +86,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private readonly submission: SendSubmissionService; private readonly submission: SendSubmissionService;
private readonly completion: SendCompletionService; private readonly completion: SendCompletionService;
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService; private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
private readonly attemptCompletion?: AttemptCompletion;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -94,6 +97,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
@Optional() phoneRouting?: PhoneRoutingLookupService, @Optional() phoneRouting?: PhoneRoutingLookupService,
@Optional() metrics?: MetricsService, @Optional() metrics?: MetricsService,
) { ) {
const rootPrisma = prisma;
prisma = completionDatabase(prisma);
this.prisma = prisma;
// Structural unit-test doubles may omit the durable delegate; real Prisma always has it.
const coordinatedBilling = rootPrisma.smsAttemptCompletionWork ? new BillingService(prisma) : billing;
const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma); const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma);
this.submission = new SendSubmissionService( this.submission = new SendSubmissionService(
prisma, prisma,
@@ -109,12 +117,46 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}, },
metrics, metrics,
); );
this.completion = new SendCompletionService(prisma, billing, openApi, this as unknown as SendCompletionFacade); this.completion = new SendCompletionService(
prisma,
coordinatedBilling,
openApi,
this as unknown as SendCompletionFacade,
);
if (rootPrisma.smsAttemptCompletionWork) {
this.attemptCompletion = new AttemptCompletion(
rootPrisma,
async (kind, payload) => {
const event = payload as unknown as {
data: GatewayReceiptEventDto & GatewaySubmitResultDto & GatewaySubmitSegmentResultDto;
incomingIdentity?: Parameters<SendChainService['handleReceipt']>[1];
olderThanHours?: number;
errorCode?: string;
reason?: string;
};
if (kind === 'receipt') return this.completion.handleReceipt(event.data, event.incomingIdentity);
if (kind === 'submit') return this.completion.handleSubmitResult(event.data);
if (kind === 'segment') return this.completion.handleSubmitSegmentResult(event.data);
if (kind === 'timeout') return this.completion.markUnknownTimeout({ olderThanHours: event.olderThanHours });
const message = await prisma.smsMessageRecord.findUniqueOrThrow({
where: { id: completionContext.getStore()!.messageRecordId },
});
if (
message.status === 'delivered' ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT')
)
return message;
return this.completion.recordCmppFailureReceipt(message, event.errorCode!, event.reason!);
},
(route) => this.submission.waitForChannelRateLimit(route.channel.id, route.channel.rateLimitPerSecond),
);
}
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this); this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
} }
onModuleInit() { onModuleInit() {
const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'all'; const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'all';
if (['all', 'worker', 'callback'].includes(processRole)) this.attemptCompletion?.start();
if (processRole === 'api' || processRole === 'callback') return; if (processRole === 'api' || processRole === 'callback') return;
if (processRole === 'outbox') { if (processRole === 'outbox') {
this.submission.startSubmitOutboxPublisher(); this.submission.startSubmitOutboxPublisher();
@@ -202,6 +244,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
async onModuleDestroy() { async onModuleDestroy() {
this.attemptCompletion?.stop();
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer); if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer); if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer); if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
@@ -484,6 +527,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
if (this.attemptCompletion && !completionContext.getStore()) {
const message = await this.completion.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const source = await this.completion.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
return this.attemptCompletion.enqueue(message.id, source.id, 'segment', { data });
}
return this.completion.handleSubmitSegmentResult(data); return this.completion.handleSubmitSegmentResult(data);
} }
@@ -495,6 +543,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
async handleSubmitResult(data: GatewaySubmitResultDto) { async handleSubmitResult(data: GatewaySubmitResultDto) {
if (this.attemptCompletion && !completionContext.getStore()) {
const message = await this.completion.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const source = await this.completion.resolveSubmitRecordForGatewayResult(message.id, data);
return this.attemptCompletion.enqueue(message.id, source.id, 'submit', { data }, data.eventId);
}
return this.completion.handleSubmitResult(data); return this.completion.handleSubmitResult(data);
} }
@@ -528,6 +581,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
cmppVersion: string; cmppVersion: string;
}, },
) { ) {
if (this.attemptCompletion && !completionContext.getStore()) {
const resolved = await this.completion.resolveReceiptMessage(data, incomingIdentity);
if (!resolved.submitRecordId) throw new NotFoundException('回执缺少可确认的提交尝试关联');
return this.attemptCompletion.enqueue(resolved.message.id, resolved.submitRecordId, 'receipt', {
data,
incomingIdentity,
});
}
return this.completion.handleReceipt(data, incomingIdentity); return this.completion.handleReceipt(data, incomingIdentity);
} }
@@ -637,6 +698,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
tenantId?: string; tenantId?: string;
applicationId?: string; applicationId?: string;
messageRecordId?: string; messageRecordId?: string;
messageId?: string;
matchStatus: string; matchStatus: string;
matchReason: string; matchReason: string;
candidates: UplinkMatchCandidateInput[]; candidates: UplinkMatchCandidateInput[];
@@ -726,6 +788,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
async markUnknownTimeout(data: TimeoutUnknownDto) { async markUnknownTimeout(data: TimeoutUnknownDto) {
if (this.attemptCompletion && !completionContext.getStore()) {
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, 72);
const candidates = await this.prisma.smsMessageRecord.findMany({
where: {
tenantId: { not: null },
OR: [
{
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: new Date(Date.now() - olderThanHours * 3600_000) },
},
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: { id: true, submitId: true, status: true },
take: 100,
});
let timeout = 0;
for (const message of candidates) {
const source = message.submitId
? await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: message.submitId } })
: null;
const result = await this.attemptCompletion.enqueue(message.id, source?.id, 'timeout', { olderThanHours });
if (message.status !== 'timeout' && result?.status === 'timeout') timeout++;
}
return { timeout };
}
return this.completion.markUnknownTimeout(data); return this.completion.markUnknownTimeout(data);
} }
@@ -803,6 +891,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}, },
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> { ): Promise<RoutedChannel> {
const context = completionContext.getStore();
if (context) {
const prepare = () => this.submission.selectChannelForMessage(message, { ...options, previewOnly: true });
if (!context.routePlanned) throw new CompletionRouteRequired(prepare);
const current = await this.submission.selectChannelForMessage(message, options);
if (!context.route || current.channel.id !== context.route.channel.id) throw new CompletionRouteRequired(prepare);
return current;
}
return this.submission.selectChannelForMessage(message, options); return this.submission.selectChannelForMessage(message, options);
} }
@@ -923,6 +1019,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
errorCode: string, errorCode: string,
reason: string, reason: string,
) { ) {
if (this.attemptCompletion && !completionContext.getStore()) {
return this.attemptCompletion.enqueue(message.id, undefined, 'rejection', { errorCode, reason });
}
return this.completion.recordCmppFailureReceipt(message, errorCode, reason); return this.completion.recordCmppFailureReceipt(message, errorCode, reason);
} }
@@ -1004,6 +1103,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
private async waitForChannelRateLimit(channelId: string, tps: number) { private async waitForChannelRateLimit(channelId: string, tps: number) {
if (completionContext.getStore()) return;
return this.submission.waitForChannelRateLimit(channelId, tps); return this.submission.waitForChannelRateLimit(channelId, tps);
} }
+38 -9
View File
@@ -1,7 +1,21 @@
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import type { OpenApiService } from '../open-api/open-api.service'; import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; import type {
GatewaySubmitResultDto,
GatewaySubmitSegmentResultDto,
GatewayReceiptEventDto,
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayPendingDeliveryQueryDto,
GatewayDownstreamSentDto,
GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewaySubmitDeadLetterDto,
RequeueGatewaySubmitExceptionDto,
GatewayDownstreamRecoveryStatusDto,
TimeoutUnknownDto,
} from './send-chain.contracts';
import { downstreamPendingTimeoutHours } from './send-chain.helpers'; import { downstreamPendingTimeoutHours } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service'; import type { SendSubmissionService } from './send-submission.service';
import { SendAccountingService } from './send-accounting.service'; import { SendAccountingService } from './send-accounting.service';
@@ -13,7 +27,6 @@ import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service'; import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>; export type SendCompletionCallbacks = Record<string, never>;
export type SendCompletionFacade = SendCompletionService & SendSubmissionService; export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
@@ -49,10 +62,7 @@ export class SendCompletionService {
return this.gatewayResult.handleSubmitSegmentResult(data); return this.gatewayResult.handleSubmitSegmentResult(data);
} }
async resolveSubmitRecordForGatewaySegmentResult( async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
messageRecordId: string,
data: GatewaySubmitSegmentResultDto,
) {
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data); return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
} }
@@ -109,7 +119,13 @@ export class SendCompletionService {
async handleReceipt( async handleReceipt(
data: GatewayReceiptEventDto, data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) { ) {
return this.receipt.handleReceipt(data, incomingIdentity); return this.receipt.handleReceipt(data, incomingIdentity);
} }
@@ -145,7 +161,13 @@ export class SendCompletionService {
async resolveReceiptMessage( async resolveReceiptMessage(
data: GatewayReceiptEventDto, data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) { ) {
return this.receipt.resolveReceiptMessage(data, incomingIdentity); return this.receipt.resolveReceiptMessage(data, incomingIdentity);
} }
@@ -206,7 +228,13 @@ export class SendCompletionService {
} }
async releaseMessageReservation( async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string, remark: string,
) { ) {
return this.accounting.releaseMessageReservation(message, remark); return this.accounting.releaseMessageReservation(message, remark);
@@ -279,6 +307,7 @@ export class SendCompletionService {
tenantId?: string; tenantId?: string;
applicationId?: string; applicationId?: string;
messageRecordId?: string; messageRecordId?: string;
messageId?: string;
matchStatus: string; matchStatus: string;
matchReason: string; matchReason: string;
candidates: UplinkMatchCandidateInput[]; candidates: UplinkMatchCandidateInput[];
@@ -1,3 +1,6 @@
import { protocolUint32ToDb } from '../common/protocol-uint32';
import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
@@ -31,7 +34,20 @@ export class SendDownstreamDeliveryService {
) {} ) {}
async handleUplink(data: GatewayUplinkEventDto) { async handleUplink(data: GatewayUplinkEventDto) {
protocolUint32ToDb(data.sequenceId);
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
{ timeout: 15_000 },
);
}
return this.persistUplink(data);
}
private async persistUplink(data: GatewayUplinkEventDto) {
if (data.eventId) { if (data.eventId) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-event:${data.eventId}`},0))`;
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } }); const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing; if (existing) return existing;
} }
@@ -47,9 +63,9 @@ export class SendDownstreamDeliveryService {
applicationId: match.applicationId, applicationId: match.applicationId,
messageRecordId: match.messageRecordId, messageRecordId: match.messageRecordId,
channelId: data.channelId, channelId: data.channelId,
messageId: data.messageId, messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: protocolUint32ToDb(data.sequenceId),
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
destId: data.destId, destId: data.destId,
content: data.content, content: data.content,
@@ -77,10 +93,10 @@ export class SendDownstreamDeliveryService {
tenantId: match.tenantId, tenantId: match.tenantId,
applicationId: match.applicationId, applicationId: match.applicationId,
messageRecordId: match.messageRecordId, messageRecordId: match.messageRecordId,
messageId: data.messageId, messageId: match.messageId,
deliveryType: 'uplink', deliveryType: 'uplink',
payload: { payload: {
messageId: data.messageId, messageId: match.messageId,
applicationId: match.applicationId, applicationId: match.applicationId,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
destId: data.destId, destId: data.destId,
@@ -94,6 +110,21 @@ export class SendDownstreamDeliveryService {
} }
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) =>
completionContext.run({ tx, messageRecordId: '' }, () =>
this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId),
),
{ timeout: 15_000 },
);
}
return this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId);
}
private async persistUplinkClaim(uplinkMessageId: string, candidateId: string, operatorId?: string) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-claim:${uplinkMessageId}`},0))`;
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({ const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
where: { id: candidateId, uplinkMessageId }, where: { id: candidateId, uplinkMessageId },
include: { include: {
@@ -111,10 +142,10 @@ export class SendDownstreamDeliveryService {
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') { if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
throw new BadRequestException('该上行记录已完成匹配,不能重复认领'); throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
} }
if (candidate.status === 'claimed') return candidate.uplinkMessage;
const claimedAt = new Date(); const claimedAt = new Date();
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null; const messageId = candidate.messageRecord?.messageId ?? null;
const [updatedUplink] = await this.prisma.$transaction([ const updatedUplink = await this.prisma.smsUplinkMessage.update({
this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId }, where: { id: uplinkMessageId },
data: { data: {
tenantId: candidate.tenantId, tenantId: candidate.tenantId,
@@ -124,24 +155,24 @@ export class SendDownstreamDeliveryService {
matchStatus: 'matched', matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`, matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
}, },
}), });
this.prisma.smsUplinkMatchCandidate.updateMany({ await this.prisma.smsUplinkMatchCandidate.updateMany({
where: { where: {
uplinkMessageId, uplinkMessageId,
id: { not: candidate.id }, id: { not: candidate.id },
status: 'pending', status: 'pending',
}, },
data: { status: 'rejected' }, data: { status: 'rejected' },
}), });
this.prisma.smsUplinkMatchCandidate.update({ await this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id }, where: { id: candidate.id },
data: { data: {
status: 'claimed', status: 'claimed',
claimedAt, claimedAt,
claimedById: operatorId, claimedById: operatorId,
}, },
}), });
this.prisma.operationLog.create({ await this.prisma.operationLog.create({
data: { data: {
tenantId: candidate.tenantId, tenantId: candidate.tenantId,
userId: operatorId, userId: operatorId,
@@ -159,8 +190,7 @@ export class SendDownstreamDeliveryService {
destId: candidate.uplinkMessage.destId, destId: candidate.uplinkMessage.destId,
}, },
}, },
}), });
]);
await this.facade.queueAndTryDownstreamDelivery({ await this.facade.queueAndTryDownstreamDelivery({
tenantId: candidate.tenantId, tenantId: candidate.tenantId,
@@ -217,20 +247,24 @@ export class SendDownstreamDeliveryService {
const cmppDeliveryAllowed = deliveryAllowed || data.allowBusinessRejectionCmppDelivery === true; const cmppDeliveryAllowed = deliveryAllowed || data.allowBusinessRejectionCmppDelivery === true;
if (deliveryAllowed && data.queueHttpWebhook !== false) { if (deliveryAllowed && data.queueHttpWebhook !== false) {
try { try {
await this.openApi?.queueWebhookEvent({ await this.openApi?.queueWebhookEvent(
{
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
messageRecordId: data.messageRecordId, messageRecordId: data.messageRecordId,
messageId: data.messageId, messageId: data.messageId,
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined, uplinkMessageId:
typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
eventType: data.deliveryType, eventType: data.deliveryType,
payload: data.payload, payload: data.payload,
}); },
completionContext.getStore()?.tx,
);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`, `HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`,
); );
if (data.propagateHttpQueueError) throw error; if (data.propagateHttpQueueError || completionContext.getStore()) throw error;
} }
} }
if (data.queueCmppDelivery === false) { if (data.queueCmppDelivery === false) {
@@ -249,6 +283,37 @@ export class SendDownstreamDeliveryService {
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string' : data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}` ? `uplink:${data.payload.uplinkMessageId}`
: null; : null;
if (completionContext.getStore()) {
if (!dedupeKey) throw new Error('completion_notification_identity_missing');
await this.prisma.cmppDownstreamDelivery.createMany({
data: [
{
tenantId: data.tenantId,
applicationId: data.applicationId,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
dedupeKey,
deliveryType: data.deliveryType,
payload,
retryEnabled:
cmppDeliveryAllowed &&
(data.deliveryType === 'uplink'
? application.downstreamUplinkRetryEnabled
: application.downstreamReceiptRetryEnabled),
status: cmppDeliveryAllowed ? 'pending' : 'abandoned',
lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
},
],
skipDuplicates: true,
});
const retained = await this.prisma.cmppDownstreamDelivery.findUniqueOrThrow({ where: { dedupeKey } });
if (
(retained.messageRecordId ?? null) !== (data.messageRecordId ?? null) ||
retained.applicationId !== data.applicationId
)
throw new Error('completion_notification_identity_mismatch');
return retained;
}
let delivery; let delivery;
try { try {
delivery = await this.prisma.cmppDownstreamDelivery.create({ delivery = await this.prisma.cmppDownstreamDelivery.create({
@@ -344,107 +409,12 @@ export class SendDownstreamDeliveryService {
tenantId?: string; tenantId?: string;
applicationId?: string; applicationId?: string;
messageRecordId?: string; messageRecordId?: string;
messageId?: string;
matchStatus: string; matchStatus: string;
matchReason: string; matchReason: string;
candidates: UplinkMatchCandidateInput[]; candidates: UplinkMatchCandidateInput[];
}> { }> {
if (data.messageId) { return resolveUplinkMatch(this.prisma, data, channel);
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
if (message?.tenantId) {
return {
tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined,
messageRecordId: message.id,
matchStatus: message.applicationId ? 'matched' : 'unmatched',
matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用',
candidates: [],
};
}
}
const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [
...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))),
];
const accessApplications =
accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
if (accessApplications.length === 1) {
return {
tenantId: accessApplications[0].tenantId,
applicationId: accessApplications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用',
candidates: [],
};
}
if (accessApplications.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
candidates: accessApplications.map((application) => ({
tenantId: application.tenantId,
applicationId: application.id,
matchSource: 'access_number',
confidence: 70,
reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`,
})),
};
}
const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000);
const recentMessages = await this.prisma.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submittedAt: { gte: since },
},
orderBy: { submittedAt: 'desc' },
take: 2,
});
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
if (matchableRecentMessages.length === 1) {
return {
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
messageRecordId: matchableRecentMessages[0].id,
matchStatus: 'matched',
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
candidates: [],
};
}
if (matchableRecentMessages.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
};
}
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
} }
async recordCmppFailureReceipt( async recordCmppFailureReceipt(
@@ -33,7 +33,11 @@ function normalizedFilter(filter: DownstreamRequeueFilter): DownstreamRequeueFil
}; };
} }
function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayableByDefault = true): Prisma.CmppDownstreamDeliveryWhereInput { function taskWhere(
filter: DownstreamRequeueFilter,
snapshotAt: Date,
replayableByDefault = true,
): Prisma.CmppDownstreamDeliveryWhereInput {
const normalized = normalizedFilter(filter); const normalized = normalizedFilter(filter);
const from = parseDateBoundary(normalized.createdAtFrom, false); const from = parseDateBoundary(normalized.createdAtFrom, false);
const to = parseDateBoundary(normalized.createdAtTo, true); const to = parseDateBoundary(normalized.createdAtTo, true);
@@ -41,16 +45,19 @@ function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayable
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined, tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined, applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined,
deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined, deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined,
status: normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined, status:
normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined,
createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt }, createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt },
OR: normalized.keyword ? [ OR: normalized.keyword
? [
{ messageId: { contains: normalized.keyword } }, { messageId: { contains: normalized.keyword } },
{ payload: { path: ['account'], string_contains: normalized.keyword } }, { payload: { path: ['account'], string_contains: normalized.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } }, { payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
{ lastError: { contains: normalized.keyword } }, { lastError: { contains: normalized.keyword } },
{ tenant: { name: { contains: normalized.keyword } } }, { tenant: { name: { contains: normalized.keyword } } },
{ application: { name: { contains: normalized.keyword } } }, { application: { name: { contains: normalized.keyword } } },
] : undefined, ]
: undefined,
}; };
} }
@@ -71,9 +78,19 @@ function verifyPreview(token: string, operatorId?: string) {
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检'); if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
const expected = createHmac('sha256', previewSecret()).update(encoded).digest(); const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
let actual: Buffer; let actual: Buffer;
try { actual = Buffer.from(supplied, 'base64url'); } catch { throw new BadRequestException('预检凭证无效,请重新预检'); } try {
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new BadRequestException('预检凭证无效,请重新预检'); actual = Buffer.from(supplied, 'base64url');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { filter: DownstreamRequeueFilter; snapshotAt: string; operatorId?: string; expiresAt: number }; } catch {
throw new BadRequestException('预检凭证无效,请重新预检');
}
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
throw new BadRequestException('预检凭证无效,请重新预检');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as {
filter: DownstreamRequeueFilter;
snapshotAt: string;
operatorId?: string;
expiresAt: number;
};
if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检'); if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检');
if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致'); if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
return payload; return payload;
@@ -85,21 +102,35 @@ function jsonFailures(value: unknown): Record<string, number> {
} }
export class SendDownstreamRequeueTaskService { export class SendDownstreamRequeueTaskService {
constructor(private readonly prisma: PrismaService, private readonly facade: RequeueFacade) {} constructor(
private readonly prisma: PrismaService,
private readonly facade: RequeueFacade,
) {}
async preview(filter: DownstreamRequeueFilter, operatorId?: string) { async preview(filter: DownstreamRequeueFilter, operatorId?: string) {
const snapshotAt = new Date(); const snapshotAt = new Date();
const normalized = normalizedFilter(filter); const normalized = normalizedFilter(filter);
const base = taskWhere(normalized, snapshotAt, false); const base = taskWhere(normalized, snapshotAt, false);
const replayableWhere = { AND: [base, { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput; const replayableWhere = {
AND: [base, { status: { in: REPLAYABLE_STATUSES } }],
} as Prisma.CmppDownstreamDeliveryWhereInput;
const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([ const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([
this.prisma.cmppDownstreamDelivery.count({ where: base }), this.prisma.cmppDownstreamDelivery.count({ where: base }),
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }), this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.findFirst({ where: base, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }), this.prisma.cmppDownstreamDelivery.findFirst({
where: base,
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
]); ]);
const tokenPayload = { filter: normalized, snapshotAt: snapshotAt.toISOString(), operatorId: operatorId || '', expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS }; const tokenPayload = {
filter: normalized,
snapshotAt: snapshotAt.toISOString(),
operatorId: operatorId || '',
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
};
return { return {
snapshotAt, snapshotAt,
previewToken: signPreview(tokenPayload), previewToken: signPreview(tokenPayload),
@@ -113,36 +144,82 @@ export class SendDownstreamRequeueTaskService {
}; };
} }
async create(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, createdById?: string) { async create(
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
createdById?: string,
) {
const reason = data.reason?.trim(); const reason = data.reason?.trim();
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字'); if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
const preview = verifyPreview(data.previewToken, createdById); const preview = verifyPreview(data.previewToken, createdById);
const filter = normalizedFilter(preview.filter); const filter = normalizedFilter(preview.filter);
const snapshotAt = new Date(preview.snapshotAt); const snapshotAt = new Date(preview.snapshotAt);
if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录'); if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录');
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: { const activeTask = await this.prisma.downstreamRequeueTask.findFirst({
where: {
status: { in: ACTIVE_TASK_STATUSES }, status: { in: ACTIVE_TASK_STATUSES },
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}), ...(filter.applicationId !== 'all'
}, select: { taskNo: true } }); ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] }
: {}),
},
select: { taskNo: true },
});
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`); if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
const where = { AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput; const where = {
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ where, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: 100001, select: { id: true, applicationId: true, status: true } }); AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }],
} as Prisma.CmppDownstreamDeliveryWhereInput;
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
where,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: 100001,
select: { id: true, applicationId: true, status: true },
});
if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录'); if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录');
if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围'); if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10))); const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10)));
const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10))); const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10)));
const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`; const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000)
.toString()
.padStart(3, '0')}`;
const task = await this.prisma.$transaction(async (tx) => { const task = await this.prisma.$transaction(async (tx) => {
const created = await tx.downstreamRequeueTask.create({ data: { const created = await tx.downstreamRequeueTask.create({
data: {
taskNo, taskNo,
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null, tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null, applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
filterSnapshot: filter as Prisma.InputJsonValue, filterSnapshot: filter as Prisma.InputJsonValue,
snapshotAt, reason, ratePerSecond, consecutiveFailureLimit: failureLimit, snapshotAt,
totalCount: deliveries.length, createdById, reason,
} }); ratePerSecond,
await tx.downstreamRequeueTaskItem.createMany({ data: deliveries.map((item) => ({ taskId: created.id, deliveryId: item.id, applicationId: item.applicationId, previousStatus: item.status })) }); consecutiveFailureLimit: failureLimit,
await tx.operationLog.create({ data: { userId: createdById, action: 'gateway.downstream_requeue_task_created', resource: 'downstream_requeue_task', resourceId: created.id, detail: { taskNo, reason, totalCount: deliveries.length, snapshotAt, filter, ratePerSecond, consecutiveFailureLimit: failureLimit } } }); totalCount: deliveries.length,
createdById,
},
});
await tx.downstreamRequeueTaskItem.createMany({
data: deliveries.map((item) => ({
taskId: created.id,
deliveryId: item.id,
applicationId: item.applicationId,
previousStatus: item.status,
})),
});
await tx.operationLog.create({
data: {
userId: createdById,
action: 'gateway.downstream_requeue_task_created',
resource: 'downstream_requeue_task',
resourceId: created.id,
detail: {
taskNo,
reason,
totalCount: deliveries.length,
snapshotAt,
filter,
ratePerSecond,
consecutiveFailureLimit: failureLimit,
},
},
});
return created; return created;
}); });
return this.get(task.id); return this.get(task.id);
@@ -153,16 +230,37 @@ export class SendDownstreamRequeueTaskService {
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10))); const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
const where = { status: query.status && query.status !== 'all' ? query.status : undefined }; const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTask.findMany({ where, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), this.prisma.downstreamRequeueTask.findMany({
where,
include: {
tenant: true,
application: true,
createdBy: { select: { id: true, displayName: true, username: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.downstreamRequeueTask.count({ where }), this.prisma.downstreamRequeueTask.count({ where }),
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
async get(id: string) { async get(id: string) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } } }); const task = await this.prisma.downstreamRequeueTask.findUnique({
where: { id },
include: {
tenant: true,
application: true,
createdBy: { select: { id: true, displayName: true, username: true } },
},
});
if (!task) throw new NotFoundException('后台重投任务不存在'); if (!task) throw new NotFoundException('后台重投任务不存在');
const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId: id }, _count: { _all: true } }); const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({
by: ['status'],
where: { taskId: id },
_count: { _all: true },
});
return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) }; return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) };
} }
@@ -175,14 +273,22 @@ export class SendDownstreamRequeueTaskService {
const where: Prisma.DownstreamRequeueTaskItemWhereInput = { const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
taskId: id, taskId: id,
status: query.status && query.status !== 'all' ? query.status : undefined, status: query.status && query.status !== 'all' ? query.status : undefined,
OR: keyword ? [ OR: keyword
? [
{ delivery: { messageId: { contains: keyword } } }, { delivery: { messageId: { contains: keyword } } },
{ skipReason: { contains: keyword } }, { skipReason: { contains: keyword } },
{ errorMessage: { contains: keyword } }, { errorMessage: { contains: keyword } },
] : undefined, ]
: undefined,
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTaskItem.findMany({ where, include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }), this.prisma.downstreamRequeueTaskItem.findMany({
where,
include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.downstreamRequeueTaskItem.count({ where }), this.prisma.downstreamRequeueTaskItem.count({ where }),
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
@@ -192,18 +298,47 @@ export class SendDownstreamRequeueTaskService {
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作'); if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } }); const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
if (!task) throw new NotFoundException('后台重投任务不存在'); if (!task) throw new NotFoundException('后台重投任务不存在');
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused']; const allowed =
action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作'); if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作');
const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated'; const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated';
const updated = await this.prisma.downstreamRequeueTask.update({ where: { id }, data: { status, pausedAt: status === 'paused' ? new Date() : null, finishedAt: status === 'terminated' ? new Date() : undefined, scanLeaseOwner: null, scanLeaseUntil: null } }); const updated = await this.prisma.downstreamRequeueTask.update({
if (status === 'terminated') await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } }, data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() } }); where: { id },
await this.prisma.operationLog.create({ data: { userId: operatorId, action: `gateway.downstream_requeue_task_${action}`, resource: 'downstream_requeue_task', resourceId: id, detail: { taskNo: task.taskNo, previousStatus: task.status, status } } }); data: {
status,
pausedAt: status === 'paused' ? new Date() : null,
finishedAt: status === 'terminated' ? new Date() : undefined,
scanLeaseOwner: null,
scanLeaseUntil: null,
},
});
if (status === 'terminated')
await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } },
data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() },
});
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: `gateway.downstream_requeue_task_${action}`,
resource: 'downstream_requeue_task',
resourceId: id,
detail: { taskNo: task.taskNo, previousStatus: task.status, status },
},
});
return updated; return updated;
} }
async runScan() { async runScan() {
await this.prisma.downstreamRequeueRateWindow.deleteMany({ where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } } }); await this.prisma.downstreamRequeueRateWindow.deleteMany({
const tasks = await this.prisma.downstreamRequeueTask.findMany({ where: { status: { in: ['queued', 'running'] } }, orderBy: { createdAt: 'asc' }, take: 3, select: { id: true } }); where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } },
});
const tasks = await this.prisma.downstreamRequeueTask.findMany({
where: { status: { in: ['queued', 'running'] } },
orderBy: { createdAt: 'asc' },
take: 3,
select: { id: true },
});
for (const task of tasks) await this.processTask(task.id); for (const task of tasks) await this.processTask(task.id);
} }
@@ -211,7 +346,11 @@ export class SendDownstreamRequeueTaskService {
const leaseOwner = randomUUID(); const leaseOwner = randomUUID();
const now = new Date(); const now = new Date();
const lease = await this.prisma.downstreamRequeueTask.updateMany({ const lease = await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] }, OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }] }, where: {
id: taskId,
status: { in: ['queued', 'running'] },
OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }],
},
data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) }, data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) },
}); });
if (!lease.count) return; if (!lease.count) return;
@@ -220,7 +359,10 @@ export class SendDownstreamRequeueTaskService {
if (!task || !['queued', 'running'].includes(task.status)) return; if (!task || !['queued', 'running'].includes(task.status)) return;
// A process may die after the database claim but before the Gateway call. The lease makes that // A process may die after the database claim but before the Gateway call. The lease makes that
// ambiguous window visible and recoverable; every recovered item is revalidated before replay. // ambiguous window visible and recoverable; every recovered item is revalidated before replay.
await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } }, data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' } }); await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } },
data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' },
});
let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures)); let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures));
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit); const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (existingFailureEntry) { if (existingFailureEntry) {
@@ -228,19 +370,36 @@ export class SendDownstreamRequeueTaskService {
await this.refreshTask(taskId); await this.refreshTask(taskId);
return; return;
} }
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'running', startedAt: task.startedAt ?? new Date() } }); await this.prisma.downstreamRequeueTask.update({
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'queued' }, orderBy: { createdAt: 'asc' }, take: Math.min(200, task.ratePerSecond * 3), select: { id: true, deliveryId: true, applicationId: true, previousStatus: true } }); where: { id: taskId },
data: { status: 'running', startedAt: task.startedAt ?? new Date() },
});
const items = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: 'queued' },
orderBy: { createdAt: 'asc' },
take: Math.min(200, task.ratePerSecond * 3),
select: { id: true, deliveryId: true, applicationId: true, previousStatus: true },
});
for (const item of items) { for (const item of items) {
const latestTask = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } }); const latestTask = await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
select: { status: true },
});
if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break; if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break;
if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue; if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue;
const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'queued' }, data: { status: 'processing', claimedAt: new Date() } }); const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { id: item.id, status: 'queued' },
data: { status: 'processing', claimedAt: new Date() },
});
if (!claimed.count) continue; if (!claimed.count) continue;
const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus); const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus);
if (outcome === 'success') failures[item.applicationId] = 0; if (outcome === 'success') failures[item.applicationId] = 0;
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1; if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
const maxFailures = Math.max(0, ...Object.values(failures)); const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } }); await this.prisma.downstreamRequeueTask.update({
where: { id: taskId },
data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures },
});
if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) { if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) {
await this.autoPause(task, item.applicationId, failures[item.applicationId]); await this.autoPause(task, item.applicationId, failures[item.applicationId]);
break; break;
@@ -248,16 +407,26 @@ export class SendDownstreamRequeueTaskService {
} }
failures = await this.reconcileWaiting(taskId, failures); failures = await this.reconcileWaiting(taskId, failures);
const maxFailures = Math.max(0, ...Object.values(failures)); const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, status: { in: ['queued', 'running'] } }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } }); await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] } },
data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures },
});
const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit); const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]); if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
await this.refreshTask(taskId); await this.refreshTask(taskId);
} finally { } finally {
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, scanLeaseOwner: leaseOwner }, data: { scanLeaseOwner: null, scanLeaseUntil: null } }); await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, scanLeaseOwner: leaseOwner },
data: { scanLeaseOwner: null, scanLeaseUntil: null },
});
} }
} }
private async processItem(itemId: string, deliveryId: string, previousStatus: string): Promise<'success' | 'failed' | 'waiting' | 'skipped'> { private async processItem(
itemId: string,
deliveryId: string,
previousStatus: string,
): Promise<'success' | 'failed' | 'waiting' | 'skipped'> {
try { try {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id: deliveryId }, where: { id: deliveryId },
@@ -271,32 +440,88 @@ export class SendDownstreamRequeueTaskService {
return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认'); return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认');
} }
if (!REPLAYABLE_STATUSES.includes(delivery.status)) { if (!REPLAYABLE_STATUSES.includes(delivery.status)) {
if (delivery.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_external_ack', skipReason: null } }); return 'waiting'; } if (delivery.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'waiting_external_ack', skipReason: null },
});
return 'waiting';
}
return this.finishItem(itemId, 'skipped', '执行前状态已变化'); return this.finishItem(itemId, 'skipped', '执行前状态已变化');
} }
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用'); if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled)
if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType)) return this.finishItem(itemId, 'skipped', '投递数据不完整'); return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({ where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } }, select: { id: true } }); if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType))
return this.finishItem(itemId, 'skipped', '投递数据不完整');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({
where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } },
select: { id: true },
});
if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理'); if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理');
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } }); const connected = await this.prisma.cmppDownstreamConnection.count({
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' } }); return 'waiting'; } where: { applicationId: delivery.applicationId, status: 'connected' },
const result = await this.facade.requeueDownstreamDelivery(deliveryId) as { status?: string; lastError?: string | null }; });
if (result?.status === 'delivered') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'success', completedAt: new Date() } }); return 'success'; } if (connected === 0) {
if (result?.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_ack', completedAt: null } }); return 'waiting'; } await this.prisma.downstreamRequeueTaskItem.update({
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'failed', errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态', completedAt: new Date() } }); where: { id: itemId },
data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' },
});
return 'waiting';
}
const result = (await this.facade.requeueDownstreamDelivery(deliveryId)) as {
status?: string;
lastError?: string | null;
};
if (result?.status === 'delivered') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'success', completedAt: new Date() },
});
return 'success';
}
if (result?.status === 'awaiting_ack') {
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status: 'waiting_ack', completedAt: null },
});
return 'waiting';
}
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: {
status: 'failed',
errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态',
completedAt: new Date(),
},
});
return 'failed'; return 'failed';
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '后台重投失败'; const message = error instanceof Error ? error.message : '后台重投失败';
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化' const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message)
: /payload|投递类型/.test(message) ? '投递数据不完整' ? '执行前状态已变化'
: /Submit|Msg_Id|Sequence/.test(message) ? '缺少原Submit映射,无法安全重投' : null; : /payload|投递类型/.test(message)
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: skipReason ? 'skipped' : 'failed', skipReason, errorMessage: skipReason ? null : message, completedAt: new Date() } }); ? '投递数据不完整'
: /Submit|Msg_Id|Sequence/.test(message)
? '缺少原Submit映射,无法安全重投'
: null;
await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: {
status: skipReason ? 'skipped' : 'failed',
skipReason,
errorMessage: skipReason ? null : message,
completedAt: new Date(),
},
});
return skipReason ? 'skipped' : 'failed'; return skipReason ? 'skipped' : 'failed';
} }
} }
private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> { private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status, skipReason: reason, completedAt: new Date() } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: itemId },
data: { status, skipReason: reason, completedAt: new Date() },
});
return 'skipped'; return 'skipped';
} }
@@ -314,24 +539,61 @@ export class SendDownstreamRequeueTaskService {
} }
private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) { private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) {
const task = currentFailures ? null : await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { applicationFailures: true } }); const task = currentFailures
? null
: await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
select: { applicationFailures: true },
});
const failures = currentFailures ?? jsonFailures(task?.applicationFailures); const failures = currentFailures ?? jsonFailures(task?.applicationFailures);
const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'waiting_connection' }, select: { id: true, applicationId: true } }); const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: 'waiting_connection' },
select: { id: true, applicationId: true },
});
for (const item of connectionItems) { for (const item of connectionItems) {
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: item.applicationId, status: 'connected' } }); const connected = await this.prisma.cmppDownstreamConnection.count({
if (connected > 0) await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'waiting_connection' }, data: { status: 'queued', errorMessage: null, claimedAt: null } }); where: { applicationId: item.applicationId, status: 'connected' },
});
if (connected > 0)
await this.prisma.downstreamRequeueTaskItem.updateMany({
where: { id: item.id, status: 'waiting_connection' },
data: { status: 'queued', errorMessage: null, claimedAt: null },
});
} }
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } }, include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } }, take: 500 }); const items = await this.prisma.downstreamRequeueTaskItem.findMany({
where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } },
include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } },
take: 500,
});
const now = new Date(); const now = new Date();
for (const item of items) { for (const item of items) {
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) { if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0n) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now } : { status: 'success', completedAt: now } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data:
item.status === 'waiting_external_ack'
? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now }
: { status: 'success', completedAt: now },
});
if (item.status === 'waiting_ack') failures[item.applicationId] = 0; if (item.status === 'waiting_ack') failures[item.applicationId] = 0;
} else if (['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) || (item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)) { } else if (
['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) ||
(item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)
) {
if (item.status === 'waiting_external_ack') { if (item.status === 'waiting_external_ack') {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null },
});
} else { } else {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'failed', errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时', completedAt: now } }); await this.prisma.downstreamRequeueTaskItem.update({
where: { id: item.id },
data: {
status: 'failed',
errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时',
completedAt: now,
},
});
failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1; failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
} }
} }
@@ -339,21 +601,70 @@ export class SendDownstreamRequeueTaskService {
return failures; return failures;
} }
private async autoPause(task: { id: string; taskNo: string; consecutiveFailureLimit: number }, applicationId: string, count: number) { private async autoPause(
task: { id: string; taskNo: string; consecutiveFailureLimit: number },
applicationId: string,
count: number,
) {
const pausedAt = new Date(); const pausedAt = new Date();
const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`; const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`;
const updated = await this.prisma.downstreamRequeueTask.updateMany({ where: { id: task.id, status: { in: ['queued', 'running'] } }, data: { status: 'paused', pausedAt, lastError: message } }); const updated = await this.prisma.downstreamRequeueTask.updateMany({
if (updated.count) await this.prisma.operationLog.create({ data: { action: 'gateway.downstream_requeue_task_auto_paused', resource: 'downstream_requeue_task', resourceId: task.id, detail: { taskNo: task.taskNo, applicationId, consecutiveFailures: count, failureLimit: task.consecutiveFailureLimit, pausedAt } } }); where: { id: task.id, status: { in: ['queued', 'running'] } },
data: { status: 'paused', pausedAt, lastError: message },
});
if (updated.count)
await this.prisma.operationLog.create({
data: {
action: 'gateway.downstream_requeue_task_auto_paused',
resource: 'downstream_requeue_task',
resourceId: task.id,
detail: {
taskNo: task.taskNo,
applicationId,
consecutiveFailures: count,
failureLimit: task.consecutiveFailureLimit,
pausedAt,
},
},
});
} }
private async refreshTask(taskId: string) { private async refreshTask(taskId: string) {
const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId }, _count: { _all: true } }); const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({
by: ['status'],
where: { taskId },
_count: { _all: true },
});
const counts = new Map(groups.map((item) => [item.status, item._count._all])); const counts = new Map(groups.map((item) => [item.status, item._count._all]));
const queued = counts.get('queued') ?? 0; const queued = counts.get('queued') ?? 0;
const active = (counts.get('processing') ?? 0) + (counts.get('waiting_connection') ?? 0) + (counts.get('waiting_ack') ?? 0) + (counts.get('waiting_external_ack') ?? 0); const active =
(counts.get('processing') ?? 0) +
(counts.get('waiting_connection') ?? 0) +
(counts.get('waiting_ack') ?? 0) +
(counts.get('waiting_external_ack') ?? 0);
const failed = counts.get('failed') ?? 0; const failed = counts.get('failed') ?? 0;
const current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } }); const current = await this.prisma.downstreamRequeueTask.findUnique({
const status = current?.status === 'paused' || current?.status === 'terminated' ? current.status : queued + active === 0 ? (failed > 0 ? 'partial_completed' : 'completed') : 'running'; where: { id: taskId },
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status, successCount: counts.get('success') ?? 0, failedCount: failed, skippedCount: counts.get('skipped') ?? 0, waitingCount: active, ...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}) } }); select: { status: true },
});
const status =
current?.status === 'paused' || current?.status === 'terminated'
? current.status
: queued + active === 0
? failed > 0
? 'partial_completed'
: 'completed'
: 'running';
await this.prisma.downstreamRequeueTask.update({
where: { id: taskId },
data: {
status,
successCount: counts.get('success') ?? 0,
failedCount: failed,
skippedCount: counts.get('skipped') ?? 0,
waitingCount: active,
...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}),
},
});
} }
} }

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