20 Commits
Author SHA1 Message Date
hectorzhao 3cacb6e8e7 fix: 修复模板唯一性及六项运营页面问题
CSS quality / css-quality (push) Waiting to run
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
245 changed files with 20174 additions and 5025 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# CMPP 平台仓库开发约束
# 聆界短信平台仓库开发约束
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
@@ -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();
+204 -7
View File
@@ -776,6 +776,8 @@ model ReportNotificationRead {
}
model SmsSignature {
// Active name uniqueness (including null applicationId) is enforced by two partial SQL indexes.
// Owned by migration 20260917120000_signature_active_name_unique; do not replace with @@unique.
id String @id @default(cuid())
tenantId String
applicationId String?
@@ -851,6 +853,7 @@ model SignatureMaterial {
}
model SmsTemplate {
optOutRules Json @default("[]")
id String @id @default(cuid())
tenantId String
applicationId String
@@ -1829,6 +1832,7 @@ model SmsDrainageDecision {
}
model SmsMessageRecord {
originalContent String?
channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[]
@@ -1922,6 +1926,8 @@ model CmppSubmitSession {
}
model SmsSubmitRecord {
sentContent String?
contentPolicy Json?
drainageGate Json?
id String @id @default(cuid())
tenantId String?
@@ -1933,7 +1939,7 @@ model SmsSubmitRecord {
sessionId String?
retryOfSubmitRecordId String? @unique
submitId String @unique
sequenceId Int?
sequenceId BigInt?
gatewayMessageId String?
submitStatus String @default("queued")
resultEventId String? @unique
@@ -2084,7 +2090,7 @@ model SmsMessageSegmentAudit {
attempt Int @default(0)
segmentTotal Int @default(1)
segmentIndex Int @default(1)
sequenceId Int?
sequenceId BigInt?
gatewayMessageId String?
submitStatus String @default("queued")
receiptStatus String?
@@ -2197,7 +2203,7 @@ model SmsReceiptRecord {
messageId String
gatewayMessageId String
phoneNumber String?
sequenceId Int?
sequenceId BigInt?
receiptStatus String
rawStatus String
errorCode String?
@@ -2268,7 +2274,7 @@ model SmsUplinkMessage {
messageRecordId String?
messageId String?
gatewayMessageId String?
sequenceId Int?
sequenceId BigInt?
phoneNumber String
destId String
content String
@@ -2337,7 +2343,7 @@ model CmppDownstreamDelivery {
sentAt DateTime?
acknowledgedAt DateTime?
ackDeadlineAt DateTime?
ackResult Int?
ackResult BigInt?
ackSequenceId String?
ackMessageId String?
connectionId String?
@@ -2445,7 +2451,7 @@ model CmppDownstreamDeliveryAttempt {
sentAt DateTime?
ackDeadlineAt DateTime?
acknowledgedAt DateTime?
ackResult Int?
ackResult BigInt?
failureType String?
errorMessage String?
createdAt DateTime @default(now())
@@ -2469,7 +2475,7 @@ model UpstreamReceiptInbox {
protocol String
protocolVersion String
provisionalMessageId String?
sequenceId Int?
sequenceId BigInt?
gatewayMessageId String
phoneNumber String?
receiptStatus String
@@ -2840,3 +2846,194 @@ model OpenApiDispatchOutbox {
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 { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module';
@@ -57,13 +61,20 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
InfrastructureMonitoringModule,
OpenApiModule,
SignatureRetirementModule,
SignatureAnalyticsModule,
HomeModule,
SecurityDetectionModule,
MetricsModule,
ReportNotificationsModule,
SendingMonitorModule,
],
controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
RequestContextMiddleware,
SessionValidationMiddleware,
ManualOperationAuditMiddleware,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
@@ -0,0 +1,53 @@
import { ChannelConfigurationService } from './channel-configuration.service';
import { selectChannelCandidate } from '../send-chain/send-chain.helpers';
describe('carrier capability reduction', () => {
it('removes incompatible group members in a transaction without reconnecting', async () => {
const channel = {
id: 'c',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
status: 'active',
config: {},
};
const prisma = {
$transaction: jest.fn(),
smsChannel: {
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })),
},
operationLog: { create: jest.fn() },
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([{ id: 'member', group: { name: 'existing', carrier: 'telecom' } }]),
deleteMany: jest.fn(),
},
};
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
carriers: ['mobile', 'unicom'],
});
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
);
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['member'] } } });
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
const candidate = {
channelId: 'c',
carrier: 'telecom',
channel: {
...channel,
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
},
};
expect(
selectChannelCandidate([candidate], {
carrier: 'telecom',
excludedChannelIds: new Set(),
approvedChannelIds: new Set(['c']),
}),
).toBeUndefined();
});
});
+120 -88
View File
@@ -1,17 +1,26 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
import type { ChangeChannelStatusDto, CreateChannelDto, UpdateChannelDto } from './channels.contracts';
import {
channelConnectionSettingsChanged,
currentShanghaiDayRange,
legacyCarrierFromCapabilities,
normalizeBusinessCarrier,
normalizeChannelCarriers,
normalizeChannelRateLimit,
normalizeChannelRuntimeConfig,
normalizeCmppVersion,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelConfigurationService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
listChannels() {
return this.prisma.smsChannel.findMany({
@@ -20,7 +29,13 @@ export class ChannelConfigurationService {
});
}
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
async listChannelsPage(query: {
keyword?: string;
carrier?: string;
status?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsChannelWhereInput = {
@@ -44,12 +59,18 @@ export class ChannelConfigurationService {
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
const pageIds = candidates
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|| left.name.localeCompare(right.name, 'zh-CN')
|| left.id.localeCompare(right.id))
.sort(
(left, right) =>
(countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) ||
left.name.localeCompare(right.name, 'zh-CN') ||
left.id.localeCompare(right.id),
)
.slice((page - 1) * pageSize, page * pageSize)
.map((channel) => channel.id);
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } });
const pageItems = await this.prisma.smsChannel.findMany({
where: { id: { in: pageIds } },
include: { connectionStates: true },
});
const itemById = new Map(pageItems.map((item) => [item.id, item]));
const items = pageIds.flatMap((id) => {
const item = itemById.get(id);
@@ -122,39 +143,28 @@ export class ChannelConfigurationService {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined
|| data.desiredConnections !== undefined
|| data.windowSize !== undefined
|| data.heartbeatIntervalSeconds !== undefined
|| data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const config =
data.config !== undefined ||
data.desiredConnections !== undefined ||
data.windowSize !== undefined ||
data.heartbeatIntervalSeconds !== undefined ||
data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond =
data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond);
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
const carriers = data.carriers !== undefined || data.carrier !== undefined
? normalizeChannelCarriers(data.carriers, data.carrier)
: existingCarriers;
if (data.carriers !== undefined || data.carrier !== undefined) {
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
if (removed.length) {
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
include: { group: true },
});
if (blockingGroups.length) {
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
}
}
}
const carriers =
data.carriers !== undefined || data.carrier !== undefined
? normalizeChannelCarriers(data.carriers, data.carrier)
: existingCarriers;
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort,
@@ -163,50 +173,72 @@ export class ChannelConfigurationService {
cmppVersion: cmppVersion ?? channel.cmppVersion,
config: config ?? channel.config,
});
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
await this.prisma.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: data,
} as Prisma.InputJsonValue,
},
const updated = await this.prisma.$transaction(async (tx) => {
const updated = await tx.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
const removedGroupItems =
data.carriers !== undefined || data.carrier !== undefined
? await tx.smsChannelGroupItem.findMany({
where: { channelId, group: { carrier: { notIn: carriers } } },
select: {
id: true,
groupId: true,
carrier: true,
province: true,
group: { select: { name: true, carrier: true } },
},
})
: [];
if (removedGroupItems.length)
await tx.smsChannelGroupItem.deleteMany({ where: { id: { in: removedGroupItems.map((item) => item.id) } } });
await tx.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: { ...data, passwordCipher: data.passwordCipher ? '[updated]' : undefined },
removedGroupItems,
} as Prisma.InputJsonValue,
},
});
return updated;
});
const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
+55 -15
View File
@@ -1,3 +1,4 @@
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -362,6 +363,10 @@ export class ChannelReportingService {
status?: string;
reportType?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
@@ -450,17 +455,25 @@ export class ChannelReportingService {
}),
);
const drainageDetails = signature.drainageItems.flatMap((drainageInfo) =>
channels
.map((channel) => {
const existing = signature.reportTasks.find(
(task) =>
task.reportType === 'drainage' &&
task.channelId === channel.id &&
task.drainageItemId === drainageInfo.id,
channels.flatMap((channel) =>
normalizeChannelCarriers(channel.carriers, channel.carrier).flatMap((carrier) => {
const tasks = signature.reportTasks.filter(
(task) => task.reportType === 'drainage' && task.drainageItemId === drainageInfo.id,
);
return existing ? { ...existing, signature } : undefined;
})
.filter(Boolean),
const existing = selectDrainageReportTask(tasks, channel.id, carrier);
return existing
? [
{
...existing,
id: existing.carrier ? existing.id : `virtual:${drainageInfo.id}:${channel.id}:${carrier}`,
carrier,
virtual: !existing.carrier,
signature,
},
]
: [];
}),
),
);
return [...signatureDetails, ...drainageDetails];
})
@@ -473,6 +486,16 @@ export class ChannelReportingService {
const changedAt = new Date(task.updatedAt);
if (query.createdAtFrom && changedAt < new Date(`${query.createdAtFrom}T00:00:00+08:00`)) return false;
if (query.createdAtTo && changedAt > new Date(`${query.createdAtTo}T23:59:59.999+08:00`)) return false;
const matches = (value: string | null | undefined, filter?: string) =>
!filter?.trim() || (value ?? '').includes(filter.trim());
if (!matches(task.signature.tenant.name, query.enterpriseKeyword)) return false;
if (!matches(task.signature.application?.name, query.applicationKeyword)) return false;
if (!matches(task.channel.name, query.channelKeyword)) return false;
const objects =
task.reportType === 'drainage'
? [task.drainageInfo?.siteName, task.drainageInfo?.url]
: [task.signature.name];
if (query.objectKeyword?.trim() && !objects.some((value) => matches(value, query.objectKeyword))) return false;
if (!query.keyword?.trim()) return true;
const keyword = query.keyword.trim();
return [
@@ -551,6 +574,9 @@ export class ChannelReportingService {
throw new BadRequestException('unsupported report task source entry');
}
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 = [
...new Set(
data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId),
@@ -561,6 +587,7 @@ export class ChannelReportingService {
reportType: 'drainage';
drainageItemId: string;
channelId: string;
carrier: string | null;
status: string;
}> = [];
for (const item of data.items) {
@@ -577,7 +604,7 @@ export class ChannelReportingService {
if (drainageInfo.auditStatus !== 'approved')
throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
const carrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
@@ -587,11 +614,23 @@ export class ChannelReportingService {
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null,
carrier,
},
});
if (reportType === 'drainage' && !existing)
throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
if (reportType === 'drainage' && !existing) {
const legacy = carrier
? await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: item.drainageItemId,
carrier: null,
},
})
: null;
if (!legacy) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
}
if (reportType === 'signature' && !carrier && !existing)
throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt =
@@ -603,7 +642,7 @@ export class ChannelReportingService {
const task = existing
? await tx.channelSignatureReportTask.update({
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({
data: {
@@ -638,6 +677,7 @@ export class ChannelReportingService {
reportType,
drainageItemId: item.drainageItemId!,
channelId: item.channelId,
carrier,
status: item.status,
});
}
+16 -12
View File
@@ -1,18 +1,24 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import type { TestChannelDto } from './channels.contracts';
import {
normalizeTestPhones,
normalizeTestContent,
normalizeGatewayConnectionStatus,
calculateBillingUnits,
buildChannelTestSubmitCommand,
} from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelTestService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
async testChannel(channelId: string, data: TestChannelDto = {}) {
const phoneNumbers = normalizeTestPhones(data);
@@ -27,8 +33,8 @@ export class ChannelTestService {
if (channel.status !== 'active') {
throw new BadRequestException('通道未启用,不能发送测试短信');
}
const connectedState = channel.connectionStates.find((state) =>
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
const connectedState = channel.connectionStates.find(
(state) => normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
);
if (!connectedState) {
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
@@ -36,7 +42,6 @@ export class ChannelTestService {
const createdAt = new Date();
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const drainageDetection = await detectDrainageContent(this.prisma, content);
const results = [];
for (const [index, phoneNumber] of phoneNumbers.entries()) {
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
@@ -51,7 +56,6 @@ export class ChannelTestService {
messageId,
phoneNumber,
content,
...drainageDetection,
billingUnits: calculateBillingUnits(content),
unitPrice: 0,
amountCents: 0,
+8
View File
@@ -246,6 +246,10 @@ export class ChannelsController {
@Query('status') status?: string,
@Query('reportType') reportType?: string,
@Query('keyword') keyword?: string,
@Query('enterpriseKeyword') enterpriseKeyword?: string,
@Query('applicationKeyword') applicationKeyword?: string,
@Query('channelKeyword') channelKeyword?: string,
@Query('objectKeyword') objectKeyword?: string,
@Query('createdAtFrom') createdAtFrom?: string,
@Query('createdAtTo') createdAtTo?: string,
@Query('page') page?: string,
@@ -260,6 +264,10 @@ export class ChannelsController {
status,
reportType,
keyword,
enterpriseKeyword,
applicationKeyword,
channelKeyword,
objectKeyword,
createdAtFrom,
createdAtTo,
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 {
$queryRaw: jest.fn().mockResolvedValue([]),
$transaction: jest.fn((callback) =>
callback({
smsChannel: {
update: channelUpdate,
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
},
smsChannelGroup: {
@@ -86,6 +89,7 @@ function createPrismaMock() {
.mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
},
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([]),
deleteMany: jest.fn(),
createMany: jest.fn(),
},
@@ -98,7 +102,7 @@ function createPrismaMock() {
createMany: jest.fn(),
},
operationLog: {
create: jest.fn(),
create: operationLogCreate,
},
}),
),
@@ -106,7 +110,7 @@ function createPrismaMock() {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })),
update: channelUpdate,
},
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
@@ -228,7 +232,7 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
operationLog: {
create: jest.fn(),
create: operationLogCreate,
findMany: jest.fn().mockResolvedValue([
{
id: 'log-1',
@@ -370,6 +374,7 @@ describe('ChannelsService', () => {
updatedAt: new Date(),
};
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
channelReportField: {
findMany: jest
.fn()
@@ -519,6 +524,7 @@ describe('ChannelsService', () => {
async (sourceEntry) => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
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 () => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
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 () => {
const prisma = createPrismaMock();
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(),
@@ -699,6 +707,7 @@ describe('ChannelsService', () => {
reportType: 'drainage',
drainageItemId: 'drain-1',
channelId: 'channel-1',
carrier: null,
status: 'approved',
},
]);
@@ -1259,6 +1268,7 @@ describe('ChannelsService', () => {
const transactionCallback = prisma.$transaction.mock.calls[0][0];
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
smsChannelGroup: {
update: jest.fn(),
+4
View File
@@ -202,6 +202,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
status?: string;
reportType?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
channelKeyword?: string;
objectKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
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 { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@@ -21,6 +23,7 @@ import { SendChainService } from './send-chain/send-chain.service';
],
controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ProtocolFieldsInterceptor },
BillingService,
RiskReviewService,
PhoneFrequencyService,
+108
View File
@@ -0,0 +1,108 @@
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
id: 'a',
accepted: true,
costUnitPrice: 300n,
gatewayId: 'g1',
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
});
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
attemptId: 'a',
gatewayId,
status,
at: at(day),
approximate: false,
});
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
describe('homepage business receipt projection', () => {
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
const a = attempt(Array(size).fill('delivered'), size);
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
const result = homeFact({ ...message, billingUnits: size }, [a], events);
expect(result.successDay).toBe('2026-09-17');
expect(result.revenue).toBe(String(size * 500));
});
it('includes paid successful fragments of prior failed attempts once', () => {
const earlier = attempt(['delivered', 'failed', 'failed']);
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
const result = homeFact(
message,
[earlier, final],
[
event('g1', 'delivered'),
event('g2', 'failed'),
event('g3', 'failed'),
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
],
);
expect(result.units).toBe(3);
expect(result.revenue).toBe('1500');
expect(result.cost).toBe('1200');
});
it('accounts for all expected units when only one failure arrives', () => {
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
expect(f.units).toBe(3);
expect(f.successDay).toBeNull();
expect(f.receiptDays).toEqual(['2026-09-17']);
});
it('rejects partial success and missing parts even if message says delivered', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered'])],
[event('g1', 'delivered'), event('g2', 'delivered')],
);
expect(f.successDay).toBeNull();
expect(f.incomplete).toBe(true);
});
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered', 'delivered'])],
[
event('g1', 'delivered', 16),
event('g2', 'delivered', 16),
event('g3', 'delivered'),
event('g3', 'delivered', 18),
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
expect(f.revenue).toBe('1500');
expect(f.cost).toBe('900');
});
it('does not combine different attempts into a complete message', () => {
const f = homeFact(
message,
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
);
expect(f.successDay).toBeNull();
});
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
const a = attempt(['delivered', 'delivered', 'delivered']);
a.segments[1].inferred = a.segments[2].inferred = true;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
a.segments[1].inferred = false;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
});
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
const f = homeFact(
message,
[{ ...attempt([]), gatewayId: 'g1' }],
[
{ ...event('g1', 'delivered'), approximate: true },
{ ...event('bad', 'failed'), attemptId: null },
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.approximate).toBe(true);
expect(f.incomplete).toBe(true);
});
it('rejects invalid units and unsafe money without rounding', () => {
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
expect(safeMoney('12345')).toBe(12345);
expect(() => safeMoney(9007199254740992n)).toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { todayKey } from '../signature-analytics/analytics-date';
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
export type HomeAttempt = {
id: string;
accepted: boolean;
gatewayId: string | null;
costUnitPrice: bigint;
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
};
export type HomeFact = {
units: number;
delivered: boolean;
successDay: string | null;
successAt: string | null;
receiptDays: string[];
revenue: string;
cost: string;
approximate: boolean;
incomplete: boolean;
};
/** Pure projection: never writes a message status or invents missing supplier receipts. */
export function homeFact(
message: { billingUnits: number; unitPrice: bigint; status: string },
attempts: HomeAttempt[],
incoming: HomeEvent[],
): HomeFact {
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
const events = new Map<string, HomeEvent>();
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
if (!events.has(key)) events.set(key, event);
}
const successes: Date[] = [];
let cost = 0n;
let incomplete = !units || incoming.some((e) => !e.attemptId);
for (const attempt of attempts) {
if (!attempt.accepted) continue;
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
const successFor = (id: string | null) =>
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
if (!attempt.segments.length) {
const success = successFor(attempt.gatewayId);
if (success && units) {
successes.push(success.at);
cost += BigInt(units) * attempt.costUnitPrice;
} else if (receipts.length) incomplete = true;
continue;
}
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
const expected = Math.max(...attempt.segments.map((s) => s.total));
const times: Date[] = [];
for (const part of parts.values()) {
// A contractual message-level receipt can account for the explicitly inferred parts only.
const received =
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
if (part.status === 'delivered' && received) {
times.push(received.at);
cost += attempt.costUnitPrice;
}
}
const complete =
expected > 0 &&
parts.size === expected &&
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
if (!complete) incomplete = true;
}
const success =
message.status === 'delivered' && successes.length
? new Date(Math.min(...successes.map((s) => s.getTime())))
: null;
if (message.status === 'delivered' && !success) incomplete = true;
const effective = [...events.values()].filter((e) => !success || e.at <= success);
return {
units,
delivered: message.status === 'delivered',
successDay: success ? todayKey(success) : null,
successAt: success?.toISOString() ?? null,
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
cost: success ? cost.toString() : '0',
approximate: effective.some((e) => e.approximate),
incomplete,
};
}
export function safeMoney(value: bigint | string | number) {
const integer = BigInt(value);
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
throw new Error('金额超过安全展示范围');
return Number(integer);
}
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
+97
View File
@@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { sourceFacts } from './home-source';
import { pruneHomeVersions } from './home-retention';
@Injectable()
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
private timer?: NodeJS.Timeout;
private readonly logger = new Logger(HomeProjection.name);
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (
process.env.NODE_ENV === 'test' ||
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
)
return;
this.timer = setInterval(() => void this.run(), 10_000);
this.timer.unref();
void this.run();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async run() {
try {
await this.tick();
} catch (error) {
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
await this.db.homeProjectionState
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
.catch(() => undefined);
}
}
async tick(now = new Date()) {
return this.db.$transaction(
async (tx) => {
const [lock] = await tx.$queryRaw<
Array<{ locked: boolean }>
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
if (!lock.locked) return { busy: true };
const date = todayKey(now),
first = addDays(date, -3);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (state.seededDay !== date) {
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
}
const work = await tx.$queryRaw<
Array<{ messageRecordId: string }>
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
const ids = work.map((w) => w.messageRecordId),
version = state.version + 1;
const sources = await sourceFacts(tx, ids);
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
for (const id of ids) {
const next = sources.find((s) => s.message.id === id);
const old = prior.find((p) => p.messageRecordId === id);
const day = next ? todayKey(next.message.queuedAt) : '';
const payload = next && day >= first && day <= date ? next.fact : null;
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
if (old)
await tx.homeMessageFact.update({
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
data: { toVersion: version },
});
if (payload)
await tx.homeMessageFact.create({
data: {
messageRecordId: id,
fromVersion: version,
queuedDay: day,
payload: payload as unknown as Prisma.InputJsonValue,
},
});
}
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
const remaining = await tx.homeProjectionDirty.count();
await tx.homeProjectionState.update({
where: { id: 'home' },
data: {
version,
seededDay: date,
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
updatedAt: now,
lastError: null,
},
});
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
return { remaining, version };
},
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import { Prisma } from '@prisma/client';
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
import { percentage, safeMoney } from './home-fact';
type MetricRow = {
queuedDay: string;
total: bigint;
delivered: bigint;
units: bigint;
successUnits: bigint;
successMessages: bigint;
revenue: bigint;
cost: bigint;
approximate: bigint;
incomplete: bigint;
};
export function metrics(row?: MetricRow) {
const total = Number(row?.total ?? 0),
delivered = Number(row?.delivered ?? 0);
const units = Number(row?.units ?? 0),
success = Number(row?.successUnits ?? 0);
const revenue = safeMoney(row?.revenue ?? 0n),
cost = safeMoney(row?.cost ?? 0n);
return {
sent: total,
delivered: Number(row?.successMessages ?? 0),
successRate: percentage(delivered, total),
receiptUnits: units,
successUnits: success,
receiptSuccessRate: percentage(success, units),
revenueCents: revenue,
profitCents: revenue - cost,
profitRate: percentage(revenue - cost, revenue),
approximate: Number(row?.approximate ?? 0),
incomplete: Number(row?.incomplete ?? 0),
};
}
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
}
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const rows = await tx.$queryRaw<
Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
todayReturnedCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>
>`
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
return rows.map((r) => ({
...r,
todaySpendCents: safeMoney(r.todaySpendCents),
todayReturnedCents: safeMoney(r.todayReturnedCents),
balanceCents: safeMoney(r.balanceCents),
creditCents: safeMoney(r.creditCents),
}));
}
export async function operationStatus(tx: Prisma.TransactionClient) {
const window = downstreamAlertWindows();
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
await Promise.all([
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
tx.smsBatchTask.count(),
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
tx.cmppDownstreamDelivery.count({
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
}),
]);
return {
taskCount,
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
};
}
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
GROUP BY hour ORDER BY hour`);
const byHour = new Map(rows.map((row) => [row.hour, row]));
return Array.from({ length: 24 }, (_, hour) => ({
hour,
label: String(hour).padStart(2, '0') + ':00',
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
successCount: Number(byHour.get(hour)?.successCount ?? 0),
}));
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
/** Only disposable dashboard projections; never source SMS, receipt or accounting records. */
export async function pruneHomeVersions(tx: Prisma.TransactionClient, now: Date, firstDay: string, version: number) {
const active = await tx.homeSnapshot.aggregate({ where: { expiresAt: { gt: now } }, _min: { version: true } });
const minimum = active._min.version ?? version;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "toVersion"<=${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "queuedDay"<${firstDay}
AND "fromVersion"<${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeSnapshot" WHERE id IN (SELECT id FROM "HomeSnapshot"
WHERE "expiresAt"<${new Date(now.getTime() - 86400000)} LIMIT 1000)`;
}
+83
View File
@@ -0,0 +1,83 @@
import { Prisma } from '@prisma/client';
import { homeFact, type HomeEvent } from './home-fact';
export async function sourceFacts(tx: Prisma.TransactionClient, ids: string[]) {
const [messages, inbox, legacy] = await Promise.all([
tx.smsMessageRecord.findMany({
where: { id: { in: ids } },
include: { submitRecords: true, segmentAudits: true },
}),
tx.upstreamReceiptInbox.findMany({ where: { matchedMessageRecordId: { in: ids }, status: 'matched' } }),
tx.smsReceiptRecord.findMany({ where: { messageRecordId: { in: ids } } }),
]);
const ownedReceiptKeys = new Set(
(
await tx.upstreamReceiptInbox.findMany({
where: { receiptKey: { in: legacy.map((r) => r.receiptKey) } },
select: { receiptKey: true },
})
).map((r) => r.receiptKey),
);
return messages.map((message) => {
const parts = (id: string, submitId: string) =>
message.segmentAudits.filter((p) => p.submitRecordId === id || (!p.submitRecordId && p.submitId === submitId));
const candidates = (gatewayId: string, channelId: string | null) =>
message.submitRecords.filter(
(s) =>
s.channelId === channelId &&
(s.gatewayMessageId === gatewayId || parts(s.id, s.submitId).some((p) => p.gatewayMessageId === gatewayId)),
);
const events: HomeEvent[] = inbox
.filter((i) => i.matchedMessageRecordId === message.id)
.map((i) => {
const direct = message.submitRecords.find((s) => s.id === i.matchedSubmitRecordId);
const possible = candidates(i.gatewayMessageId, i.matchedChannelId ?? i.incomingChannelId);
return {
attemptId: direct?.id ?? (possible.length === 1 ? possible[0].id : null),
gatewayId: i.gatewayMessageId,
status: i.receiptStatus,
at: i.gatewayReceivedAt ?? i.receivedAt,
approximate: !i.gatewayReceivedAt,
};
});
for (const r of legacy.filter((r) => r.messageRecordId === message.id)) {
// An Inbox event (including unresolved/re-associated events) is not a legacy fallback.
if (ownedReceiptKeys.has(r.receiptKey)) continue;
const possible = candidates(r.gatewayMessageId, r.channelId);
const attemptId = possible.length === 1 ? possible[0].id : null;
if (
events.some(
(e) => e.attemptId === attemptId && e.gatewayId === r.gatewayMessageId && e.status === r.receiptStatus,
)
)
continue;
events.push({
attemptId,
gatewayId: r.gatewayMessageId,
status: r.receiptStatus,
at: r.createdAt,
approximate: true,
});
}
return {
message,
fact: homeFact(
message,
message.submitRecords.map((s) => ({
id: s.id,
accepted: s.submitStatus === 'accepted',
gatewayId: s.gatewayMessageId,
costUnitPrice: s.costUnitPrice,
segments: parts(s.id, s.submitId).map((p) => ({
index: p.segmentIndex,
total: p.segmentTotal,
gatewayId: p.gatewayMessageId,
status: p.receiptStatus,
inferred: p.compensationType === 'supplier_message_level_receipt',
})),
})),
events,
),
};
});
}
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Module, Query, Req } from '@nestjs/common';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { PrismaModule } from '../prisma/prisma.module';
import { HomeProjection } from './home-projection';
import { HomeService } from './home.service';
@Controller('admin/operations/home')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Get('summary')
async summary(@Req() req: SessionRequest) {
return this.home.summary(await this.home.authorize(req));
}
@Get('receipt-breakdown')
async receipts(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'receipt');
}
@Get('revenue-breakdown')
async revenue(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'revenue');
}
}
@Module({ imports: [PrismaModule], controllers: [HomeController], providers: [HomeService, HomeProjection] })
export class HomeModule {}
+118
View File
@@ -0,0 +1,118 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
@Injectable()
export class HomeService {
constructor(private readonly db: PrismaService) {}
async authorize(req: SessionRequest) {
const user =
req.authSession?.portal === 'admin' &&
req.sessionUserId &&
(await this.db.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!user) throw new ForbiddenException('无运营首页查看权限');
return user.id;
}
async summary(userId: string, now = new Date()) {
return this.db.$transaction(
async (tx) => {
// Read committed after this lock: no token may reference an already pruned version.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(17100917)`;
const date = todayKey(now);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (!state.initialized || state.seededDay !== date)
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
aggregateHome(tx, date, state.version),
enterpriseRanks(tx, date),
operationStatus(tx),
tx.homeProjectionDirty.count(),
tx.upstreamReceiptInbox.count({
where: {
status: { not: 'matched' },
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
},
}),
hourlySendTrend(tx, date),
]);
const values = metrics(rows[0]);
const summary = {
businessDate: date,
asOf: now.toISOString(),
dataThrough: state.updatedAt.toISOString(),
definitionVersion: 1,
processing:
pending > 0 ||
unresolved > 0 ||
Boolean(state.lastError) ||
now.getTime() - state.updatedAt.getTime() > 60_000,
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
today: values,
enterpriseSpendRanks: ranks,
hourlySendTrend: hourlyTrend,
...status,
};
const snapshot = await tx.homeSnapshot.create({
data: {
id: randomUUID(),
userId,
businessDate: date,
version: state.version,
createdAt: now,
expiresAt: new Date(now.getTime() + 15 * 60_000),
summary,
},
});
return { ...summary, snapshotToken: snapshot.id };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30_000 },
);
}
async breakdown(userId: string, token: string | undefined, kind: 'receipt' | 'revenue', now = new Date()) {
if (!token || !/^[0-9a-f-]{36}$/i.test(token)) throw new BadRequestException('统计快照参数无效');
return this.db.$transaction(
async (tx) => {
const snapshot = await tx.homeSnapshot.findUnique({ where: { id: token } });
if (!snapshot || snapshot.userId !== userId) throw new ForbiddenException('统计快照不可访问');
if (snapshot.expiresAt <= now || snapshot.businessDate !== todayKey(now))
throw new ConflictException('统计快照已过期,请刷新首页后重试');
const rows = await aggregateHome(tx, snapshot.businessDate, snapshot.version, true);
return {
snapshotToken: token,
businessDate: snapshot.businessDate,
items: Array.from({ length: 4 }, (_, offset) => {
const submitDate = addDays(snapshot.businessDate, -offset),
value = metrics(rows.find((r) => r.queuedDay === submitDate));
return kind === 'receipt'
? { submitDate, total: value.receiptUnits, success: value.successUnits, rate: value.receiptSuccessRate }
: {
submitDate,
revenueCents: value.revenueCents,
profitCents: value.profitCents,
rate: value.profitRate,
};
}),
};
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
@@ -37,6 +37,15 @@ export class InfrastructureMonitoringController {
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
}
@Post('alerts/:fingerprint/clear')
clearAlert(
@Param('fingerprint') fingerprint: string,
@Body('activeAt') activeAt: unknown,
@CurrentSessionUserId() userId: string,
) {
return this.monitoring.clearAlert(fingerprint, activeAt, userId);
}
@Get('alert-thresholds')
alertThresholds() {
return this.settings.get();
@@ -1,8 +1,15 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'node:crypto';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
import { retainedAlerts } from './persistent-alerts';
jest.mock('./persistent-alerts', () => ({
retainAlerts: jest.fn(async (_prisma, alerts) => alerts),
retainedAlerts: jest.fn(),
clearRetainedAlert: jest.fn(),
}));
function success(data: unknown) {
return {
@@ -14,7 +21,12 @@ function success(data: unknown) {
describe('InfrastructureMonitoringService', () => {
const prisma = {
infrastructureAlertRead: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), findUniqueOrThrow: jest.fn() },
infrastructureAlertRead: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
findUniqueOrThrow: jest.fn(),
},
operationLog: { create: jest.fn() },
$transaction: jest.fn(),
};
@@ -34,9 +46,27 @@ describe('InfrastructureMonitoringService', () => {
});
it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => {
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }), prisma as never)).toThrow('must not contain credentials');
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }), prisma as never)).toThrow('must use HTTPS');
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }), prisma as never)).not.toThrow();
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }),
prisma as never,
),
).toThrow('must not contain credentials');
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }),
prisma as never,
),
).toThrow('must use HTTPS');
expect(
() =>
new InfrastructureMonitoringService(
new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }),
prisma as never,
),
).not.toThrow();
});
it('loads real Prometheus vectors, ranges, services and active alerts', async () => {
@@ -45,34 +75,52 @@ describe('InfrastructureMonitoringService', () => {
const url = new URL(String(input));
requestedUrls.push(url);
if (url.pathname.endsWith('/alerts')) {
return success({ alerts: [{
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
state: 'firing',
activeAt: '2026-08-14T03:00:00.000Z',
value: '88.2',
}] });
return success({
alerts: [
{
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
state: 'firing',
activeAt: '2026-08-14T03:00:00.000Z',
value: '88.2',
},
],
});
}
const query = url.searchParams.get('query') ?? '';
if (url.pathname.endsWith('/query_range')) {
return success({ result: [{ metric: {}, values: [[1_765_000_000, '12.5'], [1_765_000_060, '14.5']] }] });
return success({
result: [
{
metric: {},
values: [
[1_765_000_000, '12.5'],
[1_765_000_060, '14.5'],
],
},
],
});
}
if (query.includes('node_systemd_unit_state')) {
return success({ result: [
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
] });
return success({
result: [
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
],
});
}
if (query.includes('cmpp:service_.*')) {
return success({ result: [
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
] });
return success({
result: [
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
],
});
}
if (query.includes('timestamp(node_uname_info)')) {
return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] });
@@ -87,14 +135,27 @@ describe('InfrastructureMonitoringService', () => {
expect(result.metrics.cpuUsagePercent).toBe(25);
expect(result.trends.cpuUsagePercent).toHaveLength(2);
expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 });
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({ unit: 'redis-server.service', status: 'healthy' });
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({
unit: 'redis-server.service',
status: 'healthy',
});
expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true });
expect(result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')?.value).toBe(3);
expect(
result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')
?.value,
).toBe(3);
expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' });
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5);
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range')).every((url) => url.searchParams.get('step') === '60')).toBe(true);
expect(requestedUrls.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))?.searchParams.get('query'))
.toContain('cmpp-api\\\\.service');
expect(
requestedUrls
.filter((url) => url.pathname.endsWith('/query_range'))
.every((url) => url.searchParams.get('step') === '60'),
).toBe(true);
expect(
requestedUrls
.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))
?.searchParams.get('query'),
).toContain('cmpp-api\\\\.service');
});
it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => {
@@ -124,12 +185,36 @@ describe('InfrastructureMonitoringService', () => {
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
if (!query.includes('node_filesystem_')) return success({ result: [] });
expect(query).not.toContain('mountpoint="/"');
if (url.pathname.endsWith('/query_range')) return success({ result: [...metrics].reverse().map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })) });
return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, query === FILESYSTEM_USAGE_PERCENT ? (metric.mountpoint === '/' ? '91' : '12') : query.includes('avail') ? '9' : '100'] })) });
if (url.pathname.endsWith('/query_range'))
return success({
result: [...metrics]
.reverse()
.map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })),
});
return success({
result: metrics.map((metric) => ({
metric,
value: [
1765000060,
query === FILESYSTEM_USAGE_PERCENT
? metric.mountpoint === '/'
? '91'
: '12'
: query.includes('avail')
? '9'
: '100',
],
})),
});
});
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']);
expect(result.disks[0]).toMatchObject({ usagePercent: 91, totalBytes: 100, availableBytes: 9, trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }] });
expect(result.disks[0]).toMatchObject({
usagePercent: 91,
totalBytes: 100,
availableBytes: 9,
trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }],
});
expect(result.disks[2].trend[0].value).toBe(12);
expect(result.metrics.diskUsagePercent).toBe(91);
expect(result.trends.diskUsagePercent[0].value).toBe(91);
@@ -146,20 +231,44 @@ describe('InfrastructureMonitoringService', () => {
if (!query.includes('node_filesystem_')) return success({ result: [] });
if (url.pathname.endsWith('/query_range')) {
expect(query).toBe(FILESYSTEM_USAGE_PERCENT);
return success({ result: [
{ metric: data, values: [[1765000000, '82'], [1765000060, 'NaN'], [1765000120, '83.5']] },
{ metric: root, values: [[1765000000, '91']] },
] });
return success({
result: [
{
metric: data,
values: [
[1765000000, '82'],
[1765000060, 'NaN'],
[1765000120, '83.5'],
],
},
{ metric: root, values: [[1765000000, '91']] },
],
});
}
if (query.startsWith('node_filesystem_size_bytes')) return success({ result: [
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
...['/var/root-bind', '/'].map((mountpoint) => ({ metric: { ...root, mountpoint }, value: [1765000120, '200'] })),
] });
if (query === FILESYSTEM_USAGE_PERCENT) return success({ result: [
{ metric: data, value: [1765000120, '83.5'] }, { metric: root, value: [1765000120, '91'] },
] });
if (query.startsWith('node_filesystem_size_bytes'))
return success({
result: [
...mounts.map((mountpoint) => ({ metric: { ...data, mountpoint }, value: [1765000120, '100'] })),
...['/var/root-bind', '/'].map((mountpoint) => ({
metric: { ...root, mountpoint },
value: [1765000120, '200'],
})),
],
});
if (query === FILESYSTEM_USAGE_PERCENT)
return success({
result: [
{ metric: data, value: [1765000120, '83.5'] },
{ metric: root, value: [1765000120, '91'] },
],
});
expect(query).toContain('min by (instance, device, fstype)');
return success({ result: [{ metric: data, value: [1765000120, '16.5'] }, { metric: root, value: [1765000120, '18'] }] });
return success({
result: [
{ metric: data, value: [1765000120, '16.5'] },
{ metric: root, value: [1765000120, '18'] },
],
});
});
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
const result = await service.overview('1h');
@@ -169,7 +278,13 @@ describe('InfrastructureMonitoringService', () => {
expect(result.metrics.diskUsagePercent).toBe(91);
expect(result.trends.diskUsagePercent[0].value).toBe(91);
const disk = result.disks[1];
expect(disk).toMatchObject({ id: filesystemIdentity(data), mountpoint: '/data', totalBytes: 100, availableBytes: 16.5, usagePercent: 83.5 });
expect(disk).toMatchObject({
id: filesystemIdentity(data),
mountpoint: '/data',
totalBytes: 100,
availableBytes: 16.5,
usagePercent: 83.5,
});
expect(disk.mountpoints).toEqual(['/data', '/var/lib/minio', '/var/lib/pgsql', '/var/lib/redis']);
expect(disk.trend.map((point) => point.value)).toEqual([82, 83.5]);
mounts.reverse();
@@ -198,33 +313,77 @@ describe('InfrastructureMonitoringService', () => {
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
expect(result.disks).toHaveLength(4);
expect(new Set(result.disks.map((disk) => disk.id)).size).toBe(4);
expect(result.disks.every((disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0)).toBe(true);
expect(
result.disks.every(
(disk) => disk.usagePercent === null && disk.availableBytes === null && disk.trend.length === 0,
),
).toBe(true);
expect(result.metrics.diskUsagePercent).toBeNull();
expect(result.trends.diskUsagePercent).toEqual([]);
});
it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' };
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' }] }));
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') }]);
const fingerprint = createHash('sha256')
.update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))))
.digest('hex')
.slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(
success({
alerts: [
{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' },
],
}),
);
prisma.infrastructureAlertRead.findMany.mockResolvedValue([
{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') },
]);
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 });
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') }]);
prisma.infrastructureAlertRead.findMany.mockResolvedValue([
{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') },
]);
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 });
});
it('upserts an idempotent per-user read record only for a currently active occurrence', async () => {
const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' };
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' }] }));
prisma.$transaction.mockResolvedValue([{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') }, {}]);
const fingerprint = createHash('sha256')
.update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))))
.digest('hex')
.slice(0, 24);
jest.spyOn(global, 'fetch').mockResolvedValue(
success({
alerts: [
{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' },
],
}),
);
prisma.$transaction.mockResolvedValue([
{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') },
{},
]);
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({ fingerprint, acknowledged: true });
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) }));
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow('已结束或已重新触发');
jest.mocked(retainedAlerts).mockResolvedValue([
{
fingerprint,
startedAt: '2026-08-16T02:00:00.000Z',
name: 'QaCritical',
severity: 'critical',
} as never,
]);
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({
fingerprint,
acknowledged: true,
});
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) }),
);
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow(
'已结束或已重新触发',
);
});
});
@@ -1,3 +1,4 @@
import { retainAlerts, retainedAlerts, clearRetainedAlert } from './persistent-alerts';
import {
BadRequestException,
Injectable,
@@ -202,6 +203,8 @@ export class InfrastructureMonitoringService {
private readonly logger = new Logger(InfrastructureMonitoringService.name);
private readonly prometheusUrl: string;
private readonly queryTimeoutMs: number;
private alertTimer?: ReturnType<typeof setInterval>;
private alertPoll?: Promise<InfrastructureAlert[]>;
constructor(
config: ConfigService,
@@ -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)));
}
onModuleInit() {
if (process.env.CMPP_PROCESS_ROLE && !['api', 'all'].includes(process.env.CMPP_PROCESS_ROLE)) return;
const poll = () =>
void this.loadRetainedAlerts().catch(() => this.logger.warn('Persistent alert collection unavailable'));
poll();
this.alertTimer = setInterval(poll, 30_000);
this.alertTimer.unref();
}
onModuleDestroy() {
if (this.alertTimer) clearInterval(this.alertTimer);
}
private loadRetainedAlerts() {
if (!this.alertPoll) {
const observedAt = new Date();
this.alertPoll = this.getJson<PrometheusAlertResponse>('/api/v1/alerts')
.then((response) => retainAlerts(this.prisma, this.parseAlerts(response), observedAt))
.finally(() => {
this.alertPoll = undefined;
});
}
return this.alertPoll;
}
clearAlert(fingerprint: string, activeAt: unknown, userId: string) {
return clearRetainedAlert(this.prisma, fingerprint, activeAt, userId);
}
async overview(rawRange?: string, userId?: string): Promise<InfrastructureMonitoringOverview> {
const range = this.parseRange(rawRange);
const collectedAt = new Date().toISOString();
@@ -220,11 +252,11 @@ export class InfrastructureMonitoringService {
this.loadTrends(range),
this.query(QUERIES.services),
this.query(SERVICE_METRICS_QUERY),
this.getJson<PrometheusAlertResponse>('/api/v1/alerts'),
this.loadRetainedAlerts(),
]);
const services = this.parseServices(serviceResponse);
const serviceMetrics = this.parseServiceMetrics(serviceMetricResponse);
const alerts = await this.attachReadState(this.parseAlerts(alertResponse), userId);
const alerts = await this.attachReadState(alertResponse, userId);
const warningAlerts = alerts.filter((item) => item.severity === 'warning').length;
const criticalAlerts = alerts.filter((item) => item.severity === 'critical').length;
const overallStatus = criticalAlerts > 0 ? 'critical' : warningAlerts > 0 ? 'warning' : 'healthy';
@@ -250,7 +282,7 @@ export class InfrastructureMonitoringService {
alerts,
};
} catch (error) {
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断
// 客户端保留最后成功快照;采集失败不推断告警恢复
this.logger.warn(
`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
);
@@ -260,10 +292,7 @@ export class InfrastructureMonitoringService {
async notificationSummary(userId?: string) {
try {
const alerts = await this.attachReadState(
this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')),
userId,
);
const alerts = await this.attachReadState(await this.loadRetainedAlerts(), userId);
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
return {
count: unreadAlerts.length,
@@ -281,7 +310,7 @@ export class InfrastructureMonitoringService {
if (!/^[a-f0-9]{24}$/.test(fingerprint)) throw new BadRequestException('告警指纹无效');
const activeAt = new Date(String(rawActiveAt ?? ''));
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts'));
const activeAlerts = await retainedAlerts(this.prisma);
const current = activeAlerts.find(
(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(),
};
});
}
+3 -3
View File
@@ -26,8 +26,8 @@ async function bootstrap() {
configureHttpBodyParsers(app);
const swaggerConfig = new DocumentBuilder()
.setTitle('CMPP Platform API')
.setDescription('First-version CMPP SMS platform API')
.setTitle('聆界短信平台 API')
.setDescription('聆界短信平台 API')
.setVersion('0.1.0')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
@@ -36,7 +36,7 @@ async function bootstrap() {
const clientDocument = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('CMPP短信平台 HTTP 客户接口')
.setTitle('聆界短信平台 HTTP 客户接口')
.setDescription('单条短信发送、短信状态查询、上行短信查询及回调验签接口')
.setVersion('1.0.0')
.build(),
+2
View File
@@ -1,3 +1,4 @@
import { renderCompletionMetrics } from '../send-chain/completion-metrics';
import { Injectable, OnModuleDestroy } from '@nestjs/common';
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 }));
}
this.eventLoopDelay.reset();
lines.push(...renderCompletionMetrics());
return `${lines.join('\n')}\n`;
}
+178 -43
View File
@@ -1,47 +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: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); 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 aside { min-width: 0; padding: 24px 16px; }
.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-x: auto; 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 <= 1400px) {
.http-developer-docs section { grid-template-columns: minmax(0, 1fr); }
.http-developer-docs aside { padding: 16px 24px; }
.http-developer-docs aside:empty { display: none; }
.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, .http-developer-docs aside { padding: 16px; }
.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;
}
}
.http-developer-docs .http-doc-sample-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.http-developer-docs .http-doc-sample-tabs button[aria-pressed="true"] { background: #eff6ff; color: #2563eb; border-color: #2563eb; }
-7
View File
@@ -1,13 +1,6 @@
/* global document, navigator, window, Event */
const copyStatus = document.getElementById('copy-status');
document.addEventListener('click', async (event) => {
const tab = event.target.closest('button[data-show-sample]');
if (tab) {
const aside = tab.closest('aside');
aside.querySelectorAll('.http-doc-sample').forEach((sample, index) => { sample.hidden = index !== Number(tab.dataset.showSample); });
aside.querySelectorAll('button[data-show-sample]').forEach((button) => button.setAttribute('aria-pressed', String(button === tab)));
return;
}
const button = event.target.closest('button[data-copy]');
if (!button) return;
const content = document.getElementById(button.dataset.copy);
+7 -5
View File
@@ -23,8 +23,8 @@ function inline(value: string): string {
}
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[]; samples: string[] }> = [];
let current = { id: 'introduction', title: '接入指南', body: [] as string[], samples: [] as string[] };
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 = '';
@@ -35,7 +35,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
for (const line of lines) {
if (code) {
if (/^```/.test(line)) {
current.samples.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>`);
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;
@@ -46,7 +46,7 @@ export function renderHttpGuide(markdown: string, origin: string) {
closeTable();
sampleTitle = heading[2];
if (heading[1].length === 2) {
current = { id: 'section-' + sections.length, title: heading[2], body: [], samples: [] };
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;
@@ -58,8 +58,10 @@ export function renderHttpGuide(markdown: string, origin: string) {
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><aside aria-label="${escapeHtml(section.title)} 示例">${section.samples.length > 1 ? '<div class="http-doc-sample-tabs" role="group" aria-label="切换示例">' + section.samples.map((_sample, index) => '<button type="button" data-show-sample="' + index + '" aria-pressed="' + (index === 0) + '">示例 ' + (index + 1) + '</button>').join('') + '</div>' : ''}${section.samples.map((sample, index) => sample.replace('class="http-doc-sample"', 'class="http-doc-sample"' + (index ? ' hidden' : ''))).join('')}</aside></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>`;
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>`;
}
+2 -3
View File
@@ -15,7 +15,7 @@ import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@@ -97,14 +97,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
}
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = openApiBodyHash(request.rawBody, request.body);
const expected = openApiSignature(
decryptSecret(credential.secretEncrypted),
request.method,
path,
timestampText,
nonce,
bodyHash,
request.rawBody,
);
const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
+41 -20
View File
@@ -12,6 +12,30 @@ const auth = {
};
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(
@@ -20,37 +44,23 @@ describe('HTTP API remediation boundaries', () => {
'/api/openapi/v1/sms/uplinks',
'1789344000',
'550e8400-e29b-41d4-a716-446655440000',
openApiBodyHash(undefined, undefined),
undefined,
),
).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65');
).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
});
it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => {
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', openApiBodyHash(raw, {})].join('\n');
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',
openApiBodyHash(raw, {}),
),
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',
openApiBodyHash(raw, {}),
),
openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw),
);
});
@@ -167,6 +177,17 @@ describe('HTTP API remediation boundaries', () => {
).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```',
@@ -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'),
);
}
});
});
+6 -1
View File
@@ -37,7 +37,12 @@ import {
@ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', 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 })
+15 -5
View File
@@ -64,7 +64,7 @@ export function sendOpenApiProblem(
});
}
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */
/** 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 ?? {})))
@@ -77,11 +77,21 @@ export function openApiSignature(
path: string,
timestamp: string,
nonce: string,
bodyHash: string,
rawBody?: Buffer,
) {
return createHmac('sha256', secret)
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
.digest('hex');
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) {
+23 -13
View File
@@ -449,6 +449,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
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);
@@ -522,24 +526,28 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
return row;
}
async queueWebhookEvent(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
uplinkMessageId?: string | null;
eventType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
async queueWebhookEvent(
data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
uplinkMessageId?: string | null;
eventType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
},
transaction?: Prisma.TransactionClient,
) {
const db = transaction ?? this.prisma;
if (!data.applicationId) return null;
const application = await this.prisma.smsApplication.findUnique({
const application = await db.smsApplication.findUnique({
where: { id: data.applicationId },
include: { httpConfig: true },
});
const config = application?.httpConfig;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
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 } },
});
if (!endpoint || endpoint.status !== 'active') return null;
@@ -549,7 +557,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
: data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`;
const delivery = await this.prisma.$transaction(async (tx) => {
const persist = async (tx: Prisma.TransactionClient) => {
const event = await tx.httpWebhookEvent.upsert({
where: { eventId },
update: {},
@@ -569,7 +577,9 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
update: {},
create: { eventId: event.id, endpointId: endpoint.id, recoveryVersion: 1 },
});
});
};
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(
'deliver',
@@ -178,8 +178,8 @@ export class AdminOperationsController {
return this.operations.signatureQuality({
date,
keyword,
page: Number(page),
pageSize: Number(pageSize),
page: page === undefined ? 1 : Number(page),
pageSize: pageSize === undefined ? 25 : Number(pageSize),
});
}
+10 -7
View File
@@ -10,13 +10,15 @@ import type {
// Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere =
query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
query.status === 'unknown'
? { status: { in: ['submitted', 'unknown'] } }
: query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
@@ -268,6 +270,7 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
originalContent: message.originalContent ?? null,
drainageGate: message.drainageGate
? {
version: message.drainageGate.version,
@@ -1,3 +1,4 @@
import { OperationsQualityQueries } from './queries/quality.queries';
import { OperationsService } from './operations.service';
function createPrismaMock() {
@@ -372,6 +373,8 @@ describe('OperationsService', () => {
id: true,
content: true,
hasDrainageContent: true,
receiptStatus: true,
deliveredAt: true,
tenant: { select: { id: true, name: true } },
}),
}),
@@ -934,10 +937,8 @@ describe('OperationsService', () => {
averageArrivalMs: 1800,
},
]);
const service = new OperationsService(prisma as never);
await expect(
service.signatureQuality({
new OperationsQualityQueries(prisma as never).signatureQualityLive({
date: '2026-07-24',
keyword: '测试',
page: 2,
@@ -985,9 +986,9 @@ describe('OperationsService', () => {
it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
await expect(
new OperationsQualityQueries(prisma as never).signatureQualityLive({ date: '2026-07-24' }),
).resolves.toEqual({
date: '2026-07-24',
items: [],
total: 0,
@@ -84,12 +84,15 @@ export class OperationsMessageQueries {
carrier: true,
province: true,
content: true,
originalContent: true,
hasDrainageContent: true,
drainageDetection: true,
billingUnits: true,
amountCents: true,
status: true,
submitStatus: true,
receiptStatus: true,
deliveredAt: true,
queuedAt: true,
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
@@ -113,8 +116,11 @@ export class OperationsMessageQueries {
application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } },
submitRecords: {
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: {
id: true,
sentContent: true,
contentPolicy: true,
submitId: true,
channelId: true,
channelGroupId: true,
+80 -35
View File
@@ -1,4 +1,6 @@
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 type { SignatureQualityQuery } from '../operations.contracts';
@@ -321,9 +323,30 @@ export class OperationsQualityQueries {
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
}
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 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 keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<
@@ -341,6 +364,8 @@ export class OperationsQualityQueries {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
rowCount: number;
}>
>(Prisma.sql`
@@ -351,6 +376,12 @@ export class OperationsQualityQueries {
message.status,
message."submitStatus" AS submit_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
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
@@ -361,6 +392,15 @@ export class OperationsQualityQueries {
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
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
signature.id AS "signatureId",
@@ -368,37 +408,21 @@ export class OperationsQualityQueries {
tenant.id AS "tenantId",
tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total,
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(*) FILTER (
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",
COUNT(base.signature_id)::integer AS total,
COUNT(base.signature_id) FILTER (WHERE base.quality_status <> 'submit_failed')::integer AS "acceptedCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'submit_failed')::integer AS "submitFailureCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'success')::integer AS "successCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'unknown')::integer AS "unknownCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'failure')::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (
WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0
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
/ COUNT(*) FILTER (
/ COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
),
@@ -407,10 +431,11 @@ export class OperationsQualityQueries {
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
FROM dimensions
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"
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
LEFT JOIN "SmsApplication" application ON application.id = COALESCE(base.application_id,signature."applicationId")
WHERE (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
@@ -441,6 +466,8 @@ export class OperationsQualityQueries {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
WITH base AS (
@@ -457,12 +484,12 @@ export class OperationsQualityQueries {
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_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.failure_count, 0) AS segment_failure_count,
CASE
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")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0
@@ -474,6 +501,7 @@ export class OperationsQualityQueries {
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
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(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
@@ -532,6 +560,8 @@ export class OperationsQualityQueries {
1
)::double precision
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"
FROM classified
GROUP BY signature_id, channel_id, carrier, drainage_state
@@ -548,6 +578,8 @@ export class OperationsQualityQueries {
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
SELECT
@@ -620,6 +652,8 @@ type SignatureSplitRow = {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
};
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
@@ -632,7 +666,7 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
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,
);
return {
@@ -650,7 +684,10 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
arrivalWeight === 0
? null
: 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;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
};
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
@@ -688,7 +727,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 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 {
signatureId: first.signatureId,
channelId: first.channelId,
@@ -705,7 +747,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
arrivalWeight === 0
? null
: 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;
if (scope.batchItem.reportType === 'drainage' && task.drainageItemId !== scope.batchItem.drainageItemId)
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;
const key = `${scope.batchItem.id}:${task.id}`;
if (seen.has(key)) continue;
@@ -664,7 +667,7 @@ export class ReportBatchGenerationService {
where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const targetCarriers = selected.reportType === 'signature' ? [...carriers].sort() : ['all'];
const targetCarriers = [...carriers].sort();
for (const carrier of targetCarriers) {
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
@@ -677,11 +680,13 @@ export class ReportBatchGenerationService {
if (missing.length)
targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const existingTask = currentTasks.find(
(task) => task.channelId === channel.id && (selected.reportType === 'drainage' || task.carrier === carrier),
);
const existingTask = currentTasks.find((task) => task.channelId === channel.id && task.carrier === carrier);
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} 生成`);
targets.push({
id: `${channel.id}:${carrier}`,
@@ -71,47 +71,48 @@ export class ReportChannelExportService {
: missing.length
? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}`
: null;
const reportCarriers =
reportType === 'signature'
? item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom')
: [null];
const reportCarriers = item.eligibleTargets
.filter((target) => target.channelId === channelId)
.map((target) => target.carrier as 'mobile' | 'unicom' | 'telecom');
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> =
[];
for (const carrier of reportCarriers) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
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: {
signatureId: item.signature.id,
channelId,
carrier,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null,
},
});
const task = existingTask
? await tx.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
approvedAt: null,
},
})
: await tx.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: 'carrier_specific',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
return { task, existingTask };
});
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({
where: { id: existingTask.id },
data: {
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
...(reportType === 'signature' ? { approvedAt: null } : {}),
},
})
: await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: item.signature.tenantId,
signatureId: item.signature.id,
channelId,
carrier,
approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel',
reportType,
drainageItemId: item.drainageInfo?.id,
status: missingReason ? 'waiting_material' : 'exporting',
reason: missingReason,
},
});
tasks.push({ task, existingTask });
tasks.push(entry);
}
const task = tasks[0].task;
if (missingReason) {
@@ -312,7 +313,7 @@ export class ReportChannelExportService {
missingFields: detail.missingFields,
});
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
workbook.creator = '聆界短信平台';
const sheet = workbook.addWorksheet('签名报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.columns = detail.fields.map((field) => ({
header: field.exportName || field.name,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.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 {
normalizePage,
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
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(
tenantId: string,
applicationId: string | undefined,
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
if (!name) throw new Error('缺少短信签名');
const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
const existing = await this.findImportSignature(tenantId, applicationId, name);
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else {
const duplicate = await this.prisma.smsSignature.findFirst({
where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
});
const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id;
try {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id;
} catch (error) {
if (!(error instanceof SignatureNameConflict)) throw error;
// A concurrent create won. Imports continue to supplement the existing materials.
const current = await this.findImportSignature(batch.tenantId, applicationId, name);
if (!current) throw error;
targetId = current.id;
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
}
}
}
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
@@ -1,58 +1,85 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.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 { safeSpreadsheetText, styleHeader } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
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) {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
const headers = reportType === 'signature'
const workbook = new ExcelJS.Workbook();
workbook.creator = '聆界短信平台';
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', {
views: [{ state: 'frozen', ySplit: 1 }],
});
const headers =
reportType === 'signature'
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
: ['短信签名', '引流 URL 或号码', '备注', '主体证明'];
sheet.addRow(headers);
sheet.addRow(reportType === 'signature'
sheet.addRow(headers);
sheet.addRow(
reportType === 'signature'
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
: ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']);
styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => { column.width = 24; });
sheet.getRow(2).height = 48;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
await this.prisma.operationLog.create({ data: {
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
: ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片'],
);
styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => {
column.width = 24;
});
sheet.getRow(2).height = 48;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'report_material.template_downloaded',
resource: 'report_material',
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { fileName, content };
}
},
});
return { fileName, content };
}
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
const items = await this.pending.findPendingItems(query);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
styleHeader(sheet.getRow(1));
for (const item of items) sheet.addRow([
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
async exportPending(
query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string },
operatorId?: string,
) {
const items = await this.pending.findPendingItems(query);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
styleHeader(sheet.getRow(1));
for (const item of items)
sheet.addRow([
item.reportType === 'signature' ? '签名' : '引流信息',
safeSpreadsheetText(item.tenant?.name),
safeSpreadsheetText(item.application?.name),
safeSpreadsheetText(item.name),
safeSpreadsheetText(item.detail),
item.changedAt,
]);
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; });
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
await this.prisma.operationLog.create({ data: {
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
sheet.columns.forEach((column, index) => {
column.width = index === 4 ? 42 : 22;
});
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
await this.prisma.operationLog.create({
data: {
tenantId: query.tenantId,
userId: operatorId,
action: 'report_material.pending_export',
resource: 'report_material',
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
} });
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 rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => {
it('checks rewritten content separately for each candidate', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: '拒收请回复R' }]);
expect(
snapshot.select('m', '正文拒收请回复R', items, options, (id) => (id === 'a' ? '正文' : '正文拒收请回复R'))
.selected?.channelId,
).toBe('a');
expect(snapshot.select('n', '正文', items, options, () => '正文拒收请回复R').selected?.channelId).toBe('b');
});
it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
@@ -40,13 +40,16 @@ export class ChannelWordSnapshot {
content: string,
items: T[],
options: Parameters<typeof selectChannelCandidate>[1],
contentForChannel?: (channelId: string) => string,
) {
const candidates = items.filter((item) => selectChannelCandidate([item], options));
const candidateIds = new Set(candidates.map((item) => item.channelId));
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
const hits = this.hits(content)
.filter((hit) => candidateIds.has(hit.channelId))
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const hits = (
contentForChannel
? [...candidateIds].flatMap((id) => this.hits(contentForChannel(id)).filter((hit) => hit.channelId === id))
: this.hits(content).filter((hit) => candidateIds.has(hit.channelId))
).map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
const rejected = !selected && candidates.length > 0 && hits.length > 0;
@@ -59,6 +62,13 @@ export class ChannelWordSnapshot {
readAt: this.readAt,
stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'),
...(contentForChannel
? {
candidateContentHashes: Object.fromEntries(
[...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).digest('hex')]),
),
}
: {}),
candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId),
hits,
+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';
export type FinalReceiptMessage = {
@@ -44,29 +45,33 @@ async function resolveClientReceiptTargets(
});
if (group?.segments.length) {
return group.segments.flatMap((segment) => {
const submitSequenceId = Number(segment.sequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
return [{
segmentIndex: segment.segmentIndex,
segmentTotal: group.segmentTotal,
submitSequenceId,
submitGroupMessageId: group.messageId,
registeredDelivery: segment.registeredDelivery,
}];
const submitSequenceId = parseProtocolSequence(segment.sequenceId);
if (submitSequenceId === undefined) return [];
return [
{
segmentIndex: segment.segmentIndex,
segmentTotal: group.segmentTotal,
submitSequenceId,
submitGroupMessageId: group.messageId,
registeredDelivery: segment.registeredDelivery,
},
];
});
}
}
const submitSequenceId = Number(message.cmppSubmitSequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
return [{
segmentIndex: 1,
segmentTotal: 1,
submitSequenceId,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
// Null means a historical CMPP record created before this field existed.
registeredDelivery: message.cmppRegisteredDelivery !== false,
}];
const submitSequenceId = parseProtocolSequence(message.cmppSubmitSequenceId);
if (submitSequenceId === undefined) return [];
return [
{
segmentIndex: 1,
segmentTotal: 1,
submitSequenceId,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
// Null means a historical CMPP record created before this field existed.
registeredDelivery: message.cmppRegisteredDelivery !== false,
},
];
}
/**
@@ -104,8 +109,7 @@ export async function queueFinalReceiptDeliveries(
propagateHttpQueueError: data.propagateHttpQueueError,
});
const targets = (await resolveClientReceiptTargets(prisma, message))
.filter((target) => target.registeredDelivery);
const targets = (await resolveClientReceiptTargets(prisma, message)).filter((target) => target.registeredDelivery);
for (const target of targets) {
const isSingleFragment = target.segmentTotal === 1;
await queue({
@@ -30,6 +30,28 @@ const material = (id: string, url: string, channels: string[], auditStatus = 'ap
})),
});
describe('drainage authorization', () => {
it.each(['详情:https://qa0915.example.com', '详情:https://qa0915.example.com'])(
'keeps prose separators outside explicit URLs: %s',
(content) => {
const text = 'https://qa0915.example.com';
const start = content.indexOf(text);
const targets = drainageTargets(content, [
{
ruleId: 'url',
ruleCode: 'URL',
ruleName: 'URL',
category: 'url',
text,
normalizedText: text,
start,
end: start + text.length,
},
]);
expect(targets).toHaveLength(1);
expect(targets[0].text).toBe(text);
expect(materialMatches(targets[0], text)).toBe(true);
},
);
test.each([
'lisglo.cn',
'sms.lisglo.cn',
@@ -128,3 +150,17 @@ describe('drainage authorization', () => {
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 { selectDrainageReportTask } from '../common/drainage-report-task';
import { isIP } from 'node:net';
import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service';
@@ -19,7 +20,7 @@ export type DrainageMaterial = {
url: string;
auditStatus: string;
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 = {
version: string;
@@ -80,7 +81,11 @@ export function drainageTargets(content: string, matches: DrainageDetectionMatch
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
// Extend the entire URL token, including suffix labels, userInfo and query.
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
while (start > 0 && token.test(normalized.text[start - 1])) 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++;
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
const value = drainageHost(text);
@@ -138,8 +143,9 @@ export function assessDrainage(
}
const channels = new Set(
approved.flatMap((item) =>
item.reportTasks
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
[...new Set(item.reportTasks.map((task) => task.channelId))]
.map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier))
.filter((task): task is NonNullable<typeof task> => task?.status === 'approved')
.map((task) => task.channelId),
),
);
@@ -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 (
!submit ||
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: '提交意图与真实消息不一致' };
let message = submit.messageRecord;
let message = { ...submit.messageRecord, content: submit.sentContent ?? submit.messageRecord.content };
if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) {
const template = await tx.smsTemplate.findFirst({
where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId },
@@ -58,19 +60,9 @@ export class DrainageSubmitGuardController {
)
return { allowed: false, code: 'DRN', reason: '提交或消息已终结,不得重复发送' };
if (!message.tenantId && !message.batchTaskId) {
try {
await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
// Operations channel tests bypass signature/drainage policy only after
// verifying the durable submit, channel, content and unfinished state.
return { allowed: true };
}
if (!message.tenantId || !message.applicationId || !message.signatureId)
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,
};
}
+28 -19
View File
@@ -1,16 +1,10 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { Logger } from '@nestjs/common';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.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';
/**
* R10 accounting implementation.
* 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 billingUnits = message.billingUnits ?? 0;
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') {
if (exists && ['charged', 'refunded'].includes(exists.billingStatus)) {
return;
}
const transaction = amountCents > 0 ? await this.billing.settleFrozenCharge({
tenantId: message.tenantId,
amountCents,
taskId: message.batchTaskId,
messageId: message.messageId,
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
}) : null;
const transaction =
amountCents > 0
? await this.billing.settleFrozenCharge({
tenantId: message.tenantId,
amountCents,
taskId: message.batchTaskId,
messageId: message.messageId,
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
})
: null;
const data = {
tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined,
@@ -72,14 +69,22 @@ export class SendAccountingService {
}
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,
) {
const amountCents = moneyToNumber(message.amountCents);
if (amountCents <= 0) {
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) {
return;
}
@@ -107,11 +112,15 @@ export class SendAccountingService {
if (amountCents <= 0) {
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) {
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) {
return;
}
+11 -3
View File
@@ -87,7 +87,9 @@ export class SendBatchEntryService {
const httpRequest = data[HTTP_REQUEST_CONTEXT];
const phones = [...new Set(data.phones ?? [])];
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);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
@@ -568,11 +570,17 @@ export class SendBatchEntryService {
if (!applicationId) {
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') {
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('短信应用接口未开通,不能发送短信');
}
if (!templateId) {
@@ -258,6 +258,7 @@ export interface SendJob {
export type QueuePriority = 'normal' | 'priority';
export type RoutedChannel = {
contentPolicy?: import('./template-optout-policy').ContentPolicyDecision;
channel: {
id: string;
code: string;
+87 -52
View File
@@ -1,6 +1,16 @@
import { parseProtocolSequence } from '../common/protocol-uint32';
import { BadRequestException } from '@nestjs/common';
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.
@@ -70,6 +80,7 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
void _drainage; // Kept in the signature for existing callers.
return undefined;
}
@@ -171,7 +182,7 @@ export function parseImportRows(content: string, delimiter?: ',' | '\t') {
return dataLines.map((line, index) => {
const cells = splitImportLine(line, firstDelimiter);
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],
variables: {},
};
@@ -194,7 +205,9 @@ export function cellByHeader(headers: string[], cells: string[], candidates: str
}
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 (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
@@ -226,14 +239,20 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
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));
const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier;
}
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) {
@@ -281,7 +300,10 @@ export function isNationalChannel(item: { province?: string | null; channel: { s
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) {
return false;
}
@@ -307,11 +329,13 @@ export function validateInboundApplicationSrcId(
}
const fillPrefix = application.cmppAccessNumberFillEnabled
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
? (application.cmppAccessNumberFillPrefix?.trim() ?? '')
: '';
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
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;
}
@@ -330,9 +354,7 @@ export function positiveInteger(value: string | undefined, fallback: number) {
}
export function parseOptionalSequenceId(value: string | null | undefined) {
if (!value) return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
return parseProtocolSequence(value);
}
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
@@ -344,13 +366,17 @@ export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['r
}
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
return createHash('sha256').update([
data.id,
data.connectionId ?? '',
data.sequenceId ?? '',
data.messageId ?? '',
data.sequenceId ? '' : data.sentAt ?? '',
].join('\u0000')).digest('hex');
return createHash('sha256')
.update(
[
data.id,
data.connectionId ?? '',
data.sequenceId ?? '',
data.messageId ?? '',
data.sequenceId ? '' : (data.sentAt ?? ''),
].join('\u0000'),
)
.digest('hex');
}
export function shanghaiDateKey(now = new Date()) {
@@ -378,12 +404,14 @@ export function bullmqConnection() {
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
if (data.authSource && data.timestamp !== undefined) {
const expected = createHash('md5')
.update(Buffer.concat([
Buffer.from(octetString(data.account, 6), 'binary'),
Buffer.alloc(9),
Buffer.from(secretHash),
Buffer.from(String(data.timestamp).padStart(10, '0')),
]))
.update(
Buffer.concat([
Buffer.from(octetString(data.account, 6), 'binary'),
Buffer.alloc(9),
Buffer.from(secretHash),
Buffer.from(String(data.timestamp).padStart(10, '0')),
]),
)
.digest('base64');
return expected === data.authSource;
}
@@ -410,8 +438,9 @@ export function hasRecoveryAuditStateChanged(
if (!previous) {
return true;
}
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'].some(
(key) => (previous[key] ?? null) !== (current[key] ?? null),
);
}
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
@@ -463,8 +492,9 @@ export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
if (channel.status !== 'active') {
return false;
}
return (channel.connectionStates ?? []).some((connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
return (channel.connectionStates ?? []).some(
(connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
);
}
@@ -483,11 +513,12 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
routingKey?: string;
},
) {
const eligible = items.filter((item) =>
!options.excludedChannelIds.has(item.channelId)
&& options.approvedChannelIds.has(item.channelId)
&& normalizeCarrier(item.carrier) === options.carrier
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
const eligible = items.filter(
(item) =>
!options.excludedChannelIds.has(item.channelId) &&
options.approvedChannelIds.has(item.channelId) &&
normalizeCarrier(item.carrier) === options.carrier &&
isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
);
const provinceCandidates = options.forceNational
? []
@@ -537,11 +568,8 @@ export function aggregateReceiptSegmentState(
deliveredAt: Date,
) {
if (audits.length === 0) {
const status = data.receiptStatus === 'delivered'
? 'delivered'
: data.receiptStatus === 'unknown'
? 'unknown'
: 'failed';
const status =
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
return {
terminal: true,
segmentTotal: 1,
@@ -576,7 +604,8 @@ export function aggregateReceiptSegmentState(
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
if (delivered.length >= segmentTotal) {
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 {
terminal: true,
segmentTotal,
@@ -617,20 +646,26 @@ export function isSameUpstreamEndpointIdentity(
left: { 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()
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase()
&& left.gatewayPort === right.gatewayPort
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase()
&& left.cmppVersion.trim() === right.cmppVersion.trim();
return (
left.account.trim() === right.account.trim() &&
left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase() &&
left.gatewayPort === right.gatewayPort &&
left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase() &&
left.cmppVersion.trim() === right.cmppVersion.trim()
);
}
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
return createHash('sha256').update([
channelId,
data.gatewayMessageId,
data.phoneNumber?.trim() ?? '',
data.receiptStatus,
data.rawStatus.trim(),
data.errorCode ?? '',
].join('\u0000')).digest('hex');
return createHash('sha256')
.update(
[
channelId,
data.gatewayMessageId,
data.phoneNumber?.trim() ?? '',
data.receiptStatus,
data.rawStatus.trim(),
data.errorCode ?? '',
].join('\u0000'),
)
.digest('hex');
}
+129 -57
View File
@@ -298,6 +298,10 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
},
cmppDownstreamDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'delivery-1', messageRecordId: 'record-1', applicationId: 'app-1' }),
create: jest
.fn()
.mockImplementation(({ data }) =>
@@ -462,6 +466,34 @@ function createPrismaMock() {
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 }) {
const billing = {
estimateSmsCost: jest.fn().mockReturnValue({
@@ -1694,6 +1726,7 @@ describe('SendChainService', () => {
applicationId: 'app-1',
eventType: 'receipt',
}),
undefined,
);
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
});
@@ -3254,10 +3287,10 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
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({
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' }),
});
expect(billing.settleFrozenCharge).toHaveBeenCalledWith(
@@ -3542,6 +3575,7 @@ describe('SendChainService', () => {
expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }),
);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
@@ -3596,6 +3630,7 @@ describe('SendChainService', () => {
},
});
await receiptEvidence(prisma, { ...(await prisma.smsMessageRecord.findFirst()), submitId: 'SUB-1' });
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
@@ -3631,6 +3666,10 @@ describe('SendChainService', () => {
unitPrice: 3,
});
await receiptEvidence(prisma, await prisma.smsMessageRecord.findFirst(), {
channelId: 'channel-old',
submitId: 'SUB-OLD',
});
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-old',
@@ -3656,29 +3695,28 @@ describe('SendChainService', () => {
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'submit-timeout-1',
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'submit-timeout-1',
submitId: 'SUB-1',
messageRecordId: 'record-1',
channelId: 'channel-1',
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
messageRecord: {
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
channelId: 'channel-1',
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
messageRecord: {
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
channelId: 'channel-1',
gatewayMessageId: null,
status: 'timeout',
},
status: 'timeout',
},
]);
},
]);
await service.handleReceipt({
messageId: 'receipt-123456789',
@@ -3698,7 +3736,7 @@ describe('SendChainService', () => {
},
data: {
gatewayMessageId: 'GW-RECOVERED-1',
sequenceId: 7,
sequenceId: 7n,
},
});
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
@@ -3716,6 +3754,8 @@ describe('SendChainService', () => {
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([
{
id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID',
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({
messageId: 'receipt-SHARED-UPSTREAM-ID',
channelId: 'channel-b',
@@ -3745,7 +3791,6 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: { phoneNumber: '15601992925' },
}),
@@ -3783,7 +3828,14 @@ describe('SendChainService', () => {
submitRecordId: 'submit-original',
channelId: 'channel-original',
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: {
id: 'channel-original',
account: 'C59748',
@@ -3809,6 +3861,12 @@ describe('SendChainService', () => {
])
.mockResolvedValueOnce([]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
});
await service.handleReceipt({
messageId: 'receipt-736070230367350788',
channelId: 'channel-copy',
@@ -3846,7 +3904,13 @@ describe('SendChainService', () => {
submitId: 'SUB-ORIGINAL',
channelId: 'channel-original',
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: {
id: 'channel-original',
account: 'C59748',
@@ -3872,7 +3936,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
}),
).rejects.toThrow('SMS message record not found');
).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
});
@@ -3920,6 +3984,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
]);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-LONG-1',
channelId: 'channel-1',
@@ -4009,6 +4074,7 @@ describe('SendChainService', () => {
},
]);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-MESSAGE-LEVEL',
channelId: 'channel-1',
@@ -4073,6 +4139,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
]);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-CONFLICT',
channelId: 'channel-1',
@@ -4233,6 +4300,7 @@ describe('SendChainService', () => {
},
});
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-LONG-FAIL',
channelId: 'channel-1',
@@ -4274,6 +4342,7 @@ describe('SendChainService', () => {
rawStatus: 'DELIVRD',
deliveredAt: '2026-07-01T10:01:00.000Z',
};
await receiptEvidence(prisma);
await service.handleReceipt(receipt);
await service.handleReceipt(receipt);
@@ -4284,19 +4353,16 @@ describe('SendChainService', () => {
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'submit-timeout-1',
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
},
{
id: 'submit-timeout-2',
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
},
]);
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'submit-timeout-1',
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
},
{
id: 'submit-timeout-2',
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
},
]);
await expect(
service.handleReceipt({
@@ -4307,7 +4373,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
}),
).rejects.toThrow('SMS message record not found');
).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
});
@@ -4419,6 +4485,7 @@ describe('SendChainService', () => {
it('records receipts and uplink messages from gateway events', async () => {
const { service, prisma } = createService();
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
@@ -4454,6 +4521,7 @@ describe('SendChainService', () => {
it('records ambiguous uplink match candidates for shared access numbers', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]);
prisma.smsApplication.findMany.mockResolvedValue([
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
@@ -4474,7 +4542,7 @@ describe('SendChainService', () => {
tenantId: undefined,
applicationId: undefined,
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
}),
});
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
@@ -4516,15 +4584,18 @@ describe('SendChainService', () => {
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
data: { status: 'rejected' },
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'uplink',
status: 'pending',
}),
expect(prisma.cmppDownstreamDelivery.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'uplink',
status: 'pending',
}),
],
skipDuplicates: true,
});
});
@@ -4782,7 +4853,7 @@ describe('SendChainService', () => {
},
create: expect.objectContaining({
segmentTotal: 3,
sequenceId: 71,
sequenceId: 71n,
gatewayMessageId: 'GW-SEG-1',
submitStatus: 'accepted',
}),
@@ -4791,7 +4862,7 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
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(
1,
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' }),
}),
);
@@ -5189,7 +5260,7 @@ describe('SendChainService', () => {
expect.objectContaining({
data: expect.objectContaining({
status: 'delivered',
ackResult: 0,
ackResult: 0n,
deliveredAt: new Date('2026-07-14T03:40:18.060Z'),
}),
}),
@@ -5199,7 +5270,7 @@ describe('SendChainService', () => {
update: expect.objectContaining({
status: 'acknowledged',
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.objectContaining({
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }),
data: expect.objectContaining({ ackResult: 0n, ackMessageId: '0' }),
}),
);
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(
@@ -5567,6 +5638,7 @@ describe('SendChainService', () => {
errorCode: 'RECEIPT_TIMEOUT',
}),
}),
undefined,
);
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
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 {
BadRequestException,
forwardRef,
@@ -84,6 +86,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private readonly submission: SendSubmissionService;
private readonly completion: SendCompletionService;
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
private readonly attemptCompletion?: AttemptCompletion;
constructor(
private readonly prisma: PrismaService,
@@ -94,6 +97,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
@Optional() phoneRouting?: PhoneRoutingLookupService,
@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);
this.submission = new SendSubmissionService(
prisma,
@@ -109,12 +117,46 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
},
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);
}
onModuleInit() {
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 === 'outbox') {
this.submission.startSubmitOutboxPublisher();
@@ -202,6 +244,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
async onModuleDestroy() {
this.attemptCompletion?.stop();
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
@@ -484,6 +527,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
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);
}
@@ -495,6 +543,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
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);
}
@@ -528,6 +581,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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);
}
@@ -637,6 +698,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
@@ -726,6 +788,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
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);
}
@@ -803,6 +891,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
},
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): 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);
}
@@ -923,6 +1019,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
errorCode: 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);
}
@@ -1004,6 +1103,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async waitForChannelRateLimit(channelId: string, tps: number) {
if (completionContext.getStore()) return;
return this.submission.waitForChannelRateLimit(channelId, tps);
}
+38 -9
View File
@@ -1,7 +1,21 @@
import { BillingService } from '../billing/billing.service';
import type { OpenApiService } from '../open-api/open-api.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 type { SendSubmissionService } from './send-submission.service';
import { SendAccountingService } from './send-accounting.service';
@@ -13,7 +27,6 @@ import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>;
export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
@@ -49,10 +62,7 @@ export class SendCompletionService {
return this.gatewayResult.handleSubmitSegmentResult(data);
}
async resolveSubmitRecordForGatewaySegmentResult(
messageRecordId: string,
data: GatewaySubmitSegmentResultDto,
) {
async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
}
@@ -109,7 +119,13 @@ export class SendCompletionService {
async handleReceipt(
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);
}
@@ -145,7 +161,13 @@ export class SendCompletionService {
async resolveReceiptMessage(
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);
}
@@ -206,7 +228,13 @@ export class SendCompletionService {
}
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,
) {
return this.accounting.releaseMessageReservation(message, remark);
@@ -279,6 +307,7 @@ export class SendCompletionService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
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 { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
@@ -31,7 +34,20 @@ export class SendDownstreamDeliveryService {
) {}
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) {
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 } });
if (existing) return existing;
}
@@ -47,9 +63,9 @@ export class SendDownstreamDeliveryService {
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
channelId: data.channelId,
messageId: data.messageId,
messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId,
sequenceId: protocolUint32ToDb(data.sequenceId),
phoneNumber: data.phoneNumber,
destId: data.destId,
content: data.content,
@@ -77,10 +93,10 @@ export class SendDownstreamDeliveryService {
tenantId: match.tenantId,
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
messageId: data.messageId,
messageId: match.messageId,
deliveryType: 'uplink',
payload: {
messageId: data.messageId,
messageId: match.messageId,
applicationId: match.applicationId,
phoneNumber: data.phoneNumber,
destId: data.destId,
@@ -94,6 +110,21 @@ export class SendDownstreamDeliveryService {
}
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({
where: { id: candidateId, uplinkMessageId },
include: {
@@ -111,56 +142,55 @@ export class SendDownstreamDeliveryService {
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
}
if (candidate.status === 'claimed') return candidate.uplinkMessage;
const claimedAt = new Date();
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null;
const [updatedUplink] = await this.prisma.$transaction([
this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
const messageId = candidate.messageRecord?.messageId ?? null;
const updatedUplink = await this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
applicationId: candidate.applicationId,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
},
});
await this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
});
await this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
});
await this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
}),
this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
}),
this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
}),
this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
},
}),
]);
},
});
await this.facade.queueAndTryDownstreamDelivery({
tenantId: candidate.tenantId,
@@ -217,20 +247,24 @@ export class SendDownstreamDeliveryService {
const cmppDeliveryAllowed = deliveryAllowed || data.allowBusinessRejectionCmppDelivery === true;
if (deliveryAllowed && data.queueHttpWebhook !== false) {
try {
await this.openApi?.queueWebhookEvent({
tenantId: data.tenantId,
applicationId: data.applicationId,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
eventType: data.deliveryType,
payload: data.payload,
});
await this.openApi?.queueWebhookEvent(
{
tenantId: data.tenantId,
applicationId: data.applicationId,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId:
typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
eventType: data.deliveryType,
payload: data.payload,
},
completionContext.getStore()?.tx,
);
} catch (error) {
this.logger.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) {
@@ -249,6 +283,37 @@ export class SendDownstreamDeliveryService {
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: 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;
try {
delivery = await this.prisma.cmppDownstreamDelivery.create({
@@ -344,107 +409,12 @@ export class SendDownstreamDeliveryService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
}> {
if (data.messageId) {
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: [] };
return resolveUplinkMatch(this.prisma, data, channel);
}
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 from = parseDateBoundary(normalized.createdAtFrom, false);
const to = parseDateBoundary(normalized.createdAtTo, true);
@@ -41,16 +45,19 @@ function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayable
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : 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 },
OR: normalized.keyword ? [
{ messageId: { contains: normalized.keyword } },
{ payload: { path: ['account'], string_contains: normalized.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
{ lastError: { contains: normalized.keyword } },
{ tenant: { name: { contains: normalized.keyword } } },
{ application: { name: { contains: normalized.keyword } } },
] : undefined,
OR: normalized.keyword
? [
{ messageId: { contains: normalized.keyword } },
{ payload: { path: ['account'], string_contains: normalized.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
{ lastError: { contains: normalized.keyword } },
{ tenant: { name: { contains: normalized.keyword } } },
{ application: { name: { contains: normalized.keyword } } },
]
: undefined,
};
}
@@ -71,9 +78,19 @@ function verifyPreview(token: string, operatorId?: string) {
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
let actual: Buffer;
try { actual = Buffer.from(supplied, 'base64url'); } 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 };
try {
actual = Buffer.from(supplied, 'base64url');
} 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.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
return payload;
@@ -85,21 +102,35 @@ function jsonFailures(value: unknown): Record<string, number> {
}
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) {
const snapshotAt = new Date();
const normalized = normalizedFilter(filter);
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([
this.prisma.cmppDownstreamDelivery.count({ where: base }),
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
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.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 {
snapshotAt,
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();
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
const preview = verifyPreview(data.previewToken, createdById);
const filter = normalizedFilter(preview.filter);
const snapshotAt = new Date(preview.snapshotAt);
if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录');
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: {
status: { in: ACTIVE_TASK_STATUSES },
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}),
}, select: { taskNo: true } });
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({
where: {
status: { in: ACTIVE_TASK_STATUSES },
...(filter.applicationId !== 'all'
? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] }
: {}),
},
select: { taskNo: true },
});
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
const where = { 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 } });
const where = {
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 > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
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 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 created = await tx.downstreamRequeueTask.create({ data: {
taskNo,
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
filterSnapshot: filter as Prisma.InputJsonValue,
snapshotAt, reason, 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 } } });
const created = await tx.downstreamRequeueTask.create({
data: {
taskNo,
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
filterSnapshot: filter as Prisma.InputJsonValue,
snapshotAt,
reason,
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 this.get(task.id);
@@ -153,16 +230,37 @@ export class SendDownstreamRequeueTaskService {
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
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 }),
]);
return { items, total, page, pageSize };
}
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('后台重投任务不存在');
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])) };
}
@@ -175,14 +273,22 @@ export class SendDownstreamRequeueTaskService {
const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
taskId: id,
status: query.status && query.status !== 'all' ? query.status : undefined,
OR: keyword ? [
{ delivery: { messageId: { contains: keyword } } },
{ skipReason: { contains: keyword } },
{ errorMessage: { contains: keyword } },
] : undefined,
OR: keyword
? [
{ delivery: { messageId: { contains: keyword } } },
{ skipReason: { contains: keyword } },
{ errorMessage: { contains: keyword } },
]
: undefined,
};
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 }),
]);
return { items, total, page, pageSize };
@@ -192,18 +298,47 @@ export class SendDownstreamRequeueTaskService {
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
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('当前任务状态不允许此操作');
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 } });
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 } } });
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,
},
});
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;
}
async runScan() {
await this.prisma.downstreamRequeueRateWindow.deleteMany({ 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 } });
await this.prisma.downstreamRequeueRateWindow.deleteMany({
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);
}
@@ -211,7 +346,11 @@ export class SendDownstreamRequeueTaskService {
const leaseOwner = randomUUID();
const now = new Date();
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) },
});
if (!lease.count) return;
@@ -220,7 +359,10 @@ export class SendDownstreamRequeueTaskService {
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
// 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));
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (existingFailureEntry) {
@@ -228,19 +370,36 @@ export class SendDownstreamRequeueTaskService {
await this.refreshTask(taskId);
return;
}
await this.prisma.downstreamRequeueTask.update({ 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 } });
await this.prisma.downstreamRequeueTask.update({
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) {
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 (!(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;
const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus);
if (outcome === 'success') failures[item.applicationId] = 0;
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
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) {
await this.autoPause(task, item.applicationId, failures[item.applicationId]);
break;
@@ -248,16 +407,26 @@ export class SendDownstreamRequeueTaskService {
}
failures = await this.reconcileWaiting(taskId, 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);
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
await this.refreshTask(taskId);
} 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 {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id: deliveryId },
@@ -271,32 +440,88 @@ export class SendDownstreamRequeueTaskService {
return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认');
}
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', '执行前状态已变化');
}
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
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 (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled)
return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
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', '已被其他任务处理');
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } });
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ 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() } });
const connected = await this.prisma.cmppDownstreamConnection.count({
where: { applicationId: delivery.applicationId, status: 'connected' },
});
if (connected === 0) {
await this.prisma.downstreamRequeueTaskItem.update({
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';
} catch (error) {
const message = error instanceof Error ? error.message : '后台重投失败';
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化'
: /payload|投递类型/.test(message) ? '投递数据不完整'
: /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() } });
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message)
? '执行前状态已变化'
: /payload|投递类型/.test(message)
? '投递数据不完整'
: /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';
}
}
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';
}
@@ -314,24 +539,61 @@ export class SendDownstreamRequeueTaskService {
}
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 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) {
const connected = await this.prisma.cmppDownstreamConnection.count({ 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 connected = await this.prisma.cmppDownstreamConnection.count({
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();
for (const item of items) {
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) {
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.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 },
});
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') {
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 {
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;
}
}
@@ -339,21 +601,70 @@ export class SendDownstreamRequeueTaskService {
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 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 } });
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 } } });
const updated = await this.prisma.downstreamRequeueTask.updateMany({
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) {
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 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 current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, 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() } : {}) } });
const current = await this.prisma.downstreamRequeueTask.findUnique({
where: { id: taskId },
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() } : {}),
},
});
}
}
@@ -1,15 +1,34 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { protocolUint32, protocolUint32ToDb } from '../common/protocol-uint32';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.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 {
GatewayPendingDeliveryQueryDto,
GatewayDownstreamSentDto,
GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewayControlDeliveryResult,
GatewayDownstreamRecoveryStatusDto,
} from './send-chain.contracts';
import {
positiveInteger,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
isObjectRecord,
asDateOrNull,
downstreamRetryDelayMs,
downstreamAckTimeoutMs,
downstreamMaxRetries,
downstreamControlFailureMessage,
downstreamDeliveryAttemptKey,
hasRecoveryAuditStateChanged,
normalizeRecoveryFailureCategory,
} from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/**
* R10 downstreamState implementation.
@@ -37,7 +56,11 @@ export class SendDownstreamStateService {
take: 500,
});
for (const expired of expiredAcknowledgements) {
await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout');
await this.facade.markDownstreamDeliveryFailed(
expired.id,
'CMPP_DELIVER_RESP timeout recovered after Gateway restart',
'ack_timeout',
);
}
const now = new Date();
await this.prisma.cmppDownstreamDelivery.updateMany({
@@ -150,6 +173,7 @@ export class SendDownstreamStateService {
}
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
protocolUint32(data.result, 'result');
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
const acknowledgedMessageId = String(data.messageId ?? '').trim();
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
@@ -166,7 +190,7 @@ export class SendDownstreamStateService {
sequenceId: data.sequenceId,
messageId: data.messageId,
acknowledgedAt,
ackResult: data.result,
ackResult: protocolUint32ToDb(data.result, 'result'),
ackDeadlineAt: null,
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted
@@ -184,7 +208,7 @@ export class SendDownstreamStateService {
messageId: data.messageId,
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
acknowledgedAt,
ackResult: data.result,
ackResult: protocolUint32ToDb(data.result, 'result'),
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted
? null
@@ -201,7 +225,7 @@ export class SendDownstreamStateService {
acknowledgedAt,
deliveredAt: acknowledgedAt,
ackDeadlineAt: null,
ackResult: data.result,
ackResult: protocolUint32ToDb(data.result, 'result'),
ackSequenceId: data.sequenceId,
ackMessageId: data.messageId,
connectionId: data.connectionId,
@@ -215,16 +239,24 @@ export class SendDownstreamStateService {
where: { id: data.id, status: { not: 'delivered' } },
data: {
acknowledgedAt,
ackResult: data.result,
ackResult: protocolUint32ToDb(data.result, 'result'),
ackSequenceId: data.sequenceId,
ackMessageId: data.messageId,
connectionId: data.connectionId,
},
});
if (data.result === 0) {
return this.facade.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid');
return this.facade.markDownstreamDeliveryFailed(
data.id,
'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信',
'ack_invalid',
);
}
return this.facade.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
return this.facade.markDownstreamDeliveryFailed(
data.id,
`downstream CMPP_DELIVER_RESP result=${data.result}`,
'ack_rejected',
);
}
async markDownstreamDeliveryFailed(
@@ -261,7 +293,11 @@ export class SendDownstreamStateService {
return delivery;
}
const retryCount = (delivery.retryCount ?? 0) + 1;
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost';
const acknowledgementFailure =
failureType === 'ack_timeout' ||
failureType === 'ack_rejected' ||
failureType === 'ack_invalid' ||
failureType === 'connection_lost';
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
@@ -330,12 +366,14 @@ export class SendDownstreamStateService {
if (!account) {
throw new BadRequestException('account is required');
}
const recoveryStatuses = (this.prisma as PrismaService & {
gatewayDownstreamRecoveryStatus: {
findUnique: (args: Record<string, unknown>) => Promise<any>;
upsert: (args: Record<string, unknown>) => Promise<any>;
};
}).gatewayDownstreamRecoveryStatus;
const recoveryStatuses = (
this.prisma as PrismaService & {
gatewayDownstreamRecoveryStatus: {
findUnique: (args: Record<string, unknown>) => Promise<any>;
upsert: (args: Record<string, unknown>) => Promise<any>;
};
}
).gatewayDownstreamRecoveryStatus;
const previous = await recoveryStatuses.findUnique({
where: { account },
select: {
@@ -499,7 +537,7 @@ export class SendDownstreamStateService {
},
});
try {
const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
const result = (await this.facade.postGatewayControl(path, requestPayload)) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) {
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
}
@@ -517,10 +555,13 @@ export class SendDownstreamStateService {
}
async recoverStaleDownstreamManualRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
));
const staleCutoff = new Date(
now.getTime() -
positiveInteger(
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
),
);
const stale = await this.prisma.cmppDownstreamDelivery.findMany({
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
select: { id: true, updatedAt: true },
@@ -1,3 +1,4 @@
import { protocolUint32ToDb } from '../common/protocol-uint32';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { BillingService } from '../billing/billing.service';
@@ -43,6 +44,7 @@ export class SendGatewayResultService {
) {}
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
protocolUint32ToDb(data.sequenceId);
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
const effectiveSubmitId = submitRecord.submitId;
@@ -87,7 +89,7 @@ export class SendGatewayResultService {
gatewayMessageId: null,
},
data: {
sequenceId: data.sequenceId,
sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId,
submittedAt,
},
@@ -155,6 +157,8 @@ export class SendGatewayResultService {
}
async handleSubmitResult(data: GatewaySubmitResultDto) {
protocolUint32ToDb(data.sequenceId);
for (const segment of data.segments ?? []) protocolUint32ToDb(segment.sequenceId);
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
if (data.eventId && submitRecord.resultEventId) {
@@ -171,7 +175,7 @@ export class SendGatewayResultService {
await this.prisma.smsSubmitRecord.updateMany({
where: { id: submitRecord.id },
data: {
sequenceId: data.sequenceId,
sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId,
submitStatus: data.submitStatus,
errorCode: data.errorCode,
@@ -188,11 +192,19 @@ export class SendGatewayResultService {
}
const status =
data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
if (
data.submitStatus !== 'accepted' &&
(['delivered', 'failed'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT'))
) {
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
return message;
}
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
await this.facade.chargeAcceptedMessage(businessMessage);
const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
if (latest?.status === 'failed') {
if (latest?.status === 'failed' || (latest?.status === 'timeout' && latest.errorCode === 'RECEIPT_TIMEOUT')) {
await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款');
}
} else if (
@@ -219,7 +231,7 @@ export class SendGatewayResultService {
data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结',
);
}
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown', 'timeout'];
const updated = await this.prisma.smsMessageRecord.updateMany({
where:
data.submitStatus === 'accepted'
@@ -374,10 +386,8 @@ export class SendGatewayResultService {
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: {
messageRecordId: message.id,
OR: [
data.submitId ? { submitId: data.submitId } : undefined,
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>,
channelId: data.channelId,
...(data.submitId ? { submitId: data.submitId } : { gatewayMessageId: data.gatewayMessageId }),
},
orderBy: { createdAt: 'desc' },
});
@@ -428,7 +438,7 @@ export class SendGatewayResultService {
channelId: data.channelId ?? message.channelId ?? null,
attempt,
segmentTotal,
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
submitStatus: status,
errorCode: segment.errorCode ?? data.errorCode ?? null,
@@ -446,7 +456,7 @@ export class SendGatewayResultService {
attempt,
segmentTotal,
segmentIndex,
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
sequenceId: protocolUint32ToDb(segment.sequenceId ?? data.sequenceId) ?? null,
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
submitStatus: status,
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
@@ -1,3 +1,5 @@
import { applyOptOutRule, loadOptOutPolicies, policyAudit } from './template-optout-policy';
import { completionContext } from './completion-context';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
@@ -299,7 +301,9 @@ export class SendGatewaySubmitService {
sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId));
}),
);
const prepared = planned.map(({ message, routed }) => {
const prepared = planned.map(({ message: input, routed }) => {
const decision = routed.contentPolicy ?? applyOptOutRule(input);
const message = { ...input, content: decision.content };
const submitId = `SUB-${randomUUID()}`;
const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension);
return {
@@ -307,14 +311,17 @@ export class SendGatewaySubmitService {
routed,
submitId,
command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId),
decision,
sessionId: sessionByChannel.get(routed.channel.id),
};
});
const writeOutbox = this.submitOutboxEnabled();
const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled();
await this.measureSendStage('submit_transaction', () =>
this.prisma.$transaction(async (tx) => {
await tx.smsSubmitRecord.createMany({
data: prepared.map(({ message, routed, submitId, sessionId }) => ({
data: prepared.map(({ message, routed, submitId, sessionId, decision }) => ({
sentContent: message.content,
contentPolicy: policyAudit(decision),
id: randomUUID(),
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
@@ -331,15 +338,18 @@ export class SendGatewaySubmitService {
});
const updates = Prisma.join(
prepared.map(
({ message, routed, submitId }) => Prisma.sql`(
({ message, routed, submitId, decision }) => Prisma.sql`(
${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text,
${routed.province ?? null}::text, ${submitId}::text
${routed.province ?? null}::text, ${submitId}::text, ${message.content}::text,
${message.originalContent ?? (decision.content !== decision.originalContent ? decision.originalContent : null)}::text
)`,
),
);
await tx.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET "channelId" = updates."channelId",
SET content = updates.content,
"originalContent" = COALESCE(message."originalContent", updates."originalContent"),
"channelId" = updates."channelId",
carrier = updates.carrier,
province = updates.province,
"submitId" = updates."submitId",
@@ -349,7 +359,7 @@ export class SendGatewaySubmitService {
"errorCode" = NULL,
"errorMessage" = NULL,
"updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId")
FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId", content, "originalContent")
WHERE message.id = updates.id AND message.status = 'queued'
`);
if (writeOutbox) {
@@ -365,7 +375,7 @@ export class SendGatewaySubmitService {
}
}),
);
if (!this.submitOutboxPublishEnabled()) {
if (!completionContext.getStore() && !this.submitOutboxPublishEnabled()) {
await Promise.all(prepared.map(({ command }) => this.facade.publishGatewaySubmitCommand(command)));
}
await this.refreshTaskProgressBatch(prepared.map(({ message }) => message));
@@ -392,6 +402,8 @@ export class SendGatewaySubmitService {
signatureId?: string | null;
phoneNumber: string;
content?: string;
originalContent?: string | null;
billingUnits?: number;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
@@ -466,6 +478,10 @@ export class SendGatewaySubmitService {
this.prisma,
routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
);
const policies = await loadOptOutPolicies(
this.prisma,
messages.map((m) => ({ ...m, content: m.content ?? '' })),
);
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) {
@@ -484,10 +500,10 @@ export class SendGatewaySubmitService {
input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message;
content = stored.content!;
content = stored.originalContent ?? stored.content!;
gate = await evaluateMessageDrainage(
this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
{ ...input.message, content, signatureId: input.signatureId },
input.carrier,
drainageMaterials
.filter(
@@ -529,13 +545,19 @@ export class SendGatewaySubmitService {
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
),
);
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, {
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)),
routingKey: input.message.id,
});
const { selected, rejected } = channelWords.select(
input.message.id,
content,
approvedItems,
{
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)),
routingKey: input.message.id,
},
(id) => policies({ ...input.message, content }, id).content,
);
if (!selected) {
failed.push({
message: input.message,
@@ -547,6 +569,7 @@ export class SendGatewaySubmitService {
planned.push({
message: input.message,
routed: {
contentPolicy: policies({ ...input.message, content }, selected.channelId),
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier: input.carrier,
province: input.province,
@@ -754,6 +777,8 @@ export class SendGatewaySubmitService {
attempt: number,
retryOfSubmitRecordId?: string,
) {
const decision = routed.contentPolicy ?? applyOptOutRule(message);
const submittedMessage = { ...message, content: decision.content };
const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.measureSendStage('rate_limit', () =>
@@ -761,8 +786,8 @@ export class SendGatewaySubmitService {
);
const submitId = `SUB-${randomUUID()}`;
const sessionId = await this.getOpenSubmitSessionId(channel.id);
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId);
const writeOutbox = this.submitOutboxEnabled();
const command = this.buildGatewaySubmitCommand(submittedMessage, routed, attempt, submitId, upstreamSrcId);
const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled();
try {
await this.measureSendStage('submit_transaction', () =>
this.prisma.$transaction(async (tx) => {
@@ -776,6 +801,8 @@ export class SendGatewaySubmitService {
channelGroupName: routed.groupName,
sessionId,
retryOfSubmitRecordId,
sentContent: decision.content,
contentPolicy: policyAudit(decision),
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0,
@@ -785,6 +812,8 @@ export class SendGatewaySubmitService {
await tx.smsMessageRecord.update({
where: { id: message.id },
data: {
content: decision.content,
originalContent: decision.content !== decision.originalContent ? decision.originalContent : undefined,
channelId: channel.id,
carrier: routed.carrier,
province: routed.province,
@@ -849,7 +878,7 @@ export class SendGatewaySubmitService {
}
throw error;
}
if (!this.submitOutboxPublishEnabled()) {
if (!completionContext.getStore() && !this.submitOutboxPublishEnabled()) {
await this.measureSendStage('gateway_stream_publish', () => this.facade.publishGatewaySubmitCommand(command));
}
await this.measureSendStage('task_progress', () =>
@@ -1087,7 +1116,7 @@ return streamId`;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
},
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
options: { forceNational?: boolean; excludeChannelIds?: string[]; previewOnly?: boolean } = {},
): Promise<RoutedChannel> {
if (!message.applicationId) {
throw new BadRequestException('短信应用未配置,无法选择通道组');
@@ -1100,7 +1129,7 @@ return streamId`;
this.facade.identifyCarrier(message.phoneNumber),
this.facade.identifyProvince(message.phoneNumber),
]);
if (!hasPersistedRouting) {
if (!hasPersistedRouting && !options.previewOnly) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { carrier: resolved[0], province: resolved[1] },
@@ -1117,7 +1146,9 @@ return streamId`;
);
const excluded = new Set(options.excludeChannelIds ?? []);
const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier);
const original = { ...stored, content: stored.originalContent ?? stored.content };
const policies = await loadOptOutPolicies(this.prisma, [original]);
const gate = await evaluateMessageDrainage(this.prisma, { ...original, signatureId }, carrier);
const approvedChannelIds = new Set(
route.group.items
.map((item) => item.channelId)
@@ -1129,20 +1160,27 @@ return streamId`;
this.prisma,
route.group.items.map((item) => item.channelId),
);
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, {
carrier,
province,
forceNational: options.forceNational,
excludedChannelIds: excluded,
approvedChannelIds,
routingKey: message.id,
});
await channelWords.persist(this.prisma);
const { selected, rejected } = channelWords.select(
message.id,
original.content,
route.group.items,
{
carrier,
province,
forceNational: options.forceNational,
excludedChannelIds: excluded,
approvedChannelIds,
routingKey: message.id,
},
(id) => policies(original, id).content,
);
if (!options.previewOnly) await channelWords.persist(this.prisma);
if (rejected) throw new ChannelWordRejection();
if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道');
}
return {
contentPolicy: policies(original, selected.channelId),
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier,
province,
@@ -1352,6 +1390,10 @@ return streamId`;
`);
if (direct === 1) return;
}
if (completionContext.getStore()) {
await this.prisma.$queryRaw`SELECT id FROM "SmsBatchTask" WHERE id=${batchTaskId} FOR UPDATE`;
return this.refreshTaskProgressUntilClean(batchTaskId, true);
}
const running = this.taskProgressRefreshes.get(batchTaskId);
if (running) {
// A state transition committed after the running aggregate may not be visible
@@ -1372,9 +1414,9 @@ return streamId`;
}
}
private async refreshTaskProgressUntilClean(batchTaskId: string) {
private async refreshTaskProgressUntilClean(batchTaskId: string, transactional = false) {
do {
this.dirtyTaskProgressRefreshes.delete(batchTaskId);
if (!transactional) this.dirtyTaskProgressRefreshes.delete(batchTaskId);
const groups = await this.prisma.smsMessageRecord.groupBy({
by: ['status'],
where: { batchTaskId },
@@ -1401,7 +1443,7 @@ return streamId`;
where: { id: batchTaskId },
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
});
} while (this.dirtyTaskProgressRefreshes.has(batchTaskId));
} while (!transactional && this.dirtyTaskProgressRefreshes.has(batchTaskId));
}
private async recoverNightReviews() {
@@ -1464,6 +1506,17 @@ return streamId`,
}
private getOpenSubmitSessionId(channelId: string) {
if (completionContext.getStore()) {
const sessionNo = `OPEN-${channelId}`;
return this.prisma.cmppSubmitSession
.upsert({
where: { sessionNo },
update: {},
create: { channelId, sessionNo, submitTotal: 0 },
select: { id: true },
})
.then((session) => session.id);
}
const cached = this.openSubmitSessionIds.get(channelId);
if (cached) return cached;
const sessionNo = `OPEN-${channelId}`;
+128 -228
View File
@@ -1,3 +1,6 @@
import { protocolUint32ToDb, protocolUint32FromDb } from '../common/protocol-uint32';
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { completionContext } from './completion-context';
import { Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -13,7 +16,6 @@ import {
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
aggregateReceiptSegmentState,
isSameUpstreamEndpointIdentity,
receiptEventKey,
longMessageReceiptMode,
} from './send-chain.helpers';
@@ -38,6 +40,7 @@ export class SendReceiptService {
) {}
async intakeReceipt(data: GatewayReceiptEventDto) {
protocolUint32ToDb(data.sequenceId);
const channel = await this.prisma.smsChannel.findUnique({
where: { id: data.channelId },
select: {
@@ -54,34 +57,45 @@ export class SendReceiptService {
}
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const receiptKey = receiptEventKey(data, data.channelId);
const inbox = await this.prisma.upstreamReceiptInbox.upsert({
where: { receiptKey },
update: {
incomingConnectionId: data.connectionId,
},
create: {
receiptKey,
incomingChannelId: data.channelId,
incomingConnectionId: data.connectionId,
upstreamAccount: channel.account,
upstreamHost: channel.gatewayHost,
upstreamPort: channel.gatewayPort,
protocol: channel.protocol,
protocolVersion: channel.cmppVersion,
provisionalMessageId: data.messageId,
sequenceId: data.sequenceId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || null,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
status: 'pending',
nextRetryAt: new Date(),
},
});
const inbox = await this.prisma.upstreamReceiptInbox
.upsert({
where: { receiptKey },
update: {
incomingConnectionId: data.connectionId,
},
create: {
receiptKey,
incomingChannelId: data.channelId,
incomingConnectionId: data.connectionId,
upstreamAccount: channel.account,
upstreamHost: channel.gatewayHost,
upstreamPort: channel.gatewayPort,
protocol: channel.protocol,
protocolVersion: channel.cmppVersion,
provisionalMessageId: data.messageId,
sequenceId: protocolUint32ToDb(data.sequenceId),
gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || null,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
status: 'pending',
nextRetryAt: new Date(),
},
})
.catch(async (error: unknown) => {
// Prisma can emulate an upsert when the optional update is empty. Another
// callback may win the unique receiptKey insert; acknowledge only the
// exact durable fact, never swallow unrelated persistence failures.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = await this.prisma.upstreamReceiptInbox.findUnique({ where: { receiptKey } });
if (existing) return existing;
}
throw error;
});
if (['pending', 'retrying'].includes(inbox.status)) {
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
}
@@ -142,7 +156,7 @@ export class SendReceiptService {
messageId: inbox.provisionalMessageId ?? undefined,
channelId: inbox.incomingChannelId,
connectionId: inbox.incomingConnectionId ?? undefined,
sequenceId: inbox.sequenceId ?? undefined,
sequenceId: protocolUint32FromDb(inbox.sequenceId),
gatewayMessageId: inbox.gatewayMessageId,
phoneNumber: inbox.phoneNumber ?? undefined,
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
@@ -222,6 +236,7 @@ export class SendReceiptService {
cmppVersion: string;
},
) {
protocolUint32ToDb(data.sequenceId);
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
const logicalChannelId = resolved.channelId ?? data.channelId;
const receiptKey = receiptEventKey(data, logicalChannelId);
@@ -229,10 +244,12 @@ export class SendReceiptService {
where: { receiptKey },
include: { messageRecord: true },
});
if (existingReceipt?.messageRecord) {
if (existingReceipt?.messageRecord && !completionContext.getStore()) {
return existingReceipt.messageRecord;
}
const message = resolved.message;
if (existingReceipt && existingReceipt.messageRecordId !== message.id)
throw new Error('completion_receipt_owner_mismatch');
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
if (resolved.submitRecordId) {
await this.prisma.smsSubmitRecord.updateMany({
@@ -242,42 +259,71 @@ export class SendReceiptService {
},
data: {
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId,
sequenceId: protocolUint32ToDb(data.sequenceId),
},
});
}
let receiptRecordId: string | undefined;
try {
const createdReceipt = await this.prisma.smsReceiptRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey,
channelId: logicalChannelId,
messageId: resolved.messageId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
sequenceId: data.sequenceId,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
},
});
receiptRecordId = createdReceipt.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
where: { receiptKey },
include: { messageRecord: true },
let receiptRecordId: string | undefined = existingReceipt?.id;
if (!existingReceipt)
try {
const createdReceipt = await this.prisma.smsReceiptRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey,
channelId: logicalChannelId,
messageId: resolved.messageId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
sequenceId: protocolUint32ToDb(data.sequenceId),
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
},
});
if (duplicate?.messageRecord) return duplicate.messageRecord;
receiptRecordId = createdReceipt.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
where: { receiptKey },
include: { messageRecord: true },
});
if (duplicate?.messageRecord) return duplicate.messageRecord;
}
throw error;
}
throw error;
}
const logicalReceipt = { ...data, channelId: logicalChannelId };
// The message row is locked by AttemptCompletion. A committed final decision
// also commits accounting and notifications; later evidence cannot undo it.
const sameAttempt =
(!message.submitId || message.submitId === resolved.submitId) &&
(!message.channelId || message.channelId === logicalChannelId);
const frozen =
['failed', 'delivered'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT');
if (sameAttempt && frozen) {
const contradicts =
message.status === 'delivered'
? !['delivered', 'unknown'].includes(data.receiptStatus)
: data.receiptStatus === 'delivered';
if (contradicts) {
if (!existingReceipt)
await this.recordReceiptConflict({
message,
submitRecordId: resolved.submitRecordId,
submitId: resolved.submitId,
receiptRecordId,
receiptKey,
data: logicalReceipt,
});
} else if (data.receiptStatus !== 'unknown') {
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
}
return message;
}
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
const receiptMode =
Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment';
@@ -300,8 +346,10 @@ export class SendReceiptService {
if (!aggregate.terminal) {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
if (message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT') return message;
const status = aggregate.status;
const isCurrentAttempt =
(!message.submitId || message.submitId === resolved.submitId) &&
(!message.channelId || message.channelId === logicalChannelId) &&
(!message.gatewayMessageId ||
message.gatewayMessageId === data.gatewayMessageId ||
@@ -449,7 +497,10 @@ export class SendReceiptService {
receiptKey: input.receiptKey,
gatewayMessageId: input.data.gatewayMessageId,
phoneNumber: input.data.phoneNumber,
reason: 'message_level_success_followed_by_failure',
reason:
input.message.status === 'delivered'
? 'message_level_success_followed_by_failure'
: 'final_failure_followed_by_success',
};
await this.prisma.smsReceiptAnomaly.upsert({
where: { anomalyKey },
@@ -473,7 +524,8 @@ export class SendReceiptService {
messageRecordId: input.message.id,
submitRecordId: input.submitRecordId,
receiptRecordId: input.receiptRecordId,
anomalyType: 'aggregate_success_then_failure',
anomalyType:
input.message.status === 'delivered' ? 'aggregate_success_then_failure' : 'final_failure_then_success',
previousStatus: input.message.status,
incomingStatus: input.data.receiptStatus,
rawStatus: input.data.rawStatus,
@@ -499,12 +551,21 @@ export class SendReceiptService {
submitRecordId?: string,
) {
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
const source = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: null;
if (submitRecordId && (!source || source.messageRecordId !== message.id || source.channelId !== data.channelId))
throw new Error('completion_receipt_source_mismatch');
if (!source) throw new NotFoundException('回执缺少可确认的提交尝试关联');
const updated = await segmentAudits.updateMany({
where: {
messageRecordId: message.id,
channelId: source.channelId,
OR: [{ submitRecordId: source.id }, { submitRecordId: null, submitId: source.submitId }],
gatewayMessageId: data.gatewayMessageId,
},
data: {
submitRecordId: source.id,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode ?? null,
@@ -514,12 +575,7 @@ export class SendReceiptService {
if (updated.count > 0) {
return;
}
const submitRecord = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: await this.prisma.smsSubmitRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
orderBy: { createdAt: 'desc' },
});
const submitRecord = source;
await segmentAudits.upsert({
where: {
messageRecordId_submitId_segmentIndex: {
@@ -531,7 +587,7 @@ export class SendReceiptService {
update: {
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
channelId: data.channelId ?? message.channelId ?? null,
sequenceId: data.sequenceId ?? null,
sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
gatewayMessageId: data.gatewayMessageId,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
@@ -548,7 +604,7 @@ export class SendReceiptService {
attempt: 0,
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
segmentIndex: 1,
sequenceId: data.sequenceId ?? null,
sequenceId: protocolUint32ToDb(data.sequenceId) ?? null,
gatewayMessageId: data.gatewayMessageId,
submitStatus: submitRecord?.submitStatus ?? 'accepted',
receiptStatus: data.receiptStatus,
@@ -594,162 +650,6 @@ export class SendReceiptService {
cmppVersion: string;
},
) {
const exactMessage = data.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
: null;
if (exactMessage) {
const segmentAudit = data.gatewayMessageId
? await this.facade.smsMessageSegmentAuditDelegate().findFirst({
where: {
messageRecordId: exactMessage.id,
gatewayMessageId: data.gatewayMessageId,
},
orderBy: { updatedAt: 'desc' },
})
: null;
if (segmentAudit) {
return {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: segmentAudit.submitRecordId ?? undefined,
submitId: segmentAudit.submitId,
channelId: segmentAudit.channelId ?? data.channelId,
};
}
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: {
messageRecordId: exactMessage.id,
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
},
orderBy: { createdAt: 'desc' },
});
return {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: submitRecord?.id,
submitId: submitRecord?.submitId,
channelId: submitRecord?.channelId ?? data.channelId,
};
}
const phoneNumber = data.phoneNumber?.trim();
const exactSubmits = await this.prisma.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
...(phoneNumber ? { messageRecord: { phoneNumber } } : {}),
},
include: { messageRecord: true },
orderBy: { createdAt: 'desc' },
take: 2,
});
if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) {
return {
message: exactSubmits[0].messageRecord,
messageId: exactSubmits[0].messageRecord.messageId,
submitRecordId: exactSubmits[0].id,
submitId: exactSubmits[0].submitId,
channelId: exactSubmits[0].channelId,
};
}
if (!phoneNumber) {
throw new NotFoundException('SMS message record not found');
}
const incomingChannel =
incomingIdentity ?? (await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }));
if (!incomingChannel) {
throw new NotFoundException('SMS message record not found');
}
const segmentMatches = await this.facade.smsMessageSegmentAuditDelegate().findMany({
where: {
gatewayMessageId: data.gatewayMessageId,
messageRecord: { phoneNumber },
},
include: { messageRecord: true, submitRecord: true, channel: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId);
if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) {
return {
message: exactSegmentMatches[0].messageRecord,
messageId: exactSegmentMatches[0].messageRecord.messageId,
submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined,
submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId,
channelId: exactSegmentMatches[0].channelId,
};
}
const sameSupplierSegments = segmentMatches.filter(
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
);
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
return {
message: sameSupplierSegments[0].messageRecord,
messageId: sameSupplierSegments[0].messageRecord.messageId,
submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined,
submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId,
channelId: sameSupplierSegments[0].channelId,
};
}
const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({
where: {
gatewayMessageId: data.gatewayMessageId,
messageRecord: { phoneNumber },
},
include: { messageRecord: true, channel: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
const sameSupplierSubmits = crossConnectionSubmits.filter(
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
);
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
return {
message: sameSupplierSubmits[0].messageRecord,
messageId: sameSupplierSubmits[0].messageRecord.messageId,
submitRecordId: sameSupplierSubmits[0].id,
submitId: sameSupplierSubmits[0].submitId,
channelId: sameSupplierSubmits[0].channelId,
};
}
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000);
const candidates = await this.prisma.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: {
gte: submittedAfter,
lte: deliveredAt,
},
messageRecord: {
phoneNumber,
},
},
include: {
messageRecord: true,
},
orderBy: {
submittedAt: 'desc',
},
take: 10,
});
if (candidates.length !== 1 || !candidates[0]?.messageRecord) {
throw new NotFoundException('SMS message record not found');
}
return {
message: candidates[0].messageRecord,
messageId: candidates[0].messageRecord.messageId,
submitRecordId: candidates[0].id,
submitId: candidates[0].submitId,
channelId: candidates[0].channelId,
};
return resolveReceiptAttempt(this.prisma, data, incomingIdentity);
}
}
+116 -82
View File
@@ -1,16 +1,18 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, HttpException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.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 { GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto } from './send-chain.contracts';
import {
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
gatewaySubmitRequeueKey,
isObjectRecord,
normalizeCarrier,
positiveInteger,
} from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/**
* R10 retry implementation.
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
@@ -87,11 +89,12 @@ export class SendRetryService {
const message = deadLetter.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
: null;
if (message && (
message.submitStatus === 'accepted'
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
)) {
if (
message &&
(message.submitStatus === 'accepted' ||
['submitted', 'delivered', 'unknown'].includes(message.status) ||
['delivered', 'unknown'].includes(message.receiptStatus ?? ''))
) {
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
}
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
@@ -118,7 +121,10 @@ export class SendRetryService {
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
let retryStreamMessageId: string;
try {
const publishedStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
const publishedStreamMessageId = await this.facade.publishGatewaySubmitCommand(
deadLetter.commandPayload,
requeueKey,
);
if (!publishedStreamMessageId) {
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
}
@@ -208,10 +214,10 @@ export class SendRetryService {
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
));
const staleCutoff = new Date(
now.getTime() -
positiveInteger(process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS),
);
const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({
where: { status: 'requeueing', updatedAt: { lt: staleCutoff } },
orderBy: { updatedAt: 'asc' },
@@ -235,7 +241,10 @@ export class SendRetryService {
if (claimed.count !== 1) continue;
try {
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
const retryStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
const retryStreamMessageId = await this.facade.publishGatewaySubmitCommand(
deadLetter.commandPayload,
requeueKey,
);
if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号');
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id: deadLetter.id, status: 'requeue_recovering' },
@@ -264,7 +273,9 @@ export class SendRetryService {
where: { id: deadLetter.id, status: 'requeue_recovering' },
data: { status: 'requeueing' },
});
this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`);
this.logger.error(
`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return { recovered, failed };
@@ -300,100 +311,123 @@ export class SendRetryService {
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
let sourceAttempt = sourceSubmitRecordId
? attempts.find((attempt) => attempt.id === sourceSubmitRecordId)
: attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1];
: (attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1]);
if (!sourceAttempt && sourceSubmitRecordId) {
sourceAttempt = await this.prisma.smsSubmitRecord.findUnique({
where: { id: sourceSubmitRecordId },
}) ?? undefined;
sourceAttempt =
(await this.prisma.smsSubmitRecord.findUnique({
where: { id: sourceSubmitRecordId },
})) ?? undefined;
}
if (!sourceAttempt || sourceAttempt.messageRecordId !== message.id) {
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason,
sourceSubmitRecordId,
sourceMessageRecordId: sourceAttempt?.messageRecordId,
error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing',
})}`);
this.logger.error(
`sms_retry_route_failed ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason,
sourceSubmitRecordId,
sourceMessageRecordId: sourceAttempt?.messageRecordId,
error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing',
})}`,
);
return null;
}
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
where: { retryOfSubmitRecordId: sourceAttempt.id },
});
if (existingRetry) {
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
retryOfSubmitRecordId: sourceAttempt.id,
submitId: existingRetry.submitId,
channelId: existingRetry.channelId,
})}`);
this.logger.warn(
`sms_retry_claim_reused ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
retryOfSubmitRecordId: sourceAttempt.id,
submitId: existingRetry.submitId,
channelId: existingRetry.channelId,
})}`,
);
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
this.logger.log(`sms_retry_route_started ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason,
attemptedChannelIds,
ageMinutes: Math.round(ageMinutes * 100) / 100,
})}`);
if (ageMinutes >= 72 * 60) {
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
this.logger.log(
`sms_retry_route_started ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason: 'maximum_message_age_exceeded',
reason,
attemptedChannelIds,
ageMinutes: Math.round(ageMinutes * 100) / 100,
})}`);
})}`,
);
if (ageMinutes >= 72 * 60) {
this.logger.warn(
`sms_retry_route_skipped ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason: 'maximum_message_age_exceeded',
ageMinutes: Math.round(ageMinutes * 100) / 100,
})}`,
);
return null;
}
const retryCarrier = message.carrier
? normalizeCarrier(message.carrier)
: await this.facade.identifyCarrier(message.phoneNumber);
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier);
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
const route = await this.facade.findApplicationRoute(
message.tenantId,
message.applicationId ?? undefined,
retryCarrier,
);
const retryTimeLimitMinutes = Math.min(
route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60,
72 * 60,
);
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
groupId: route.groupId,
reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded',
ageMinutes: Math.round(ageMinutes * 100) / 100,
retryTimeLimitMinutes,
})}`);
this.logger.warn(
`sms_retry_route_skipped ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
groupId: route.groupId,
reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded',
ageMinutes: Math.round(ageMinutes * 100) / 100,
retryTimeLimitMinutes,
})}`,
);
return null;
}
try {
const routed = await this.facade.selectChannelForMessage({ ...message, carrier: retryCarrier }, {
forceNational: true,
excludeChannelIds: attemptedChannelIds,
});
const routed = await this.facade.selectChannelForMessage(
{ ...message, carrier: retryCarrier },
{
forceNational: true,
excludeChannelIds: attemptedChannelIds,
},
);
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { errorMessage: reason },
});
const retried = await this.facade.submitMessageToGateway(
message,
routed,
attempts.length,
sourceAttempt.id,
const retried = await this.facade.submitMessageToGateway(message, routed, attempts.length, sourceAttempt.id);
this.logger.log(
`sms_retry_route_selected ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
groupId: routed.groupId,
channelId: routed.channel.id,
attempt: attempts.length,
})}`,
);
this.logger.log(`sms_retry_route_selected ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
groupId: routed.groupId,
channelId: routed.channel.id,
attempt: attempts.length,
})}`);
return retried;
} catch (error) {
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason,
attemptedChannelIds,
error: error instanceof Error ? error.message : String(error),
})}`);
// Infrastructure failures and the completion routing probe must abort the
// transaction; treating them as "no retry route" would finalize a failure.
if (!(error instanceof HttpException) || error.getStatus() >= 500) throw error;
this.logger.error(
`sms_retry_route_failed ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
reason,
attemptedChannelIds,
error: error instanceof Error ? error.message : String(error),
})}`,
);
return null;
}
}
+176 -72
View File
@@ -5,17 +5,32 @@ import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import { MetricsService } from '../metrics/metrics.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import type {
CreateBatchTaskDto,
CreateHttpBatchTaskDto,
GatewayInboundAuthDto,
GatewayInboundSubmitDto,
ImportPreviewDto,
ConfirmImportDto,
SendJob,
QueuePriority,
RoutedChannel,
} from './send-chain.contracts';
import { SendBatchEntryService } from './send-batch-entry.service';
import { SendGatewaySubmitService } from './send-gateway-submit.service';
import { SendInboundEntryService } from './send-inbound-entry.service';
import { SendReviewContinuationService } from './send-review-continuation.service';
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
export type SendSubmissionCallbacks = {
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,
) => Promise<void>;
recordCmppFailureReceipt: (
@@ -37,6 +52,7 @@ export type SendSubmissionCallbacks = {
export type SendResourceValidationOptions = {
usePersistedTemplateSnapshot?: boolean;
httpRequest?: boolean;
};
/**
@@ -59,53 +75,92 @@ export class SendSubmissionService {
callbacks: SendSubmissionCallbacks,
metrics?: MetricsService,
) {
this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks, metrics);
this.batchEntry = new SendBatchEntryService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
);
this.inboundEntry = new SendInboundEntryService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
metrics,
);
this.reviewContinuation = new SendReviewContinuationService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
);
this.scheduledDispatch = new SendScheduledDispatchService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
);
this.gatewaySubmit = new SendGatewaySubmitService(
prisma,
billing,
riskReview,
phoneFrequency,
phoneRouting,
facade,
callbacks,
metrics,
);
}
onModuleDestroy() {
return Promise.all([
this.gatewaySubmit.onModuleDestroy(),
this.inboundEntry.stopInboundWorkflowWorker(),
]);
return Promise.all([this.gatewaySubmit.onModuleDestroy(), this.inboundEntry.stopInboundWorkflowWorker()]);
}
async createBatchTask(data: CreateBatchTaskDto) {
async createBatchTask(data: CreateBatchTaskDto) {
return this.batchEntry.createBatchTask(data);
}
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
return this.batchEntry.createHttpBatchTask(data);
}
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return this.batchEntry.getBatchTask(taskId, tenantId, sourceType);
}
async previewImport(data: ImportPreviewDto) {
async previewImport(data: ImportPreviewDto) {
return this.batchEntry.previewImport(data);
}
async confirmImport(data: ConfirmImportDto) {
async confirmImport(data: ConfirmImportDto) {
return this.batchEntry.confirmImport(data);
}
async resolveUnitPrice(tenantId: string, applicationId?: string) {
async resolveUnitPrice(tenantId: string, applicationId?: string) {
return this.batchEntry.resolveUnitPrice(tenantId, applicationId);
}
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
return this.batchEntry.resolveQueuePriority(tenantId, applicationId);
}
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
return this.batchEntry.resolveApplicationAccessNumber(tenantId, applicationId);
}
async resolveTemplateMessageClassification(
async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
@@ -114,35 +169,40 @@ async resolveTemplateMessageClassification(
return this.batchEntry.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content);
}
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
}
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options?: SendResourceValidationOptions,
) {
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
}
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
return this.batchEntry.reserveDailySendQuota(applicationId, requestedCount);
}
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
return this.batchEntry.tryReserveDailySendQuota(applicationId, requestedCount, reservationKey);
}
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
return this.inboundEntry.authenticateInboundApplication(data);
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
async submitInboundMessage(data: GatewayInboundSubmitDto) {
return this.inboundEntry.submitInboundMessage(data);
}
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
return this.inboundEntry.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers);
}
async submitCompleteInboundMessage(
async submitCompleteInboundMessage(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
@@ -150,10 +210,17 @@ async submitCompleteInboundMessage(
requestedMessageIds?: string[],
workflowKey?: string,
) {
return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
return this.inboundEntry.submitCompleteInboundMessage(
data,
phoneNumbers,
application,
requestedGroupMessageId,
requestedMessageIds,
workflowKey,
);
}
async collectInboundLongMessageFragment(
async collectInboundLongMessageFragment(
data: GatewayInboundSubmitDto,
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
phoneNumbers: string[],
@@ -161,11 +228,11 @@ async collectInboundLongMessageFragment(
return this.inboundEntry.collectInboundLongMessageFragment(data, application, phoneNumbers);
}
async expireInboundLongMessages(now = new Date()) {
async expireInboundLongMessages(now = new Date()) {
return this.inboundEntry.expireInboundLongMessages(now);
}
async submitInboundSingleMessage(
async submitInboundSingleMessage(
data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string,
submitGroupMessageId: string,
@@ -174,78 +241,94 @@ async submitInboundSingleMessage(
receiptRejection?: { code: string; reason: string },
workflowItemKey?: string,
) {
return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
return this.inboundEntry.submitInboundSingleMessage(
data,
messageId,
submitGroupMessageId,
application,
synchronousRejection,
receiptRejection,
workflowItemKey,
);
}
async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}, reservationKey?: string) {
async evaluateRiskWithPhoneFrequency(
input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
},
reservationKey?: string,
) {
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input, reservationKey);
}
findInboundApplication(account: string) {
findInboundApplication(account: string) {
return this.inboundEntry.findInboundApplication(account);
}
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
return this.inboundEntry.resolveInboundTemplateCandidate(applicationId, content);
}
resolveInboundSignatureCandidate(applicationId: string, content: string) {
resolveInboundSignatureCandidate(applicationId: string, content: string) {
return this.inboundEntry.resolveInboundSignatureCandidate(applicationId, content);
}
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
}
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
async attachMessageToReviewTask(
reviewTaskId: string,
messageRecordId: string,
signatureId: string,
drainageInfoId?: string,
) {
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
}
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
return this.reviewContinuation.handleReviewDecision(reviewTaskId, decision, reason);
}
async dispatchDueScheduledTasks(now = new Date()) {
async dispatchDueScheduledTasks(now = new Date()) {
return this.scheduledDispatch.dispatchDueScheduledTasks(now);
}
async runScheduledDispatchScan() {
async runScheduledDispatchScan() {
return this.scheduledDispatch.runScheduledDispatchScan();
}
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
return this.gatewaySubmit.enqueueBatchTask(taskId, preparedMessage);
}
startWorker() {
startWorker() {
return this.gatewaySubmit.startWorker();
}
startSubmitOutboxPublisher() {
startSubmitOutboxPublisher() {
return this.gatewaySubmit.startSubmitOutboxPublisher();
}
startInboundWorkflowWorker() {
startInboundWorkflowWorker() {
return this.inboundEntry.startInboundWorkflowWorker();
}
stopInboundWorkflowWorker() {
stopInboundWorkflowWorker() {
return this.inboundEntry.stopInboundWorkflowWorker();
}
async processSendJob(job: SendJob) {
async processSendJob(job: SendJob) {
return this.gatewaySubmit.processSendJob(job);
}
async submitMessageToGateway(
async submitMessageToGateway(
message: {
id: string;
tenantId: string;
@@ -272,26 +355,42 @@ async submitMessageToGateway(
return this.gatewaySubmit.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId);
}
async selectChannelForMessage(
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
async selectChannelForMessage(
message: {
id: string;
tenantId: string;
applicationId?: string | null;
templateId?: string | null;
signatureId?: string | null;
phoneNumber: string;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
},
options: { forceNational?: boolean; excludeChannelIds?: string[]; previewOnly?: boolean } = {},
): Promise<RoutedChannel> {
return this.gatewaySubmit.selectChannelForMessage(message, options);
}
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
async findApplicationRoute(
tenantId: string,
applicationId: string | undefined,
carrier: string,
signatureId?: string,
) {
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
async identifyCarrier(phoneNumber: string) {
async identifyCarrier(phoneNumber: string) {
return this.gatewaySubmit.identifyCarrier(phoneNumber);
}
async identifyProvince(phoneNumber: string) {
async identifyProvince(phoneNumber: string) {
return this.gatewaySubmit.identifyProvince(phoneNumber);
}
async ensureSignatureReportedForChannel(
async ensureSignatureReportedForChannel(
message: {
id: string;
templateId?: string | null;
@@ -304,31 +403,36 @@ async ensureSignatureReportedForChannel(
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier);
}
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
async resolveMessageSignatureId(message: {
templateId?: string | null;
signatureId?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
}) {
return this.gatewaySubmit.resolveMessageSignatureId(message);
}
async waitForChannelRateLimit(channelId: string, tps: number) {
async waitForChannelRateLimit(channelId: string, tps: number) {
return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps);
}
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
return this.gatewaySubmit.refreshTaskProgress(batchTaskId, knownSingleMessageStatus);
}
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
return this.gatewaySubmit.getSendQueue();
}
getGatewayQueue(): Queue {
getGatewayQueue(): Queue {
return this.gatewaySubmit.getGatewayQueue();
}
getRedis() {
getRedis() {
return this.gatewaySubmit.getRedis();
}
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.gatewaySubmit.publishGatewaySubmitCommand(command, idempotencyKey);
}
}
+28 -25
View File
@@ -1,16 +1,12 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { Logger } from '@nestjs/common';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.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 { completionContext } from './completion-context';
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
import type { TimeoutUnknownDto } from './send-chain.contracts';
import { DEFAULT_RECEIPT_TIMEOUT_HOURS, downstreamPendingTimeoutHours, positiveInteger } from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/**
* R10 timeout implementation.
@@ -29,10 +25,12 @@ export class SendTimeoutService {
) {}
async markUnknownTimeout(data: TimeoutUnknownDto) {
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
const olderThanHours =
data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
const candidates = await this.prisma.smsMessageRecord.findMany({
where: {
id: completionContext.getStore()?.messageRecordId,
tenantId: { not: null },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: cutoff } },
@@ -79,7 +77,10 @@ export class SendTimeoutService {
// Refund uses the platform-message idempotency key. Re-running it for a
// timeout whose downstream outbox was not fully queued also recovers a
// crash between the state transition and the original refund call.
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
await this.facade.refundMessage(
candidate as typeof candidate & { tenantId: string },
`${olderThanHours}小时未收到明确回执,自动超时退款`,
);
const queued = await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
@@ -117,10 +118,7 @@ export class SendTimeoutService {
const expired = await this.prisma.cmppDownstreamDelivery.findMany({
where: {
status: 'pending',
OR: [
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }],
},
select: { id: true },
take: 500,
@@ -139,16 +137,21 @@ export class SendTimeoutService {
if (this.receiptTimeoutScanRunning) return;
this.receiptTimeoutScanRunning = true;
try {
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
this.facade.markUnknownTimeout({}),
this.facade.markExpiredDownstreamDeliveries(),
this.facade.recoverStaleGatewaySubmitRequeues(),
this.facade.recoverStaleDownstreamManualRequeues(),
]);
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] =
await Promise.all([
this.facade.markUnknownTimeout({}),
this.facade.markExpiredDownstreamDeliveries(),
this.facade.recoverStaleGatewaySubmitRequeues(),
this.facade.recoverStaleDownstreamManualRequeues(),
]);
if (receiptResult.timeout > 0)
this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
if (downstreamResult.failed > 0)
this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
if (requeueRecoveryResult.recovered > 0)
this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
if (downstreamManualRecoveryResult.recovered > 0)
this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
} catch (error) {
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
} finally {
@@ -0,0 +1,57 @@
import { applyOptOutRule, gatewayFragmentCount, loadOptOutPolicies, OPT_OUT_SUFFIX } from './template-optout-policy';
import { PrismaService } from '../prisma/prisma.service';
const add = { channelId: 'a', action: 'add' as const };
const remove = { channelId: 'b', action: 'remove' as const };
describe('template opt-out fragment preservation', () => {
test.each([1, 64, 65, 69, 70, 71, 77, 128, 129, 134, 135, 195, 201])(
'preserves billing and wire parts at length %i',
(length) => {
const content = '文'.repeat(length);
const result = applyOptOutRule({ content }, add);
expect(gatewayFragmentCount(result.content)).toBe(gatewayFragmentCount(content));
expect(result.reason).toBe(
gatewayFragmentCount(content + OPT_OUT_SUFFIX) === gatewayFragmentCount(content)
? 'applied'
: 'fragment_count_changed',
);
},
);
it('adds 71 to 77 but skips 69 to 75', () => {
expect(applyOptOutRule({ content: '文'.repeat(69) }, add).content).toHaveLength(69);
expect(applyOptOutRule({ content: '文'.repeat(71) }, add).content).toHaveLength(77);
});
it('does not remove suffix across a fragment boundary or alter inline text', () => {
expect(applyOptOutRule({ content: '文'.repeat(69) + OPT_OUT_SUFFIX }, remove).reason).toBe(
'fragment_count_changed',
);
const content = `正文${OPT_OUT_SUFFIX}。后文`;
expect(applyOptOutRule({ content }, remove).content).toBe(content);
});
it('retains original on alternate-channel retry and never stacks additions', () => {
const originalContent = '文'.repeat(71);
const first = applyOptOutRule({ content: originalContent }, add);
expect(applyOptOutRule({ originalContent, content: first.content }, add).content).toBe(first.content);
expect(applyOptOutRule({ originalContent, content: first.content }, remove).content).toBe(originalContent);
expect(applyOptOutRule({ originalContent, content: first.content }).content).toBe(originalContent);
});
it('preserves UTF-16 parts and skips historical billing mismatch', () => {
const content = '😀'.repeat(34);
expect(applyOptOutRule({ content }, add).reason).toBe('fragment_count_changed');
expect(applyOptOutRule({ content: '文'.repeat(71), billingUnits: 1 }, add).reason).toBe('fragment_count_changed');
expect(gatewayFragmentCount('😀'.repeat(67))).toBe(3);
});
it('matches independently of template admission, scopes tenants/apps and prefers exact text', async () => {
const templates = [
{ id: 'v', tenantId: 't', applicationId: 'app', content: '【测】${name}', optOutRules: [remove] },
{ id: 'e', tenantId: 't', applicationId: 'app', content: '【测】正文', optOutRules: [add] },
];
const db = { smsTemplate: { findMany: jest.fn().mockResolvedValue(templates) } };
const message = { tenantId: 't', applicationId: 'app', content: '【测】正文' };
const policies = await loadOptOutPolicies(db as unknown as PrismaService, [message]);
expect(policies(message, 'a').reason).toBe('applied');
expect(policies({ ...message, tenantId: 'other' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, content: '其他短信' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, templateId: 'v' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, templateId: 'unconfigured-template' }, 'a').reason).toBe('no_policy');
});
});
@@ -0,0 +1,106 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { estimateBillingUnits } from '../sms-config/sms-config.helpers';
import { matchTemplateContent } from './send-chain.helpers';
export const OPT_OUT_SUFFIX = '拒收请回复R';
export type OptOutRule = { channelId: string; action: 'add' | 'remove' };
export type ContentPolicyDecision = {
originalContent: string;
content: string;
templateId: string | null;
action: 'add' | 'remove' | 'none';
reason: 'applied' | 'unchanged' | 'fragment_count_changed' | 'no_policy';
};
type PolicyMessage = {
tenantId?: string | null;
applicationId?: string | null;
templateId?: string | null;
content: string;
originalContent?: string | null;
billingUnits?: number;
};
// Gateway UCS2 uses UTF-16 and never splits a Unicode character across parts.
export function gatewayFragmentCount(content: string) {
if (content.length * 2 <= 140) return 1;
let count = 1,
units = 0;
for (const character of content) {
if (units + character.length > 67) {
count++;
units = 0;
}
units += character.length;
}
return count;
}
export function applyOptOutRule(message: PolicyMessage, rule?: OptOutRule, templateId?: string): ContentPolicyDecision {
const originalContent = message.originalContent ?? message.content;
const result: ContentPolicyDecision = {
originalContent,
content: originalContent,
templateId: templateId ?? null,
action: rule?.action ?? 'none',
reason: 'no_policy',
};
if (!rule) return result;
const content =
rule.action === 'add'
? originalContent.endsWith(OPT_OUT_SUFFIX)
? originalContent
: originalContent + OPT_OUT_SUFFIX
: originalContent.endsWith(OPT_OUT_SUFFIX)
? originalContent.slice(0, -OPT_OUT_SUFFIX.length)
: originalContent;
if (content === originalContent) return { ...result, reason: 'unchanged' };
const originalUnits = estimateBillingUnits(originalContent);
if (
estimateBillingUnits(content) !== originalUnits ||
(message.billingUnits !== undefined && originalUnits !== message.billingUnits) ||
gatewayFragmentCount(content) !== gatewayFragmentCount(originalContent)
) {
return { ...result, reason: 'fragment_count_changed' };
}
return { ...result, content, reason: 'applied' };
}
export async function loadOptOutPolicies(db: PrismaService, messages: PolicyMessage[]) {
const applicationIds = [...new Set(messages.map((m) => m.applicationId).filter((id): id is string => Boolean(id)))];
const templates = applicationIds.length
? await db.smsTemplate.findMany({
where: {
applicationId: { in: applicationIds },
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
NOT: { optOutRules: { equals: [] } },
},
select: { id: true, tenantId: true, applicationId: true, content: true, optOutRules: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
})
: [];
return (message: PolicyMessage, channelId: string) => {
const content = message.originalContent ?? message.content;
const matches = templates.filter(
(t) =>
t.tenantId === message.tenantId &&
t.applicationId === message.applicationId &&
(t.content === content || matchTemplateContent(t.content, content) !== null),
);
const template = message.templateId
? matches.find((t) => t.id === message.templateId)
: (matches.find((t) => t.content === content) ?? matches[0]);
const rules = (template?.optOutRules ?? []) as unknown as OptOutRule[];
return applyOptOutRule(
message,
rules.find((r) => r.channelId === channelId),
template?.id,
);
};
}
export function policyAudit(decision?: ContentPolicyDecision): Prisma.InputJsonValue | undefined {
if (!decision || decision.action === 'none') return undefined;
return { templateId: decision.templateId, action: decision.action, reason: decision.reason, preserveFragments: true };
}
+146
View File
@@ -0,0 +1,146 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewayUplinkEventDto, UplinkMatchCandidateInput } from './send-chain.contracts';
export type UplinkMatch = {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
};
/** Attribution is application-level; a reply need not identify one original SMS. */
export async function resolveUplinkMatch(
db: PrismaService,
data: GatewayUplinkEventDto,
channel: { id: string; srcId?: string | null },
): Promise<UplinkMatch> {
const receivedAt = data.receivedAt ? new Date(data.receivedAt) : new Date();
if (!Number.isFinite(receivedAt.getTime())) throw new BadRequestException('上行接收时间无效');
const configuredHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const hours =
Number.isFinite(configuredHours) && configuredHours >= 1 && configuredHours <= 8760 ? configuredHours : 72;
const since = new Date(receivedAt.getTime() - hours * 3_600_000);
const channelEvidence = {
channelId: channel.id,
submitStatus: 'accepted',
submittedAt: { gte: since, lte: receivedAt },
};
if (data.messageId) {
const message = await db.smsMessageRecord.findFirst({
where: {
messageId: data.messageId,
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submitRecords: { some: channelEvidence },
},
select: { id: true, messageId: true, tenantId: true, applicationId: true },
});
if (message?.tenantId && message.applicationId)
return {
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
matchStatus: 'matched',
matchReason: 'messageId 与手机号、通道发送事实一致',
candidates: [],
};
}
const accessNumber = data.destId || channel.srcId || '';
// Do not truncate routes before deduplicating applications: it can manufacture uniqueness.
const routes = accessNumber
? await db.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
distinct: ['applicationId'],
})
: [];
const ids = routes.flatMap((r) => (r.applicationId ? [r.applicationId] : []));
const applications = ids.length
? await db.smsApplication.findMany({
where: { id: { in: ids }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
// Read only attribution columns, but inspect the complete window, not its last two SMS.
const messages = await db.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submitRecords: { some: channelEvidence },
},
select: { id: true, messageId: true, tenantId: true, applicationId: true },
orderBy: { id: 'asc' },
});
const groups = new Map<string, typeof messages>();
for (const message of messages) {
if (!message.tenantId || !message.applicationId) continue;
const key = JSON.stringify([message.tenantId, message.applicationId]);
const group = groups.get(key) ?? [];
group.push(message);
groups.set(key, group);
}
const accessCandidates: UplinkMatchCandidateInput[] = applications.map((a) => ({
tenantId: a.tenantId,
applicationId: a.id,
matchSource: 'access_number',
confidence: 70,
reason: '共享接入号应用候选,尚无唯一发送证据',
}));
if (groups.size === 1) {
const records = [...groups.values()][0];
const message = records[0];
// A conflicting configured access number is evidence against automatic assignment.
if (!applications.length || applications.some((a) => a.id === message.applicationId))
return {
tenantId: message.tenantId!,
applicationId: message.applicationId!,
messageRecordId: records.length === 1 ? message.id : undefined,
messageId: records.length === 1 ? message.messageId : undefined,
matchStatus: 'matched',
matchReason:
records.length === 1
? `手机号、通道及接收前 ${hours} 小时唯一匹配`
: `手机号、通道及接收前 ${hours} 小时应用唯一;原短信不唯一`,
candidates: [],
};
}
// No sending evidence: retain the existing unique-access application attribution.
if (!groups.size && applications.length === 1)
return {
tenantId: applications[0].tenantId,
applicationId: applications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用,无唯一原短信',
candidates: [],
};
const candidates = new Map(accessCandidates.map((c) => [c.applicationId, c]));
for (const records of groups.values()) {
const m = records[0];
candidates.set(m.applicationId!, {
tenantId: m.tenantId!,
applicationId: m.applicationId!,
messageRecordId: records.length === 1 ? m.id : undefined,
matchSource: 'phone_window',
confidence: 70,
reason: `同通道接收前 ${hours} 小时有 ${records.length} 条下发;须确认应用归属`,
});
}
return candidates.size
? {
matchStatus: 'ambiguous',
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
candidates: [...candidates.values()],
}
: { matchStatus: 'unmatched', matchReason: '未匹配到接入号应用或时间窗内同通道发送事实', candidates: [] };
}
@@ -0,0 +1,125 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { startOfDay, addDays } from './analytics-date';
import { leadingSignatureSql } from './signature-extraction';
export type ActivityDimension = {
dimensionKey: string;
dimensionType: string;
tenantId: string;
applicationId: string | null;
signatureId: string;
channelKey: string;
carrier: string;
approvedAt: Date;
signatureName: string;
tenantName: string;
applicationName: string;
channelName: string;
};
export type ActivityCount = {
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
};
/** Reconstruct membership at the end of the activity day, not from today's active routes. */
export async function activityDimensions(db: PrismaService, date: string): Promise<ActivityDimension[]> {
const end = startOfDay(addDays(date, 1));
const tasks = await db.$queryRaw<Array<Omit<ActivityDimension, 'dimensionKey' | 'dimensionType'>>>(Prisma.sql`
SELECT t."signatureId",t."channelId" AS "channelKey",t.carrier,
s."tenantId",s."applicationId",s.name AS "signatureName",c.name AS "channelName",
tenant.name AS "tenantName",COALESCE(a.name,'') AS "applicationName",
COALESCE(approved."createdAt",t."approvedAt") AS "approvedAt"
FROM "ChannelSignatureReportTask" t
JOIN "SmsSignature" s ON s.id=t."signatureId" JOIN "Tenant" tenant ON tenant.id=s."tenantId"
JOIN "SmsChannel" c ON c.id=t."channelId" LEFT JOIN "SmsApplication" a ON a.id=s."applicationId"
LEFT JOIN LATERAL (SELECT r."statusAfter" FROM "ChannelSignatureReportRecord" r
WHERE r."taskId"=t.id AND r."createdAt"<${end} ORDER BY r."createdAt" DESC,r.id DESC LIMIT 1) history ON TRUE
LEFT JOIN LATERAL (SELECT r."createdAt" FROM "ChannelSignatureReportRecord" r
WHERE r."taskId"=t.id AND r."createdAt"<${end} AND r."statusAfter"='approved'
AND r."statusBefore" IS DISTINCT FROM 'approved' ORDER BY r."createdAt" DESC,r.id DESC LIMIT 1) approved ON TRUE
WHERE t."reportType"='signature' AND t."approvalScope"='carrier_specific' AND t.carrier IS NOT NULL
AND t."createdAt"<${end}
AND COALESCE(history."statusAfter",CASE WHEN t."approvedAt"<${end} THEN t.status END)='approved'
AND COALESCE(approved."createdAt",t."approvedAt")<${end}`);
const dimensions = new Map<string, ActivityDimension>();
for (const t of tasks) {
const channelKey = JSON.stringify(['channel', t.tenantId, t.applicationId, t.signatureId, t.channelKey, t.carrier]);
dimensions.set(channelKey, { ...t, dimensionKey: channelKey, dimensionType: 'channel' });
const enterpriseKey = JSON.stringify(['enterprise', t.tenantId, t.applicationId, t.signatureId, '', t.carrier]);
const existing = dimensions.get(enterpriseKey);
if (!existing || existing.approvedAt > t.approvedAt)
dimensions.set(enterpriseKey, {
...t,
dimensionKey: enterpriseKey,
dimensionType: 'enterprise',
channelKey: '',
channelName: '',
});
}
return [...dimensions.values()];
}
/** One bounded source scan for all dimensions; no per-signature correlated receipt scan. */
export async function activityCounts(db: PrismaService, dimensions: ActivityDimension[], date: string) {
if (!dimensions.length) return new Map<string, ActivityCount>();
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const json = JSON.stringify(
dimensions.map((d) => ({
key: d.dimensionKey,
signature: d.signatureId,
channel: d.channelKey,
carrier: d.carrier,
approved: d.approvedAt.toISOString(),
})),
);
const rows = await db.$queryRaw<Array<ActivityCount & { key: string }>>(Prisma.sql`
WITH dims AS (SELECT * FROM jsonb_to_recordset(${json}::jsonb) AS d(key text,signature text,channel text,carrier text,approved timestamptz)),
attempts AS MATERIALIZED (
SELECT s.id,s."messageRecordId",s."channelId",s."gatewayMessageId",s."submitStatus",m."signatureId",m.carrier,m."billingUnits",
COALESCE(s."submittedAt",s."createdAt") AS at
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId"
WHERE COALESCE(s."submittedAt",s."createdAt")>=${start} AND COALESCE(s."submittedAt",s."createdAt")<${end}
AND m."signatureId" IS NOT NULL
), segments AS (
SELECT a.id,COUNT(g.id)::int AS present,GREATEST(MAX(g."segmentTotal"),MAX(a."billingUnits")) AS expected,
COUNT(g.id) FILTER(WHERE g."receiptStatus"='delivered') AS delivered
FROM attempts a LEFT JOIN "SmsMessageSegmentAudit" g ON g."submitRecordId"=a.id GROUP BY a.id
), delivered AS (
SELECT DISTINCT a.id FROM attempts a JOIN segments g ON g.id=a.id
WHERE (g.present>0 AND g.delivered=g.expected AND g.present=g.expected)
OR (g.present=0 AND EXISTS(SELECT 1 FROM "SmsReceiptRecord" r
WHERE r."channelId"=a."channelId" AND r."gatewayMessageId"=a."gatewayMessageId" AND r."receiptStatus"='delivered'))
)
SELECT d.key,COUNT(a.id)::int AS "submittedAttempts",
COUNT(DISTINCT a."messageRecordId") FILTER(WHERE a."submitStatus"='accepted')::int AS "acceptedBusinessCount",
COUNT(DISTINCT a."messageRecordId") FILTER(WHERE a."submitStatus"='accepted' AND delivered.id IS NOT NULL)::int AS "deliveredBusinessCount"
FROM dims d LEFT JOIN attempts a ON a."signatureId"=d.signature AND a.carrier=d.carrier
AND (d.channel='' OR a."channelId"=d.channel) AND a.at>=d.approved AT TIME ZONE 'UTC'
LEFT JOIN delivered ON delivered.id=a.id GROUP BY d.key`);
return new Map(rows.map(({ key, ...counts }) => [key, counts]));
}
export async function unreportedRows(db: PrismaService, date: string) {
return db.$queryRaw<
Array<{
dimensionKey: string;
tenantId: string;
applicationId: string;
signatureName: string;
tenantName: string;
applicationName: string;
messageCount: number;
}>
>(Prisma.sql`
WITH extracted AS (
SELECT m."tenantId",m."applicationId",${leadingSignatureSql(Prisma.sql`m.content`)} AS name
FROM "SmsMessageRecord" m WHERE m."queuedAt">=${startOfDay(date)} AND m."queuedAt"<${startOfDay(addDays(date, 1))} AND m."signatureId" IS NULL
) SELECT jsonb_build_array(e."tenantId",e."applicationId",e.name)::text AS "dimensionKey",
e."tenantId",e."applicationId",e.name AS "signatureName",t.name AS "tenantName",a.name AS "applicationName",COUNT(*)::int AS "messageCount"
FROM extracted e JOIN "Tenant" t ON t.id=e."tenantId" JOIN "SmsApplication" a ON a.id=e."applicationId"
WHERE e.name IS NOT NULL AND NOT EXISTS(SELECT 1 FROM "SmsSignature" s WHERE s."tenantId"=e."tenantId" AND s."applicationId"=e."applicationId" AND s.name=e.name AND s."auditStatus"<>'deleted')
GROUP BY e."tenantId",e."applicationId",e.name,t.name,a.name`);
}
@@ -0,0 +1,23 @@
import { addDays, analyticsDate, analyticsPage, mutableDay, todayKey } from './analytics-date';
describe('signature analytics business-day contract', () => {
const now = new Date('2026-09-16T16:00:00Z');
it('uses Shanghai midnight and freezes T-4', () => {
expect(todayKey(now)).toBe('2026-09-17');
expect(mutableDay('2026-09-14', now)).toBe(true);
expect(mutableDay('2026-09-13', now)).toBe(false);
expect(mutableDay('2026-09-17', now)).toBe(false);
expect(addDays('2024-03-01', -1)).toBe('2024-02-29');
});
it.each(['2026-02-29', '2026-09-18', '2026-13-01', '2026-9-1'])('rejects invalid or future date %s', (date) => {
expect(() => analyticsDate(date, now)).toThrow();
});
it.each([
[0, 25],
[1, 1000],
[Number.NaN, 25],
[1.5, 25],
])('rejects invalid pagination', (page, size) => {
expect(() => analyticsPage(page, size)).toThrow();
});
});
@@ -0,0 +1,31 @@
import { BadRequestException } from '@nestjs/common';
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
export const todayKey = (now = new Date()) => dayFormatter.format(now);
export const databaseDay = (key: string) => new Date(`${key}T00:00:00.000Z`);
export const startOfDay = (key: string) => new Date(`${key}T00:00:00+08:00`);
export const addDays = (key: string, days: number) =>
new Date(databaseDay(key).getTime() + days * 86_400_000).toISOString().slice(0, 10);
export function analyticsDate(value?: string, now = new Date()) {
const key = value || todayKey(now);
if (
!/^\d{4}-\d{2}-\d{2}$/.test(key) ||
!Number.isFinite(databaseDay(key).getTime()) ||
databaseDay(key).toISOString().slice(0, 10) !== key ||
key > todayKey(now)
) {
throw new BadRequestException('统计日期必须为有效的北京时间日期,不能晚于今天');
}
return key;
}
export function analyticsPage(page = 1, pageSize = 25) {
if (!Number.isInteger(page) || page < 1 || ![10, 25, 50, 100].includes(pageSize))
throw new BadRequestException('分页参数无效');
return { page, pageSize };
}
export const mutableDay = (day: string, now = new Date()) => day < todayKey(now) && day >= addDays(todayKey(now), -3);
@@ -0,0 +1,92 @@
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { databaseDay, todayKey } from './analytics-date';
/** Lease acquisition is outside the source snapshot. Publishing always rechecks its fencing token. */
export async function analyticsJob<T>(
db: PrismaService,
scope: string,
date: string,
work: (tx: PrismaService, generation: string, checkpoint: Prisma.JsonValue | null) => Promise<T>,
now = new Date(),
prepare?: () => Promise<Prisma.InputJsonValue>,
): Promise<{ skipped: boolean; result?: T }> {
const businessDate = databaseDay(date),
refreshFor = databaseDay(todayKey(now));
await db.signatureAnalyticsRun.createMany({
data: [{ scope, businessDate, refreshFor, nextAttemptAt: now, generationId: randomUUID() }],
skipDuplicates: true,
});
const owner = randomUUID(),
generationId = randomUUID();
const claimed = await db.signatureAnalyticsRun.updateMany({
where: {
scope,
businessDate,
AND: [
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
{
OR: [
{ refreshFor: { lt: refreshFor } },
{ state: { not: 'succeeded' }, attempt: { lt: 5 }, nextAttemptAt: { lte: now } },
],
},
],
},
data: {
owner,
generationId,
leaseUntil: new Date(now.getTime() + 300_000),
fence: { increment: 1 },
state: 'running',
startedAt: now,
error: null,
},
});
if (!claimed.count) return { skipped: true };
const run = await db.signatureAnalyticsRun.findUniqueOrThrow({
where: { scope_businessDate: { scope, businessDate } },
});
const attempt = run.refreshFor < refreshFor ? 1 : run.attempt + 1;
await db.signatureAnalyticsRun.update({ where: { id: run.id }, data: { refreshFor, attempt } });
try {
// Preserve the first decision's rule/report snapshot across retries. New-day runs take a new snapshot.
const checkpoint = run.refreshFor < refreshFor ? null : run.checkpoint;
const prepared = checkpoint ?? (prepare ? await prepare() : null);
if (prepared !== null && checkpoint === null) {
const saved = await db.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence, state: 'running' },
data: { checkpoint: prepared as Prisma.InputJsonValue },
});
if (saved.count !== 1) throw new Error('签名统计任务认领已失效');
}
const result = await db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='90s'");
const result = await work(tx as PrismaService, generationId, prepared as Prisma.JsonValue | null);
const current = new Date();
const fenced = await tx.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence, state: 'running', leaseUntil: { gt: current } },
data: { state: 'succeeded', owner: null, leaseUntil: null, finishedAt: current, error: null },
});
if (fenced.count !== 1) throw new Error('签名统计任务租约已失效,拒绝发布');
return result;
},
{ isolationLevel: 'RepeatableRead', timeout: 120_000, maxWait: 5000 },
);
return { skipped: false, result };
} catch (error) {
await db.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence },
data: {
state: attempt >= 5 ? 'failed' : 'retry_wait',
owner: null,
leaseUntil: null,
nextAttemptAt: new Date(Date.now() + Math.min(900_000, 60_000 * 2 ** (attempt - 1))),
error: '签名统计生成失败,请查看服务日志',
},
});
throw error;
}
}
@@ -0,0 +1,217 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, analyticsDate, analyticsPage, databaseDay, todayKey } from './analytics-date';
export interface ActivityQuery {
date?: string;
dimensionType: string;
page?: number;
pageSize?: number;
tenantName?: string;
applicationName?: string;
signatureName?: string;
channelName?: string;
}
export class SignatureAnalyticsRead {
constructor(private readonly db: PrismaService) {}
async metadata(date: string, now = new Date()) {
const record = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(date) } });
const run = await this.db.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(date) } },
});
const reportState =
run?.state === 'running'
? 'refreshing'
: ['retry_wait', 'failed'].includes(run?.state ?? '')
? 'failed'
: (record?.state ?? 'missing');
return {
dataSource: 'report' as const,
businessDate: date,
serverBusinessDate: todayKey(now),
reportState,
frozen: date <= addDays(todayKey(now), -4),
generatedAt: record?.generatedAt ?? null,
sourceAsOf: record?.sourceAsOf ?? null,
generationId: record?.publishedGenerationId ?? null,
schemaVersion: record?.schemaVersion ?? 1,
provenance: record?.provenance ?? null,
};
}
async quality(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) {
const date = analyticsDate(query.date);
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
return this.db.$transaction(
async (tx) => {
const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date);
if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta };
const where: Prisma.SignatureQualityDailyWhereInput = {
generationId: meta.generationId,
...(query.keyword?.trim()
? {
OR: ['signatureName', 'tenantName', 'applicationNames'].map((field) => ({
[field]: { contains: query.keyword!.trim(), mode: 'insensitive' },
})),
}
: {}),
};
const total = await tx.signatureQualityDaily.count({ where });
const rows = await tx.signatureQualityDaily.findMany({
where,
orderBy: [{ total: 'desc' }, { signatureName: 'asc' }, { signatureId: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
});
return { date, items: rows.map((r) => r.payload), total, page, pageSize, ...meta };
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
async unreported(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) {
const date = analyticsDate(query.date);
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
return this.db.$transaction(
async (tx) => {
const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date);
if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta };
const where: Prisma.UnreportedSignatureDailyWhereInput = {
generationId: meta.generationId,
...(query.keyword?.trim()
? {
OR: ['signatureName', 'tenantName', 'applicationName'].map((field) => ({
[field]: { contains: query.keyword!.trim(), mode: 'insensitive' },
})),
}
: {}),
};
const total = await tx.unreportedSignatureDaily.count({ where });
const rows = await tx.unreportedSignatureDaily.findMany({
where,
orderBy: [{ messageCount: 'desc' }, { dimensionKey: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
date,
items: rows.map((r) => ({ ...r, signatureId: r.dimensionKey })),
total,
page,
pageSize,
...meta,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
async activity(query: ActivityQuery) {
const date = analyticsDate(query.date);
if (!['enterprise', 'channel'].includes(query.dimensionType))
throw new BadRequestException('必须指定企业或通道维度');
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
const dates = Array.from({ length: 30 }, (_, i) => addDays(date, -i - 1));
return this.db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const manifests = await tx.signatureAnalyticsDay.findMany({
where: { businessDate: { in: dates.map(databaseDay) } },
});
const runs = await tx.signatureAnalyticsRun.findMany({
where: { scope: 'daily', businessDate: { in: dates.map(databaseDay) } },
});
const runStates = new Map(runs.map((r) => [r.businessDate.toISOString().slice(0, 10), r.state]));
const byDate = new Map(manifests.map((r) => [r.businessDate.toISOString().slice(0, 10), r]));
const coverage = dates.map((d) => {
const r = byDate.get(d);
return {
date: d,
generationId: r?.publishedGenerationId ?? null,
reportState:
runStates.get(d) === 'running'
? 'refreshing'
: ['failed', 'retry_wait'].includes(runStates.get(d) ?? '')
? 'failed'
: (r?.state ?? 'missing'),
generatedAt: r?.generatedAt ?? null,
sourceAsOf: r?.sourceAsOf ?? null,
frozen: d <= addDays(todayKey(), -4),
};
});
const generations = coverage.flatMap((c) => (c.generationId ? [c.generationId] : []));
if (!generations.length)
return { date, items: [], dimensions: [], total: 0, page, pageSize, coverage, complete: false };
const filters = [
['tenantName', query.tenantName],
['applicationName', query.applicationName],
['signatureName', query.signatureName],
['channelName', query.channelName],
]
.filter(([, value]) => value?.trim())
.map(([field, value]) => Prisma.sql`AND r.${Prisma.raw(`"${field}"`)} ILIKE ${`%${value!.trim()}%`}`);
const dimensions = await tx.$queryRaw<
Array<{
dimensionKey: string;
dimensionType: string;
signatureId: string;
channelKey: string;
carrier: string;
signatureName: string;
channelName: string;
tenantName: string;
applicationName: string;
approvedAt: Date | null;
total: number;
rowCount: number;
}>
>(Prisma.sql`
WITH selected AS (
SELECT * FROM "SignatureActivityDaily" WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType}
), latest AS (
SELECT DISTINCT ON ("dimensionKey") * FROM selected ORDER BY "dimensionKey","businessDate" DESC
), sums AS (SELECT "dimensionKey",SUM("acceptedBusinessCount")::integer AS total FROM selected GROUP BY 1)
SELECT r.*,s.total,COUNT(*) OVER()::integer AS "rowCount" FROM latest r JOIN sums s USING("dimensionKey")
WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty} ORDER BY s.total DESC,r."signatureName",r."dimensionKey"
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`);
// An empty out-of-range page still reports the filtered total.
const emptyPageCount = dimensions.length
? []
: await tx.$queryRaw<Array<{ total: number }>>(Prisma.sql`
WITH latest AS (
SELECT DISTINCT ON ("dimensionKey") * FROM "SignatureActivityDaily"
WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType}
ORDER BY "dimensionKey","businessDate" DESC
) SELECT COUNT(*)::integer AS total FROM latest r WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty}`);
const items = dimensions.length
? await tx.signatureActivityDaily.findMany({
where: {
generationId: { in: generations },
dimensionType: query.dimensionType,
dimensionKey: { in: dimensions.map((d) => d.dimensionKey) },
},
})
: [];
return {
date,
dimensions: dimensions.map((d) => ({ ...d, channelId: d.channelKey || null })),
items: items.map((r) => ({
...r,
id: `${r.generationId}:${r.dimensionKey}`,
channelId: r.channelKey || null,
activityDate: r.businessDate.toISOString().slice(0, 10),
status: r.applicability,
})),
total: dimensions[0]?.rowCount ?? emptyPageCount[0]?.total ?? 0,
page,
pageSize,
coverage,
complete: coverage.every((c) => Boolean(c.generationId)),
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
}
@@ -0,0 +1,210 @@
import { Prisma, SignatureRetirementRule } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, databaseDay, startOfDay, todayKey } from './analytics-date';
import { analyticsJob } from './analytics-job';
const keyOf = (d: { dimensionType: string; signatureId: string; channelKey: string; carrier: string }) =>
JSON.stringify([d.dimensionType, d.signatureId, d.channelKey, d.carrier]);
const carrierNames: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
export async function detectRetirement(db: PrismaService, date: string) {
if (date !== todayKey()) throw new Error('自动退网检测仅处理当天,不补发历史预警');
const dependency = await db.signatureAnalyticsDay.findUnique({
where: { businessDate: databaseDay(addDays(date, -1)) },
});
if (
!dependency?.publishedGenerationId ||
dependency.state !== 'ready' ||
dependency.refreshFor?.getTime() !== databaseDay(date).getTime()
)
throw new Error('签名退网检测等待昨日活动日报完成');
const job = await analyticsJob(
db,
'retirement',
date,
async (tx, _generation, checkpoint) => {
const report = await tx.signatureAnalyticsDay.findUnique({
where: { businessDate: databaseDay(addDays(date, -1)) },
});
if (
!report?.publishedGenerationId ||
report.state !== 'ready' ||
report.refreshFor?.getTime() !== databaseDay(date).getTime()
)
throw new Error('签名退网检测等待昨日活动日报完成');
const snapshot = checkpoint as unknown as { generationId: string; rules: SignatureRetirementRule[] };
const daily = await tx.signatureActivityDaily.findMany({ where: { generationId: snapshot.generationId } });
const rules = snapshot.rules;
const existing = new Set(
(await tx.signatureRetirementDetection.findMany({ where: { detectionDate: databaseDay(date) } })).map(keyOf),
);
const dimensions = daily.flatMap((d) => {
if (existing.has(keyOf(d)) || !d.approvedAt) return [];
const enterprise = d.dimensionType === 'enterprise';
const special = enterprise ? 'enterprise_application' : 'channel';
const global = enterprise ? 'enterprise_global' : 'channel_global';
const target = enterprise ? d.applicationId : d.channelKey;
const rule =
rules.find((r) => r.ruleType === special && r.targetId === target) ??
rules.find((r) => r.ruleType === global && r.targetKey === '');
if (!rule) return [];
const [windowDays, threshold] =
d.carrier === 'mobile'
? [rule.mobileWindowDays, rule.mobileThreshold]
: d.carrier === 'unicom'
? [rule.unicomWindowDays, rule.unicomThreshold]
: [rule.telecomWindowDays, rule.telecomThreshold];
const windowStart = startOfDay(addDays(date, -windowDays));
return [
{
...d,
approvedAt: d.approvedAt,
rule,
windowDays,
threshold,
windowStart,
observing: d.approvedAt > windowStart,
},
];
});
const eligible = dimensions.filter((d) => !d.observing);
const windowCounts = new Map<string, number>();
if (eligible.length) {
const earliest = new Date(Math.min(...eligible.map((d) => d.windowStart.getTime())));
const defs = JSON.stringify(
eligible.map((d) => ({
key: d.dimensionKey,
signature: d.signatureId,
carrier: d.carrier,
channel: d.channelKey,
start: d.windowStart.toISOString(),
})),
);
const rows = await tx.$queryRaw<Array<{ key: string; count: number }>>(Prisma.sql`
WITH dimensions AS (SELECT * FROM jsonb_to_recordset(${defs}::jsonb) AS d(key text,signature text,carrier text,channel text,start timestamptz)),
accepted AS MATERIALIZED (
SELECT s."messageRecordId",s."channelId",m."signatureId",m.carrier,COALESCE(s."submittedAt",s."createdAt") AS at
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId"
WHERE s."submitStatus"='accepted' AND COALESCE(s."submittedAt",s."createdAt")>=${earliest}
AND COALESCE(s."submittedAt",s."createdAt")<${startOfDay(date)} AND m."signatureId" IS NOT NULL
) SELECT d.key,COUNT(DISTINCT a."messageRecordId")::int AS count FROM dimensions d
LEFT JOIN accepted a ON a."signatureId"=d.signature AND a.carrier=d.carrier
AND (d.channel='' OR a."channelId"=d.channel) AND a.at>=d.start AT TIME ZONE 'UTC'
GROUP BY d.key`);
for (const row of rows) windowCounts.set(row.key, row.count);
}
const suppressions = new Map(
(await tx.signatureRetirementSuppression.findMany({ where: { active: true } })).map((r) => [keyOf(r), r]),
);
const cycles = new Map(
(await tx.signatureRetirementCycle.findMany({ where: { status: 'open' } })).map((r) => [keyOf(r), r]),
);
const rows: Prisma.SignatureRetirementDetectionCreateManyInput[] = [];
const continued: string[] = [],
resolved: string[] = [];
const newCycles: Prisma.SignatureRetirementCycleCreateManyInput[] = [];
let alerted = 0,
healthy = 0,
ineligible = 0;
for (const d of dimensions) {
const key = keyOf(d),
count = windowCounts.get(d.dimensionKey) ?? 0;
const alert = !d.observing && count < d.threshold;
let cycleId: string | null = null;
const cycle = cycles.get(key);
if (d.observing) ineligible++;
else if (alert) {
alerted++;
cycleId = cycle?.id ?? randomUUID();
if (cycle) continued.push(cycle.id);
else
newCycles.push({
id: cycleId,
dimensionType: d.dimensionType,
signatureId: d.signatureId,
channelId: d.channelKey || null,
channelKey: d.channelKey,
carrier: d.carrier,
startedOn: databaseDay(date),
lastDetectedOn: databaseDay(date),
});
} else {
healthy++;
if (cycle) resolved.push(cycle.id);
}
const suppression = suppressions.get(key);
const suppressed = Boolean(
suppression &&
(suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= databaseDay(date)),
);
const fallback =
d.dimensionType === 'enterprise'
? '请通知 {enterprise}{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
let content = d.rule.messageTemplate?.trim() || fallback;
for (const [name, value] of Object.entries({
enterprise: d.tenantName,
signature: d.signatureName,
carrier: carrierNames[d.carrier] ?? d.carrier,
days: d.windowDays,
actual: count,
threshold: d.threshold,
channel: d.channelName || '-',
}))
content = content.replaceAll(`{${name}}`, String(value));
rows.push({
detectionDate: databaseDay(date),
dimensionType: d.dimensionType,
tenantId: d.tenantId,
applicationId: d.applicationId,
signatureId: d.signatureId,
channelId: d.channelKey || null,
channelKey: d.channelKey,
carrier: d.carrier,
windowDays: d.windowDays,
threshold: d.threshold,
submittedAttempts: d.submittedAttempts,
acceptedBusinessCount: d.acceptedBusinessCount,
deliveredBusinessCount: d.deliveredBusinessCount,
approvedAt: d.approvedAt,
ruleId: d.rule.id,
ruleVersion: d.rule.version,
status: d.observing ? 'observing' : alert ? 'alert' : 'healthy',
cycleId,
suppressed,
notificationTitle: alert
? d.dimensionType === 'enterprise'
? '企业签名清退预警'
: '通道签名清退预警'
: null,
notificationContent: alert ? content : null,
});
}
for (let i = 0; i < newCycles.length; i += 250)
await tx.signatureRetirementCycle.createMany({ data: newCycles.slice(i, i + 250) });
if (continued.length)
await tx.signatureRetirementCycle.updateMany({
where: { id: { in: continued } },
data: { lastDetectedOn: databaseDay(date) },
});
if (resolved.length)
await tx.signatureRetirementCycle.updateMany({
where: { id: { in: resolved } },
data: { status: 'resolved', resolvedOn: databaseDay(date), lastDetectedOn: databaseDay(date) },
});
for (let i = 0; i < rows.length; i += 250)
await tx.signatureRetirementDetection.createMany({ data: rows.slice(i, i + 250) });
return { detectionDate: date, dimensions: dimensions.length, alerted, healthy, ineligible };
},
new Date(),
async () =>
JSON.parse(
JSON.stringify({
generationId: dependency.publishedGenerationId,
rules: await db.signatureRetirementRule.findMany({ where: { enabled: true } }),
}),
) as Prisma.InputJsonValue,
);
return job.result ?? { detectionDate: date, skipped: true };
}
@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SignatureAnalyticsService } from './signature-analytics.service';
@Module({ imports: [PrismaModule], providers: [SignatureAnalyticsService], exports: [SignatureAnalyticsService] })
export class SignatureAnalyticsModule {}
@@ -0,0 +1,129 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { OperationsQualityQueries } from '../operations/queries/quality.queries';
import { activityCounts, activityDimensions, unreportedRows } from './analytics-aggregate';
import { addDays, analyticsDate, databaseDay, mutableDay, todayKey } from './analytics-date';
import { analyticsJob } from './analytics-job';
@Injectable()
export class SignatureAnalyticsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SignatureAnalyticsService.name);
private timer?: NodeJS.Timeout;
private running = false;
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (process.env.NODE_ENV === 'test' || process.env.SIGNATURE_ANALYTICS_ENABLED === 'false') return;
this.timer = setInterval(() => void this.tick(), 60_000);
this.timer.unref();
void this.tick();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async tick(now = new Date()) {
const hour = Number(
new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(now),
);
if (hour < 3 || this.running) return;
this.running = true;
try {
for (const offset of [-3, -2, -1]) {
try {
await this.generate(addDays(todayKey(now), offset));
} catch (error) {
this.logger.error(
`signature_analytics_failed date=${addDays(todayKey(now), offset)}`,
error instanceof Error ? error.stack : String(error),
);
}
}
} finally {
this.running = false;
}
}
/** Explicit offline backfill only; no controller exposes this write operation. Existing frozen days cannot be overwritten. */
async generate(value: string, backfill = false) {
const startedAt = new Date();
const date = analyticsDate(value, startedAt);
if (!mutableDay(date, startedAt) && !backfill) return { skipped: true };
if (date >= todayKey(startedAt)) throw new Error('日报只生成完整自然日');
const businessDate = databaseDay(date);
const existing = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate } });
if (backfill && existing?.publishedGenerationId) throw new Error('历史补建不得覆盖已发布日报');
await this.db.signatureAnalyticsDay.upsert({ where: { businessDate }, create: { businessDate }, update: {} });
return await analyticsJob(
this.db,
'daily',
date,
async (tx, generationId) => {
const sourceAsOf = new Date();
await tx.signatureAnalyticsGeneration.create({ data: { id: generationId, businessDate, sourceAsOf } });
const quality = await new OperationsQualityQueries(tx).signatureQualityLive({ date }, true);
const dimensions = await activityDimensions(tx, date);
const counts = await activityCounts(tx, dimensions, date);
const unreported = await unreportedRows(tx, date);
// All candidate rows and manifest publication share this transaction: readers never see half a day.
for (let i = 0; i < quality.items.length; i += 250)
await tx.signatureQualityDaily.createMany({
data: quality.items.slice(i, i + 250).map((row) => ({
businessDate,
generationId,
signatureId: row.signatureId,
signatureName: row.signatureName,
tenantId: row.tenantId,
tenantName: row.tenantName,
applicationNames: row.applicationNames ?? '',
total: row.total,
payload: JSON.parse(JSON.stringify(row)) as Prisma.InputJsonValue,
})),
});
for (let i = 0; i < dimensions.length; i += 250)
await tx.signatureActivityDaily.createMany({
data: dimensions.slice(i, i + 250).map((d) => ({
...d,
businessDate,
generationId,
...(counts.get(d.dimensionKey) ?? {
submittedAttempts: 0,
acceptedBusinessCount: 0,
deliveredBusinessCount: 0,
}),
applicability: 'applicable',
})),
});
for (let i = 0; i < unreported.length; i += 250)
await tx.unreportedSignatureDaily.createMany({
data: unreported.slice(i, i + 250).map((r) => ({ ...r, businessDate, generationId })),
});
if (!backfill && !mutableDay(date)) throw new Error('日报已进入冻结区,拒绝跨日发布');
if (backfill) {
const current = await tx.signatureAnalyticsDay.findUniqueOrThrow({ where: { businessDate } });
if (current.publishedGenerationId) throw new Error('已有发布版本,拒绝覆盖');
}
await tx.signatureAnalyticsDay.update({
where: { businessDate },
data: {
publishedGenerationId: generationId,
state: 'ready',
error: null,
generatedAt: new Date(),
sourceAsOf,
refreshFor: databaseDay(todayKey(startedAt)),
provenance: backfill ? 'backfill-current-source' : 'daily',
rowCounts: { quality: quality.items.length, activity: dimensions.length, unreported: unreported.length },
},
});
return {
date,
generationId,
quality: quality.items.length,
activity: dimensions.length,
unreported: unreported.length,
};
},
startedAt,
);
}
}

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