Compare commits
18
Commits
4665079ca3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cacb6e8e7 | ||
|
|
28951b4fc4 | ||
|
|
001d5f2cbd | ||
|
|
b24cd7c08d | ||
|
|
c20c2246b2 | ||
|
|
1676cfe622 | ||
|
|
5e4d644788 | ||
|
|
627fa7ec97 | ||
|
|
572290308c | ||
|
|
4eb7b16d12 | ||
|
|
010ba32168 | ||
|
|
a350aca883 | ||
|
|
a0209f93bc | ||
|
|
86cb9aea36 | ||
|
|
cbc4a03325 | ||
|
|
c781313de5 | ||
|
|
18ecf8045f | ||
|
|
bcb278be29 |
@@ -1,4 +1,4 @@
|
||||
# CMPP 平台仓库开发约束
|
||||
# 聆界短信平台仓库开发约束
|
||||
|
||||
本文件适用于整个仓库。进入子目录工作时,如果存在更具体的 `AGENTS.md`,还应同时遵守子目录规范。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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')) {
|
||||
|
||||
@@ -363,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;
|
||||
@@ -482,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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { protocolFieldsToJson } from './protocol-uint32';
|
||||
|
||||
@Injectable()
|
||||
export class ProtocolFieldsInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler) {
|
||||
return next.handle().pipe(map(protocolFieldsToJson));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
protocolFieldsToJson,
|
||||
protocolUint32,
|
||||
protocolUint32FromDb,
|
||||
protocolUint32ToDb,
|
||||
parseProtocolSequence,
|
||||
} from './protocol-uint32';
|
||||
|
||||
describe('CMPP unsigned protocol fields', () => {
|
||||
it.each([0, 2147483647, 2147483648, 4294967295])(
|
||||
'round trips %s without changing the JSON number contract',
|
||||
(value) => {
|
||||
expect(protocolUint32FromDb(protocolUint32ToDb(value))).toBe(value);
|
||||
expect(
|
||||
JSON.parse(
|
||||
JSON.stringify(protocolFieldsToJson({ rows: [{ sequenceId: BigInt(value), ackResult: BigInt(value) }] })),
|
||||
),
|
||||
).toEqual({ rows: [{ sequenceId: value, ackResult: value }] });
|
||||
},
|
||||
);
|
||||
it.each([-1, 4294967296, 1.5, NaN, Infinity, '', '0', ' ', {}, true])('rejects invalid wire value %s', (value) => {
|
||||
expect(() => protocolUint32(value)).toThrow();
|
||||
expect(() => protocolUint32ToDb(value)).toThrow();
|
||||
});
|
||||
it('preserves optional historical nulls and unrelated serializers', () => {
|
||||
expect(protocolUint32ToDb(null)).toBeUndefined();
|
||||
expect(protocolUint32FromDb(null)).toBeUndefined();
|
||||
const date = new Date();
|
||||
expect(
|
||||
protocolFieldsToJson({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' }),
|
||||
).toEqual({ date, money: 10000n, sequenceId: null, gatewayMessageId: '18446744073709551615' });
|
||||
expect(() => protocolUint32FromDb(4294967296n)).toThrow();
|
||||
});
|
||||
it('distinguishes text zero from missing or malformed historical sequences', () => {
|
||||
for (const value of [null, undefined, '', ' ', '-1', '1.5', '1e2', '4294967296'])
|
||||
expect(parseProtocolSequence(value)).toBeUndefined();
|
||||
expect(parseProtocolSequence('0')).toBe(0);
|
||||
expect(parseProtocolSequence('4294967295')).toBe(4294967295);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/** Protocol integers are exact JS numbers on the wire and bigint in PostgreSQL. */
|
||||
export function protocolUint32(value: unknown, field = 'sequenceId'): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
||||
throw new BadRequestException(`${field} must be an unsigned 32-bit integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function protocolUint32ToDb(value: unknown, field = 'sequenceId'): bigint | undefined {
|
||||
return value == null ? undefined : BigInt(protocolUint32(value, field));
|
||||
}
|
||||
|
||||
export function protocolUint32FromDb(value: bigint | number | null | undefined): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
return protocolUint32(typeof value === 'bigint' ? Number(value) : value);
|
||||
}
|
||||
|
||||
/** Historical Submit sequence columns are text; blanks must never become zero. */
|
||||
export function parseProtocolSequence(value: string | null | undefined): number | undefined {
|
||||
if (value == null || !/^\d+$/.test(value)) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number <= 0xffffffff ? number : undefined;
|
||||
}
|
||||
|
||||
/** Only protocol fields are converted, leaving money and dates to their existing serializers. */
|
||||
export function protocolFieldsToJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(protocolFieldsToJson);
|
||||
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
(key === 'sequenceId' || key === 'ackResult') && typeof item === 'bigint'
|
||||
? protocolUint32FromDb(item)
|
||||
: protocolFieldsToJson(item),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
|
||||
|
||||
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
|
||||
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
|
||||
id: 'a',
|
||||
accepted: true,
|
||||
costUnitPrice: 300n,
|
||||
gatewayId: 'g1',
|
||||
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
|
||||
});
|
||||
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
|
||||
attemptId: 'a',
|
||||
gatewayId,
|
||||
status,
|
||||
at: at(day),
|
||||
approximate: false,
|
||||
});
|
||||
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
|
||||
describe('homepage business receipt projection', () => {
|
||||
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
|
||||
const a = attempt(Array(size).fill('delivered'), size);
|
||||
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
|
||||
const result = homeFact({ ...message, billingUnits: size }, [a], events);
|
||||
expect(result.successDay).toBe('2026-09-17');
|
||||
expect(result.revenue).toBe(String(size * 500));
|
||||
});
|
||||
it('includes paid successful fragments of prior failed attempts once', () => {
|
||||
const earlier = attempt(['delivered', 'failed', 'failed']);
|
||||
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
|
||||
const result = homeFact(
|
||||
message,
|
||||
[earlier, final],
|
||||
[
|
||||
event('g1', 'delivered'),
|
||||
event('g2', 'failed'),
|
||||
event('g3', 'failed'),
|
||||
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
|
||||
],
|
||||
);
|
||||
expect(result.units).toBe(3);
|
||||
expect(result.revenue).toBe('1500');
|
||||
expect(result.cost).toBe('1200');
|
||||
});
|
||||
it('accounts for all expected units when only one failure arrives', () => {
|
||||
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
|
||||
expect(f.units).toBe(3);
|
||||
expect(f.successDay).toBeNull();
|
||||
expect(f.receiptDays).toEqual(['2026-09-17']);
|
||||
});
|
||||
it('rejects partial success and missing parts even if message says delivered', () => {
|
||||
const f = homeFact(
|
||||
message,
|
||||
[attempt(['delivered', 'delivered'])],
|
||||
[event('g1', 'delivered'), event('g2', 'delivered')],
|
||||
);
|
||||
expect(f.successDay).toBeNull();
|
||||
expect(f.incomplete).toBe(true);
|
||||
});
|
||||
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
|
||||
const f = homeFact(
|
||||
message,
|
||||
[attempt(['delivered', 'delivered', 'delivered'])],
|
||||
[
|
||||
event('g1', 'delivered', 16),
|
||||
event('g2', 'delivered', 16),
|
||||
event('g3', 'delivered'),
|
||||
event('g3', 'delivered', 18),
|
||||
],
|
||||
);
|
||||
expect(f.successDay).toBe('2026-09-17');
|
||||
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
|
||||
expect(f.revenue).toBe('1500');
|
||||
expect(f.cost).toBe('900');
|
||||
});
|
||||
it('does not combine different attempts into a complete message', () => {
|
||||
const f = homeFact(
|
||||
message,
|
||||
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
|
||||
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
|
||||
);
|
||||
expect(f.successDay).toBeNull();
|
||||
});
|
||||
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
|
||||
const a = attempt(['delivered', 'delivered', 'delivered']);
|
||||
a.segments[1].inferred = a.segments[2].inferred = true;
|
||||
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
|
||||
a.segments[1].inferred = false;
|
||||
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
|
||||
});
|
||||
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
|
||||
const f = homeFact(
|
||||
message,
|
||||
[{ ...attempt([]), gatewayId: 'g1' }],
|
||||
[
|
||||
{ ...event('g1', 'delivered'), approximate: true },
|
||||
{ ...event('bad', 'failed'), attemptId: null },
|
||||
],
|
||||
);
|
||||
expect(f.successDay).toBe('2026-09-17');
|
||||
expect(f.approximate).toBe(true);
|
||||
expect(f.incomplete).toBe(true);
|
||||
});
|
||||
it('rejects invalid units and unsafe money without rounding', () => {
|
||||
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
|
||||
expect(safeMoney('12345')).toBe(12345);
|
||||
expect(() => safeMoney(9007199254740992n)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { todayKey } from '../signature-analytics/analytics-date';
|
||||
|
||||
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
|
||||
export type HomeAttempt = {
|
||||
id: string;
|
||||
accepted: boolean;
|
||||
gatewayId: string | null;
|
||||
costUnitPrice: bigint;
|
||||
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
|
||||
};
|
||||
export type HomeFact = {
|
||||
units: number;
|
||||
delivered: boolean;
|
||||
successDay: string | null;
|
||||
successAt: string | null;
|
||||
receiptDays: string[];
|
||||
revenue: string;
|
||||
cost: string;
|
||||
approximate: boolean;
|
||||
incomplete: boolean;
|
||||
};
|
||||
|
||||
/** Pure projection: never writes a message status or invents missing supplier receipts. */
|
||||
export function homeFact(
|
||||
message: { billingUnits: number; unitPrice: bigint; status: string },
|
||||
attempts: HomeAttempt[],
|
||||
incoming: HomeEvent[],
|
||||
): HomeFact {
|
||||
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
|
||||
const events = new Map<string, HomeEvent>();
|
||||
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
|
||||
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
|
||||
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
|
||||
if (!events.has(key)) events.set(key, event);
|
||||
}
|
||||
const successes: Date[] = [];
|
||||
let cost = 0n;
|
||||
let incomplete = !units || incoming.some((e) => !e.attemptId);
|
||||
for (const attempt of attempts) {
|
||||
if (!attempt.accepted) continue;
|
||||
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
|
||||
const successFor = (id: string | null) =>
|
||||
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
|
||||
if (!attempt.segments.length) {
|
||||
const success = successFor(attempt.gatewayId);
|
||||
if (success && units) {
|
||||
successes.push(success.at);
|
||||
cost += BigInt(units) * attempt.costUnitPrice;
|
||||
} else if (receipts.length) incomplete = true;
|
||||
continue;
|
||||
}
|
||||
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
|
||||
const expected = Math.max(...attempt.segments.map((s) => s.total));
|
||||
const times: Date[] = [];
|
||||
for (const part of parts.values()) {
|
||||
// A contractual message-level receipt can account for the explicitly inferred parts only.
|
||||
const received =
|
||||
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
|
||||
if (part.status === 'delivered' && received) {
|
||||
times.push(received.at);
|
||||
cost += attempt.costUnitPrice;
|
||||
}
|
||||
}
|
||||
const complete =
|
||||
expected > 0 &&
|
||||
parts.size === expected &&
|
||||
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
|
||||
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
|
||||
if (!complete) incomplete = true;
|
||||
}
|
||||
const success =
|
||||
message.status === 'delivered' && successes.length
|
||||
? new Date(Math.min(...successes.map((s) => s.getTime())))
|
||||
: null;
|
||||
if (message.status === 'delivered' && !success) incomplete = true;
|
||||
const effective = [...events.values()].filter((e) => !success || e.at <= success);
|
||||
return {
|
||||
units,
|
||||
delivered: message.status === 'delivered',
|
||||
successDay: success ? todayKey(success) : null,
|
||||
successAt: success?.toISOString() ?? null,
|
||||
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
|
||||
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
|
||||
cost: success ? cost.toString() : '0',
|
||||
approximate: effective.some((e) => e.approximate),
|
||||
incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeMoney(value: bigint | string | number) {
|
||||
const integer = BigInt(value);
|
||||
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
|
||||
throw new Error('金额超过安全展示范围');
|
||||
return Number(integer);
|
||||
}
|
||||
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
|
||||
import { sourceFacts } from './home-source';
|
||||
import { pruneHomeVersions } from './home-retention';
|
||||
|
||||
@Injectable()
|
||||
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
|
||||
private timer?: NodeJS.Timeout;
|
||||
private readonly logger = new Logger(HomeProjection.name);
|
||||
constructor(private readonly db: PrismaService) {}
|
||||
onModuleInit() {
|
||||
if (
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
|
||||
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
|
||||
)
|
||||
return;
|
||||
this.timer = setInterval(() => void this.run(), 10_000);
|
||||
this.timer.unref();
|
||||
void this.run();
|
||||
}
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
private async run() {
|
||||
try {
|
||||
await this.tick();
|
||||
} catch (error) {
|
||||
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
|
||||
await this.db.homeProjectionState
|
||||
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
async tick(now = new Date()) {
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const [lock] = await tx.$queryRaw<
|
||||
Array<{ locked: boolean }>
|
||||
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
|
||||
if (!lock.locked) return { busy: true };
|
||||
const date = todayKey(now),
|
||||
first = addDays(date, -3);
|
||||
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
|
||||
if (state.seededDay !== date) {
|
||||
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
|
||||
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
const work = await tx.$queryRaw<
|
||||
Array<{ messageRecordId: string }>
|
||||
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
|
||||
const ids = work.map((w) => w.messageRecordId),
|
||||
version = state.version + 1;
|
||||
const sources = await sourceFacts(tx, ids);
|
||||
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
|
||||
for (const id of ids) {
|
||||
const next = sources.find((s) => s.message.id === id);
|
||||
const old = prior.find((p) => p.messageRecordId === id);
|
||||
const day = next ? todayKey(next.message.queuedAt) : '';
|
||||
const payload = next && day >= first && day <= date ? next.fact : null;
|
||||
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
|
||||
if (old)
|
||||
await tx.homeMessageFact.update({
|
||||
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
|
||||
data: { toVersion: version },
|
||||
});
|
||||
if (payload)
|
||||
await tx.homeMessageFact.create({
|
||||
data: {
|
||||
messageRecordId: id,
|
||||
fromVersion: version,
|
||||
queuedDay: day,
|
||||
payload: payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
|
||||
const remaining = await tx.homeProjectionDirty.count();
|
||||
await tx.homeProjectionState.update({
|
||||
where: { id: 'home' },
|
||||
data: {
|
||||
version,
|
||||
seededDay: date,
|
||||
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
|
||||
updatedAt: now,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
|
||||
return { remaining, version };
|
||||
},
|
||||
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
|
||||
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
|
||||
import { percentage, safeMoney } from './home-fact';
|
||||
|
||||
type MetricRow = {
|
||||
queuedDay: string;
|
||||
total: bigint;
|
||||
delivered: bigint;
|
||||
units: bigint;
|
||||
successUnits: bigint;
|
||||
successMessages: bigint;
|
||||
revenue: bigint;
|
||||
cost: bigint;
|
||||
approximate: bigint;
|
||||
incomplete: bigint;
|
||||
};
|
||||
export function metrics(row?: MetricRow) {
|
||||
const total = Number(row?.total ?? 0),
|
||||
delivered = Number(row?.delivered ?? 0);
|
||||
const units = Number(row?.units ?? 0),
|
||||
success = Number(row?.successUnits ?? 0);
|
||||
const revenue = safeMoney(row?.revenue ?? 0n),
|
||||
cost = safeMoney(row?.cost ?? 0n);
|
||||
return {
|
||||
sent: total,
|
||||
delivered: Number(row?.successMessages ?? 0),
|
||||
successRate: percentage(delivered, total),
|
||||
receiptUnits: units,
|
||||
successUnits: success,
|
||||
receiptSuccessRate: percentage(success, units),
|
||||
revenueCents: revenue,
|
||||
profitCents: revenue - cost,
|
||||
profitRate: percentage(revenue - cost, revenue),
|
||||
approximate: Number(row?.approximate ?? 0),
|
||||
incomplete: Number(row?.incomplete ?? 0),
|
||||
};
|
||||
}
|
||||
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
|
||||
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
|
||||
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
|
||||
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
|
||||
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
|
||||
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
|
||||
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
|
||||
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
|
||||
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
|
||||
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
|
||||
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
|
||||
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
|
||||
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
|
||||
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
|
||||
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
|
||||
}
|
||||
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
|
||||
const start = startOfDay(date),
|
||||
end = startOfDay(addDays(date, 1));
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
todaySpendCents: bigint;
|
||||
todayReturnedCents: bigint;
|
||||
balanceCents: bigint;
|
||||
creditCents: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
|
||||
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
|
||||
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
|
||||
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
|
||||
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
|
||||
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
|
||||
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
|
||||
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
todaySpendCents: safeMoney(r.todaySpendCents),
|
||||
todayReturnedCents: safeMoney(r.todayReturnedCents),
|
||||
balanceCents: safeMoney(r.balanceCents),
|
||||
creditCents: safeMoney(r.creditCents),
|
||||
}));
|
||||
}
|
||||
export async function operationStatus(tx: Prisma.TransactionClient) {
|
||||
const window = downstreamAlertWindows();
|
||||
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
|
||||
await Promise.all([
|
||||
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
|
||||
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
|
||||
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
|
||||
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
|
||||
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
|
||||
tx.smsBatchTask.count(),
|
||||
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
|
||||
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
|
||||
tx.cmppDownstreamDelivery.count({
|
||||
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
taskCount,
|
||||
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
|
||||
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore the original hourly business-message series, bounded by today's queuedAt index. */
|
||||
export async function hourlySendTrend(tx: Prisma.TransactionClient, date: string) {
|
||||
const rows = await tx.$queryRaw<Array<{ hour: number; submittedCount: bigint; successCount: bigint }>>(Prisma.sql`
|
||||
SELECT EXTRACT(HOUR FROM ("queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
|
||||
COUNT(*)::bigint AS "submittedCount",COUNT(*) FILTER (WHERE status='delivered')::bigint AS "successCount"
|
||||
FROM "SmsMessageRecord" WHERE "queuedAt">=${startOfDay(date)} AND "queuedAt"<${startOfDay(addDays(date, 1))}
|
||||
GROUP BY hour ORDER BY hour`);
|
||||
const byHour = new Map(rows.map((row) => [row.hour, row]));
|
||||
return Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour,
|
||||
label: String(hour).padStart(2, '0') + ':00',
|
||||
submittedCount: Number(byHour.get(hour)?.submittedCount ?? 0),
|
||||
successCount: Number(byHour.get(hour)?.successCount ?? 0),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/** Only disposable dashboard projections; never source SMS, receipt or accounting records. */
|
||||
export async function pruneHomeVersions(tx: Prisma.TransactionClient, now: Date, firstDay: string, version: number) {
|
||||
const active = await tx.homeSnapshot.aggregate({ where: { expiresAt: { gt: now } }, _min: { version: true } });
|
||||
const minimum = active._min.version ?? version;
|
||||
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
|
||||
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "toVersion"<=${minimum} LIMIT 1000)`;
|
||||
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
|
||||
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "queuedDay"<${firstDay}
|
||||
AND "fromVersion"<${minimum} LIMIT 1000)`;
|
||||
await tx.$executeRaw`DELETE FROM "HomeSnapshot" WHERE id IN (SELECT id FROM "HomeSnapshot"
|
||||
WHERE "expiresAt"<${new Date(now.getTime() - 86400000)} LIMIT 1000)`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { homeFact, type HomeEvent } from './home-fact';
|
||||
|
||||
export async function sourceFacts(tx: Prisma.TransactionClient, ids: string[]) {
|
||||
const [messages, inbox, legacy] = await Promise.all([
|
||||
tx.smsMessageRecord.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { submitRecords: true, segmentAudits: true },
|
||||
}),
|
||||
tx.upstreamReceiptInbox.findMany({ where: { matchedMessageRecordId: { in: ids }, status: 'matched' } }),
|
||||
tx.smsReceiptRecord.findMany({ where: { messageRecordId: { in: ids } } }),
|
||||
]);
|
||||
const ownedReceiptKeys = new Set(
|
||||
(
|
||||
await tx.upstreamReceiptInbox.findMany({
|
||||
where: { receiptKey: { in: legacy.map((r) => r.receiptKey) } },
|
||||
select: { receiptKey: true },
|
||||
})
|
||||
).map((r) => r.receiptKey),
|
||||
);
|
||||
return messages.map((message) => {
|
||||
const parts = (id: string, submitId: string) =>
|
||||
message.segmentAudits.filter((p) => p.submitRecordId === id || (!p.submitRecordId && p.submitId === submitId));
|
||||
const candidates = (gatewayId: string, channelId: string | null) =>
|
||||
message.submitRecords.filter(
|
||||
(s) =>
|
||||
s.channelId === channelId &&
|
||||
(s.gatewayMessageId === gatewayId || parts(s.id, s.submitId).some((p) => p.gatewayMessageId === gatewayId)),
|
||||
);
|
||||
const events: HomeEvent[] = inbox
|
||||
.filter((i) => i.matchedMessageRecordId === message.id)
|
||||
.map((i) => {
|
||||
const direct = message.submitRecords.find((s) => s.id === i.matchedSubmitRecordId);
|
||||
const possible = candidates(i.gatewayMessageId, i.matchedChannelId ?? i.incomingChannelId);
|
||||
return {
|
||||
attemptId: direct?.id ?? (possible.length === 1 ? possible[0].id : null),
|
||||
gatewayId: i.gatewayMessageId,
|
||||
status: i.receiptStatus,
|
||||
at: i.gatewayReceivedAt ?? i.receivedAt,
|
||||
approximate: !i.gatewayReceivedAt,
|
||||
};
|
||||
});
|
||||
for (const r of legacy.filter((r) => r.messageRecordId === message.id)) {
|
||||
// An Inbox event (including unresolved/re-associated events) is not a legacy fallback.
|
||||
if (ownedReceiptKeys.has(r.receiptKey)) continue;
|
||||
const possible = candidates(r.gatewayMessageId, r.channelId);
|
||||
const attemptId = possible.length === 1 ? possible[0].id : null;
|
||||
if (
|
||||
events.some(
|
||||
(e) => e.attemptId === attemptId && e.gatewayId === r.gatewayMessageId && e.status === r.receiptStatus,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
events.push({
|
||||
attemptId,
|
||||
gatewayId: r.gatewayMessageId,
|
||||
status: r.receiptStatus,
|
||||
at: r.createdAt,
|
||||
approximate: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
message,
|
||||
fact: homeFact(
|
||||
message,
|
||||
message.submitRecords.map((s) => ({
|
||||
id: s.id,
|
||||
accepted: s.submitStatus === 'accepted',
|
||||
gatewayId: s.gatewayMessageId,
|
||||
costUnitPrice: s.costUnitPrice,
|
||||
segments: parts(s.id, s.submitId).map((p) => ({
|
||||
index: p.segmentIndex,
|
||||
total: p.segmentTotal,
|
||||
gatewayId: p.gatewayMessageId,
|
||||
status: p.receiptStatus,
|
||||
inferred: p.compensationType === 'supplier_message_level_receipt',
|
||||
})),
|
||||
})),
|
||||
events,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Module, Query, Req } from '@nestjs/common';
|
||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { HomeProjection } from './home-projection';
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@Controller('admin/operations/home')
|
||||
export class HomeController {
|
||||
constructor(private readonly home: HomeService) {}
|
||||
@Get('summary')
|
||||
async summary(@Req() req: SessionRequest) {
|
||||
return this.home.summary(await this.home.authorize(req));
|
||||
}
|
||||
@Get('receipt-breakdown')
|
||||
async receipts(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
|
||||
return this.home.breakdown(await this.home.authorize(req), token, 'receipt');
|
||||
}
|
||||
@Get('revenue-breakdown')
|
||||
async revenue(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
|
||||
return this.home.breakdown(await this.home.authorize(req), token, 'revenue');
|
||||
}
|
||||
}
|
||||
@Module({ imports: [PrismaModule], controllers: [HomeController], providers: [HomeService, HomeProjection] })
|
||||
export class HomeModule {}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
|
||||
import { aggregateHome, enterpriseRanks, hourlySendTrend, metrics, operationStatus } from './home-read';
|
||||
|
||||
@Injectable()
|
||||
export class HomeService {
|
||||
constructor(private readonly db: PrismaService) {}
|
||||
async authorize(req: SessionRequest) {
|
||||
const user =
|
||||
req.authSession?.portal === 'admin' &&
|
||||
req.sessionUserId &&
|
||||
(await this.db.user.findFirst({
|
||||
where: {
|
||||
id: req.sessionUserId,
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
roles: { some: { role: { code: 'platform_admin' } } },
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
if (!user) throw new ForbiddenException('无运营首页查看权限');
|
||||
return user.id;
|
||||
}
|
||||
async summary(userId: string, now = new Date()) {
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
// Read committed after this lock: no token may reference an already pruned version.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(17100917)`;
|
||||
const date = todayKey(now);
|
||||
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
|
||||
if (!state.initialized || state.seededDay !== date)
|
||||
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
|
||||
const [rows, ranks, status, pending, unresolved, hourlyTrend] = await Promise.all([
|
||||
aggregateHome(tx, date, state.version),
|
||||
enterpriseRanks(tx, date),
|
||||
operationStatus(tx),
|
||||
tx.homeProjectionDirty.count(),
|
||||
tx.upstreamReceiptInbox.count({
|
||||
where: {
|
||||
status: { not: 'matched' },
|
||||
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
|
||||
},
|
||||
}),
|
||||
hourlySendTrend(tx, date),
|
||||
]);
|
||||
const values = metrics(rows[0]);
|
||||
const summary = {
|
||||
businessDate: date,
|
||||
asOf: now.toISOString(),
|
||||
dataThrough: state.updatedAt.toISOString(),
|
||||
definitionVersion: 1,
|
||||
processing:
|
||||
pending > 0 ||
|
||||
unresolved > 0 ||
|
||||
Boolean(state.lastError) ||
|
||||
now.getTime() - state.updatedAt.getTime() > 60_000,
|
||||
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
|
||||
today: values,
|
||||
enterpriseSpendRanks: ranks,
|
||||
hourlySendTrend: hourlyTrend,
|
||||
...status,
|
||||
};
|
||||
const snapshot = await tx.homeSnapshot.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
businessDate: date,
|
||||
version: state.version,
|
||||
createdAt: now,
|
||||
expiresAt: new Date(now.getTime() + 15 * 60_000),
|
||||
summary,
|
||||
},
|
||||
});
|
||||
return { ...summary, snapshotToken: snapshot.id };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
async breakdown(userId: string, token: string | undefined, kind: 'receipt' | 'revenue', now = new Date()) {
|
||||
if (!token || !/^[0-9a-f-]{36}$/i.test(token)) throw new BadRequestException('统计快照参数无效');
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const snapshot = await tx.homeSnapshot.findUnique({ where: { id: token } });
|
||||
if (!snapshot || snapshot.userId !== userId) throw new ForbiddenException('统计快照不可访问');
|
||||
if (snapshot.expiresAt <= now || snapshot.businessDate !== todayKey(now))
|
||||
throw new ConflictException('统计快照已过期,请刷新首页后重试');
|
||||
const rows = await aggregateHome(tx, snapshot.businessDate, snapshot.version, true);
|
||||
return {
|
||||
snapshotToken: token,
|
||||
businessDate: snapshot.businessDate,
|
||||
items: Array.from({ length: 4 }, (_, offset) => {
|
||||
const submitDate = addDays(snapshot.businessDate, -offset),
|
||||
value = metrics(rows.find((r) => r.queuedDay === submitDate));
|
||||
return kind === 'receipt'
|
||||
? { submitDate, total: value.receiptUnits, success: value.successUnits, rate: value.receiptSuccessRate }
|
||||
: {
|
||||
submitDate,
|
||||
revenueCents: value.revenueCents,
|
||||
profitCents: value.profitCents,
|
||||
rate: value.profitRate,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,10 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
||||
continue;
|
||||
}
|
||||
closeTable();
|
||||
if (line.trim().endsWith(':') && line.trim().length < 70) sampleTitle = line.trim().replace(/:$/, '');
|
||||
const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1');
|
||||
if (caption.endsWith(':') && caption.length < 70) sampleTitle = caption.replace(/:$/, '');
|
||||
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
|
||||
}
|
||||
closeTable();
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聆界短信 · HTTP 接入文档</title><style>${asset('reader.css')}</style></head><body class="http-developer-docs"><header class="http-doc-header"><div><strong>聆界短信 · 开发者文档</strong><h1>HTTP 接口接入文档</h1><p>${escapeHtml(httpDocVersion(markdown))} · 基础地址 ${escapeHtml(origin || '当前环境')}/api/openapi/v1</p></div><div class="http-doc-actions"><a href="/api/client-docs?format=md" download="client-http-api-guide.md">下载 MD</a><a href="/api/client-docs-json" target="_blank" rel="noreferrer">OpenAPI JSON</a></div></header><div class="http-doc-layout"><nav aria-label="文档目录"><details open><summary>目录</summary>${sections.map((section) => `<a href="#${section.id}">${inline(section.title)}</a>`).join('')}</details><label for="doc-search">错误码 / 文档检索</label><input id="doc-search" type="search" placeholder="输入错误码或关键词"><p id="search-status" role="status"></p></nav><main>${sections.map((section) => `<section id="${section.id}" data-doc-section><div class="http-doc-body"><h2>${inline(section.title)}</h2>${section.body.join('')}</div></section>`).join('')}<p id="no-results" hidden>没有匹配的文档内容,请更换关键词。</p></main></div><p class="http-doc-copy-status" role="status" id="copy-status"></p><script>${asset('reader.js')}</script></body></html>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -169,11 +179,11 @@ describe('HTTP API remediation boundaries', () => {
|
||||
});
|
||||
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```',
|
||||
'**接口版本: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).toContain('签名原文 · text');
|
||||
expect(html).not.toContain('data-show-sample');
|
||||
expect(html).not.toContain('<aside');
|
||||
expect(html).not.toMatch(/>示例 \d+</);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { openApiSignature } from './open-api.protocol';
|
||||
|
||||
describe('raw-body request signing contract', () => {
|
||||
const path = '/api/openapi/v1/sms/messages';
|
||||
const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db';
|
||||
const body = Buffer.from(
|
||||
'{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"}',
|
||||
);
|
||||
const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL';
|
||||
const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw);
|
||||
|
||||
it('matches the independently computed published POST vector', () => {
|
||||
expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124');
|
||||
});
|
||||
it('does not accept legacy body digests or changed body bytes', () => {
|
||||
const legacy = createHmac('sha256', secret)
|
||||
.update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n'))
|
||||
.digest('hex');
|
||||
expect(sign(body)).not.toBe(legacy);
|
||||
for (const changed of [
|
||||
Buffer.concat([body, Buffer.from('\n')]),
|
||||
Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)),
|
||||
Buffer.from(body.toString().replace('123456', '654321')),
|
||||
]) {
|
||||
expect(sign(changed)).not.toBe(sign(body));
|
||||
}
|
||||
});
|
||||
it('rejects missing POST raw bytes instead of reconstructing JSON', () => {
|
||||
expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体');
|
||||
});
|
||||
it('rejects nonempty GET bodies and distinguishes a trailing LF', () => {
|
||||
const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce];
|
||||
const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]);
|
||||
expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex'));
|
||||
expect(actual).not.toBe(
|
||||
createHmac('sha256', secret)
|
||||
.update(fields.join('\n') + '\n')
|
||||
.digest('hex'),
|
||||
);
|
||||
expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文');
|
||||
});
|
||||
it('executes both handbook examples and verifies every complete request packet', () => {
|
||||
const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
);
|
||||
expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例');
|
||||
expect(guide).not.toContain('### 2.4');
|
||||
const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
|
||||
expect(scripts).toHaveLength(2);
|
||||
for (const script of scripts) {
|
||||
const outputs: string[] = [];
|
||||
runInNewContext(script[1], {
|
||||
Buffer,
|
||||
require: () => ({ createHmac }),
|
||||
console: { log: (value: string) => outputs.push(value) },
|
||||
});
|
||||
expect(outputs).toHaveLength(1);
|
||||
expect(guide).toContain(outputs[0]);
|
||||
}
|
||||
const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)];
|
||||
expect(packets).toHaveLength(5);
|
||||
for (const [, packet] of packets) {
|
||||
const split = packet.indexOf('\n\n');
|
||||
const headers = packet.slice(0, split);
|
||||
const [method, url] = headers.split('\n')[0].split(' ');
|
||||
const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1];
|
||||
const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined;
|
||||
if (raw) expect(raw.length).toBe(Number(header('Content-Length')));
|
||||
expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe(
|
||||
header('X-Signature'),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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末尾无LF,POST追加LF及原始UTF-8正文,不计算正文摘要。',
|
||||
})
|
||||
@ApiResponse({ status: 400, type: OpenApiProblemDto })
|
||||
@ApiResponse({ status: 401, type: OpenApiProblemDto })
|
||||
@ApiResponse({ status: 403, type: OpenApiProblemDto })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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() {
|
||||
@@ -936,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,
|
||||
@@ -987,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,6 +84,7 @@ export class OperationsMessageQueries {
|
||||
carrier: true,
|
||||
province: true,
|
||||
content: true,
|
||||
originalContent: true,
|
||||
hasDrainageContent: true,
|
||||
drainageDetection: true,
|
||||
billingUnits: true,
|
||||
@@ -115,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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -313,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()) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { RoutedChannel } from './send-chain.contracts';
|
||||
|
||||
export type CompletionContext = {
|
||||
tx: Prisma.TransactionClient;
|
||||
messageRecordId: string;
|
||||
route?: RoutedChannel;
|
||||
routePlanned?: boolean;
|
||||
};
|
||||
export const completionContext = new AsyncLocalStorage<CompletionContext>();
|
||||
|
||||
// Only send-chain collaborators use this adapter. Nested billing/outbox transactions
|
||||
// join the explicitly established completion transaction, never start a second one.
|
||||
export function completionDatabase(prisma: PrismaService): PrismaService {
|
||||
return new Proxy(prisma, {
|
||||
get(target, key) {
|
||||
const tx = completionContext.getStore()?.tx;
|
||||
if (tx && key === '$transaction') {
|
||||
return (operation: ((client: Prisma.TransactionClient) => unknown) | Promise<unknown>[]) =>
|
||||
typeof operation === 'function' ? operation(tx) : Promise.all(operation);
|
||||
}
|
||||
const owner = tx && key in tx ? tx : target;
|
||||
const value = Reflect.get(owner, key);
|
||||
return typeof value === 'function' ? value.bind(owner) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class CompletionRouteRequired extends Error {
|
||||
constructor(readonly select: () => Promise<RoutedChannel>) {
|
||||
super('completion_route_required');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
type CompletionMetric =
|
||||
'claimed' | 'not_claimed' | 'recovered' | 'fence_rejected' | 'event_committed' | 'retry_wait' | 'needs_review';
|
||||
const counts = new Map<CompletionMetric, number>();
|
||||
let snapshot: Array<{ state: string; count: number; age: number }> = [];
|
||||
const states = ['pending', 'processing', 'retry_wait', 'needs_review'];
|
||||
export function countCompletion(event: CompletionMetric) {
|
||||
counts.set(event, (counts.get(event) ?? 0) + 1);
|
||||
}
|
||||
export function observeCompletion(rows: typeof snapshot) {
|
||||
snapshot = rows;
|
||||
}
|
||||
export function renderCompletionMetrics() {
|
||||
return [
|
||||
'# TYPE cmpp_completion_events_total counter',
|
||||
...[...counts].map(([event, value]) => `cmpp_completion_events_total{event="${event}"} ${value}`),
|
||||
'# TYPE cmpp_completion_work gauge',
|
||||
'# TYPE cmpp_completion_oldest_seconds gauge',
|
||||
...states.flatMap((state) => {
|
||||
const row = snapshot.find((item) => item.state === state);
|
||||
return [
|
||||
`cmpp_completion_work{state="${state}"} ${Number(row?.count ?? 0)}`,
|
||||
`cmpp_completion_oldest_seconds{state="${state}"} ${Math.max(0, Number(row?.age ?? 0))}`,
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseProtocolSequence } from '../common/protocol-uint32';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
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',
|
||||
|
||||
@@ -81,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);
|
||||
|
||||
@@ -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: '提交消息未关联企业应用和签名' };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/** Keep delimiters as whole strings: SQL_ASCII treats a Chinese character class as bytes. */
|
||||
export function leadingSignatureSql(content: Prisma.Sql): Prisma.Sql {
|
||||
// The lookahead excludes complete brackets without rejecting bytes shared by other Chinese characters.
|
||||
// Noncapturing groups keep SUBSTRING returning the complete signature, including its brackets.
|
||||
return Prisma.sql`SUBSTRING(${content} FROM '^【(?:(?!【|】).)+】')`;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ describe('daily application messages and date formatting', () => {
|
||||
notificationContent: `冻结正文${item.id}`,
|
||||
}));
|
||||
const prisma = {
|
||||
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
|
||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue(detections) },
|
||||
smsApplication: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
@@ -60,6 +61,7 @@ describe('daily application messages and date formatting', () => {
|
||||
['2026-09-01', '2026-08-31'],
|
||||
])('keeps T-1 semantics for %s without constructing formatters per row', async (date, expected) => {
|
||||
const prisma = {
|
||||
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
|
||||
signatureRetirementDetection: {
|
||||
findMany: jest.fn().mockResolvedValue(
|
||||
Array.from({ length: 100 }, (_, i) => ({
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||
import type {
|
||||
CancelRetirementSuppressionDto,
|
||||
CreateRetirementWebhookDto,
|
||||
RetirementMessageQuery,
|
||||
SuppressRetirementMessageDto,
|
||||
UnreportedSignatureQuery,
|
||||
UpsertRetirementRuleDto,
|
||||
} from './signature-retirement.contracts';
|
||||
import { SignatureRetirementService } from './signature-retirement.service';
|
||||
|
||||
@ApiTags('signature-retirement')
|
||||
@Controller('admin/signature-retirement')
|
||||
export class SignatureRetirementController {
|
||||
constructor(private readonly service: SignatureRetirementService) {}
|
||||
constructor(
|
||||
private readonly service: SignatureRetirementService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('configuration')
|
||||
getConfiguration() {
|
||||
@@ -45,7 +57,17 @@ export class SignatureRetirementController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const query: RetirementMessageQuery = { dateFrom, dateTo, dimensionType, tenantId, applicationId, signatureKeyword, channelId, page: Number(page), pageSize: Number(pageSize) };
|
||||
const query: RetirementMessageQuery = {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
dimensionType,
|
||||
tenantId,
|
||||
applicationId,
|
||||
signatureKeyword,
|
||||
channelId,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
};
|
||||
return this.service.listMessages(query);
|
||||
}
|
||||
|
||||
@@ -66,7 +88,11 @@ export class SignatureRetirementController {
|
||||
|
||||
@Post('messages/:id/suppress')
|
||||
@RequireRecentAuthentication()
|
||||
suppress(@Param('id') id: string, @Body() body: SuppressRetirementMessageDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
suppress(
|
||||
@Param('id') id: string,
|
||||
@Body() body: SuppressRetirementMessageDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.service.suppressMessage(id, body, operatorId);
|
||||
}
|
||||
|
||||
@@ -77,10 +103,35 @@ export class SignatureRetirementController {
|
||||
|
||||
@Post('suppressions/:id/cancel')
|
||||
@RequireRecentAuthentication()
|
||||
cancelSuppression(@Param('id') id: string, @Body() body: CancelRetirementSuppressionDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
cancelSuppression(
|
||||
@Param('id') id: string,
|
||||
@Body() body: CancelRetirementSuppressionDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.service.cancelSuppression(id, body, operatorId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
activity(
|
||||
@Query()
|
||||
query: {
|
||||
date?: string;
|
||||
dimensionType: string;
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
tenantName?: string;
|
||||
applicationName?: string;
|
||||
signatureName?: string;
|
||||
channelName?: string;
|
||||
},
|
||||
) {
|
||||
return new SignatureAnalyticsRead(this.prisma).activity({
|
||||
...query,
|
||||
page: query.page === undefined ? 1 : Number(query.page),
|
||||
pageSize: query.pageSize === undefined ? 25 : Number(query.pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('heatmap')
|
||||
heatmap(@Query('date') date?: string) {
|
||||
return this.service.heatmap(date);
|
||||
@@ -93,8 +144,12 @@ export class SignatureRetirementController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const query: UnreportedSignatureQuery = { date, keyword, page: Number(page), pageSize: Number(pageSize) };
|
||||
const query: UnreportedSignatureQuery = {
|
||||
date,
|
||||
keyword,
|
||||
page: page === undefined ? 1 : Number(page),
|
||||
pageSize: pageSize === undefined ? 25 : Number(pageSize),
|
||||
};
|
||||
return this.service.unreportedSignatures(query);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,57 +1,6 @@
|
||||
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
|
||||
|
||||
describe('SignatureRetirementService dimensions', () => {
|
||||
const service = new SignatureRetirementService({} as never);
|
||||
|
||||
it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => {
|
||||
const rules = [
|
||||
rule('enterprise_global', ''),
|
||||
rule('enterprise_application', 'app-1'),
|
||||
rule('channel_global', ''),
|
||||
rule('channel', 'channel-2'),
|
||||
];
|
||||
const tasks = [
|
||||
task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'),
|
||||
task('channel-2', '移动二号', 'mobile', '2026-06-05T00:00:00Z'),
|
||||
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
|
||||
];
|
||||
|
||||
const dimensions = (
|
||||
service as unknown as {
|
||||
buildDimensions: (
|
||||
inputRules: unknown[],
|
||||
inputTasks: unknown[],
|
||||
) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }>;
|
||||
}
|
||||
).buildDimensions(rules, tasks);
|
||||
|
||||
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
|
||||
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
|
||||
expect(
|
||||
dimensions
|
||||
.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')
|
||||
?.approvedAt.toISOString(),
|
||||
).toBe('2026-06-01T00:00:00.000Z');
|
||||
expect(
|
||||
dimensions
|
||||
.filter((item) => item.dimensionType === 'enterprise')
|
||||
.every((item) => item.rule.ruleType === 'enterprise_application'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType,
|
||||
).toBe('channel');
|
||||
});
|
||||
|
||||
it('does not monitor legacy carrier-null reporting facts', () => {
|
||||
const dimensions = (
|
||||
service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }
|
||||
).buildDimensions(
|
||||
[rule('enterprise_global', ''), rule('channel_global', '')],
|
||||
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
|
||||
);
|
||||
expect(dimensions).toEqual([]);
|
||||
});
|
||||
|
||||
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
|
||||
@@ -69,6 +18,7 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
notificationContent: '冻结后的预警正文',
|
||||
};
|
||||
const prisma = {
|
||||
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
|
||||
signatureRetirementDetection: {
|
||||
findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]),
|
||||
},
|
||||
@@ -138,47 +88,17 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
|
||||
});
|
||||
|
||||
it('persists daily observing snapshots without opening alert cycles', async () => {
|
||||
const prisma = {
|
||||
signatureRetirementSuppression: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
signatureRetirementRule: {
|
||||
findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]),
|
||||
},
|
||||
signatureRetirementDetection: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'detection-1' }),
|
||||
},
|
||||
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||
$queryRaw: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
|
||||
};
|
||||
const observingService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({
|
||||
detectionDate: '2026-08-10',
|
||||
dimensions: 2,
|
||||
alerted: 0,
|
||||
healthy: 0,
|
||||
ineligible: 2,
|
||||
});
|
||||
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'observing',
|
||||
acceptedBusinessCount: 10,
|
||||
cycleId: undefined,
|
||||
notificationTitle: null,
|
||||
notificationContent: null,
|
||||
}),
|
||||
});
|
||||
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
|
||||
it('waits for a complete daily report before retirement detection', async () => {
|
||||
const prisma = { signatureAnalyticsDay: { findUnique: jest.fn().mockResolvedValue(null) } };
|
||||
const date = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
await expect(new SignatureRetirementService(prisma as never).runDetection(date)).rejects.toThrow(
|
||||
'等待昨日活动日报',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the real unreported-signature aggregation to an independent page', async () => {
|
||||
@@ -199,7 +119,7 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
const unreportedService = new SignatureRetirementService(prisma as never);
|
||||
|
||||
await expect(
|
||||
unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }),
|
||||
unreportedService.unreportedSignaturesLive({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }),
|
||||
).resolves.toEqual({
|
||||
date: '2026-08-10',
|
||||
items: [
|
||||
@@ -219,7 +139,7 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
});
|
||||
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
||||
const sql = query.strings?.join('?') ?? '';
|
||||
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
|
||||
expect(sql).toContain("SUBSTRING(message.content FROM '^【(?:(?!【|】).)+】')");
|
||||
expect(sql).toContain('message."signatureId" IS NULL');
|
||||
expect(sql).toContain('FROM "SmsSignature" signature');
|
||||
expect(sql).toContain('signature."applicationId" = extracted.application_id');
|
||||
@@ -311,32 +231,3 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function rule(ruleType: string, targetKey: string) {
|
||||
return {
|
||||
id: `${ruleType}-${targetKey}`,
|
||||
ruleType,
|
||||
targetId: targetKey || null,
|
||||
targetKey,
|
||||
enabled: true,
|
||||
mobileWindowDays: 30,
|
||||
mobileThreshold: 1,
|
||||
unicomWindowDays: 30,
|
||||
unicomThreshold: 1,
|
||||
telecomWindowDays: 30,
|
||||
telecomThreshold: 1,
|
||||
messageTemplate: null,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
|
||||
return {
|
||||
signatureId: 'signature-1',
|
||||
channelId,
|
||||
carrier,
|
||||
approvedAt: new Date(approvedAt),
|
||||
signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } },
|
||||
channel: { name: channelName },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
|
||||
import { analyticsDate, analyticsPage, todayKey } from '../signature-analytics/analytics-date';
|
||||
import { detectRetirement } from '../signature-analytics/retirement-batch';
|
||||
import { leadingSignatureSql } from '../signature-analytics/signature-extraction';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -37,28 +41,6 @@ const shanghaiHourFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||
const DAY_MS = 86_400_000;
|
||||
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||||
const DEFAULT_DELIVERY_INTERVAL_MS = 60_000;
|
||||
const carrierLabels: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||
|
||||
type RuleRecord = Awaited<ReturnType<PrismaService['signatureRetirementRule']['findFirst']>>;
|
||||
type DetectionDimension = {
|
||||
dimensionType: 'enterprise' | 'channel';
|
||||
tenantId: string;
|
||||
applicationId: string | null;
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantName: string;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
carrier: string;
|
||||
approvedAt: Date;
|
||||
rule: NonNullable<RuleRecord>;
|
||||
};
|
||||
|
||||
type ActivityCounts = {
|
||||
submittedAttempts: number;
|
||||
acceptedBusinessCount: number;
|
||||
deliveredBusinessCount: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -66,6 +48,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
private detectionTimer?: ReturnType<typeof setTimeout>;
|
||||
private notificationTimer?: ReturnType<typeof setTimeout>;
|
||||
private deliveryTimer?: ReturnType<typeof setInterval>;
|
||||
private compensationRunning = false;
|
||||
private publishedDate?: string;
|
||||
private startupTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -78,7 +62,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
this.scheduleDetection();
|
||||
this.scheduleNotification();
|
||||
this.deliveryTimer = setInterval(
|
||||
() => void this.deliverPendingWebhooks(),
|
||||
() => void this.runStartupCompensation(),
|
||||
positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS),
|
||||
);
|
||||
this.deliveryTimer.unref?.();
|
||||
@@ -540,6 +524,30 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
|
||||
async unreportedSignatures(query: UnreportedSignatureQuery) {
|
||||
analyticsPage(query.page, query.pageSize);
|
||||
const date = analyticsDate(query.date);
|
||||
if (date !== todayKey()) return new SignatureAnalyticsRead(this.prisma).unreported({ ...query, date });
|
||||
return this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
|
||||
const data = await new SignatureRetirementService(tx as PrismaService).unreportedSignaturesLive({
|
||||
...query,
|
||||
date,
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
dataSource: 'live',
|
||||
reportState: 'ready',
|
||||
frozen: false,
|
||||
sourceAsOf: new Date(),
|
||||
serverBusinessDate: date,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
|
||||
);
|
||||
}
|
||||
|
||||
async unreportedSignaturesLive(query: UnreportedSignatureQuery) {
|
||||
const date = assertDateKey(query.date || shanghaiDateKey());
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
|
||||
@@ -561,7 +569,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
SELECT
|
||||
message."tenantId" AS tenant_id,
|
||||
message."applicationId" AS application_id,
|
||||
SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name
|
||||
${leadingSignatureSql(Prisma.sql`message.content`)} AS signature_name
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${shanghaiStart(date)}
|
||||
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
|
||||
@@ -630,68 +638,16 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
|
||||
async runDetection(date?: string) {
|
||||
const detectionKey = assertDateKey(date || shanghaiDateKey());
|
||||
await this.prisma.signatureRetirementSuppression.updateMany({
|
||||
where: { active: true, mode: 'temporary', muteUntil: { lt: databaseDate(detectionKey) } },
|
||||
data: { active: false },
|
||||
});
|
||||
const [rules, approvedTasks] = await Promise.all([
|
||||
this.prisma.signatureRetirementRule.findMany({ where: { enabled: true } }),
|
||||
this.prisma.channelSignatureReportTask.findMany({
|
||||
where: {
|
||||
reportType: 'signature',
|
||||
status: 'approved',
|
||||
carrier: { not: null },
|
||||
approvalScope: 'carrier_specific',
|
||||
approvedAt: { not: null },
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
channel: { status: { not: 'deleted' } },
|
||||
},
|
||||
include: { signature: { include: { tenant: true, application: true } }, channel: true },
|
||||
}),
|
||||
]);
|
||||
const dimensions = this.buildDimensions(rules, approvedTasks);
|
||||
let alerted = 0;
|
||||
let healthy = 0;
|
||||
let ineligible = 0;
|
||||
for (const dimension of dimensions) {
|
||||
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
|
||||
const windowStartKey = addDays(detectionKey, -windowDays);
|
||||
const windowStart = shanghaiStart(windowStartKey);
|
||||
const activityStart = shanghaiStart(addDays(detectionKey, -1));
|
||||
const activityEnd = shanghaiStart(detectionKey);
|
||||
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
|
||||
if (effectiveActivityStart >= activityEnd) {
|
||||
ineligible += 1;
|
||||
continue;
|
||||
}
|
||||
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
|
||||
if (dimension.approvedAt > windowStart) {
|
||||
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
|
||||
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
|
||||
ineligible += 1;
|
||||
continue;
|
||||
}
|
||||
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
|
||||
const isAlert = windowCounts.acceptedBusinessCount < threshold;
|
||||
await this.persistDetection(
|
||||
detectionKey,
|
||||
dimension,
|
||||
windowDays,
|
||||
threshold,
|
||||
dailyCounts,
|
||||
isAlert,
|
||||
false,
|
||||
windowCounts,
|
||||
);
|
||||
if (isAlert) alerted += 1;
|
||||
else healthy += 1;
|
||||
}
|
||||
return { detectionDate: detectionKey, dimensions: dimensions.length, alerted, healthy, ineligible };
|
||||
return detectRetirement(this.prisma, analyticsDate(date));
|
||||
}
|
||||
|
||||
async publishNotifications(date?: string) {
|
||||
const notificationKey = assertDateKey(date || shanghaiDateKey());
|
||||
const notificationKey = analyticsDate(date);
|
||||
if (this.publishedDate === notificationKey) return { notificationDate: notificationKey, created: 0 };
|
||||
const completed = await this.prisma.signatureAnalyticsRun.findUnique({
|
||||
where: { scope_businessDate: { scope: 'retirement', businessDate: databaseDate(notificationKey) } },
|
||||
});
|
||||
if (completed?.state !== 'succeeded') throw new Error('签名退网检测尚未完整完成,暂不发布通知');
|
||||
const detections = await this.prisma.signatureRetirementDetection.findMany({
|
||||
where: {
|
||||
detectionDate: databaseDate(notificationKey),
|
||||
@@ -749,10 +705,13 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
}
|
||||
await this.enqueueWebhookSummaries(notificationKey);
|
||||
this.publishedDate = notificationKey;
|
||||
return { notificationDate: notificationKey, created };
|
||||
}
|
||||
|
||||
private async runStartupCompensation() {
|
||||
if (this.compensationRunning) return;
|
||||
this.compensationRunning = true;
|
||||
const now = new Date();
|
||||
const hour = shanghaiHour(now);
|
||||
try {
|
||||
@@ -765,6 +724,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
this.logger.error(
|
||||
`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.compensationRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,231 +762,6 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
this.notificationTimer.unref?.();
|
||||
}
|
||||
|
||||
private buildDimensions(
|
||||
rules: Array<NonNullable<RuleRecord>>,
|
||||
tasks: Array<{
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
carrier: string | null;
|
||||
approvedAt: Date | null;
|
||||
signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } };
|
||||
channel: { name: string };
|
||||
}>,
|
||||
) {
|
||||
const dimensions: DetectionDimension[] = [];
|
||||
const enterprise = new Map<string, DetectionDimension>();
|
||||
for (const task of tasks) {
|
||||
if (!task.carrier || !task.approvedAt) continue;
|
||||
const channelRule = selectRule(rules, 'channel', task.channelId);
|
||||
if (channelRule)
|
||||
dimensions.push({
|
||||
dimensionType: 'channel',
|
||||
tenantId: task.signature.tenantId,
|
||||
applicationId: task.signature.applicationId,
|
||||
signatureId: task.signatureId,
|
||||
signatureName: task.signature.name,
|
||||
tenantName: task.signature.tenant.name,
|
||||
channelId: task.channelId,
|
||||
channelName: task.channel.name,
|
||||
carrier: task.carrier,
|
||||
approvedAt: task.approvedAt,
|
||||
rule: channelRule,
|
||||
});
|
||||
const enterpriseRule = selectRule(rules, 'enterprise', task.signature.applicationId);
|
||||
if (!enterpriseRule) continue;
|
||||
const key = `${task.signatureId}:${task.carrier}`;
|
||||
const current = enterprise.get(key);
|
||||
if (!current || task.approvedAt < current.approvedAt)
|
||||
enterprise.set(key, {
|
||||
dimensionType: 'enterprise',
|
||||
tenantId: task.signature.tenantId,
|
||||
applicationId: task.signature.applicationId,
|
||||
signatureId: task.signatureId,
|
||||
signatureName: task.signature.name,
|
||||
tenantName: task.signature.tenant.name,
|
||||
channelId: null,
|
||||
channelName: null,
|
||||
carrier: task.carrier,
|
||||
approvedAt: task.approvedAt,
|
||||
rule: enterpriseRule,
|
||||
});
|
||||
}
|
||||
return [...enterprise.values(), ...dimensions];
|
||||
}
|
||||
|
||||
private async activityCounts(dimension: DetectionDimension, startAt: Date, endAt: Date): Promise<ActivityCounts> {
|
||||
const channelFilter = dimension.channelId
|
||||
? Prisma.sql`AND submit."channelId" = ${dimension.channelId}`
|
||||
: Prisma.empty;
|
||||
const rows = await this.prisma.$queryRaw<Array<ActivityCounts>>(Prisma.sql`
|
||||
WITH attempts AS (
|
||||
SELECT
|
||||
submit.id,
|
||||
submit."messageRecordId" AS message_id,
|
||||
submit."submitStatus" AS submit_status,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id)
|
||||
THEN NOT EXISTS (
|
||||
SELECT 1 FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id AND segment."receiptStatus" IS DISTINCT FROM 'delivered'
|
||||
)
|
||||
ELSE EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
)
|
||||
END AS delivery_success
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
WHERE message."signatureId" = ${dimension.signatureId}
|
||||
AND message.carrier = ${dimension.carrier}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${endAt}
|
||||
${channelFilter}
|
||||
)
|
||||
SELECT
|
||||
COUNT(id)::integer AS "submittedAttempts",
|
||||
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedBusinessCount",
|
||||
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted' AND delivery_success)::integer AS "deliveredBusinessCount"
|
||||
FROM attempts
|
||||
`);
|
||||
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
|
||||
}
|
||||
|
||||
private async persistDetection(
|
||||
dateKey: string,
|
||||
dimension: DetectionDimension,
|
||||
windowDays: number,
|
||||
threshold: number,
|
||||
counts: ActivityCounts,
|
||||
isAlert: boolean,
|
||||
observing = false,
|
||||
alertCounts = counts,
|
||||
) {
|
||||
const detectionDate = databaseDate(dateKey);
|
||||
const channelKey = dimension.channelId ?? '';
|
||||
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
|
||||
where: {
|
||||
detectionDate_dimensionType_signatureId_channelKey_carrier: {
|
||||
detectionDate,
|
||||
dimensionType: dimension.dimensionType,
|
||||
signatureId: dimension.signatureId,
|
||||
channelKey,
|
||||
carrier: dimension.carrier,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
// 同一检测日的结果冻结规则版本;规则变更只在下一检测日生效。
|
||||
if (existingDetection) return;
|
||||
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({
|
||||
where: {
|
||||
dimensionType_signatureId_channelKey_carrier: {
|
||||
dimensionType: dimension.dimensionType,
|
||||
signatureId: dimension.signatureId,
|
||||
channelKey,
|
||||
carrier: dimension.carrier,
|
||||
},
|
||||
},
|
||||
});
|
||||
const suppressed = Boolean(
|
||||
suppression?.active &&
|
||||
(suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate),
|
||||
);
|
||||
let cycle = await this.prisma.signatureRetirementCycle.findFirst({
|
||||
where: {
|
||||
dimensionType: dimension.dimensionType,
|
||||
signatureId: dimension.signatureId,
|
||||
channelKey,
|
||||
carrier: dimension.carrier,
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
if (observing) {
|
||||
cycle = null;
|
||||
} else if (isAlert) {
|
||||
if (!cycle) {
|
||||
try {
|
||||
cycle = await this.prisma.signatureRetirementCycle.create({
|
||||
data: {
|
||||
dimensionType: dimension.dimensionType,
|
||||
signatureId: dimension.signatureId,
|
||||
channelId: dimension.channelId,
|
||||
channelKey,
|
||||
carrier: dimension.carrier,
|
||||
startedOn: detectionDate,
|
||||
lastDetectedOn: detectionDate,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isPrismaUniqueError(error)) throw error;
|
||||
cycle = await this.prisma.signatureRetirementCycle.findFirst({
|
||||
where: {
|
||||
dimensionType: dimension.dimensionType,
|
||||
signatureId: dimension.signatureId,
|
||||
channelKey,
|
||||
carrier: dimension.carrier,
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
cycle = await this.prisma.signatureRetirementCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: { lastDetectedOn: detectionDate },
|
||||
});
|
||||
}
|
||||
} else if (cycle) {
|
||||
await this.prisma.signatureRetirementCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate },
|
||||
});
|
||||
cycle = null;
|
||||
}
|
||||
const notificationTitle =
|
||||
isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
|
||||
const notificationContent =
|
||||
isAlert && cycle
|
||||
? renderMessage(
|
||||
dimension.rule.messageTemplate,
|
||||
dimension,
|
||||
windowDays,
|
||||
threshold,
|
||||
alertCounts.acceptedBusinessCount,
|
||||
)
|
||||
: null;
|
||||
try {
|
||||
await this.prisma.signatureRetirementDetection.create({
|
||||
data: {
|
||||
detectionDate,
|
||||
dimensionType: dimension.dimensionType,
|
||||
tenantId: dimension.tenantId,
|
||||
applicationId: dimension.applicationId,
|
||||
signatureId: dimension.signatureId,
|
||||
channelId: dimension.channelId,
|
||||
channelKey,
|
||||
carrier: dimension.carrier,
|
||||
windowDays,
|
||||
threshold,
|
||||
...counts,
|
||||
approvedAt: dimension.approvedAt,
|
||||
ruleId: dimension.rule.id,
|
||||
ruleVersion: dimension.rule.version,
|
||||
status: observing ? 'observing' : isAlert ? 'alert' : 'healthy',
|
||||
cycleId: cycle?.id,
|
||||
suppressed,
|
||||
notificationTitle,
|
||||
notificationContent,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。
|
||||
if (isPrismaUniqueError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueueWebhookSummaries(dateKey: string) {
|
||||
const detectionDate = databaseDate(dateKey);
|
||||
const [webhooks, messages] = await Promise.all([
|
||||
@@ -1119,46 +855,6 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
}
|
||||
|
||||
function selectRule(
|
||||
rules: Array<NonNullable<RuleRecord>>,
|
||||
dimension: 'enterprise' | 'channel',
|
||||
targetId: string | null,
|
||||
) {
|
||||
const specialType = dimension === 'enterprise' ? 'enterprise_application' : 'channel';
|
||||
const globalType = dimension === 'enterprise' ? 'enterprise_global' : 'channel_global';
|
||||
return (
|
||||
(targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined) ??
|
||||
rules.find((rule) => rule.ruleType === globalType && rule.targetKey === '')
|
||||
);
|
||||
}
|
||||
|
||||
function carrierRule(rule: NonNullable<RuleRecord>, carrier: string) {
|
||||
if (carrier === 'mobile') return { windowDays: rule.mobileWindowDays, threshold: rule.mobileThreshold };
|
||||
if (carrier === 'unicom') return { windowDays: rule.unicomWindowDays, threshold: rule.unicomThreshold };
|
||||
return { windowDays: rule.telecomWindowDays, threshold: rule.telecomThreshold };
|
||||
}
|
||||
|
||||
function renderMessage(
|
||||
template: string | null,
|
||||
dimension: DetectionDimension,
|
||||
windowDays: number,
|
||||
threshold: number,
|
||||
actual: number,
|
||||
) {
|
||||
const fallback =
|
||||
dimension.dimensionType === 'enterprise'
|
||||
? '请通知 {enterprise}:{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
|
||||
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
|
||||
return (template?.trim() || fallback)
|
||||
.replaceAll('{enterprise}', dimension.tenantName)
|
||||
.replaceAll('{signature}', dimension.signatureName)
|
||||
.replaceAll('{channel}', dimension.channelName ?? '-')
|
||||
.replaceAll('{carrier}', carrierLabels[dimension.carrier] ?? dimension.carrier)
|
||||
.replaceAll('{days}', String(windowDays))
|
||||
.replaceAll('{threshold}', String(threshold))
|
||||
.replaceAll('{actual}', String(actual));
|
||||
}
|
||||
|
||||
function shanghaiDateKey(date = new Date()) {
|
||||
return shanghaiDayFormatter.format(date);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user