Compare commits
11
Commits
a350aca883
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cacb6e8e7 | ||
|
|
28951b4fc4 | ||
|
|
001d5f2cbd | ||
|
|
b24cd7c08d | ||
|
|
c20c2246b2 | ||
|
|
1676cfe622 | ||
|
|
5e4d644788 | ||
|
|
627fa7ec97 | ||
|
|
572290308c | ||
|
|
4eb7b16d12 | ||
|
|
010ba32168 |
@@ -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();
|
||||
+148
-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
|
||||
@@ -2896,3 +2902,138 @@ model SmsCompletionEvent {
|
||||
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 [
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
@@ -3255,7 +3287,7 @@ 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', 'timeout'] } },
|
||||
@@ -3543,6 +3575,7 @@ describe('SendChainService', () => {
|
||||
expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }),
|
||||
);
|
||||
|
||||
await receiptEvidence(prisma);
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
@@ -3597,6 +3630,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await receiptEvidence(prisma, { ...(await prisma.smsMessageRecord.findFirst()), submitId: 'SUB-1' });
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
@@ -3632,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',
|
||||
@@ -3657,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',
|
||||
@@ -3699,7 +3736,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: 'GW-RECOVERED-1',
|
||||
sequenceId: 7,
|
||||
sequenceId: 7n,
|
||||
},
|
||||
});
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
@@ -3717,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: {
|
||||
@@ -3733,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',
|
||||
@@ -3746,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' },
|
||||
}),
|
||||
@@ -3784,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',
|
||||
@@ -3810,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',
|
||||
@@ -3847,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',
|
||||
@@ -3873,7 +3936,7 @@ describe('SendChainService', () => {
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
}),
|
||||
).rejects.toThrow('SMS message record not found');
|
||||
).rejects.toThrow('提交尝试关联');
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3921,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',
|
||||
@@ -4010,6 +4074,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await receiptEvidence(prisma);
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-MESSAGE-LEVEL',
|
||||
channelId: 'channel-1',
|
||||
@@ -4074,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',
|
||||
@@ -4234,6 +4300,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await receiptEvidence(prisma);
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-LONG-FAIL',
|
||||
channelId: 'channel-1',
|
||||
@@ -4275,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);
|
||||
|
||||
@@ -4285,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({
|
||||
@@ -4308,7 +4373,7 @@ describe('SendChainService', () => {
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
}),
|
||||
).rejects.toThrow('SMS message record not found');
|
||||
).rejects.toThrow('提交尝试关联');
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -4420,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',
|
||||
@@ -4455,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' },
|
||||
@@ -4475,7 +4542,7 @@ describe('SendChainService', () => {
|
||||
tenantId: undefined,
|
||||
applicationId: undefined,
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: '接入号匹配多个应用',
|
||||
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
|
||||
@@ -4517,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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4783,7 +4853,7 @@ describe('SendChainService', () => {
|
||||
},
|
||||
create: expect.objectContaining({
|
||||
segmentTotal: 3,
|
||||
sequenceId: 71,
|
||||
sequenceId: 71n,
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
submitStatus: 'accepted',
|
||||
}),
|
||||
@@ -4792,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' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -5190,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'),
|
||||
}),
|
||||
}),
|
||||
@@ -5200,7 +5270,7 @@ describe('SendChainService', () => {
|
||||
update: expect.objectContaining({
|
||||
status: 'acknowledged',
|
||||
acknowledgedAt: new Date('2026-07-14T03:40:18.060Z'),
|
||||
ackResult: 0,
|
||||
ackResult: 0n,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -5220,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(
|
||||
|
||||
@@ -698,6 +698,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: string;
|
||||
messageId?: string;
|
||||
matchStatus: string;
|
||||
matchReason: string;
|
||||
candidates: UplinkMatchCandidateInput[];
|
||||
|
||||
@@ -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,4 +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';
|
||||
@@ -32,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;
|
||||
}
|
||||
@@ -48,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,
|
||||
@@ -78,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,
|
||||
@@ -95,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: {
|
||||
@@ -112,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,
|
||||
@@ -278,7 +307,10 @@ export class SendDownstreamDeliveryService {
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const retained = await this.prisma.cmppDownstreamDelivery.findUniqueOrThrow({ where: { dedupeKey } });
|
||||
if (retained.messageRecordId !== data.messageRecordId || retained.applicationId !== data.applicationId)
|
||||
if (
|
||||
(retained.messageRecordId ?? null) !== (data.messageRecordId ?? null) ||
|
||||
retained.applicationId !== data.applicationId
|
||||
)
|
||||
throw new Error('completion_notification_identity_mismatch');
|
||||
return retained;
|
||||
}
|
||||
@@ -377,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,
|
||||
@@ -190,7 +194,8 @@ export class SendGatewayResultService {
|
||||
data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (
|
||||
data.submitStatus !== 'accepted' &&
|
||||
(message.status === 'delivered' || (message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT'))
|
||||
(['delivered', 'failed'].includes(message.status) ||
|
||||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT'))
|
||||
) {
|
||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||
return message;
|
||||
@@ -381,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' },
|
||||
});
|
||||
@@ -435,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,
|
||||
@@ -453,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,4 @@
|
||||
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';
|
||||
@@ -300,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 {
|
||||
@@ -308,6 +311,7 @@ export class SendGatewaySubmitService {
|
||||
routed,
|
||||
submitId,
|
||||
command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId),
|
||||
decision,
|
||||
sessionId: sessionByChannel.get(routed.channel.id),
|
||||
};
|
||||
});
|
||||
@@ -315,7 +319,9 @@ export class SendGatewaySubmitService {
|
||||
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,
|
||||
@@ -332,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",
|
||||
@@ -350,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) {
|
||||
@@ -393,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;
|
||||
@@ -467,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) {
|
||||
@@ -485,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(
|
||||
@@ -530,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,
|
||||
@@ -548,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,
|
||||
@@ -755,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', () =>
|
||||
@@ -762,7 +786,7 @@ export class SendGatewaySubmitService {
|
||||
);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
const sessionId = await this.getOpenSubmitSessionId(channel.id);
|
||||
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId);
|
||||
const command = this.buildGatewaySubmitCommand(submittedMessage, routed, attempt, submitId, upstreamSrcId);
|
||||
const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled();
|
||||
try {
|
||||
await this.measureSendStage('submit_transaction', () =>
|
||||
@@ -777,6 +801,8 @@ export class SendGatewaySubmitService {
|
||||
channelGroupName: routed.groupName,
|
||||
sessionId,
|
||||
retryOfSubmitRecordId,
|
||||
sentContent: decision.content,
|
||||
contentPolicy: policyAudit(decision),
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice ?? 0,
|
||||
@@ -786,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,
|
||||
@@ -1118,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)
|
||||
@@ -1130,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,
|
||||
});
|
||||
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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -14,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';
|
||||
@@ -39,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: {
|
||||
@@ -55,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));
|
||||
}
|
||||
@@ -143,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),
|
||||
@@ -223,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);
|
||||
@@ -245,7 +259,7 @@ export class SendReceiptService {
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -262,7 +276,7 @@ export class SendReceiptService {
|
||||
messageId: resolved.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
|
||||
sequenceId: data.sequenceId,
|
||||
sequenceId: protocolUint32ToDb(data.sequenceId),
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
@@ -282,6 +296,34 @@ export class SendReceiptService {
|
||||
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';
|
||||
@@ -455,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 },
|
||||
@@ -479,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,
|
||||
@@ -505,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,
|
||||
@@ -520,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: {
|
||||
@@ -537,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,
|
||||
@@ -554,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,
|
||||
@@ -600,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+149
-126
@@ -1,177 +1,200 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
|
||||
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
import { writeUniqueSignature } from './signature-uniqueness';
|
||||
import { templateWriteError } from './template-uniqueness';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsAuditService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly lifecycle: SmsApplicationLifecycleService,
|
||||
private readonly reportValidation: SmsReportValidationService,
|
||||
) {}
|
||||
listAuditRecords(targetType?: string, targetId?: string) {
|
||||
return this.prisma.auditRecord.findMany({
|
||||
where: {
|
||||
targetType,
|
||||
targetId,
|
||||
},
|
||||
include: {
|
||||
reviewer: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
return this.prisma.auditRecord.findMany({
|
||||
where: {
|
||||
targetType,
|
||||
targetId,
|
||||
},
|
||||
include: {
|
||||
reviewer: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
approveSignature(signatureId: string, data: ReviewDto) {
|
||||
return this.reviewSignature(signatureId, 'approved', 'approve', data);
|
||||
}
|
||||
return this.reviewSignature(signatureId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectSignature(signatureId: string, data: ReviewDto) {
|
||||
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
|
||||
}
|
||||
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
approveTemplate(templateId: string, data: ReviewDto) {
|
||||
return this.reviewTemplate(templateId, 'approved', 'approve', data);
|
||||
}
|
||||
return this.reviewTemplate(templateId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectTemplate(templateId: string, data: ReviewDto) {
|
||||
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
||||
}
|
||||
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
|
||||
await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: status }, () =>
|
||||
this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }),
|
||||
);
|
||||
await this.lifecycle.writeOperationLog(
|
||||
signature.tenantId,
|
||||
data.operatorId,
|
||||
`sms_signature.${status}`,
|
||||
'sms_signature',
|
||||
signatureId,
|
||||
{
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
},
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
|
||||
await this.lifecycle.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsTemplate
|
||||
.update({ where: { id: templateId }, data: { auditStatus: status } })
|
||||
.catch(templateWriteError);
|
||||
await this.lifecycle.writeOperationLog(
|
||||
template.tenantId,
|
||||
data.operatorId,
|
||||
`sms_template.${status}`,
|
||||
'sms_template',
|
||||
templateId,
|
||||
{
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
},
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: statusAfter }, () =>
|
||||
this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||
},
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action,
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}),
|
||||
);
|
||||
await this.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action,
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
if (!['pending', 'rejected'].includes(item.auditStatus)) {
|
||||
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
|
||||
}
|
||||
if (statusAfter === 'rejected' && !data.reason?.trim()) {
|
||||
throw new BadRequestException('驳回引流信息时必须填写原因');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: item.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: itemId,
|
||||
action,
|
||||
statusBefore: item.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
||||
else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
|
||||
return updated;
|
||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
if (!['pending', 'rejected'].includes(item.auditStatus)) {
|
||||
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
|
||||
}
|
||||
if (statusAfter === 'rejected' && !data.reason?.trim()) {
|
||||
throw new BadRequestException('驳回引流信息时必须填写原因');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: item.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: itemId,
|
||||
action,
|
||||
statusBefore: item.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
||||
else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
const updated = await this.prisma.smsTemplate
|
||||
.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||
},
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action,
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
})
|
||||
.catch(templateWriteError);
|
||||
await this.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action,
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resolveReviewerId(reviewerId?: string) {
|
||||
if (!reviewerId) {
|
||||
return undefined;
|
||||
}
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } });
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
return reviewerId;
|
||||
if (!reviewerId) {
|
||||
return undefined;
|
||||
}
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } });
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
return reviewerId;
|
||||
}
|
||||
|
||||
createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignatureNameConflict, writeUniqueSignature } from './signature-uniqueness';
|
||||
|
||||
describe('active signature uniqueness', () => {
|
||||
const identity = { tenantId: 'tenant', applicationId: 'app', name: '【测试】' };
|
||||
function fixture() {
|
||||
const findFirst = jest.fn().mockResolvedValue(null);
|
||||
return { findFirst, prisma: { smsSignature: { findFirst } } as unknown as PrismaService };
|
||||
}
|
||||
|
||||
it('rejects duplicates before writing and returns a business conflict', async () => {
|
||||
const { prisma, findFirst } = fixture();
|
||||
findFirst.mockResolvedValue({ id: 'existing' });
|
||||
const write = jest.fn();
|
||||
await expect(writeUniqueSignature(prisma, identity, write)).rejects.toBeInstanceOf(SignatureNameConflict);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(new SignatureNameConflict().getStatus()).toBe(409);
|
||||
});
|
||||
|
||||
it('scopes null applications exactly and excludes the current record', async () => {
|
||||
const { prisma, findFirst } = fixture();
|
||||
await expect(
|
||||
writeUniqueSignature(prisma, { ...identity, id: 'self', applicationId: null }, async () => 'ok'),
|
||||
).resolves.toBe('ok');
|
||||
expect(findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: 'tenant',
|
||||
applicationId: null,
|
||||
name: '【测试】',
|
||||
auditStatus: { notIn: ['deleted', 'disabled'] },
|
||||
id: { not: 'self' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['draft', 'pending', 'approved', 'rejected'])('reserves names in %s status', async (auditStatus) => {
|
||||
const { prisma, findFirst } = fixture();
|
||||
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
|
||||
expect(findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['deleted', 'disabled'])('releases names in %s status', async (auditStatus) => {
|
||||
const { prisma, findFirst } = fixture();
|
||||
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
|
||||
expect(findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['tenantId', 'applicationId', 'name']],
|
||||
[['tenantId', 'name']],
|
||||
['SmsSignature_active_application_name_key'],
|
||||
['SmsSignature_active_unbound_name_key'],
|
||||
])('maps only the signature identity race (%j)', async (target) => {
|
||||
const { prisma } = fixture();
|
||||
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
|
||||
code: 'P2002',
|
||||
clientVersion: 'test',
|
||||
meta: { target },
|
||||
});
|
||||
await expect(
|
||||
writeUniqueSignature(prisma, identity, async () => {
|
||||
throw error;
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SignatureNameConflict);
|
||||
});
|
||||
|
||||
it('preserves unrelated database failures', async () => {
|
||||
const { prisma } = fixture();
|
||||
for (const error of [
|
||||
new Error('offline'),
|
||||
new Prisma.PrismaClientKnownRequestError('id', {
|
||||
code: 'P2002',
|
||||
clientVersion: 'test',
|
||||
meta: { target: ['id'] },
|
||||
}),
|
||||
]) {
|
||||
await expect(
|
||||
writeUniqueSignature(prisma, identity, async () => {
|
||||
throw error;
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes the real Prisma pg adapter metadata and quoted identifiers', async () => {
|
||||
const { prisma } = fixture();
|
||||
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
|
||||
code: 'P2002',
|
||||
clientVersion: '7.9.0',
|
||||
meta: {
|
||||
modelName: 'SmsSignature',
|
||||
driverAdapterError: { cause: { constraint: { fields: ['"tenantId"', '"applicationId"', 'name'] } } },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
writeUniqueSignature(prisma, identity, async () => {
|
||||
throw error;
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SignatureNameConflict);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export class SignatureNameConflict extends ConflictException {
|
||||
constructor() {
|
||||
super('同一企业、同一应用下已存在同名有效签名,请修改已有签名资料');
|
||||
}
|
||||
}
|
||||
|
||||
type SignatureIdentity = {
|
||||
id?: string;
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
name: string;
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** The partial SQL indexes are authoritative when concurrent requests pass the precheck. */
|
||||
export async function writeUniqueSignature<T>(
|
||||
prisma: PrismaService,
|
||||
identity: SignatureIdentity,
|
||||
write: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!['deleted', 'disabled'].includes(identity.auditStatus ?? 'draft')) {
|
||||
const duplicate = await prisma.smsSignature.findFirst({
|
||||
where: {
|
||||
tenantId: identity.tenantId,
|
||||
applicationId: identity.applicationId ?? null,
|
||||
name: identity.name,
|
||||
auditStatus: { notIn: ['deleted', 'disabled'] },
|
||||
...(identity.id ? { id: { not: identity.id } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) throw new SignatureNameConflict();
|
||||
}
|
||||
try {
|
||||
return await write();
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
// Prisma's pg adapter exposes the constraint under driverAdapterError.cause (Prisma 7).
|
||||
const constraint = record(record(record(error.meta?.driverAdapterError).cause).constraint);
|
||||
const target = error.meta?.target ?? constraint.fields;
|
||||
const fields = Array.isArray(target)
|
||||
? target.map((field: unknown) => (typeof field === 'string' ? field.replace(/^"|"$/g, '') : field))
|
||||
: [];
|
||||
if (
|
||||
(fields.includes('tenantId') && fields.includes('name')) ||
|
||||
(typeof target === 'string' && /^SmsSignature_active_(application|unbound)_name_key$/.test(target))
|
||||
) {
|
||||
throw new SignatureNameConflict();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeUniqueSignature } from './signature-uniqueness';
|
||||
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -766,16 +767,21 @@ export class SmsSignatureService {
|
||||
data.drainageInfo,
|
||||
);
|
||||
const name = validateCompleteSmsSignature(data.name);
|
||||
const signature = await this.prisma.smsSignature.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
const signature = await writeUniqueSignature(
|
||||
this.prisma,
|
||||
{ ...data, name, auditStatus: options.initialAuditStatus },
|
||||
() =>
|
||||
this.prisma.smsSignature.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
||||
if (options.initialAuditStatus) {
|
||||
await this.audit.createAuditRecord({
|
||||
@@ -805,10 +811,11 @@ export class SmsSignatureService {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
await this.reportValidation.validateSignatureReportValues(
|
||||
data.applicationId ?? signature.applicationId ?? undefined,
|
||||
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined,
|
||||
data.drainageInfo,
|
||||
);
|
||||
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
|
||||
const applicationId =
|
||||
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined;
|
||||
const drainageInfo = data.drainageInfo
|
||||
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||
: undefined;
|
||||
@@ -822,21 +829,31 @@ export class SmsSignatureService {
|
||||
const auditStatus =
|
||||
options.initialAuditStatus ??
|
||||
(materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus);
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: materialChanged ? { increment: 1 } : undefined,
|
||||
pendingReport: materialChanged ? true : undefined,
|
||||
reportChangedAt: materialChanged ? new Date() : undefined,
|
||||
const updated = await writeUniqueSignature(
|
||||
this.prisma,
|
||||
{
|
||||
...signature,
|
||||
applicationId: applicationId ?? null,
|
||||
name: name ?? signature.name,
|
||||
auditStatus: auditStatus ?? signature.auditStatus,
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
() =>
|
||||
this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: materialChanged ? { increment: 1 } : undefined,
|
||||
pendingReport: materialChanged ? true : undefined,
|
||||
reportChangedAt: materialChanged ? new Date() : undefined,
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
}),
|
||||
);
|
||||
await this.reportValidation.syncSignatureReportValues(
|
||||
signatureId,
|
||||
updated.applicationId ?? undefined,
|
||||
@@ -897,10 +914,12 @@ export class SmsSignatureService {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: 'pending' }, () =>
|
||||
this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
}),
|
||||
);
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CreateSmsApplicationDto } from './sms-config.contracts';
|
||||
|
||||
/** Pure normalization and report-value helpers shared by the R3 domain services. */
|
||||
export const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
|
||||
export type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
|
||||
export type ApplicationQueuePriority = (typeof APPLICATION_QUEUE_PRIORITIES)[number];
|
||||
export const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
|
||||
export type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
|
||||
export type ApplicationInterfaceType = (typeof APPLICATION_INTERFACE_TYPES)[number];
|
||||
export const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
|
||||
export const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
|
||||
export const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
|
||||
@@ -55,16 +55,16 @@ export function validateAndNormalizeTemplateVariables(
|
||||
const end = content.indexOf('}', start + 2);
|
||||
if (end < 0) throw new BadRequestException('模板变量未闭合');
|
||||
const name = content.slice(start + 2, end);
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) {
|
||||
throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位');
|
||||
if (!/^[A-Za-z0-9]{1,32}$/.test(name)) {
|
||||
throw new BadRequestException('模板变量名仅允许英文字母和数字,长度1至32位');
|
||||
}
|
||||
if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`);
|
||||
names.push(name);
|
||||
cursor = end + 1;
|
||||
}
|
||||
if (!supplied) return names.map((name) => ({ name, required: true }));
|
||||
const suppliedNames = supplied.map((item) => item.name?.trim());
|
||||
if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) {
|
||||
const suppliedNames = supplied.map((item) => item.name);
|
||||
if (suppliedNames.some((name) => typeof name !== 'string' || !/^[A-Za-z0-9]{1,32}$/.test(name))) {
|
||||
throw new BadRequestException('变量配置中包含非法变量名');
|
||||
}
|
||||
if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量');
|
||||
@@ -75,7 +75,10 @@ export function validateAndNormalizeTemplateVariables(
|
||||
}
|
||||
|
||||
export function normalizeSmsSignature(name: string) {
|
||||
const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
const innerName = name
|
||||
.trim()
|
||||
.replace(/^[【[]+|[】\]]+$/g, '')
|
||||
.trim();
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
}
|
||||
|
||||
@@ -114,26 +117,27 @@ export function normalizeApplicationInterfaceType(value?: string): ApplicationIn
|
||||
}
|
||||
|
||||
export function normalizeCmppAccessNumberConfig(
|
||||
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
|
||||
data: Pick<
|
||||
CreateSmsApplicationDto,
|
||||
'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'
|
||||
>,
|
||||
current?: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
},
|
||||
) {
|
||||
const applicationExtension = (
|
||||
data.cmppApplicationExtension === undefined
|
||||
const applicationExtension =
|
||||
(data.cmppApplicationExtension === undefined
|
||||
? current?.cmppApplicationExtension
|
||||
: data.cmppApplicationExtension
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled
|
||||
?? current?.cmppAccessNumberFillEnabled
|
||||
?? false;
|
||||
const configuredPrefix = (
|
||||
data.cmppAccessNumberFillPrefix === undefined
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled ?? current?.cmppAccessNumberFillEnabled ?? false;
|
||||
const configuredPrefix =
|
||||
(data.cmppAccessNumberFillPrefix === undefined
|
||||
? current?.cmppAccessNumberFillPrefix
|
||||
: data.cmppAccessNumberFillPrefix
|
||||
)?.trim() || null;
|
||||
)?.trim() || null;
|
||||
|
||||
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
|
||||
throw new BadRequestException('cmppApplicationExtension must contain digits only');
|
||||
@@ -152,9 +156,7 @@ export function normalizeCmppAccessNumberConfig(
|
||||
}
|
||||
|
||||
const fillPrefix = fillEnabled ? configuredPrefix : null;
|
||||
const clientSrcId = applicationExtension
|
||||
? `${fillPrefix ?? ''}${applicationExtension}`
|
||||
: null;
|
||||
const clientSrcId = applicationExtension ? `${fillPrefix ?? ''}${applicationExtension}` : null;
|
||||
if (clientSrcId && clientSrcId.length > 21) {
|
||||
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
|
||||
}
|
||||
@@ -179,7 +181,9 @@ export function normalizeApplicationCmppStatus(connections: Array<{ status: stri
|
||||
if (connections.some((connection) => connection.status === 'connected')) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
if (
|
||||
connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))
|
||||
) {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'disconnected';
|
||||
@@ -202,7 +206,10 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
export function reportValueParts(value: unknown) {
|
||||
if (isRecord(value) && typeof value.fileObjectId === 'string') {
|
||||
return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId };
|
||||
return {
|
||||
fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined,
|
||||
fileObjectId: value.fileObjectId,
|
||||
};
|
||||
}
|
||||
return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TemplateOptOutController } from './template-optout.controller';
|
||||
import { AdminSmsConfigController } from './admin-sms-config.controller';
|
||||
import { ClientSmsConfigController } from './client-sms-config.controller';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
@@ -8,7 +9,12 @@ import { DeletionGovernanceModule } from '../deletion-governance/deletion-govern
|
||||
|
||||
@Module({
|
||||
imports: [DeletionGovernanceModule],
|
||||
controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController],
|
||||
controllers: [
|
||||
ClientSmsConfigController,
|
||||
AdminSmsConfigController,
|
||||
ReviewGovernanceController,
|
||||
TemplateOptOutController,
|
||||
],
|
||||
providers: [SmsConfigService, ReviewGovernanceService],
|
||||
exports: [SmsConfigService],
|
||||
})
|
||||
|
||||
@@ -88,6 +88,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
smsSignature: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TemplateOptOutController } from './template-optout.controller';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
describe('template opt-out configuration constraints', () => {
|
||||
const db = { $transaction: jest.fn() };
|
||||
const controller = new TemplateOptOutController(db as unknown as PrismaService);
|
||||
it.each([
|
||||
{ rules: [], preserveFragments: false },
|
||||
{ rules: 'bad', preserveFragments: true },
|
||||
{ rules: [{ channelId: 'x', action: 'replace' }], preserveFragments: true },
|
||||
{
|
||||
rules: [
|
||||
{ channelId: 'x', action: 'add' },
|
||||
{ channelId: 'x', action: 'remove' },
|
||||
],
|
||||
preserveFragments: true,
|
||||
},
|
||||
])('rejects unsafe input without writing: %j', async (body) => {
|
||||
await expect(controller.put('template', body)).rejects.toMatchObject({ status: 400 });
|
||||
expect(db.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Put } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OptOutRule } from '../send-chain/template-optout-policy';
|
||||
|
||||
@Controller('admin/enterprise-templates')
|
||||
export class TemplateOptOutController {
|
||||
constructor(private readonly db: PrismaService) {}
|
||||
|
||||
@Get(':id/opt-out-policy')
|
||||
async get(@Param('id') id: string) {
|
||||
const template = await this.db.smsTemplate.findUnique({ where: { id } });
|
||||
if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在');
|
||||
const channels = await this.channels(this.db, template);
|
||||
return { rules: template.optOutRules, preserveFragments: true, channels };
|
||||
}
|
||||
|
||||
@Put(':id/opt-out-policy')
|
||||
async put(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { rules?: unknown; preserveFragments?: unknown },
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
if (!body || body.preserveFragments !== true || !Array.isArray(body.rules) || body.rules.length > 500) {
|
||||
throw new BadRequestException('请提交有效规则,并保持避免影响消息分片数');
|
||||
}
|
||||
const rules: OptOutRule[] = [];
|
||||
for (const value of body.rules) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value.channelId !== 'string' ||
|
||||
!['add', 'remove'].includes(value.action) ||
|
||||
rules.some((r) => r.channelId === value.channelId)
|
||||
) {
|
||||
throw new BadRequestException('通道规则不合法或存在重复通道');
|
||||
}
|
||||
rules.push({ channelId: value.channelId, action: value.action });
|
||||
}
|
||||
return this.db.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM "SmsTemplate" WHERE id=${id} FOR UPDATE`;
|
||||
const template = await tx.smsTemplate.findUnique({ where: { id } });
|
||||
if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在');
|
||||
const channels = await this.channels(tx, template);
|
||||
if (rules.some((rule) => !channels.some((c) => c.id === rule.channelId)))
|
||||
throw new BadRequestException('只能选择本模板所属应用通道组中的通道');
|
||||
await tx.smsTemplate.update({ where: { id }, data: { optOutRules: rules } });
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
tenantId: template.tenantId,
|
||||
userId: operatorId,
|
||||
action: 'sms_template.opt_out_policy.update',
|
||||
resource: 'sms_template',
|
||||
resourceId: id,
|
||||
detail: { before: template.optOutRules, after: rules, preserveFragments: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return { rules, preserveFragments: true, channels };
|
||||
});
|
||||
}
|
||||
|
||||
private async channels(
|
||||
db: Pick<Prisma.TransactionClient, 'channelRouteRule'>,
|
||||
template: { applicationId: string; tenantId: string },
|
||||
) {
|
||||
const routes = await db.channelRouteRule.findMany({
|
||||
where: {
|
||||
applicationId: template.applicationId,
|
||||
tenantId: template.tenantId,
|
||||
status: 'active',
|
||||
channelId: null,
|
||||
group: { status: 'active' },
|
||||
},
|
||||
select: {
|
||||
group: { select: { name: true, items: { select: { channel: { select: { id: true, name: true } } } } } },
|
||||
},
|
||||
});
|
||||
const channels = new Map<string, { id: string; name: string; groupNames: string[] }>();
|
||||
for (const route of routes)
|
||||
for (const { channel } of route.group.items) {
|
||||
const entry = channels.get(channel.id) ?? { ...channel, groupNames: [] };
|
||||
if (!entry.groupNames.includes(route.group.name)) entry.groupNames.push(route.group.name);
|
||||
channels.set(channel.id, entry);
|
||||
}
|
||||
return [...channels.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export function templateWriteError(error: unknown): never {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
|
||||
throw new BadRequestException('同一企业应用下已存在相同名称的模板,请修改模板名称');
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { validateAndNormalizeTemplateVariables } from './sms-config.helpers';
|
||||
|
||||
describe('template variable names', () => {
|
||||
it.each(['code', 'Code123', '123', 'A'.repeat(32)])('accepts ASCII alphanumeric name %s', (name) => {
|
||||
expect(validateAndNormalizeTemplateVariables(`正文\${${name}}`, [{ name, example: '中文示例' }])).toEqual([
|
||||
{ name, example: '中文示例' },
|
||||
]);
|
||||
});
|
||||
it.each(['中文', 'code_1', 'code-1', ' name', '1', 'é', '', 'A'.repeat(33)])('rejects invalid name %s', (name) => {
|
||||
expect(() => validateAndNormalizeTemplateVariables(`正文\${${name}}`)).toThrow('变量名仅允许');
|
||||
});
|
||||
it('rejects unclosed, repeated and mismatched variables', () => {
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code')).toThrow('未闭合');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}${code}')).toThrow('重复');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'other' }])).toThrow('完全一致');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'code_' }])).toThrow('非法');
|
||||
expect(() => validateAndNormalizeTemplateVariables('${code}', [{ name: 'code ' }])).toThrow('非法');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,32 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
import { templateWriteError } from './template-uniqueness';
|
||||
import type {
|
||||
CreateSmsTemplateDto,
|
||||
CreateSmsTemplateOptions,
|
||||
TemplateListQuery,
|
||||
UpdateSmsTemplateDto,
|
||||
} from './sms-config.contracts';
|
||||
import {
|
||||
estimateBillingUnits,
|
||||
normalizeSmsSignature,
|
||||
validateAndNormalizeTemplateVariables,
|
||||
type TemplateVariableInput,
|
||||
} from './sms-config.helpers';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsTemplateService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: SmsAuditService,
|
||||
) {}
|
||||
listTemplates(queryOrTenantId?: string | TemplateListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
|
||||
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
|
||||
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async listTemplatesPage(query: TemplateListQuery) {
|
||||
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.SmsTemplateWhereInput = {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
@@ -52,38 +34,77 @@ export class SmsTemplateService {
|
||||
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
|
||||
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
|
||||
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listTemplates({ ...query, page, pageSize }),
|
||||
this.prisma.smsTemplate.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
OR: query.keyword
|
||||
? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize
|
||||
? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async listTemplatesPage(query: TemplateListQuery) {
|
||||
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.SmsTemplateWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
|
||||
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
|
||||
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||
OR: query.keyword
|
||||
? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listTemplates({ ...query, page, pageSize }),
|
||||
this.prisma.smsTemplate.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listClientTemplates(tenantId: string | undefined, includeHistory = false) {
|
||||
return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' });
|
||||
}
|
||||
return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' });
|
||||
}
|
||||
|
||||
async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
|
||||
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application || application.tenantId !== data.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content);
|
||||
return this.prisma.smsTemplate.create({
|
||||
const name = this.templateName(data.name);
|
||||
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: { tenantId: true },
|
||||
});
|
||||
if (!application || application.tenantId !== data.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content);
|
||||
return this.prisma.smsTemplate
|
||||
.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
@@ -97,38 +118,45 @@ export class SmsTemplateService {
|
||||
},
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(templateWriteError);
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: { tenantId: true },
|
||||
});
|
||||
if (!application || application.tenantId !== template.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application || application.tenantId !== template.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
}
|
||||
if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) {
|
||||
await this.validateTemplateSignature(
|
||||
data.signatureId === undefined ? template.signatureId : data.signatureId,
|
||||
template.tenantId,
|
||||
data.applicationId ?? template.applicationId,
|
||||
data.content ?? template.content,
|
||||
);
|
||||
}
|
||||
const variables = data.content !== undefined || data.variables !== undefined
|
||||
}
|
||||
if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) {
|
||||
await this.validateTemplateSignature(
|
||||
data.signatureId === undefined ? template.signatureId : data.signatureId,
|
||||
template.tenantId,
|
||||
data.applicationId ?? template.applicationId,
|
||||
data.content ?? template.content,
|
||||
);
|
||||
}
|
||||
const variables =
|
||||
data.content !== undefined || data.variables !== undefined
|
||||
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
|
||||
: undefined;
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId)
|
||||
|| (data.signatureId !== undefined && data.signatureId !== template.signatureId)
|
||||
|| (data.content !== undefined && data.content !== template.content)
|
||||
|| (data.category !== undefined && data.category !== template.category)
|
||||
|| data.variables !== undefined;
|
||||
const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const materialChanged =
|
||||
(data.applicationId !== undefined && data.applicationId !== template.applicationId) ||
|
||||
(data.signatureId !== undefined && data.signatureId !== template.signatureId) ||
|
||||
(data.content !== undefined && data.content !== template.content) ||
|
||||
(data.category !== undefined && data.category !== template.category) ||
|
||||
data.variables !== undefined;
|
||||
const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus;
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
}
|
||||
@@ -136,83 +164,102 @@ export class SmsTemplateService {
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
name: data.name === undefined ? undefined : this.templateName(data.name),
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||
variables: variables ? {
|
||||
create: variables.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
} : undefined,
|
||||
variables: variables
|
||||
? {
|
||||
create: variables.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(templateWriteError);
|
||||
}
|
||||
|
||||
private templateName(value: string) {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new BadRequestException('模板名称不能为空');
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改模板');
|
||||
}
|
||||
const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改模板');
|
||||
}
|
||||
const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async submitTemplate(templateId: string, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
|
||||
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action: 'submit',
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
await this.validateTemplateSignature(
|
||||
template.signatureId,
|
||||
template.tenantId,
|
||||
template.applicationId,
|
||||
template.content,
|
||||
);
|
||||
|
||||
async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) {
|
||||
if (!signatureId) {
|
||||
throw new BadRequestException('短信模板必须选择短信签名');
|
||||
}
|
||||
const signature = await this.prisma.smsSignature.findUnique({
|
||||
where: { id: signatureId },
|
||||
select: { tenantId: true, applicationId: true, name: true },
|
||||
});
|
||||
if (!signature || signature.tenantId !== tenantId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template tenant');
|
||||
}
|
||||
if (signature.applicationId && signature.applicationId !== applicationId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template application');
|
||||
}
|
||||
const signaturePrefix = normalizeSmsSignature(signature.name);
|
||||
if (!signaturePrefix || !content.startsWith(signaturePrefix)) {
|
||||
throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`);
|
||||
}
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action: 'submit',
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async validateTemplateSignature(
|
||||
signatureId: string | null | undefined,
|
||||
tenantId: string,
|
||||
applicationId: string,
|
||||
content: string,
|
||||
) {
|
||||
if (!signatureId) {
|
||||
throw new BadRequestException('短信模板必须选择短信签名');
|
||||
}
|
||||
const signature = await this.prisma.smsSignature.findUnique({
|
||||
where: { id: signatureId },
|
||||
select: { tenantId: true, applicationId: true, name: true },
|
||||
});
|
||||
if (!signature || signature.tenantId !== tenantId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template tenant');
|
||||
}
|
||||
if (signature.applicationId && signature.applicationId !== applicationId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template application');
|
||||
}
|
||||
const signaturePrefix = normalizeSmsSignature(signature.name);
|
||||
if (!signaturePrefix || !content.startsWith(signaturePrefix)) {
|
||||
throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# CMPP 协议字段兼容性整改方案
|
||||
|
||||
日期:2026-09-20。状态:六类整改已在 `b24cd7c` 基线上实施并完成本地定向验收;目标环境未迁移、未发布。实施证据及剩余边界见第 10 节。
|
||||
|
||||
## 1. 目标、范围与授权
|
||||
|
||||
解决平台内部字段容量和编解码规则小于 CMPP 协议允许范围的问题,避免合法回执、上行和提交结果被拒绝,或被错误解释。
|
||||
|
||||
本方案覆盖 CMPP 2.0/3.0 的 Gateway 编解码、Redis/HTTP 事件、后端参数、Prisma、PostgreSQL 和下游投递。初稿授权仅为文档审查;后续用户明确授权基于最新代码执行方案、修复并本地提交。实施范围为本地代码、隔离基础设施验证和文档,不包含推送、环境发布、预生产迁移、再次重投或业务配置变更。应急恢复独立记录,不作为本方案验收结果。
|
||||
|
||||
本方案是 [发送链路设计](phase-4-send-pipeline-redesign.md) 的字段兼容性补充,不替代其发送尝试归属、终态、补发、事务和账务设计;不替代 [计费方案](phase-5-billing-plan.md)、[测试计划](testing-plan.md) 和 [部署手册](production-deployment.md)。协议兼容整改不改变客户费率、路由、补发策略或退款规则。
|
||||
|
||||
实施前复读 [需求](first-version-development-requirements.md)、[系统测试用例](system-functional-test-cases.md)、[测试进度](testing-progress.md) 与当前代码;将本文新增用例纳入既有测试体系,避免形成两套验收标准。
|
||||
|
||||
## 2. 初稿审查基线与证据边界(历史记录)
|
||||
|
||||
- 本地审查基线:main / `c20c224`;存在大量其他会话未提交修改,不能将工作区整体作为本方案可提交内容。上述提交包含的长短信终态修复与本方案不是同一变更。
|
||||
- 预生产只读核验:2026-09-20 14:56:46(北京时间),应用标记为 `1676cfe622f648bbfe09ac027e3db91737a7789d`。通过 `information_schema.columns` 核对实际字段类型,并核对部署目录源码/后端产物中的相关实现。该记录不是以后发布时可复用的现场结论,也不是对运行 Gateway 二进制逐项反编译验证。
|
||||
- 已知线上故障:上游回执头序号 `2245918284`、`2245918288` 超过 PostgreSQL integer 上限,原业务回执入库受阻。完整应急恢复清单、处理结果和副作用由主任务独立记录,本方案不宣称恢复已完成。
|
||||
- 其余发现来自协议原文、静态调用路径和内存边界核算;尚未用真实 TCP、真实后端和隔离 PostgreSQL 完成回归,不能写成线上已经发生同类故障。
|
||||
- 本次未修改代码、数据库、Redis、业务配置或服务,未发送测试报文。
|
||||
|
||||
协议依据为中国移动规范原文的公开镜像:
|
||||
|
||||
1. [CMPP 2.0](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-2.0.pdf):第 7.3 节消息头及 SP/ISMG 消息定义。
|
||||
2. [CMPP 3.0](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-v30.pdf):第 8.3 节消息头、第 8.4.1.2 节连接响应、第 8.4.3 节 Submit、第 8.4.5 节 Deliver/状态报告及 Deliver 响应。
|
||||
|
||||
协议中的字段字节数还须结合语义限制解释,不能把字段可表示范围当作所有业务值都有效。例如 Msg_Length 虽占 1 字节,仍受对应编码的内容长度规定约束。
|
||||
|
||||
## 3. 问题清单
|
||||
|
||||
| 编号 | 优先级 | 问题及证据 | 后果与当前确认程度 |
|
||||
|---|---|---|---|
|
||||
| CMPP-FIELD-01 | P1,首批必修 | 5 个 sequenceId 字段为有符号 integer;协议为 4 字节无符号整数 | 已发生回执入库故障;其他字段同类风险已确认,尚未逐条实测 |
|
||||
| CMPP-FIELD-02 | P2 | 两张下游表的 ackResult 为 integer;CMPP 3.0 DELIVER_RESP.Result 为 4 字节无符号整数,9 及以上可表示其他错误 | 大错误码可能入库失败,常见 0~9 不触发此容量问题;未确认线上命中 |
|
||||
| CMPP-FIELD-03 | P1 | 2.0/3.0 共用固定 60 字节状态报告编解码,号码字段固定 21 字节 | 3.0 应为 32 字节号码字段、71 字节报告体;可能错读 SMSC_sequence,发出不符合 3.0 格式的报告 |
|
||||
| CMPP-FIELD-04 | P1 | 下游目标选择过滤 sequenceId <= 0,Gateway 用 0 表示缺失 | 0 可由无符号字段表示,已核对章节未将其保留;合法 0 序号无法正确生成或恢复通知 |
|
||||
| CMPP-FIELD-05 | P1 | 3.0 CONNECT_RESP.Status 从 uint32 强转 uint8 | 256 会截为 0,客户端误判认证成功;这是本地状态误判,不代表上游真的授予权限 |
|
||||
| CMPP-FIELD-06 | P2 | CMPP3_PACKET_MAX 固定 3335 | 合法的 99 个收件人、140 字节内容的 Submit 总长 3471,会在解码前被拒绝 |
|
||||
|
||||
### 3.1 数据库字段逐项清单
|
||||
|
||||
协议 uint32 表示范围为 `0..4294967295`,现有 integer 为 `-2147483648..2147483647`。
|
||||
|
||||
| 表 | 字段 | 当前实际类型 | 对应业务 |
|
||||
|---|---|---|---|
|
||||
| UpstreamReceiptInbox | sequenceId | integer,可空 | 上游回执接收 |
|
||||
| SmsReceiptRecord | sequenceId | integer,可空 | 业务回执记录 |
|
||||
| SmsUplinkMessage | sequenceId | integer,可空 | 上行短信 |
|
||||
| SmsSubmitRecord | sequenceId | integer,可空 | 向上游提交结果 |
|
||||
| SmsMessageSegmentAudit | sequenceId | integer,可空 | 长短信分片提交/回执审计 |
|
||||
| CmppDownstreamDelivery | ackResult | integer,可空 | 下游确认结果 |
|
||||
| CmppDownstreamDeliveryAttempt | ackResult | integer,可空 | 每次下游投递确认结果 |
|
||||
|
||||
代码入口:[schema.prisma](../api/prisma/schema.prisma)、[回执处理](../api/src/send-chain/send-receipt.service.ts)、[提交结果](../api/src/send-chain/send-gateway-result.service.ts)、[上行处理](../api/src/send-chain/send-downstream-delivery.service.ts)、[下游状态](../api/src/send-chain/send-downstream-state.service.ts)。不得只修改收件箱,否则后续落库仍可能失败。
|
||||
|
||||
### 3.2 协议与调用路径证据
|
||||
|
||||
- [Gateway 事件结构](../gateway/internal/queue/messages.go) 的 SequenceID 已是 uint32;Msg_Id 转为十进制字符串进入事件,序号超限发生在后端持久化边界。
|
||||
- [receipt.go](../gateway/third_party/gocmpp/receipt.go) 的 CmppReceiptPktLen=60,Pack/Unpack 均使用 21 字节号码。[上游处理](../gateway/internal/upstream/deliver.go) 对 2.0/3.0 共用此解析器;[下游投递](../gateway/internal/inbound/delivery.go) 共用此打包器。
|
||||
- [下游目标选择](../api/src/send-chain/downstream-receipt-targets.ts) 过滤 <=0;[下游投递](../gateway/internal/inbound/delivery.go) 使用 ==0/!=0 判定是否提供原 Submit 序号。
|
||||
- [client.go](../gateway/third_party/gocmpp/client.go) 执行 `status = uint8(rsp.Status)` 后按是否为 0 判断成功;[上游连接](../gateway/internal/upstream/connection.go) 实际调用该客户端。
|
||||
- [packet.go](../gateway/third_party/gocmpp/packet.go) 设置 3335 上限;[conn.go](../gateway/third_party/gocmpp/conn.go) 在收包时执行;[submit.go](../gateway/third_party/gocmpp/submit.go) 的 3.0 长度公式为 `12 + 129 + 32*N + 1 + 1 + MsgLength + 20`。N=99、MsgLength=140 得到 3471,且 99 满足规范“小于100”。
|
||||
|
||||
内存核算还确认:正确 71 字节回执中 SMSC_sequence 的起始偏移为 67;旧代码从偏移 56 读取。对普通 11 位号码,号码可能仍正确,但后续序号会读到补零。不能以手机号看起来正常证明整个报文正确。
|
||||
|
||||
## 4. 目标设计
|
||||
|
||||
### 4.1 uint32 存储和接口边界
|
||||
|
||||
建议统一采用:Gateway uint32 → JSON number → 后端校验后的 number → 持久化适配器 BigInt → Prisma BigInt / PostgreSQL bigint。
|
||||
|
||||
- 上述 7 字段统一升级;保留可空属性,已有 NULL 表示历史未采集,不转换成 0。
|
||||
- 非空合法范围为 0~4294967295;拒绝负数、小数、NaN、无穷大和超上限值。必填协议头字段不应因内部接口可空而被静默省略。
|
||||
- number 精确覆盖全部 uint32;进入 ORM 时显式转换为 BigInt,读取后检查范围再转 number,保持现有事件/API 的数值契约,避免把 Prisma BigInt 直接送入 JSON。
|
||||
- 使用字段专用转换函数,覆盖查询条件、create/update/upsert、原始 SQL、批量查询、DTO、队列回放、审计、报表和导出。不能靠修改全局 BigInt.toJSON 掩盖边界。
|
||||
- 数据库增加非空值范围约束。旧负值先列入异常清单,不擅自加 2^32“纠正”;须有原始报文佐证并单独处理。
|
||||
- API、Worker、Callback 等所有读取这些字段的进程必须一起完成兼容验证。协议字段升级不得污染既有金额 BigInt 的单位和序列化规则。
|
||||
|
||||
`Msg_Id` 不套用上述方案:它是 uint64,完整上限为 18446744073709551615,超出 JavaScript 安全整数及 PostgreSQL 有符号 bigint。继续使用 Gateway uint64、跨服务十进制字符串和数据库 text;必要时校验十进制格式及 uint64 上限,不使用 Number/parseInt 中转。
|
||||
|
||||
### 4.2 CMPP 2.0/3.0 状态报告编解码
|
||||
|
||||
- 显式区分协议版本:2.0 的号码字段 21 字节、报告体 60 字节;3.0 分别为 32、71 字节。
|
||||
- 收包按协商版本与报告体实际长度校验后解析;发送按下游连接协商版本打包。同步更新协议日志解析路径,避免业务解析正确但日志仍错位。
|
||||
- 严格校验截断、额外字节、填充和字段边界;不能仅更改总长度常量,必须同时更改字段偏移与读写长度。
|
||||
- 某些供应商可能在 3.0 连接上发送 60 字节历史格式。先盘点脱敏报文;如确有兼容需求,设计显式、可审计的兼容策略并测试,不凭长度默默切换,也不强制修改通道配置。本方案不默认启用降级。
|
||||
- 不新增 SMSC_sequence、LinkID、供应商时间字段的业务存储需求;它们的原始报文保留和追踪另按既有审计方案执行。暂不落库不等于可以错读字段位置。
|
||||
|
||||
### 4.3 序号 0 与缺失的区别
|
||||
|
||||
- 用 null/undefined 或明确存在标志表示未提供;0 是合法已提供的数值。
|
||||
- Gateway 可用可空 uint32 或等价带存在标志的结构,检查 JSON omitempty、默认值、恢复映射、幂等键和分片目标生成的全部路径。
|
||||
- 不将空字符串经 Number 转换为 0;旧空字符串仍视为缺失。分别测试数字 0、字符串 "0"、空串、null 和未提供字段。
|
||||
- 保留原始客户 Submit 序号及 Msg_Id 对应关系,不生成替代序号。回卷到 0 后,未完成请求不得发生映射碰撞。
|
||||
|
||||
### 4.4 连接响应状态与收包长度
|
||||
|
||||
- 3.0 CONNECT_RESP.Status 全程使用 uint32;2.0 uint8 可无损提升。只有原始值 0 表示成功,未知非零值仍失败,日志保留完整原值。
|
||||
- 根据支持的命令、版本、合法收件人数及内容长度重新计算收包边界;既不能保留过小总上限,也不能取消上限或直接接受任意长度。
|
||||
- 检查长度公式、实际编码长度、读缓冲区和内存分配顺序;拒绝长度头与正文不符、越界人数、畸形截断包及超大包。
|
||||
- 不把扩大总包上限误写成扩大单条短信正文限制;编码长度与字符数量分别校验。
|
||||
|
||||
## 5. 已核对的非问题项与范围限制
|
||||
|
||||
- 本次检查的 Msg_Id/gatewayMessageId/ackMessageId 使用字符串存储,未发现数据库容量缩小。
|
||||
- 客户原始序号、下游 ACK 序号已有 text 存储,没有此次 integer 上限问题,但仍须处理序号 0 的代码语义。
|
||||
- 已查手机号、接入号、正文、原始状态码等数据库列为 text,没有 varchar 长度不足问题;协议封包处的字节长度校验仍需保留。
|
||||
- 长短信 8/16 位引用号、分片数量和编码值可被现有 integer 容纳;语义校验仍须保留,不能只靠数据库类型。
|
||||
- CONNECT 时间戳按 MMDDHHMMSS 编码,最大合法日历组合不超过 1231235959,不属于此次 integer 超限风险。
|
||||
- 本次不是完整 CMPP 一致性认证;ISMG 间路由、所有扩展命令、二进制短信全场景及国际号码产品范围不在本次结论内。
|
||||
|
||||
## 6. 历史数据与应急恢复衔接
|
||||
|
||||
1. 实施前重新取得主任务最终恢复记录,核对原始事件摘要、业务匹配、发送尝试、扣退费、通知与客户 ACK,不直接继续任何旧脚本。
|
||||
2. 迁移只扩大存储能力,不自动重投历史事件、不重新生成通知、不重算金额,也不回写短信成功状态。
|
||||
3. 临时恢复时省略的原始序号,仅在存在原始事件且与记录唯一匹配时才可计划补录。补录审计字段不得重新触发补发、退款或下游推送;需要独立清单和恢复授权。
|
||||
4. 事件匹配必须包含通道/供应商身份、Msg_Id、号码和发送尝试,不以号码单独认领,不能跨客户合并。
|
||||
5. 原始事件可在确认耐久业务接管后按现有恢复流程收尾,但 Inbox matched 不能单独证明短信终态、账务或客户通知已经完成;还须核对收尾工作和最终结果。
|
||||
6. 再次恢复时使用原业务幂等键,尊重已完成的最终决策,禁止为让客户“看到成功”覆盖真实失败。
|
||||
|
||||
## 7. 实施、迁移与发布顺序
|
||||
|
||||
建议同一维护窗口覆盖全部问题;如必须拆批,首批至少完成 FIELD-01~04 并独立闭环验证,FIELD-05/06 必须有明确后续计划。优先级是实施顺序,不代表本轮授权执行。
|
||||
|
||||
1. 固定当前分支、精确提交、工作区保护清单、线上版本;重新盘点受影响调用点和数据规模。将本文用例与需求、设计和测试进度同步。
|
||||
2. 完成字段适配、编解码、序号存在性、状态和长度修复及测试。既有第三方库以仓库内 fork 修改留痕,不顺带升级整套依赖。
|
||||
3. 在隔离 PostgreSQL 上演练旧 schema 升级,保留旧值、NULL、索引、约束及关联,测量迁移锁持有时间、磁盘/WAL 增量和失败回滚。
|
||||
4. integer → bigint 可能引发表重写和强锁;按真实表规模选维护窗口。设置明确 lock_timeout/statement_timeout,超时退出而非无限等待,不在未知流量下直接执行。
|
||||
5. 迁移、Prisma Client 与全部相关进程作为兼容整体交付。约束可按演练结果使用 NOT VALID 后校验,但上线不能留下未登记的未验证约束。
|
||||
6. 授权发布后按 [部署手册](production-deployment.md) 使用标准 `npm run release -- ...`,依次 plan、有效测试证据、preflight、prepare、deploy、verify/status/report。每个目标环境独立核验,不用临时脚本替代应用发布。
|
||||
7. 发布前保存独立数据库/应用恢复资产,确认主任务恢复不会与迁移同时处理同一批数据。需要暂停消费者时明确范围和时长,防止新旧进程交叉写入。
|
||||
8. 发布后核对精确版本、实际列类型、迁移状态、全链路与队列;真实业务发送验收需另有明确环境和流量授权,不能借回归向真实客户发送测试短信。
|
||||
|
||||
回退限制:旧代码/旧 Prisma Client 可能无法读取新写入的大序号。应用回退不代表兼容,更不能把 bigint 直接缩回 integer。保留宽字段,优先前向修复;确需回退时必须证明旧版本兼容、未完成事件可接续。不能删除大值记录来让回退成功,已发送短信和客户已确认回执也无法用数据库快照撤销。
|
||||
|
||||
## 8. 验收用例
|
||||
|
||||
以下全部为新增待执行用例,不计入既有通过数。使用独立隔离数据;mock 只用于隔离,不替代真实 API/PG/Redis/Gateway 证据。
|
||||
|
||||
| 用例 | 场景 | 预期与证据 |
|
||||
|---|---|---|
|
||||
| CMPP-FIELD-T01 | 7 字段依次写入 0、2147483647、2147483648、4294967295 | 真实后端/PG精确保存及读回,JSON不丢精度 |
|
||||
| CMPP-FIELD-T02 | -1、4294967296、小数、NaN、空串及缺失 | 按必填/可空规则拒绝或保留缺失;不误转0,不产生假成功 |
|
||||
| CMPP-FIELD-T03 | 真实回执高序号,单条与批量回调入口 | 耐久接收、匹配、分片聚合、终态及通知闭环 |
|
||||
| CMPP-FIELD-T04 | 上行高序号及重复事件 | 上行入库、应用归属及投递正确,无重复业务记录 |
|
||||
| CMPP-FIELD-T05 | Submit/分片高序号返回 | 提交结果、分片记录及后续回执均可关联 |
|
||||
| CMPP-FIELD-T06 | 3.0 DELIVER_RESP 的大非零 Result | 保存完整错误码,进入失败/重试路径,不判为成功 |
|
||||
| CMPP-FIELD-T07 | uint64 Msg_Id 最大值跨 Gateway/Redis/API/PG/UI | 全链路字符串精确一致,无 Number 中转 |
|
||||
| CMPP-FIELD-T08 | 2.0/3.0 标准60/71字节状态报告收发 | 逐字段、偏移、长度正确;实际TCP对端解析通过 |
|
||||
| CMPP-FIELD-T09 | 3.0 32字节号码字段、最大SMSC_sequence;错版本/截断包 | 不错位,异常显式拒绝;兼容例外仅按批准策略执行 |
|
||||
| CMPP-FIELD-T10 | 客户Submit序号0、4294967295及回卷 | 单条/长短信回执正常,原Msg_Id一致,不漏目标 |
|
||||
| CMPP-FIELD-T11 | 序号0在Gateway重启、重连后恢复 | 使用持久化身份恢复;缺失序号不伪造为0 |
|
||||
| CMPP-FIELD-T12 | CONNECT_RESP.Status=0、5、255、256、4294967295 | 只有0成功;全部非零失败并保留原码 |
|
||||
| CMPP-FIELD-T13 | 3.0 Submit 99人×140字节、编码允许边界 | 3471字节合法包进入正常校验;100人按规范限制拒绝 |
|
||||
| CMPP-FIELD-T14 | 巨大/过小Total_Length、错长度、截断包 | 有界读取与内存占用,明确失败,无崩溃或无界分配 |
|
||||
| CMPP-FIELD-T15 | 旧值/NULL升级,迁移锁超时/中断 | 数据不变、失败可识别、恢复步骤真实演练 |
|
||||
| CMPP-FIELD-T16 | 高序号重复、乱序、跨尝试、并发与故障接管 | 关联不串客户/尝试;不重复补发、扣退费或最终通知 |
|
||||
| CMPP-FIELD-T17 | 真实下游ACK与断线重连 | 区分待发送、已发未确认和客户已确认,不将入队当送达 |
|
||||
| CMPP-FIELD-T18 | 应急省略序号的历史记录与原始事件核对 | 只补审计不重开业务;证据不足保留待核查 |
|
||||
|
||||
自动检查按 [测试计划](testing-plan.md) 执行 API 定向/全量测试、类型检查、构建及质量门禁,Gateway `go test ./...`、`go vet ./...`。覆盖 vendored 协议库本身的测试:不能默认 Gateway 的 `./...` 会跨越嵌套 Go module。若影响前端接口或展示,补前端定向与必要回归、真实页面验收。保留未执行原因,不以编译通过替代协议互通。
|
||||
|
||||
停止条件:新增重复Submit、账务不一致、终态被覆盖、租户串联、无法恢复的协议解析异常、持续积压或迁移锁超时。停止放量并保留证据,不靠手工改最终状态掩盖失败。
|
||||
|
||||
## 9. 完成标准与交接
|
||||
|
||||
仅在以下事实全部具备后,才能声明目标环境整改完成:
|
||||
|
||||
- 7字段覆盖、版本化编解码、序号0、连接状态与收包上限已实现,并满足选定发布范围。
|
||||
- 上述用例有对应证据,真实PG及TCP互通通过;历史数据兼容和故障恢复经过验证。
|
||||
- 精确提交已通过标准发布,目标环境版本与实际schema一致。
|
||||
- 原始事件、耐久接收、业务终态、补发尝试、账务和客户ACK分层对账,无未解释缺口;客户离线等外部阻塞单独列明。
|
||||
- 更新需求、设计、系统测试用例和测试进度,分别报告本地修改、提交、推送、测试部署、预生产部署状态。
|
||||
|
||||
目标环境实施人接手时重新核对主任务最终恢复结果和现场,不从本方案推定任何待处理队列已排空或获得生产操作授权。
|
||||
|
||||
## 10. 2026-09-20 实施审查与本地验收
|
||||
|
||||
### 实施基线与规则补充
|
||||
|
||||
- 最新本地基线为 `b24cd7c`(模板拒收策略与运营页面),已包含 `c20c224` 的终态/发送尝试归属修复;已核对远端 main 仍为 `5e4d644`,本轮不推送。其他会话未提交文件及共享文档原有差异保留。
|
||||
- 五个 `sequenceId` 与两个 `ackResult` 改为 nullable BigInt,迁移 `20260920160000_cmpp_protocol_uint32` 在一个事务内扩容并加入 0..4294967295 检查,锁等待 5 秒、单语句超时 5 分钟。负数历史值阻断并回滚,NULL 不回填。发布前需要按真实表规模重新评估重写/WAL/锁时间。
|
||||
- `protocol-uint32.ts` 明确执行数值校验、BigInt 写入、校验后读回;API 与 callback 使用专用字段响应转换,仅处理 `sequenceId`/`ackResult`,金额 BigInt 和日期保持原有行为。协议输入不接受数字字符串、空串、浮点及越界;历史文本 Submit 序号单独解析,字符串 `"0"` 有效,空白/缺失无效。Msg_Id 保持十进制字符串。
|
||||
- 版本化 `PackVersion`/`UnpackVersion` 严格检查 2.0/2.1 的 60 字节与 3.0 的 71 字节。上游、协议日志、下游均显式传版本;原 Pack/Unpack 仅作为 2.0 兼容调用保留,3.0 不自动接受 60 字节。目的号码字段为 21/32 字节,SMSC_sequence 最后 4 字节,超长字段不能截断。
|
||||
- 原 Submit 序号使用可空指针区分缺失与 0;入站 JSON 不省略 0。长短信首段判断改为实际段位置。上游分配序号时避开在途 Submit/心跳,下游 ACK 注册冲突则保留原记录、返回可重试错误,不覆盖待确认回执。
|
||||
- CONNECT_RESP 状态全程 uint32,非 0 一律失败并保留完整错误码。3.0 收包上限复用现有 Submit 最大长度常量 3491,覆盖 99 号码、140 字节的 3471,以及 ASCII 159 字节的 3490;规范原文为 ASCII <160,因此 160 字节拒绝。3491 只是保守的有界内存上限,不代表允许 160 字节内容。打包验证号码个数和数组、声明长度和实际内容一致;解包拒绝截断和多余字节。
|
||||
- T16 并发真实验收复现一个相邻缺陷:可选 connectionId 未传时 Prisma 的空 update upsert 可能退化为先查后插,两个首次相同回执发生 P2002。本轮仅在该错误后按相同 receiptKey 回读已持久化记录再确认接收;没有对应记录或其他数据库错误仍抛出,避免假成功。
|
||||
|
||||
### 验收映射
|
||||
|
||||
| 用例 | 本地证据与结果 | 尚未覆盖的目标环境项 |
|
||||
|---|---|---|
|
||||
| T01–T06 | 新脚本 `tools/testing/verify-protocol-fields.mjs` 用真实 Nest callback、PG、Redis 验证四个边界、七字段、单/批回执、Submit 分片、上行去重、大 ACK 非成功、非法输入无写入;通过 | 真实供应商/客户在线业务回归 |
|
||||
| T07 | 最大 Msg_Id `18446744073709551615` 经过 Redis/HTTP/Prisma 保持字符串;Go TCP/回执往返不截断 | 线上供应商端到端互通 |
|
||||
| T08–T09 | vendored codec 与 upstream tests 验证 60/71 字节、32 字节号码、最大 SMSC、错误版本/截断;下游 2.0/3.0 TCP 回执验证通过 | 实际通道 3.0 是否存在非规范 60 字节回执需发布前抽样 |
|
||||
| T10–T11、T17 | 入站序号 0 JSON、缺失与 null 区分;新连接无原消息内存映射时恢复 Msg_Id,2.0/3.0 真 TCP DELIVER/DELIVER_RESP Result=0;最大/0 序号冲突保护测试通过 | 实际客户端重连验证 |
|
||||
| T12–T14 | 真 TCP CONNECT 状态 0/5/255/256/4294967295;99 号码 3471/3490;100 号码、超长内容、截断/多余内容、巨大/过短头拒绝;通过 | 目标环境日志、吞吐与异常隔离观察 |
|
||||
| T15 | 全量 113 个已提交基线+本轮迁移在新隔离库成功;缩小结构演练末表非法值导致前面 DDL 整体回滚、5 秒锁超时回滚、NULL/0/2147483647 与七个索引保留 | 生产规模 WAL/耗时、发布切换与实际备份恢复未演练 |
|
||||
| T16 | 同一大序号回执并发接收、重复批回调、相反迟到回执;另现有 receipt-finality/attempt-completion 的并发、跨尝试、故障事务恢复真实 PG 测试通过 | 整个 Gateway→Redis→API→客户单进程链路未联合运行;各边界分别验证 |
|
||||
| T18 | 模拟已持久化但省略序号的历史回执,重复接收后仍 NULL,终态/通知数量不重新打开;通过 | 本轮不对线上应急历史数据做字段回填 |
|
||||
|
||||
验收脚本要求**新建空的本机 `cmpp_qa_*` 数据库**,应用基线及本轮迁移后构建 API,设置 `PROTOCOL_TEST_DATABASE_URL` 和 `PROTOCOL_TEST_REDIS_URL`。最高 Msg_Id 为固定边界 fixture,不能把多轮 fixture 混在同库当成新的发送尝试。脚本只连接本机,通道禁用、地址 127.0.0.1:1,不启动发送工作进程;产生合成业务/通知记录,不向真实运营商或客户发报文。
|
||||
|
||||
证据目录 `.local-data/protocol-fields-20260920/` 不入 Git。关键日志为 `real-chain-verified.log`、`migrate-verified.log`、`api-coverage-final.log`、`api-incremental.log`、`go-final.log`、`gocmpp-final.log`、`receipt-finality.log`、`attempt-completion.log`。最终迁移小样本为七表 21 行,扩容阶段约 106ms、WAL 81800 字节;不是线上容量承诺。保留首次 Redis 从错误工作目录读取旧 RDB 而启动失败、重复使用固定边界 fixture 库而匹配冲突、以及实际 P2002 并发失败日志,最终以专用 Redis 目录和全新库复验。
|
||||
|
||||
本地 Redis 为 5.0.14.1,实际 Stream 读写通过,但低于 BullMQ 推荐的 6.2;没有以此宣称发布环境队列版本验收。PG 驱动给出并发 query 弃用提示,当前测试通过,未修改驱动。API 响应结构保持数字/字符串不变,无前端代码或 CSS 改动;未重复浏览器验收。部署时必须一起发布 schema、API/callback/相关 worker 及 Gateway,旧 Prisma 客户端不能视为已支持超 31 位数据;不能通过缩回 integer 做应用回滚。
|
||||
|
||||
门禁:API 全量和增量覆盖率各 87 套 / 990 项通过,TypeScript 构建通过;Gateway 全量测试及根模块 vet 通过,vendored 模块测试通过;5 个队列契约、变更代码 lint(0 error,32 个存量 any warning)、格式检查和 diff 检查通过。额外运行 vendored 模块完整 vet 发现两个原有 stdmethods 提示:`packetWriter.WriteByte`/`packetReader.ReadByte` 使用累积错误接口而非标准 io 签名;本轮未更名整个协议库,记录为既有待治理项,不宣称该额外检查通过。没有通过禁用检查隐藏问题。
|
||||
|
||||
测试在保留其他会话修改的当前工作区执行,提交仅纳入本轮文件和共享文档新增段落;这些本地结果不能冒充标准发布工具绑定精确提交的发布证据。隔离 PostgreSQL、Redis 已关闭,数据/日志保留。上线前仍应按标准工具从精确提交构建并生成对应验证证据。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,7 @@
|
||||
Edit the supplied Chinese admin homepage UI into V2. Keep the same brand 聆界短信平台, white sidebar, palette, typography quality, example data, selected menu and header. This is an exact UI redesign, crisp text.
|
||||
CRITICAL: shrink TOTAL area of the three top metric sections to ONE THIRD of the original combined area. Achieve this by placing THREE compact panels SIDE BY SIDE IN A SINGLE ROW immediately below header, each one third of content width and about 180px tall. NOT three stacked wide rows. Each panel has three compact rows with label LEFT, value RIGHT, no verbose descriptions under every metric. This single row replaces the former three huge stacked panels. The entire 9-metric band should occupy only about 180px vertical space versus original about 550px. Do not shrink text to illegibility.
|
||||
Left panel heading 今日业务; rows 今日业务短信数量 120,000 条 ; 今日发送成功数量 108,000 条 ; 总体成功率 90.0%.
|
||||
Middle panel heading 今日回执 with compact outlined button 按提交日查看; rows 今日回执分片总数 240,000 片 ; 今日回执成功分片数 216,000 片 ; 今日回执成功率 90.0%. Emphasize success row with pale green background and green bold number, strongest number on page but still compact.
|
||||
Right panel heading EXACTLY 今日营业状况 (rename former 今日回执收益) and outlined button 按提交日查看; rows 今日营收金额 ¥10,800 ; 今日利润 ¥2,160 ; 今日利润率 20.0%. One shared tiny note below whole metric band: 回执及营业指标按今日收到的回执统计,包含近四日提交的短信.
|
||||
Restore ORIGINAL enterprise SPEND RANKING, not refunds. Remove entire 今日返还金额 panel and refund values. Directly below compact top band place a FULL CONTENT WIDTH table panel headed 今日企业消费排行, subtitle 来自真实账户、充值和消息金额聚合。, top right outlined 导出排行. Six columns exactly 排名 | 企业名称 | 今日消费(元) | 可用余额 | 余额状态 | 操作. 5 rows of example data: 1 示例企业 A / ent_demo_a / ¥6,280 / 28,500 / 充足 green / 查看详情 blue; 2 示例企业 B ent_demo_b ¥3,120 12,000 充足 查看详情; 3 示例企业 C ent_demo_c ¥980 85 紧张 amber 查看详情; 4 示例企业 D ent_demo_d ¥320 6,200 充足 查看详情; 5 示例企业 E ent_demo_e ¥100 0 欠费 red 查看详情. Enterprise IDs displayed small grey on second line in enterprise cell. Ranking row heights 64px, genuine enterprise admin table with discreet horizontal separators, no cards replacing table. No refund column.
|
||||
Below this full-width ranking table place full-width 运营状态 panel. Preserve seven original indicators in compact grid: 企业认证待审 2条, 短信审核待审 8条, 模板待审 3条, 签名待审 12条, 引流信息待审 1条, 平均等待 24任务 with small 批量任务总数 note, 下游投递告警 0条. Keep clickable-looking status items. Do not omit any of these. All content should fit naturally into a 1600x1000 style desktop canvas without excessive whitespace. No charts. Keep UI设计稿 · 示例数据 label. This is a compact professional operational dashboard, not a marketing layout. Main result must visibly show three small metric panels arranged horizontally, a wide original spend ranking table below, operational status below that.
|
||||
@@ -0,0 +1,9 @@
|
||||
Create a high fidelity Chinese enterprise SMS admin homepage UI mockup, a single clean straight-on desktop screen, 1600x1100 landscape, crisp legible Simplified Chinese typography. Product brand 聆界短信平台. White sidebar width 220px, pale grey #F6F7F9 canvas, white panels, subtle #E5E7EB borders, radius 8px, restrained blue #2563EB for controls only, success green #16A34A for successful receipts. No gradients, no illustrations, no charts, no extra KPIs.
|
||||
Sidebar top blue outline abstract logo + 聆界短信平台. Sidebar items 数据概览 (selected pale blue), 短信发送, 短信记录, 上行记录, 签名质量, 通道管理, 财务管理, 系统监控. Header: 数据概览, right "2026-09-17 · 北京时间" and small outlined "刷新". Subtitle "今日提交与今日回执,分别看清业务和收益". A small clearly visible label "UI设计稿 · 示例数据".
|
||||
Content consists of three generous but compact horizontally aligned three-column metric rows. Each row in white panel, title left and 3 metric columns separated subtle vertical lines:
|
||||
Row1 title 今日业务. Metrics exactly 今日业务短信数量 / 120,000 条 / 今日提交的业务短信 ; 今日发送成功数量 / 108,000 条 / 今日提交且今日成功 ; 总体成功率 / 90.0% / 沿用业务短信口径.
|
||||
Row2 title 今日回执; subtitle "今日收到 · 原提交日期 09-14 至 09-17". Top right outlined button "按提交日查看 ▾". Metrics 今日回执分片总数 / 240,000 片 / 按业务短信去重,包含应计未回分片 ; 今日回执成功分片数 / 216,000 片 / 长短信全部成功才计入 ; 今日回执成功率 / 90.0% / 成功分片数 ÷ 回执分片总数. Make CENTER 216,000 the strongest number on the whole page in green, slightly larger than others, pale green center background, not full saturated card.
|
||||
Row3 title 今日回执收益; subtitle "仅统计今日成功回执归属的收益"; top right outlined "按提交日查看 ▾". Metrics 今日营收金额 / ¥10,800 / 今日成功业务短信收入 ; 今日利润 / ¥2,160 / 对应收入减通道成本 ; 今日利润率 / 20.0% / 今日利润 ÷ 今日营收.
|
||||
Bottom two columns, left wider 60% panel "今日返还金额" bold amount "¥328.56", caption "按今日实际返还流水统计"; simple table 企业 / 今日返还金额 with 3 fictional clearly generic enterprise names 示例企业 A ¥168.56, 示例企业 B ¥100, 示例企业 C ¥60; footer small 查看返还流水.
|
||||
Right 40% panel "运营状态" compact 2-column grid: 企业认证待审 2 条, 短信审核待审 8 条, 模板待审 3 条, 签名待审 12 条, 引流信息待审 1 条, 平均等待 24 任务 (tiny subtitle 批量任务总数), 下游投递告警 0 条.
|
||||
No expanded breakdown visible on main screen. Footer unobtrusive "日期明细默认收起,点击按钮后查询". All displayed numbers are design examples, not live data. Polished utilitarian B2B design with excellent alignment and Chinese legibility, not a marketing landing page.
|
||||
@@ -2339,3 +2339,66 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
||||
5. 监控刷新失败显示真实上一快照和过期提示,超时/切换请求取消,恢复后刷新;不同查询范围隔离。
|
||||
|
||||
授权交付:代码、定向/全量测试、精确提交、推送、测试环境标准发布及隔离模拟短信验收;不操作预生产,不向真实运营商发送。
|
||||
|
||||
## 2026-09-17 签名质量独立查询与日报优化(本地实现,待发布)
|
||||
|
||||
本节对应用户本轮方案要求,权威设计见[签名退网检测与四页查询优化方案](signature-quality-optimization-plan-20260917.md),本地实施状态见下方补充,线上尚未发布。
|
||||
|
||||
1. 四TAB的日期、筛选、分页、请求、错误及结果独立;企业/通道活跃度改为带dimensionType的独立服务端查询与分页,不再各自获取两类全量数据。保留默认25条和10/25/50/100档;同TAB顶部和卡片查询应用同一组草稿条件。
|
||||
2. T为服务器北京时间今天,实际统计日为d:d=T查询时实时查业务库;T-1~T-3每天凌晨同时刷新、查询只读日报;T-4及以前只读冻结日报,普通查询、调度和重启不得重算。缺报表明确显示未生成,不回退明细或伪装零。
|
||||
3. 用户确认热力图保持所选日D之前30天,即D-1~D-30,不包含当天列;每格按实际日期相对真实T判断数据来源,不按页面D重新开放历史刷新。
|
||||
4. 日报历史名称、归属和报备适用性快照保留;冻结后迟到回执、补登记和状态变化不覆盖旧报表。预警判断/通知与可刷新的活动日报分开,刷新不得重发历史通知。
|
||||
5. 退网检测采用批量聚合、提前跳过已完成、任务原子认领与故障接续,候选索引由真实执行计划决定。保留业务短信/提交尝试/计费片数区别、长短信完整分段判定和跨通道/跨日窗口去重;现有最长365天规则不缩短。
|
||||
6. 原方案阶段为只读;本轮本地实现与隔离验收见下方补充,线上历史补建、推送、两环境部署与真实发送均未执行。本节不改变其他财务/质量报表T-4~T-1刷新规则。
|
||||
|
||||
### 2026-09-17 本轮本地实现
|
||||
|
||||
签名质量日报按专项优化方案实现:T实时、T-1~T-3每日刷新、T-4冻结;热力图D-1~D-30且企业/通道请求独立,失败保留本TAB上次真实数据并标明日期。退网窗口批量去重、规则快照/租约/fence/失败恢复与日报解耦。上行按应用唯一性而非短信条数归属,同通道接收前accepted作为证据;入库、候选、认领、通知意图保证事务一致。历史待认领不自动处理。本轮仅本地修改和提交,线上发布/补建及实际短信/通知验收另按授权执行。
|
||||
|
||||
|
||||
## 2026-09-17 首页按今日回执整改(方案,待实施)
|
||||
|
||||
按[首页整改方案](homepage-receipt-metrics-redesign-20260917.md)保留今日业务三项、今日回执三项、今日回执收益三项及返还区域和运营状态。回执及收益纳入T-3~T提交消息,依网关接收日归属;业务短信去重,billingUnits补齐应计片,长短信整条成功才计成功片及一次收入。两个提交日期明细独立点击后查询,每组4行3指标;文案明确为“今日回执归属”,不冒充原提交日全天业绩。总体成功率保持旧公式。其他首页指标与两张趋势图移除;消费排行暂按替换为返还区域设计,见方案第2节解释。该方案实施后替代此前首页10指标及旧趋势/排行展示要求,其他页面不变。当前只有方案及UI,不代表上线。
|
||||
|
||||
|
||||
## 2026-09-17 首页V2实施修订
|
||||
|
||||
用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。
|
||||
|
||||
|
||||
## 2026-09-17 有效签名名称唯一性
|
||||
|
||||
同一企业、同一应用、相同完整签名名称只能存在一条有效签名;未绑定应用单独作为一个范围。有效状态包括草稿、待审、通过、驳回,停用及删除不占用名称;新增、改名、换应用、审核和恢复均不可绕过,接口及数据库同时防重。批量导入仍更新已有资料,保留未映射字段、用途及关联记录,并发创建后重查已有签名补资料。历史重复不自动删除或合并。设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。本轮授权代码修改及本地提交,不含推送和部署。
|
||||
|
||||
|
||||
## 2026-09-18 长短信回执终态与归属补充
|
||||
|
||||
最终失败(含明确回执超时)与成功必须保持消息、账务、客户通知一致。后续同次失败分片不重复选路或退款;矛盾成功/失败回执只留原始事实并生成异常,不自动改账或重发客户通知。unknown、缺分片、普通提交超时仍可接续。回执须按业务消息、手机号、逻辑通道/上游身份和唯一发送尝试共同匹配;相同Msg_Id不能跨尝试批量更新,有歧义留待匹配。设计见phase-4-send-pipeline-redesign.md第10.13节,测试见TC-RC-20260918-01~07。此次不改协议、数据库结构和线上历史数据。
|
||||
|
||||
|
||||
## 2026-09-20 签名质量、通道能力与模板拒收指令
|
||||
|
||||
1. 签名质量成功率条按业务短信总提交数展示已到达、提交失败、回执失败、未收到回执四段,合计100%;灰色未知段的数量、比例仅悬停展示,零提交空轨道,不拆成多条。当前查询和新生成日报将没有明确失败回执的超时归未知;既有冻结日报保留原口径,不因查询重算。
|
||||
2. 通道允许减少运营商能力;保留通道组引用,发送选路按当前能力排除不支持运营商。不自动改动客户通道组或历史报备。
|
||||
3. 企业模板管理可按所属应用通道组中的通道配置固定末尾指令“拒收请回复R”的增加/删除;默认保持原文。明确模板不串用另一模板规则;无模板ID时独立匹配有效已审核模板,包括 direct_send 应用,未匹配内容不受影响。
|
||||
4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。
|
||||
5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。
|
||||
6. 设计及兼容边界见 [模板拒收策略方案](template-optout-policy-design-20260920.md)。本轮授权本地修改和提交,不推送或部署。
|
||||
|
||||
|
||||
## 2026-09-20 CMPP 协议字段兼容性整改
|
||||
|
||||
- 协议 Sequence_Id、CMPP 3.0 ACK Result 完整支持 0..4294967295;七个历史 nullable 字段扩容并约束范围,前端/HTTP 仍接收数字,Msg_Id 仍为精确十进制字符串。金额、客户费用、路由与终态规则保持既有设计。
|
||||
- 按已协商的 CMPP 2.0/2.1、3.0 编解码 60/71 字节回执;不得在 3.0 静默使用 2.0 布局。合法序号 0 可恢复下游原 Msg_Id,回绕不得覆盖在途记录;所有非零 CONNECT_RESP 状态失败,合法多号码大包可接收且异常长度有界拒绝。
|
||||
- 同一回执并发首次接收只形成一个持久化事实。历史应急记录缺失序号不自动补填,不再次触发通知、补发或账务。
|
||||
- 详细字段、迁移发布边界及验收依据:[CMPP 字段兼容性方案](cmpp-protocol-field-compatibility-remediation-20260920.md)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
|
||||
|
||||
|
||||
## 2026-09-21 未报备签名统计编码兼容修正
|
||||
|
||||
保持既有未报备签名定义:规范正文开头签名在当前企业应用有效签名库不存在才计入。实时统计和日报聚合须同时兼容现有SQL_ASCII及UTF8数据库;不能因汉字共享编码字节漏统计,也不能放宽空签名、嵌套括号或非规范开头限制。不更改分页、权限、归属、数据库编码或已冻结历史报表。根因和本地验收见[排查及修复记录](unreported-signature-diagnosis-20260921.md)。
|
||||
|
||||
|
||||
## 2026-09-21 六项运营功能更正
|
||||
|
||||
模板签名使用通用可搜索下拉框;变量名仅ASCII英文字母和数字1~32位,示例内容可中文。按用户最终更正,同一企业应用下模板名称唯一,与签名无关;名称去首尾空格,非deleted状态占用名称,跨应用同名允许,创建/编辑/恢复/并发均受约束。首页恢复北京时间今日小时发送曲线,置于企业消费排行上方;未知筛选包含submitted与历史unknown,submitted显示提交成功;签名通道质量列表删除通道提交列,详情保留;报备明细企业/应用/通道/对象四条件独立且AND组合。通道移除运营商须先提示并确认,然后事务性移除该运营商全部省网/全国组成员并审计,保留其他组成员及路由,禁止并发重新挂回不兼容成员。此项替代2026-09-20仅跳过不移除的规则。设计、迁移兼容与验收见[六项整改](operations-six-fixes-20260921.md)。授权代码提交推送,未授权环境部署。
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# 首页运营数据整改方案与 UI V1
|
||||
|
||||
## 2026-09-17 实施规格(优先于下方设计历史)
|
||||
|
||||
用户已授权实施、提交及推送;不部署。采用V2一行三块紧凑布局,营业区标题“今日营业状况”。保留今日企业消费排行原排序、余额、详情和导出入口,在消费后增加“今日返还金额(元)”列,详情/导出同步包含返还。此前“不增返还列”被本次要求替代。
|
||||
|
||||
实现将三个逻辑投影合为业务消息版本事实 `HomeMessageFact`:包含原提交日、有效回执日集合、唯一成功日及金额快照;用 `HomeProjectionState`、`HomeProjectionDirty` 和 `HomeSnapshot` 保存游标、耐久工作及发布版本。源表事务触发器仅登记待投影消息,不改发送/账务;投影按500条批次,事务级 advisory lock 和工作行锁认领,事实版本和工作删除同事务,进程故障整批回滚。旧版本事实按有效版本区间保留供快照读取;完成初始化前不发布,增量积压显示处理状态。源变化触发耐久重算,消除只用updatedAt水位漏掉晚提交事务的风险。API快照绑定平台用户与日期,15分钟有效,明细只在点击后聚合。
|
||||
|
||||
当前权限模型只有平台管理员/企业管理员,未发现独立财务角色;本次在全部新接口重新校验平台管理员角色及有效用户,不新增角色体系。客户端会话不能调用。钱使用整数万分之一元,服务层越出JS安全整数范围明确报错,不静默舍入。原排行只列未删除企业,返还列遵循同一企业范围;不再另设平台返还总额,保持现有排行语义。
|
||||
|
||||
新增统计表/触发器随迁移创建,开关 `HOME_DASHBOARD_ENABLED=false` 可暂停后台投影;页面首次无可读快照提示初始化中,保留重试入口。只在API进程后台消费;测试通过显式调用投影方法控制进度,不启动短信消费者。错误写入投影状态并记录日志,旧已发布版本可读并提示数据滞后。数据库回退保留统计资产,不回写原账務。
|
||||
|
||||
> **2026-09-17 用户修订:以UI V2为当前版本。** 顶部“今日业务”“今日回执”“今日营业状况”改为一行三块紧凑布局,整体占用压缩至V1约三分之一;“今日回执收益”更名为“今日营业状况”。**今日企业消费排行保持原样**:排名、企业名称、今日消费(元)、可用余额、余额状态、查看详情及导出排行保留,不替换为返还区域、不增返还列。运营状态保留。下文V1关于替换消费排行、返还区域及三排大卡布局的描述已被本修订替代;其他统计规则和按需查询保持。当前[UI V2](designs/homepage-20260917/homepage-v2.png),[修订提示词](designs/homepage-20260917/prompt-v2.txt)。仅改设计,未改业务代码。
|
||||
|
||||
日期:2026-09-17。状态:**设计稿,待实施**。本轮交付方案及效果图,不修改业务代码,不提交、推送或部署。
|
||||
|
||||
## 1. 范围与现状证据
|
||||
|
||||
适用运营端首页,不改变客户端首页、计费动作、供应商协议、短信发送或补发规则。实现涉及运营查询、回执统计事实及首页展示;如需增加投影表及索引,按迁移执行,不能直接在线补写业务状态。
|
||||
|
||||
核验本地 main:`627fa7ec97a656731244f5d1a7fa93b27806cd16`;实际远端 main:`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,本地领先两次提交,暂存区为空。已有版本、metrics、发布工具、部署脚本和文档草稿继续保护。本轮未连接目标环境或查询线上数据库,以下是当前源码证据,不能视为线上字段覆盖率证明。
|
||||
|
||||
| 当前实现 | 与本次要求的差异 |
|
||||
| --- | --- |
|
||||
| [首页](../src/apps/admin/AdminHome.tsx)同时请求 dashboard 和 sendQuality,显示10项指标、发送趋势、审核速度、消费排行、运营状态 | 改为9项主指标、返还区域、运营状态;删除无需展示的数据及对应首页请求 |
|
||||
| [dashboard查询](../api/src/operations/queries/dashboard.queries.ts)按 `SmsMessageRecord.queuedAt` 限定今日业务短信 | 收到回执的当天和原提交日期必须分开,不可继续用今日提交条件过滤所有指标 |
|
||||
| 总体成功率为今日业务消息 `status=delivered` 数量 / 今日业务消息总量,保留1位小数,零分母为0 | 按用户要求保持现有口径,不改为分片成功率或全历史成功率 |
|
||||
| 分片成功直接统计 `SmsMessageSegmentAudit` 成功行数 | 部分成功、跨尝试重复及缺片不满足本次业务短信整体成功口径 |
|
||||
| 今日计收按今日提交且最终成功消息的 `billingUnits × unitPrice`;成本按 accepted 尝试成功分片和成本快照统计 | 新收益按今日完整成功回执归属,需要跨提交日统计并防止重复确认收入 |
|
||||
| `clientDashboard()`复用旧 `dashboard()` | 不宜原地破坏旧接口;运营首页另建明确契约,客户端继续保持原行为 |
|
||||
|
||||
设计入口:[设计开发规范](design-development-guidelines.md)、[UI规范](ui-design-guidelines.md)、[CSS规范](css-development-guidelines.md)、[测试计划](testing-plan.md)。此方案实施后,替代运营首页旧的10指标顺序、到达率、活跃签名、消费/计收卡、两张趋势图及消费排行展示;不替代其他页面、历史测试记录和底层账务规则。与[签名质量方案](signature-quality-optimization-plan-20260917.md)共用时间/长短信约定,但不能直接复用按提交日生成的签名日报来冒充今日回执数据。
|
||||
|
||||
## 2. 首页信息结构
|
||||
|
||||
| 区域 | 保留内容 | 交互 |
|
||||
| --- | --- | --- |
|
||||
| 今日业务 | 今日业务短信数量、今日发送成功数量、总体成功率 | 3列,单位为条、条、% |
|
||||
| 今日回执 | 今日回执分片总数、今日回执成功分片数、今日回执成功率 | 成功分片数以绿色和较大字号突出;“按提交日查看”按需展开 |
|
||||
| 今日回执收益 | 今日营收金额、今日利润、今日利润率 | 单位元、元、%;独立的“按提交日查看” |
|
||||
| 今日返还金额 | 今日返还总额,企业返还明细入口 | 暂按替换整个消费排行区域设计,不保留消费、余额和排行列;这是第6点的设计解释,待用户反馈可局部调整 |
|
||||
| 运营状态 | 原企业认证待审、短信审核待审、模板待审、签名待审、引流信息待审、平均等待、下游投递告警 | 保留原入口和统计,不借本次任务修改规则 |
|
||||
|
||||
代码中“平均等待”实际上显示批量任务总数,本次按“运营状态保留”维持,同时显示“批量任务总数”说明;这是已发现的命名遗留问题,不冒称等待时长。
|
||||
|
||||
删除今日消息分片数、今日到达率、今日活跃签名、今日消费、今日计收等旧卡,以及今日发送趋势、审核处理速度。首页不再为了活跃签名调用全量发送质量查询。全局导航、通知、权限保留;图中导航只示意,实施不删实际菜单。
|
||||
|
||||
## 3. 时间、去重与分片口径
|
||||
|
||||
### 3.1 三个不同的时间
|
||||
|
||||
- T为服务器当前上海自然日,所有窗口使用 `[当日00:00, 次日00:00)`,统一 `Asia/Shanghai`,不是滚动24/72小时。
|
||||
- 原提交日期沿用旧“发送总量”的 `SmsMessageRecord.queuedAt`,即业务短信进入平台的日期;不以补发时间、最新 `submittedAt` 或供应商 Submit 日期重新归组。
|
||||
- 回执日期优先取 `UpstreamReceiptInbox.gatewayReceivedAt`。当前 [Gateway](../gateway/internal/upstream/deliver.go)生产事件的 `DeliveredAt` 为网关当时 `time.Now().UTC()`,[intake](../api/src/send-chain/send-receipt.service.ts)将其原样存入 `gatewayReceivedAt`。不能仅凭字段名把它当运营商实际送达时刻,也不能用业务记录 `updatedAt` 代替接收时间。
|
||||
- Inbox `receivedAt` 是API持久接收时间;`SmsReceiptRecord.createdAt` 是后续处理入库时间。发生积压或跨午夜处理时,二者不一定是网关接收日。旧记录缺网关时间时允许显式标注 `timeSource=inbox_received/legacy_created` 的近似兼容,不混称精确;无可靠关联/时间记录列入覆盖缺口。正式上线前只读核验各入口和历史覆盖率,不能静默默认0。
|
||||
- 同一响应带 `businessDate`、`asOf`、`dataThrough`、`timeSourceCoverage`、`definitionVersion`。`asOf` 是查询快照时刻,`dataThrough` 才是已处理到的进度,不能将二者混淆。
|
||||
|
||||
### 3.2 总数:计的是业务分片当量,不是原始回执包数
|
||||
|
||||
先选择:原提交日期位于 T-3~T、在T收到至少一次**新的、有效关联到该业务消息的回执**。按 `messageRecordId` 去重,一条业务短信在同一天只贡献一次分母,不按手机号、通道、供应商 Msg_Id 或发送尝试计多次。
|
||||
|
||||
每条业务短信贡献的分片数 N 使用其冻结的 `SmsMessageRecord.billingUnits`。这是业务短信计费分片单位,与“业务短信维度去重”的要求一致,不能求和多次补发的分片。`SmsMessageSegmentAudit.segmentTotal`、`segmentIndex` 用于核对对应发送尝试的物理分段和完整性,不能用收到的行数替代预期总量。若不同通道实际分片数不同,首页保持业务分片当量N,成本按该尝试真实分片;两者不要混用。
|
||||
|
||||
历史 N 无效或与证据明显矛盾时,按真实发送时的分段记录核对并登记缺口,不能用当前正文重新分片后伪装历史事实。正常 N=3 的长短信,即使只回一个失败片,分母也加3;无需伪造剩余2片的原始回执或修改业务状态。界面说明为“按业务短信去重,包含应计未回分片”。
|
||||
|
||||
### 3.3 成功:整条业务短信完成成功后才计入
|
||||
|
||||
某条业务短信首次形成可信的整条成功结论,且其形成成功结论所需的最后一条有效回执于T被网关收到,才在T贡献N个成功分片。成功判定沿用可靠的发送尝试级收尾结果,并核对该成功尝试的全部预期分段;不能跨通道/尝试拼接成功片,不能因为 `message.status` 当前成功就把它计到每个曾收到回执的日期。
|
||||
|
||||
- 3片中2成功1失败,或2成功1未回:成功分片为0。
|
||||
- 昨天2片成功,今天最后1片成功:今天分母3、成功3;昨天不因今天成功而补增“昨天成功分片”。
|
||||
- 通道约定整条回执 `message_level`:仅在现有明确协议及 `supplier_message_level_receipt` 补偿证据支持整条成功时计N;普通分片通道单条成功不得推断其余成功。
|
||||
- 首次失败后补发全成功:同一天分母只计N,成功也只计N;客户收入只确认一次。采用既有有效收尾结果,不由统计代码触发补发。
|
||||
- 输送重放、同一分片相同状态重复包应全局幂等去重,不能因重新入库或重试跨日再次贡献分母。需结合原事件键、尝试、分段和状态迁移,不能只依赖含不同接收时刻的包键。
|
||||
- 已最终成功后,无效的旧尝试迟到失败、重复成功只作审计,不再增加业务回执数量或撤回历史收入;真实冲突交由既有异常处理机制,禁止统计模块自行决定业务终态。
|
||||
|
||||
“有新有效回执的日分母”允许一条跨日长短信在两个不同接收日分别进入分母,因此**各天分母不能相加称为不重复短信总量**。成功及收入只在首次形成整条成功的接收日确认一次。页面今日比率 = 今日成功分片 / 今日总分片,禁止超过100%;零分母显示0%,提示“今日暂无有效回执”。这是对跨日未回齐场景的明确建议规则,实施测试必须覆盖。
|
||||
|
||||
## 4. 九项指标及财务规则
|
||||
|
||||
| 指标 | 计算 |
|
||||
| --- | --- |
|
||||
| 今日业务短信数量 | `queuedAt` 属于T的唯一业务短信数量,沿用旧 `today.sent` |
|
||||
| 今日发送成功数量 | 上述消息中,首次整条成功的回执接收日也为T的消息数;不是分片数 |
|
||||
| 总体成功率 | 保持旧 `today.successRate`:今日业务消息当前 `status=delivered` / 今日业务消息总数,不改精度与零分母规则 |
|
||||
| 今日回执分片总数 | 第3节今日有效回执消息集合的N之和,仅T-3~T提交 |
|
||||
| 今日回执成功分片数 | 第3节今日首次整条成功集合的N之和,仅T-3~T提交 |
|
||||
| 今日回执成功率 | 成功分片数 / 回执分片总数 ×100% |
|
||||
| 今日营收金额 | 今日首次整条成功集合的 `billingUnits × unitPrice` 快照之和;只计一次,不取充值、不按现行应用单价重算 |
|
||||
| 今日利润 | 上述成功消息收入减其对应已发生通道计费成本,包括该消息此前尝试已产生的计费成本,不因只看最后成功尝试漏成本 |
|
||||
| 今日利润率 | 今日利润 / 今日营收 ×100%;营收为0时显示0%及无营收说明,负利润正常显示负数 |
|
||||
|
||||
成本兼容现有“成功分片计费”口径:同一业务消息下所有 accepted 尝试,按各自成功的唯一物理分片乘其 `SmsSubmitRecord.costUnitPrice` 快照;失败尝试中已成功且需要付费的片也计入成本。`costAmountCents` 不能未核验语义就当最终结算金额;不能以客户N乘最后一次通道价概括多次尝试。缺审计的旧尝试,只能按明确关联的整条成功回执及可证明的分片数兼容,不以相同供应商ID跨通道串联。
|
||||
|
||||
本区是**今日成功回执对应的短信毛利**,不是公司净利润,也不是今日账户现金流;不包括尚未整体成功的其他业务短信亏损。若实际通道存在按提交收费等不同结算规则,应复用已有真实成本事实并扩大相关验收,不擅自修改结算方式。后续发现成功消息有额外真实费用,应有可追溯的更正版本,不能重复确认收入或无记录覆盖冻结成本。
|
||||
|
||||
金额内部用整数万分之一元(虽字段名为Cents,实际1元=10000单位),API使用十进制字符串或既有有界安全金额契约,禁止浮点累计;展示复用 `MoneyText/formatAmount` 最多4位、去尾零。比例在汇总后计算,不平均各行比例。
|
||||
|
||||
### 今日返还金额
|
||||
|
||||
沿用真实 `AccountTransaction` 返还语义:`refunded`,以及 `released AND relatedType=sms_message_record`,追加严格的今日上界与租户权限条件。按唯一流水统计,不同时再叠加账单状态推算退款;若同一业务退款与解冻代表两笔实际不同资金动作,以账务证据为准,不按消息ID盲目去掉一笔。
|
||||
|
||||
返还依今日流水日期,**不限制原短信必须在最近4天提交**;不将历史返还移到今天。总额与明细分别按相同过滤规则读取。移除消费排行后拟展示企业名称、今日返还金额及返还流水入口;企业归档/删除不得使实际返还从平台总额消失,名称使用可追溯快照或历史企业标记。保留“返还”称谓,避免把解冻全部称作已扣费退款。该模块不重复扣减成功回执营收。
|
||||
|
||||
## 5. 按需展开与文案
|
||||
|
||||
两个按钮相互独立。首次进入不请求、不预取、不后台生成这两组按提交日期的响应数据;点击才查。主卡总值仍需聚合,同源总值不等于提前查询并隐藏四行明细。
|
||||
|
||||
回执展开标题:**今日回执 · 按原提交日期查看**。说明:“以下只统计今天收到的回执,按短信最初提交日期分组。”
|
||||
|
||||
| 原提交日期 | 今日回执分片总数 | 今日回执成功分片数 | 今日回执成功率 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 09-17 提交 → 今日回执(T) | 180000 | 162000 | 90.0% |
|
||||
| 09-16 提交 → 今日回执(T-1) | 40000 | 36000 | 90.0% |
|
||||
| 09-15 提交 → 今日回执(T-2) | 15000 | 13500 | 90.0% |
|
||||
| 09-14 提交 → 今日回执(T-3) | 5000 | 4500 | 90.0% |
|
||||
|
||||
收益展开标题:**今日回执收益 · 按原提交日期查看**。说明:“以下是这些批次今天成功回执带来的收益,不是对应提交日的全天营业额。”
|
||||
|
||||
| 原提交日期 | 今日回执归属营收 | 今日回执归属利润 | 对应利润率 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 09-17 提交 → 今日成功回执(T) | ¥8100 | ¥1620 | 20.0% |
|
||||
| 09-16 提交 → 今日成功回执(T-1) | ¥1800 | ¥360 | 20.0% |
|
||||
| 09-15 提交 → 今日成功回执(T-2) | ¥675 | ¥135 | 20.0% |
|
||||
| 09-14 提交 → 今日成功回执(T-3) | ¥225 | ¥45 | 20.0% |
|
||||
|
||||
以上均为示例,与图中总数一致。每表4行、每行3指标,共12数据,不新增其他指标;分母和金额各行合计应等于同一快照主卡。比例依总分子/总分母计算。T-4及以前不混入这两个模块。
|
||||
|
||||
默认收起,展开处分别有加载、空态、失败重试与显式收起按钮。一次请求失败不清空其他区域;成功缓存限当前会话内存,保留时间标签,刷新失败展示“上次成功数据,更新失败”,不能回退假数据。跨午夜将旧数标为昨日,重新取当天总值;已收起明细失效但不发请求。页面刷新重新收起;快速点击去重在途请求,离开页面取消/忽略旧响应。
|
||||
|
||||
## 6. 建议实现
|
||||
|
||||
### 6.1 API与一致性
|
||||
|
||||
建议新增运营专用 `GET /operations/home/summary`、`GET /operations/home/receipt-breakdown`、`GET /operations/home/revenue-breakdown`,路径在实现时按既有控制器风格落地;不改变旧 dashboard/clientDashboard。总览只返回本页需要的三个汇总组、返还和运营状态。两个明细接口一次返回4行,不循环发4个请求。
|
||||
|
||||
明细请求携带总览签发的 `snapshotToken`;token绑定服务端日期、投影版本、数据截止时间、查询租户范围及权限,不接受前端任意扩大租户。主卡聚合及延迟查询都读同一发布版本;版本失效返回明确刷新要求,先刷新主卡再查明细,避免不同时间查询后四行与卡片不一致。不能只传 `asOf` 却查询可变的当前终态。
|
||||
|
||||
认证和运营财务权限分别校验;无财务权限返回不可见状态,不返回金额并仅前端隐藏。匿名401、越权403、非法日期/token400或明确失效状态;响应不暴露号码、短信内容、凭据。客户端不能通过复用接口取得其他企业数据。
|
||||
|
||||
### 6.2 耐久统计事实与性能
|
||||
|
||||
现有表足以找到原始字段,但缺少专门表达“某日首次形成完整成功及其金额版本”的稳定首页事实。建议增加只读用途的投影,而不是每次首页扫描全部历史发送/回执并依当前状态推断历史。
|
||||
|
||||
建议逻辑模型(均为待新增,不是当前已存在功能):
|
||||
|
||||
1. `HomeReceiptMessageDay`:接收日期、业务消息ID、原提交日期、租户、N快照、首次有效事件、成功认定事件、接收时间来源、revision;唯一键为接收日+业务消息ID。
|
||||
2. `HomeMessageSuccessFact`:业务消息ID唯一、成功尝试ID、完成成功所需最后回执接收时间、收入/成本快照及更正版本;解决跨日收入一次确认。
|
||||
3. `HomeProjectionRun/Version`:发布版本、截止游标、覆盖缺口、租约/fence/失败状态。分批投影更新先写不可变版本或版本化事实,完整提交后原子发布;保留页面token有效期覆盖的版本,不能用单行可变upsert假冒一致快照。
|
||||
|
||||
消费既有持久回执/收尾事件增量生成事实,按业务消息串行化或revision比较,事务中写事实与消费游标。崩溃重试不重复计入;并发worker需要租约/fence;成功事实必须在整条收尾提交后才能发布。投影消费者只写统计表,不发送短信、不改变收尾、账务、通知行为。乱序先按事件时间和可靠收尾结果重建该业务消息,不能直接累加包数。
|
||||
|
||||
迟到处理保持原网关接收日,源事件先到而发送关联后到需可重试;pending/unmatched、缺时间、缺分段不能静默当0。汇总元数据提示“统计处理中/部分数据待核对”,不额外增加用户未要求的首页业务KPI。
|
||||
|
||||
建议索引以真实EXPLAIN决定:Inbox网关接收时间+状态+关联消息,消息queuedAt范围,投影接收日+租户+原提交日,成功事实唯一业务消息与接收日。现有Inbox按receivedAt索引不等于按gatewayReceivedAt过滤可直接高效命中。避免OR大范围扫描,旧时间来源分支可UNION ALL后去重。生产建索引需评估写放大与并发索引迁移,不未经现场测量宣称提速。
|
||||
|
||||
默认总值和两个展开分别缓存、按日期/租户/定义版本隔离;投影可以生成业务事实,但不得预执行四行明细聚合。初步目标为代表性四日数据下首页API p95≤1秒、展开p95≤1秒,具体SLA以真实数据基线确定;测试同时记录CPU、扫描行数、内存、队列延迟,不只报告一个平均耗时。
|
||||
|
||||
### 6.3 历史与恢复
|
||||
|
||||
上线前先只读核验4日范围内时间字段覆盖、分段/计费单位差异、协议整条回执比例和补发成本,再使用分页、限流、可暂停的投影补建。无历史完整证据须标注覆盖缺口,不追造网关时间或假装完整成功。用旧/新查询影子对账解释差异,不能要求新口径总值强行等于旧口径。
|
||||
|
||||
新增表为可重建统计资产,原始收尾、Inbox及账务不可改写。功能开关默认保持旧首页,迁移与补建完整验收后切换;回退只回退读路径并保留投影,不删数据、不回滚客户账务。发布、补建执行及真实短信验收遵循独立授权。
|
||||
|
||||
## 7. UI设计稿与适配
|
||||
|
||||

|
||||
|
||||
图为默认收起状态。白色侧栏、浅灰底、三组横向指标;成功回执使用浅绿背景和绿色数字,其余金额沿用普通深色,负利润用红色。金额不采用整数/小数两种字号。页头显示上海日期、刷新入口与数据截止提示;设计图示例标识只用于设计交付,不作为上线UI固定文案。
|
||||
|
||||
实施复用现有Breadcrumb、Button、Table、MoneyText及原AppShell;页面CSS仅归 `AdminHome.css` 且限定页面根类。图中间距/字体为视觉意向,实施严格采用现有字号、8px圆角及248px侧栏规范。1600×1000三列;1366×768允许内容纵向滚动;390×844侧栏折叠、指标单列、两张明细表各自横向滚动,不允许整个页面横向溢出。
|
||||
|
||||
生成方式:内置imagegen,主提示词见 [UI提示词](designs/homepage-20260917/prompt.txt)。本轮仅视觉设计审阅,不是浏览器或真实API验收。
|
||||
|
||||
## 8. 实施顺序、验证与交付边界
|
||||
|
||||
1. 字段覆盖和真实四日数据只读基线;确认旧成本与通道协议事实。
|
||||
2. 时间/去重/完整成功纯规则及投影迁移、恢复机制;真实PG校验后再接查询。
|
||||
3. 运营专用API与快照token,四日数据独立对账;客户端回归。
|
||||
4. 首页9项指标、返还区域、两个按需展开;删除多余查询,保留运营状态。
|
||||
5. 真实后端页面三尺寸与网络断言、定向及全量回归、类型/生产构建/格式/样式/安全等现有门禁;如改Gateway,补Go测试和vet。
|
||||
|
||||
验证成本主要在跨日长短信、重复/补发、价格快照、迟到与故障恢复,而不是卡片布局。实现需完整覆盖下列验收,并同步需求/测试进度;发送/补发测试须另有明确环境、数据和负载授权,本方案本身不授权触发短信。
|
||||
|
||||
| 用例 | 待验证预期 |
|
||||
| --- | --- |
|
||||
| HOME0917-01 | T短信、T-1~T-3短信的今日回执纳入;T-4排除;北京时间午夜边界正确 |
|
||||
| HOME0917-02 | 3片仅一个失败回执:总3、成功0;2成功1未回仍成功0 |
|
||||
| HOME0917-03 | 全3片成功才成功3;message_level有协议证据才计整条成功;不同尝试不得拼片 |
|
||||
| HOME0917-04 | 同日重复包/乱序/重放/补发按业务去重;只确认一次成功和收入 |
|
||||
| HOME0917-05 | 昨日2成功、今日末片成功,今日计总3成功3,昨日成功不追增;已成功后的重复包不再计入 |
|
||||
| HOME0917-06 | 网关23:59接收、API次日处理仍归原接收日;缺时间字段显示覆盖说明 |
|
||||
| HOME0917-07 | 今日发送成功限今日提交且今日形成成功;总体成功率旧公式保持 |
|
||||
| HOME0917-08 | 收入使用历史价格,成本包含对应消息之前尝试已计费成功片;多通道成本、负利润、零营收、万分之一元精度正确 |
|
||||
| HOME0917-09 | 返还按今日唯一流水,与企业明细汇总一致;历史提交退款可纳入,已删除企业不漏账 |
|
||||
| HOME0917-10 | 首屏零明细请求;单击仅请求所属组,四行12值,同一版本与总卡勾稽;缓存/快速点击/午夜失效正确 |
|
||||
| HOME0917-11 | API错误保留带时标的真实旧值,首次失败不显示假0;登录和财务/租户权限隔离 |
|
||||
| HOME0917-12 | 投影并发、崩溃、重试、过期worker、乱序与补建完整性;发布半成品不可见,token过期不混版本 |
|
||||
| HOME0917-13 | 三尺寸、刷新/跨路由、展开表格、键盘焦点、空态/失败及控制台;删除旧模块但保留运营状态和客户端行为 |
|
||||
| HOME0917-14 | 代表性数据EXPLAIN、p50/p95、CPU/内存与队列影响;索引/迁移/回退只读路径验证 |
|
||||
|
||||
当前完成:代码字段核查、规则方案、UI图和文档一致性检查。未完成:业务代码、迁移、真实API/PG验收、浏览器三尺寸、性能基线、提交、推送、测试部署及预生产部署。
|
||||
|
||||
|
||||
## 9. 2026-09-17 实施与验收结果
|
||||
|
||||
当前实现已完成V2紧凑三栏、9指标及独立按需明细;保留原企业消费排行,新增今日返还金额列,详情和CSV同步。入口为运营专用 `/admin/operations/home/*`,客户端旧dashboard不变。四个投影表、两项迁移、源变化事务触发器和500条批次投影已实现;重关联同步失效新旧消息,版本事实/消费确认同事务。首次未就绪返回初始化状态,增量/未匹配回执/失败/时间近似均明确提示。已使用快照绑定用户、日期和15分钟期限;总览创建与版本回收通过数据库读写锁协调,明细读取不可变版本。仅过期首页快照和不被活跃快照使用的投影版本限量回收,源短信/回执/账务不删除。
|
||||
|
||||
实际截图:[桌面完整页面](designs/homepage-20260917/homepage-implemented.png)、[营业明细展开](designs/homepage-20260917/homepage-implemented-expanded.png)、[窄屏](designs/homepage-20260917/homepage-implemented-mobile.png)。来自本机真实Nest API、独立PostgreSQL16与隔离Redis中的验收数据,不是设计图或线上数据。Browser插件技能未提供,按既有授权使用Edge/Playwright。三尺寸1600×1000、1366×768、390×844及1600×1120完整截图通过;首次无明细请求、不调用旧dashboard/send-quality;分别点击才加载、刷新收起、错误保留真值、详情及含返还列CSV通过,页面异常0。
|
||||
|
||||
自动测试API79套854项、前端35文件163项通过;API覆盖率67.89%/53.10%/68.77%/70.65%,增量87.98%/77.03%/95.91%/91.22%,前端88.48%/85.09%/84%/88.19%,均通过各自门槛。类型、生产构建、定向ESLint、全量CSS样式、CSS治理及结构/包体检查通过。后续微调已补定向真实验收;不以历史测试数字冒充线上发布证据。
|
||||
|
||||
真实PG验收见 `tools/testing/verify-home-dashboard.mjs`,新库110项迁移通过;四日、缺片、重复、跨日、快照旧值、退款边界、归属/失效、事务回滚和认领已验收。附加真实故障注入确认投影写失败整批回滚、重试恢复,源更新与确认竞态不丢待处理记录。1/2/3/4片及旧无审计、整条协议回执、补发之前已成功片成本有定向规则测试。副作用测试仅在本地隔离库,不发送短信或向供应商/客户投递。
|
||||
|
||||
本地2020消息、增加6000片样本:2000条增量投影约4915ms,总览p50约46.8ms/p95约54.5ms,展开p95约20.1ms。未验证线上四日大数据、持续吞吐、整机CPU/峰值内存和外部供应商链路;HOME0917-14生产规模部分保留待验证,不作为容量承诺。初次数据库端口、Redis旧RDB格式以及浏览器关闭按钮/窄屏隐藏表头定位失败均留日志,修正后通过。Redis5.0版本建议保留,未改依赖。
|
||||
|
||||
本轮授权为提交、推送;不部署。两项迁移及首次投影初始化需在后续授权发布时执行,线上仍是旧首页。原始日志在忽略目录 `.local-data/homepage-implementation-20260917/`,不含密码、令牌或浏览器认证状态。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 2026-09-18 长短信回执优化复核
|
||||
|
||||
范围:用户要求检查最近长短信回执优化仍有无Bug。本轮审查、隔离复现与预生产只读核对,不修改业务代码,不提交/推送/部署,不触发线上短信发送、补发、重投、账务或配置变更。实际应用1676cfe,关注phase-4-send-pipeline-redesign.md第10节。
|
||||
|
||||
## 确认问题(修复前1676cfe基线,行号为当时版本)
|
||||
|
||||
### P1:最终失败后,矛盾成功回执可以改成成功,但不恢复账务或客户通知
|
||||
|
||||
位置:api/src/send-chain/send-receipt.service.ts:307~356。当前只有RECEIPT_TIMEOUT终态和delivered→failed方向保护,没有failed→delivered终态规则。recordReceiptSegment直接覆盖分片状态,当同次尝试的失败分片随后变成成功、其余分片也成功时,aggregate成为delivered,主消息直接更新成功。成功分支不处理已退款账务;CMPP dedupeKey与HTTP eventId固定,旧失败通知继续保留。
|
||||
|
||||
独立真实PG复现:构造合法的“最终失败+refunded账单+已生成失败通知”快照;同次两分片随后各到成功回执,通过实际SendChainService/耐久工作协调器处理。结果消息delivered、账单refunded、通知payload.receiptStatus=undelivered。事务和通知唯一键无法修复互相矛盾的终态规则。本轮使用真实持久层、实际回执/通知代码,不运行网络投递进程。该复现从已退款快照开始,不宣称执行了实际线上退款。
|
||||
|
||||
建议:明确并固化最终失败后的矛盾回执策略。若最终结果不可逆,保留审计并生成异常;若业务允许纠正,必须设计账务与通知的完整补偿,不可只改消息状态。
|
||||
|
||||
### P1:同一业务短信不同尝试复用上游Msg_Id时,回执可能跨通道匹配且同时更新两次尝试
|
||||
|
||||
位置:send-receipt.service.ts:604~620 exactMessage分支,仅按messageRecordId+gatewayMessageId选最新分片,未限定incoming channel/上游身份;494~519 recordReceiptSegment的updateMany同样未使用已传入submitRecordId或channelId。
|
||||
|
||||
独立PG复现两个不同通道、同业务短信两次尝试的相同gatewayMessageId,通过实际chain.handleReceipt入口:resolved.channelId指向另一通道,两次尝试的分片都被改成delivered(预期仅一条,无法唯一确认应拒绝匹配)。Msg_Id仅供应商作用域内有意义,不能以全平台无碰撞为前提。这会污染历史审计及当前成功汇总;工作表按sourceSubmitRecordId加锁不能修正此前错误归属。
|
||||
|
||||
建议:关联时结合逻辑通道/真实上游身份和唯一发送尝试;分片更新必须带resolved.submitRecordId(历史兼容使用可确认的submitId及身份),禁止一条回执批量跨尝试修改。
|
||||
|
||||
### P2:已经最终失败的后续分片仍重新执行补发选路
|
||||
|
||||
位置:send-receipt.service.ts:334~342、send-retry.service.ts:334~406。仅存在retryOfSubmitRecordId时复用已有补发,未保存“本次已确认无路可补、最终失败”的不可重复决策。后续合法失败分片成为新事件,会再次执行findApplicationRoute/selectChannelForMessage,并重新走终态副作用。新工作revision不意味着应该重复执行同一终态决策。
|
||||
|
||||
独立PG复现两分片先后失败:首片处理后消息已经failed,第二片仍调用选路;选路计数2。该用例仅将选路边界隔离为确定的BadRequest“无可用通道”,其余实际SendReceipt/SendRetry、工作表及数据库/通知路径真实执行;不是全量真实路由配置验收,不启动供应商网络。
|
||||
|
||||
预生产证据:2026-09-18 10:12~10:20,回调/发送worker stderr共719条sms_retry_route_failed、关联692条消息,错误均“无已报备通过且在线的可用通道”。21条消息重复2~3次,核验当前全部failed;逐条比对最早CMPP最终通知createdAt和日志秒时间,至少5条在最终通知已创建后仍出现选路失败日志。日志时间只有秒,不能据此否定其余同秒的重复。该问题增加查询/日志/事务开销,不能把1655次正常补发尝试全部归因于它,也不能量化其占CPU56.8%的比例。
|
||||
|
||||
建议:给发送尝试保存明确终态决策,后续事实仍审计/异常检测,但复用已提交的最终失败、退款及通知事实,不再选路。和第一项统一状态机处理,不能粗暴丢弃所有后到回执或unknown转明确结果。
|
||||
|
||||
## 已排除及线上边界
|
||||
|
||||
- 怀疑“第二片回执先于其SubmitSegmentResult造成永久丢失”未复现:实际外层缺少可靠sourceSubmit关联时抛出404,由Inbox等待;补齐分片元数据后重处理可正确齐段成功。保留初次该断言失败日志,最终作为通过用例而非Bug。
|
||||
- 以昨晚21:24上线后为边界查询:当前delivered且billingStatus=refunded计0;新分片中同messageRecordId+gatewayMessageId跨submitRecordId碰撞组0。只能说该窗口当前数据未发现上述两类命中,不是永久不可能发生,也不是全历史完整审计。
|
||||
- 当时18136个SmsAttemptCompletionWork全部idle;上一轮CPU诊断未发现旧的重复补发/下游回执唯一键冲突。上一轮原子认领与防重复创建机制有效,但不等同所有状态机/关联边界正确。
|
||||
|
||||
## 验证和保护
|
||||
|
||||
新建本机独立PostgreSQL16集群与cmpp_qa_receipt_review数据库,监听127.0.0.1:16439,全部111迁移成功;实际API TypeScript构建通过。未启动Gateway、HTTP投递、API调度或真实短信发送。首次尝试复用旧隔离集群时发现默认端口/角色不同,未修改旧数据库,关闭本轮启动的旧集群,改建独立集群;两套本轮启动进程均已关闭,数据及失败日志保留。
|
||||
|
||||
隔离证据.local-data/receipt-review-20260918/repro-final.log确认3项缺陷及1项通过;online.json、repeated-routes-stderr.json、route-confirm.json为只读现场聚合。日志ANSI导致首次关联统计为0,去除ANSI后重新核验21组/至少5组,原失败与修正结果明确区分。未重跑与只读审查无关的全量前端/Go测试,不宣称修复完成。
|
||||
|
||||
|
||||
## 后续整改(2026-09-18)
|
||||
|
||||
用户后续授权修改并本地提交,三项按主设计第10.13节实施;上文保留原审查证据,不代表修复后实现。真实PG回归复现结果已改变:终态之后不重复选路,矛盾成功不改失败/退款/通知,跨尝试仅更新目标记录;详情、全部测试及未验证边界见testing-progress.md同日“长短信三项缺陷整改”。未推送、未部署,线上CPU改善尚未验证。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 2026-09-21 六项运营功能整改
|
||||
|
||||
状态:实现及本机真实接口、页面验收完成,提交推送收尾。授权:本地修改、测试、提交、推送;不部署、不发送短信、不修改线上业务配置。基线main 28951b4,2026-09-21 19:25前回读实际远端main 001d5f2;推送将包含前一轮未报备统计修复及本次提交,其他会话未提交修改保留。
|
||||
|
||||
## 规则与实现范围
|
||||
|
||||
1. 模板:运营端和客户端复用Select的searchable;变量名仅ASCII英文字母和数字,保留1~32字符上限,不接受下划线、空格、汉字或其他符号,数字可以开头。自定义输入、正文占位符、API变量配置均校验;变量示例值仍可使用中文。已有模板不自动改写,编辑涉及变量/正文时按新规则校验。按用户随后更正,以“企业应用×模板名称”唯一,签名不同也不能同名;名称去首尾空格,非deleted状态均占用名称,已删除模板不占用,新建/编辑/恢复和并发保存由数据库部分唯一索引兜底,API返回明确中文冲突提示。不同应用同名允许。2026-09-21预生产只读检查有效模板按applicationId+btrim(name)重名组0;迁移不自动重命名或删除任何模板,目标环境若有新冲突则阻断迁移。
|
||||
2. 首页:恢复今日24小时发送曲线,位于企业消费排行上方。沿用原曲线口径,按北京时间queuedAt小时汇总号码级业务短信数及其中最终delivered数,不把供应商补发计为新业务短信;来自真实PG当日范围查询,空小时补0,接口失败显示不可用/旧数据提示。通过现有首页summary加载,不重新调用旧的全量dashboard接口;返回hourlySendTrend,指标卡及其他快照逻辑保留。
|
||||
3. 短信记录:“未知”筛选包含提交成功尚无最终送达结果的submitted以及历史unknown;不包含成功/失败/排队/拒绝,既有其他条件取交集。submitted显示“提交成功”,不变更真实业务状态或回执。
|
||||
4. 签名质量:仅删除“签名通道发送质量”列表的通道提交列,调整该表统计说明;保留详情的提交尝试数及后端指标,避免改变其他统计口径。
|
||||
5. 通道报备明细:拆分企业、应用、通道、报备对象四个独立文本搜索框,AND组合且各自仅匹配对应字段。报备对象按当前类型匹配签名或引流站点/URL。保留旧keyword接口兼容、时间/状态/运营商/类型条件、虚拟未报备行、详情/导出及分页,查询/重置回第一页,翻页使用已应用条件。
|
||||
6. 通道运营商缩减:保存前列出移除的运营商并要求确认影响,提示将从这些运营商的全部通道组移除(含省网/全国),可能导致该组无可用通道;重新勾选/取消不写库。后端同一事务更新通道、按通道组carrier移除成员、记录移除关系审计,其他运营商组/组本体/企业应用路由/报备历史保持。能力变更不触发无关重连。成员写入的数据库约束锁定通道并检查组运营商兼容性,阻止并发通道组保存重新挂回已移除能力。事务失败整体回滚。替代此前“缩减能力仅跳过、不移除引用”的规则。
|
||||
|
||||
## 数据、权限与兼容
|
||||
|
||||
新增迁移仅增加模板名称部分唯一索引和通道组成员写入兼容性约束,无历史重写、余额/收费调整。迁移失败不自动resolve或清数据。应用回退不移除索引/约束,旧代码写入不兼容关系会被数据库拒绝。沿用既有运营端/客户端登录、权限及租户限定;前端提示不代替服务端校验。模板名称冲突返回400而非500。
|
||||
|
||||
## 验收
|
||||
|
||||
真实本机隔离PG全迁移、API与必要Redis;模板并发创建/更新、自编辑、跨应用、软删除重用、非法变量与中文示例;小时边界/零点/重复Submit;未知筛选的命中与排除;四搜索项组合及分页虚拟行;运营商移除、取消、回滚、并发成员写入、审计和保留项。页面1600×1000、1366×768、390×844首次进入/刷新/跨路由和相关交互,检查控制台及截图。Browser插件/对应browser技能不在清单,按前端测试技能使用已有Playwright,不安装依赖。定向+前后端全量、覆盖率、TypeScript/生产构建、格式/lint/样式/包体及diff门禁;精确提交候选与工作区结果区分。实际结果收尾追加,不将方案当已完成。
|
||||
|
||||
## 2026-09-21 本轮结果
|
||||
|
||||
- 根因:签名Select未启用通用搜索;变量校验允许下划线且前端缺少输入拦截;模板无应用名称唯一约束;新首页未接回旧小时序列;未知条件直接查unknown遗漏submitted;签名质量列表保留了不需要的列;报备查询只提供合并keyword;通道更新此前明确保留失配的通道组引用。
|
||||
- 真实验收脚本:`tools/testing/verify-operations-six-fixes.mjs`,仅允许localhost专用数据库名。PG16.14隔离新库115项迁移通过(113项基线和本轮2项,不含其他会话未提交迁移);Redis专用16452,真实Nest全局认证、PG、HTTP、前端production构建。无需文件上传/发送,未启动MinIO和Gateway,无任何网络短信发送。Redis5为既有本机版本,BullMQ版本建议提示保留;本轮只验证会话和配置查询,不作为队列/Gateway验收。
|
||||
- 模板:运营与客户端均拒绝同应用跨签名重名;两个并发创建只有一个成功;首尾空格、自编辑、编辑重名、跨应用、删除后重用及恢复冲突通过;非法变量、数字开头和中文示例通过;未登录和客户端访问运营接口被拒绝。数据库唯一索引覆盖写竞争,迁移不自动处理历史重名。
|
||||
- 小时与短信:真实24小时数据,北京零点包含、前日末排除、空桶为0;同业务多条供应商提交记录不增加小时业务量;真实首页summary一致。未知筛选只含submitted/unknown,浏览器真实请求参数为unknown且展示提交成功。
|
||||
- 报备:企业/应用/通道/对象同时匹配;逐项不匹配均空;虚拟未报备记录保留,分页总数和第二页不同记录验证通过。签名质量真实页面三种尺寸均无通道提交表头。
|
||||
- 通道:删除联通时全国与湖北成员均移除,移动/电信及4条应用路由保留,审计记录2条删除关系;强制审计失败触发整体回滚;真实第二数据库连接在通道更新期间写成员等待,提交后被兼容约束拒绝。页面不确认禁止保存、确认后可保存、关闭取消不写库。提示样式由同目录ChannelFormModal.css直接归属并登记所有权,不改历史CSS基线。
|
||||
- 页面:1600×1000、1366×768、390×844覆盖首页刷新/图表位置、四个搜索框、模板签名搜索及粘贴非法变量、通道警示与取消、质量列表、未知筛选、跨路由;无pageerror。18张截图在`C:/Users/hectorzhao/AppData/Local/Temp/cmpp-operations-six-20260921-final/`,人工查看桌面首页与移动通道警示。截图为本机真实测试数据,不是预生产页面。
|
||||
- 从暂存区导出精确候选`C:/cmpp-platform-local/operations-six-candidate-20260921`:前端38组179项、覆盖率语句88.48/分支85.09/函数84/行88.19;后端87组935项、覆盖率68.02/53.71/68.98/70.76,均达到现有门禁;前后端TypeScript与生产构建通过。lint无错误,保留10项既有any/Hook依赖警告;格式、样式、CSS所有权与15项治理测试、包体和staged diff检查通过,入口107.46KiB gzip低于250KiB预算。Gateway未改,不执行Go或线上链路测试。
|
||||
- 原始日志:工作区`.local-data/operations-six-20260921/`与`C:/cmpp-platform-local/operations-six-*.log`;最终真实验收`operations-six-real-verified.log`。最初后端事务mock、浏览器控件定位/手机隐藏表头等待错误已修正;工作区并发覆盖率曾15项超时,精确候选maxWorkers=2、testTimeout=15000全通过。Windows导出CRLF造成格式检查失败,改用本次checkout-index进程级core.autocrlf=false导出提交内LF后通过,未改全局配置。旧拒收脚本中通道缩减断言同步新规则,未执行其发送命令生成流程。
|
||||
- 边界:本次交付代码和两项迁移,未部署测试或预生产;目标环境真实页面、真实历史数据量下首页查询耗时以及迁移时新增重名检查仍需在获授权的发布流程核验。应用回退不会移除已执行的数据库约束。
|
||||
- 增量覆盖率门禁另行执行87组935项通过,语句87.98/分支77.03/函数95.91/行91.22;专用PG和Redis已停止,隔离数据、产物和失败/成功日志保留。
|
||||
@@ -49,3 +49,14 @@
|
||||
- 通道、通道组、路由规则、报备字段、报备任务基础接口存在。
|
||||
- 报备导出/导入记录和报备状态同步接口存在。
|
||||
- 阶段 4 进度文档记录验证结果。
|
||||
|
||||
## 2026-09-17 有效签名名称唯一性
|
||||
|
||||
本节补充签名新增、修改和状态恢复规则;批量导入仍遵循 [补资料方案](reporting-batch-import-records-remediation-plan-20260902.md) 的字段合并规则。
|
||||
|
||||
- 同一企业、同一应用下,完整签名名称精确相同时只能存在一条有效记录。有效指审核状态不是 `deleted` 或 `disabled`,包含草稿、待审、通过和驳回。未绑定应用作为独立范围,同企业未绑定应用的同名有效签名也唯一;不同企业、不同应用允许同名。
|
||||
- 新增、改名、换应用、提交审核、审核及状态恢复均执行接口查重,修改排除自身;冲突返回 HTTP 409 和“同一企业、同一应用下已存在同名有效签名,请修改已有签名资料”。保留既有认证、租户校验、审核及报备资料行为,无新增权限、页面和短信链路变更。
|
||||
- PostgreSQL 使用两个部分唯一索引:绑定应用的 `(tenantId, applicationId, name)`,未绑定应用的 `(tenantId, name)`,都排除停用及删除状态。并发以数据库最终约束为准,唯一冲突转为相同业务错误;Prisma schema 用注释指明 SQL 所有权,不用普通复合唯一键冒充部分索引。
|
||||
- 迁移在事务和写锁内检查历史有效重名;发现冲突则明确报错、整体回滚,禁止自动删除、合并或改状态。发布前需只读盘点并另行确认历史治理,不把迁移阻断当作成功;本轮只本地提交,不部署。
|
||||
- 批量导入仍是补资料:优先匹配同范围有效签名,其次沿用未删除的停用记录;原签名 ID、未映射字段、用途及关联记录保留。导入暂存后新增了同名签名时,审核阶段重新匹配并更新;并发创建冲突时重新读取有效记录转为资料更新,仅重试一次,不吞其他错误。已有指定目标不会暗中改写为另一条记录,恢复冲突明确失败。
|
||||
- 验收覆盖新增/编辑/换应用/空应用/状态恢复、不同企业应用、并发创建、直接 SQL 防重、历史迁移失败回滚,以及导入暂存与审核后补资料保留。使用本机隔离 PostgreSQL 和真实服务/API,不发送短信,不改线上资料;线上历史数据与部署留作未验证项。
|
||||
|
||||
@@ -346,3 +346,25 @@ GatewaySubmitOutbox
|
||||
- 收尾needs_review及等待超过300秒由监控采集器直接查询耐久工作/事件表,写入同一告警事件库,恢复仍保留、仅人工清除;不依赖应用发布工具安装Prometheus规则。过程计数仍提供低基数metrics,采集进程口径需区分。
|
||||
- 两项迁移仅新增四张表及索引,无历史回填、无删除、无自动重发;全量106项已在新隔离数据库重放。工作时间使用UTC表达式,避免数据库会话时区与Prisma时间不一致。应用回退前必须盘点和接续未完成工作,旧代码不能消费这些新表。
|
||||
- 本地验证包括真实PostgreSQL、两个独立OS进程、事务回滚、旧消费者挂起后接管、三段通知目标、非零账务和重试上限可见告警;网络投递与目标环境的重启/排空验收独立记录,不将路由隔离测试冒称整链路通过。
|
||||
|
||||
### 10.12 测试环境发现的快速SubmitResp竞态(2026-09-16)
|
||||
|
||||
a350aca测试环境长短信验收发现两条消息首尝试分别仅写出2/4、1/3段,Gateway在已收到即时应答时仍等待60秒后误判SUBMIT_TIMEOUT;补发及最终账务/通知收尾正常,但不能据此视作无异常验收。只读代码证据:submitPart在SendReqPkt、异步日志启动之后才登记pending[seq],readLoop可能提前消费应答并因不存在等待者丢弃。新增真实TCP回归在修改前分别于CMPP2.0第206次、3.0第7次复现。
|
||||
|
||||
最小修复保持现有协议、接口、存储和补发策略:使用与heartbeat一致的mu→sendMu锁序,将连接有效性检查、写包和登记pending置于同一临界区,响应读取须等登记完成。网络失败仍走原连接关闭/失败处理。锁内不得执行日志、业务回调或数据库操作。验证两种协议各500次即时应答、Gateway全量test/vet及测试环境新的长短信样本;原异常证据保留,不将旧样本改成无补发成功。
|
||||
|
||||
### 10.13 2026-09-18 终态与回执归属整改(本轮授权修改并提交)
|
||||
|
||||
本节修订10.2、10.5的实现约束,针对[专项复核](long-sms-receipt-review-20260918.md)三项问题;本轮不推送、部署或修正线上历史数据。
|
||||
|
||||
- 复用现有消息终态与SmsAttemptCompletionWork.decision作为已提交决定,不新增数据库结构。原有工作→消息事务锁保证最终失败、退款、通知和工作决定同时提交。消息failed/delivered和明确RECEIPT_TIMEOUT不再因后续供应商回执自动逆转;后续同向分片仅补审计,不再选路/退款/创建通知。unknown、未齐片、普通提交timeout仍可继续处理;旧尝试事实不修改当前尝试决定。
|
||||
- 同次终态的矛盾明确回执保留SmsCompletionEvent和SmsReceiptRecord原始事实,按消息尝试生成稳定异常键,不覆盖已用于结算的规范分片结果,不改账务或已生成客户通知。失败转成功异常独立标识;重复同一回执不能增加异常计数或重复业务动作。已有message_level成功后失败异常类型兼容。
|
||||
- 回执关联同时核验可用的业务消息身份、手机号、逻辑通道/真实上游身份、上游Msg_Id、发送尝试。优先精确逻辑通道;同供应商跨连接仅在相同账号/主机/端口/协议/版本且唯一候选时匹配。Submit与Segment候选须共同消歧,不能分别取最新。多个尝试冲突保留Inbox待匹配;候选超出查询上限时保守拒绝。历史缺submitRecordId时只按可确认submitId回读归属,不能猜当前尝试。
|
||||
- 分片写入限制messageRecordId、submitRecordId/明确submitId及channelId;不得仅凭messageRecordId+gatewayMessageId批量跨尝试更新。提交分片记录同样在明确submitId存在时优先精确匹配,避免OR条件被另一尝试同Msg_Id干扰。
|
||||
- API和协议不变、鉴权/租户规则不变、无新权限。冲突/未确认回执继续走现有Inbox恢复与人工排查。发布回退只涉及应用;不自动重放已完成事件或历史退款。
|
||||
- 验收:真实PG验证顺序/并发失败分片仅一次终态选路,矛盾回执账务/通知/分片均不逆转,跨通道与同通道碰撞拒绝或准确关联,同供应商跨连接、早到回执、unknown转成功、旧尝试迟到及工作故障恢复。隔离固定输入比较选路次数;不能将隔离开销降低推算为线上CPU降幅。
|
||||
|
||||
|
||||
## 2026-09-20 模板拒收指令补充
|
||||
|
||||
参见 [模板拒收指令策略](template-optout-policy-design-20260920.md)。发送链在选路候选阶段按模板/通道生成内容快照,敏感词按各候选真实内容评估;每次尝试从不可变原文开始。Submit、消息内容与Outbox同事务保存,Gateway授权校验使用对应Submit的内容快照。保留既有计费单位、原收尾状态机及Outbox稳定提交身份,配置变化不改写已生成命令。未配置模板规则时正文保持原样;明确模板ID不串用其他模板规则。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2026-09-16 五项整改测试环境交付与验收
|
||||
|
||||
## 版本、范围与证据边界
|
||||
|
||||
截至2026-09-16 19:20 CST,测试环境100.93.204.60:12026运行`010ba3216889032a6160cdb14d8536b616ae7102`,线上标记、归档源码、Gateway产物和服务由标准release verify核验。此前依次发布五项实现`a350aca883369b1cfa67ea630a93cf592a9ed6f7`,更名提交`a0209f93bc8eee6cb08a9d7cace4163b26749ae6`随同发布。远端main已回读010ba32,分叉0/0;收尾文档后续独立提交,不表示再次重启应用。预生产未连接或部署。
|
||||
|
||||
已实现:第4阶段方案第10节的耐久收尾工作/原子认领/revision与fence/事务和Outbox/失败接续;通道测试免签名与引流检测(普通客户链路保留规则);HTTP参数化真实发送及应用层HTTP报文;告警恢复留存与人工清除;监控失败保留最后真实快照并标明采样时间。短信链路细节见[权威方案](phase-4-send-pipeline-redesign.md)第10节。
|
||||
|
||||
保护原61项修改与草稿,不提交原metrics、版本3.0.0、发布工具或部署脚本草稿。原包版本修改未进入应用提交;产品命名与工程版本提交是独立事项。共享文档仅精确暂存本轮新增段落;原文件快照及摘要保留于`.local-data/five-fixes-20260916/before`与`protected-hashes.json`。发布工具仍为受保护的未提交工具,应用SHA不代表工具版本,其摘要由每份plan.json绑定。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 范围 | 实际结果 |
|
||||
|---|---|
|
||||
| 精确应用候选a350aca | 前端35套163项、API77套834项;类型/格式/lint/样式/CSS门禁及远端生产构建通过。工作区API835项含一项受保护metrics测试,不混为候选成绩 |
|
||||
| PostgreSQL | 隔离真实PG全量106迁移重放;11组并发/原子回滚/多进程/过期租约/fence/通知账务/人工处理告警测试通过。路由/限速隔离测试不冒充真实Gateway |
|
||||
| Gateway补充010ba32 | 两种协议即时TCP应答各500次×5轮通过;全量go test ./...、go vet ./...通过,标准工具对精确提交重新validate |
|
||||
| 真实短信链路 | 专用隔离应用20条业务短信,另1条无租户通道测试;模拟供应商只监听测试机loopback,不转真实运营商。涵盖2/3/4段、逆序和每段3次重复DELIVER、补发成功及最终失败 |
|
||||
| 最终账务 | 20份账单,19条成功、1条失败;初始隔离资金1000000,扣费20150、退款650、余额980500(均数据库内部整数单位);冻结-20150与释放20150抵消,账户与流水一致 |
|
||||
| 耐久工作/Outbox | 24个业务发送尝试工作全部idle且revision=processedRevision;所有对应Outbox为published;每个源Submit最多一个后继,无额外账单或业务通知 |
|
||||
| HTTP通知 | 20个稳定业务事件全部delivered;5个早期TLS失败自动恢复,独立503和超时样本各第二次200;重试复用原投递记录,不补发短信。接收端未单独验HMAC,不宣称该项完成 |
|
||||
| CMPP下行 | 客户断线后重连,实际收到并ACK 9份客户原始分段回执;Registered_Delivery=false样本无CMPP通知,HTTP按both配置单独生成;无多余目标 |
|
||||
| 通道测试 | UI敏感操作由用户重新验证身份;195字、无签名且含未报备example.invalid链接,3段全部真实提交并delivered;tenant/batch均空,不创建客户HTTP或CMPP通知 |
|
||||
| HTTP调试页面 | 实际点击发送返回202,展示请求/返回HTTP报文;对应3段短信终态delivered,未将仅202当最终送达 |
|
||||
| 监控页面 | 1600×1000、1366×768、390×844检查,刷新、路由切换、页面重载通过;真实API成功后注入请求中断,仍保留真实快照及时间,解除中断恢复刷新;无页面JS异常 |
|
||||
| 告警人工清除 | 实际发布产生的cmpp-api恢复告警持续留存;UI手动清除后刷新不再出现,PG clearedAt=11:09:32.356Z,一条monitoring.alert_cleared审计;未清除原有磁盘/演示告警 |
|
||||
| 最终队列 | gateway.submit.commands/results/protocol.logs三个消费组pending=0、lag=0;没有凭SubmitResp推算容量 |
|
||||
|
||||
### 验收发现的附加缺陷及处理
|
||||
|
||||
a350aca初次真实验收中,CMPP样本923和页面样本931首尝试分别只写出2/4、1/3段,出现60秒SUBMIT_TIMEOUT并自动补发。只读代码确认Gateway发送后才登记等待者,快速应答可能被readLoop提前丢弃;未修代码真实TCP回归在2.0第206次、3.0第7次复现。010ba32将写包和登记等待者置于与heartbeat相同mu→sendMu临界区,已按标准流程第二次发布。
|
||||
|
||||
010ba32新增6条样本970~975(2/3/4段各两条),6个Submit、18个wire分段全部一次成功,无补发。全轮合计74个wire分段包含通道测试3段,以及修复前异常首尝试3段;20条业务消息24个Submit中的4个后继,分别为2个预设失败场景和2个修复前竞态场景。原异常保留,不算成修复后的无重复样本。短信账务仍按业务消息一份。
|
||||
|
||||
首轮模拟CMPP客户端MsgSrc误填account,入口结果9,确认无业务创建后修正测试客户端才运行正式样本;最初503/超时控制读取错误JSON层级,952/953仅作为普通200样本,修正为data.phoneNumber后962/963才计故障验收。保留原失败记录,不用后续成功覆盖。Webhook临时域名在测试机解析到198.18.1.79造成TLS失败;仅对该QA域名临时hosts映射到经TLS验证的地址,未关闭证书校验,验收后精确移除。
|
||||
|
||||
## 发布及最终工具状态
|
||||
|
||||
两轮均使用npm run release的plan→validate→preflight→prepare→deploy→verify,使用已授权测试凭据的安全本机入口。第一轮106迁移,第二轮无新增迁移。标准工具不接收业务验收录入,报告中businessAcceptance仍是“未执行”;业务证据由本文件独立维护。
|
||||
|
||||
最终verify状态为`deployed-needs-review`,唯一提示“Business counts changed during online release”。已核对:第二轮基线messages/submits为119597/130835,验收后119603/130841,恰为新增6条/6次,无额外记录。应用文件日志新增errorMarkers=0,所涉服务journal warningLines=0;精确版本、资源及服务检查通过。保留工具原始提示,不改状态文件冒充全绿。
|
||||
|
||||
| 发布计划 | 远端准备 | 备份 | 停止 | 迁移 | 启动恢复 | 工具验证 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 20260916T104026-a350aca88336-7f5f9fd9 | 58.1s | 34.6s | 3.7s | 1.2s | 5.4s | 5.0s |
|
||||
| 20260916T110901-010ba3216889-257390d0 | 17.0s | 34.7s | <0.1s | <0.1s | 2.2s | 0.9s |
|
||||
|
||||
数字取自state.steps,不将开发、浏览器诊断、验证码/重新认证等待、Webhook网络排障或附加竞态修复计为服务停机。本地候选验证日志独立保留;API全量候选179.627s,前端工作区115.33s不冒称候选耗时。
|
||||
|
||||
恢复资产分别位于:
|
||||
- `/var/backups/cmpp-platform/20260916T104026-a350aca88336-7f5f9fd9-attempt-1789555402719951496`(原cbc4a03)。
|
||||
- `/var/backups/cmpp-platform/20260916T110901-010ba3216889-257390d0-attempt-1789557105165052776`(原a350aca)。
|
||||
|
||||
两份备份经工具摘要/可读性检查,未进行整机或数据库恢复演练。旧版本不理解新工作表,禁止直接回退并宣称接续已验证。
|
||||
|
||||
## 容量与测试资源收尾
|
||||
|
||||
测试机程序、发布目录和备份均落同一`/dev/sda2`根盘,没有独立数据盘。发布前后已用84166586368→86242967552字节,增加2076381184字节;最终可用13457821696字节,df使用率87%。
|
||||
|
||||
| 路径 | 发布前字节 | 发布后字节 | 保留原因 |
|
||||
|---|---:|---:|---|
|
||||
| /opt/cmpp-platform | 1030688768 | 1032572928 | 当前010ba32 |
|
||||
| /opt/cmpp-releases | 13599055872 | 14703378432 | 候选、原产物/依赖、源包、阶段证据 |
|
||||
| /var/backups/cmpp-platform | 8042622976 | 9034113024 | 两份独立恢复点及既有备份 |
|
||||
| /var/log | 1025540096 | 1026125824 | 服务诊断日志 |
|
||||
| /root/.npm | 698204160 | 722685952 | 依赖缓存 |
|
||||
|
||||
上一有效应用a350aca的previous-dist(43720704字节)及previous-source(1654784字节)位于第二轮发布目录;首轮previous-api-node_modules(587194368字节)、previous-api-dist(4771840字节)、previous-dist(42360832字节)对应cbc4a03恢复组件。首轮candidate-1789555279750741134为397352960字节;第二轮candidate-1789557047597611732为15458304字节。精确路径清单/占用见本地final-assets.log。当前/上一有效版本之外历史资产仍保留,容量治理未完成;未擅自删除备份、旧依赖或迁移目录。
|
||||
|
||||
本轮隔离应用cmu3z59t200031ple266tmprq及HTTP接口已停用,凭据revoked、Webhook端点disabled;两个模拟通道通过真实管理API停用并断链。模拟器7910、临时隧道、Webhook监听器、本轮启动的两个本地隔离PG集群均停止;临时hosts精确映射及清理timer已撤销。测试短信、账务、协议日志与审计保留,不改回余额,不删除业务证据。其他客户/通道配置未更改。
|
||||
|
||||
## 仍未验证的范围
|
||||
|
||||
TC-RC-01~12为综合矩阵,不能逐行笼统全部通过:两独立进程和旧租约fence、事务回滚/接续等已在真实隔离PG验证;目标环境已完成前述真实端到端,但未在所有生产消费者位置逐断点kill/restart,未等待真实72小时,未演练旧版恢复及全部历史脏数据组合。未执行修复前后同负载性能对照或持续容量压测,不能宣称CPU下降幅度/TPS。TC-RC-13即时应答竞态本地及目标补测通过。预生产验收完全未执行。
|
||||
|
||||
测试证据:`.local-data/five-fixes-20260916/{completion-final.log,immediate-before.log,immediate-after.log,go-all.log,go-vet.log,reconcile-final.log,count-diagnosis.log,cleanup-fixture.log,final-verification-detail.json,capacity-before.log,capacity-after.log,final-assets.log}`;标准计划/验证/报告在`.local-data/releases/`对应上述两计划目录。浏览器截图及受控脚本位于本机临时目录`cmpp-five-fixes-20260916`,不包含密码、Cookie或认证状态。测试脚本PASS文字已将“audited recovery”纠正为“isolated database recovery”,该恢复测试不代表人工恢复审计功能。
|
||||
@@ -0,0 +1,191 @@
|
||||
# 签名退网检测与签名质量四页查询优化方案
|
||||
|
||||
维护日期:2026-09-17。状态:**本地实现及隔离验收完成,待发布**。用户于本轮授权修改并本地提交;没有授权本轮推送、部署、历史上行认领或真实短信/通知发送。第1节保留实施前取证;本轮实现与验收以第8节为准。
|
||||
|
||||
本文是本次三项需求的统一实施设计,补充[签名清退设计](signature-retirement-alert-design.md)。实施后替代该设计第6节、第7节第15步中“热力图直接读检测快照、按当前报备维度展示、前端筛选分页”的实现方式,以及[运营修复设计](operations-fixes-20260908.md)第4项中的客户端热力图分页;保留四TAB独立状态、默认25条、10/25/50/100档、D-1至D-30列序和原业务统计单位。现阶段旧代码仍在运行,不能把本方案描述为已上线功能。
|
||||
|
||||
本方案仅针对“签名质量检测”四TAB与签名退网检测,不改变“报表对账/发送质量报表”的既有T-4~T-1刷新任务,不改变短信门禁、补发、计费、余额或报备状态。
|
||||
|
||||
## 1. 核查结论与证据
|
||||
|
||||
### 1.1 基线
|
||||
|
||||
- 本地main与实际远端main均为`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,暂存区空;已有版本、metrics、发布工具、部署脚本及文档草稿保留。
|
||||
- 2026-09-17 09:37北京时间只读核验预生产运行`010ba3216889032a6160cdb14d8536b616ae7102`。上述两个提交间本方案涉及的页面、质量查询和退网检测源码没有差异。
|
||||
- 证据:`src/apps/admin/AdminAnalyticsPage.tsx`、`src/api/admin/signature-retirement.api.ts`、`api/src/operations/queries/quality.queries.ts`、`api/src/signature-retirement/signature-retirement.service.ts`、对应controller及`api/prisma/schema.prisma`。
|
||||
- 本机原始取证在忽略目录`.local-data/cpu-20260917/`:`prom.json`、`detail.jsonl`、`design-audit.json`及只读脚本。线上DB使用只读事务与8秒语句超时,未启动应用调度器。此次未执行登录后的四TAB真实浏览器/HTTP复测;以下交互结论来自当前源码,不冒称已复现浏览器串数据。
|
||||
|
||||
### 1.2 凌晨CPU与检测成本
|
||||
|
||||
今天检测日2026-09-17的结果从04:00:01.620写到04:04:07.792,共5,831条(企业1,431、通道4,400)。前一天4,280条。Prometheus五分钟CPU均值峰值58.79%,04:06:45为32.81%;04:06最近一分钟忙碌率约2.54%,趋势包含此前负载。日报刷新日志完成于00:33:09。
|
||||
|
||||
同窗口`SmsMessageRecord`与`SmsSubmitRecord`顺序扫描行速率峰值分别约362万、536万行/秒;这是数据库重复访问行的计数,不是短信量或TPS。IO等待较低。检测时段、真实快照与数据库扫描高度吻合,是主要负载线索;缺少历史业务进程CPU与SQL耗时采样,不能量化各进程占比或断言唯一原因。
|
||||
|
||||
现有`runDetection`逐个维度调用`activityCounts`:至少一次单日查询,完成观察期后再查一次规则窗口;每次关联消息、提交、分段/回执。已有检测是否存在的判断在这些计算之后的`persistDetection`中,重启补偿仍可能先重复计算再退出;周期、抑制、检测记录还存在逐行读写。现有唯一键保证部分去重,不等于任务已具备全程原子认领和完整事务恢复。
|
||||
|
||||
### 1.3 四TAB是否关联
|
||||
|
||||
| TAB | 当前请求与数据来源 | 当前关联/独立性 | 与目标的差距 |
|
||||
|---|---|---|---|
|
||||
| 签名通道发送质量 | `GET /admin/operations/signature-quality`;所有日期实时聚合消息、提交、分段与回执 | 独立日期、关键字、分页、请求序号;详情使用本TAB结果 | 历史也查明细,无近三天报表/冻结分支 |
|
||||
| 企业签名活跃度 | `GET /admin/signature-retirement/heatmap?date=D` | 独立组件状态,但接口返回企业和通道全部数据,再在前端选企业、过滤、排序分页 | 使用次日检测快照而非可刷新的活动日报;行集合依赖当前通过任务 |
|
||||
| 通道签名活跃度 | 同上 | 独立发起请求,但再次拉取相同两类数据,再在前端选通道 | 同上,整月全量传输和处理重复 |
|
||||
| 未报备签名 | `GET /admin/signature-retirement/unreported-signatures`;所有日期查消息正文及当前有效签名库 | 独立日期、关键字、分页与结果 | 历史实时重算;补登记/删除签名会改变旧日期结果 |
|
||||
|
||||
结论:没有发现四TAB共用筛选状态或一个TAB直接改写另一个结果的实现;存在两个活跃度TAB共用全量接口与数据源,且活跃度展示依赖退网检测任务。底层业务事实本来有关联,不能为“页面独立”复制出四套互相矛盾的短信事实;应解除请求、状态、失败和生成触发之间的耦合。
|
||||
|
||||
另需同步修复的本TAB日期歧义:未报备TAB编辑顶部日期后,卡片内“查询”仍按`appliedDate`请求,须先点顶部“查询统计”才能应用新日期。这不是跨TAB串数据,但会造成查询条件似乎未生效。
|
||||
|
||||
### 1.4 三档取数规则当前未实现
|
||||
|
||||
质量与未报备页面全部日期实时查库;活跃度使用`SignatureRetirementDetection`快照,`persistDetection`已存在即返回,不会连续刷新最近三天。历史展示又拼接当前签名名称和当前报备维度,因此既不是完整的实时统计,也不是稳定冻结日报。
|
||||
|
||||
已有`DailyQualityReport`服务另一个报表模块,按计费单位统计,维度缺少本页完整的运营商、通道、引流拆分、去重业务数与到达耗时分子分母,不能直接拿来替代本页。既有财务/质量报表T-4~T-1策略也不自动等同本次要求。
|
||||
|
||||
## 2. 已确认业务规则
|
||||
|
||||
### 2.1 日期定义和数据来源
|
||||
|
||||
T始终指服务器按`Asia/Shanghai`确定的真实今天;D指页面所选日期;d指实际统计自然日,均不依赖浏览器所在时区。用户已确认:**每天凌晨同时刷新T-1、T-2、T-3;热力图保留所选日之前30天。**
|
||||
|
||||
| 实际统计日d | 查询行为 | 后台生成/更新 |
|
||||
|---|---|---|
|
||||
| d=T | 本次查询访问真实业务库,在一致性读视图中聚合 | 不将昨日缓存冒充当天数据,不写退网检测或通知 |
|
||||
| T-3≤d≤T-1 | 只读已完整发布的日报 | 每天凌晨刷新最近三个完整自然日,吸收迟到回执 |
|
||||
| d≤T-4 | 只读冻结日报 | 普通调度、页面查询、进程重启均不得自动重算或覆盖 |
|
||||
|
||||
例:今天9月17日,9月17日实时,9月14~16日报表可刷新,9月13日及以前冻结。9月18日可刷新范围成为9月15~17日;9月14日报表保留9月17日最后成功发布的版本,不在成为T-4后再额外重算。刷新判断相对真实今天,不能相对用户选择的历史日期重新开放冻结区。
|
||||
|
||||
热力图选D=9月17日仍展示9月16日至8月18日(D-1至D-30),没有当天列;每一列按该格子的d判断可刷新/冻结。单日质量与未报备TAB选今天时采用实时路径。禁止选择未来D来变相让热力图出现今天列。
|
||||
|
||||
冻结意味着“固定统计截面”,不意味着所有短信此时都已有最终回执。冻结后迟到回执继续正常更新短信业务事实,不改已冻结报表,不把未知自动改成失败;报表与当前详单存在截面差异时明确显示截止时间。跨日补发也不得使已冻结日的历史报表被普通任务改写。
|
||||
|
||||
### 2.2 独立查询的边界
|
||||
|
||||
- 四TAB各有日期草稿/已应用日期、过滤草稿/已应用过滤、排序、分页、pageSize、请求取消/序号、loading/error和结果,任何查询只更新本TAB。
|
||||
- 首次打开TAB只请求该TAB;切回保留自己的条件及已取得结果,用户查询/刷新只重查当前TAB。隐藏TAB不自动重查,不用另一TAB成功响应填补失败。
|
||||
- 企业、通道活跃度请求明确携带不同`dimensionType`,服务端只处理所选维度,按过滤后的完整30日合计排序再分页,只返回当页维度及其30格。
|
||||
- 日期与搜索“查询”统一应用本TAB当前全部草稿条件并回到第一页;翻页只使用已应用条件。特别修正未报备卡片查询沿用旧日期的问题。
|
||||
- 底层只读聚合器、数据库连接池和已发布日报可以复用;页面查询不触发其他TAB请求、不触发报表重建、不运行退网检测、不创建预警。
|
||||
|
||||
### 2.3 指标和历史维度
|
||||
|
||||
- 质量列表/运营商概览以业务短信为单位;通道矩阵以真实发送尝试为单位;保留现有不同成功率分母并在契约逐字段登记,不把计费片数换成业务条数。
|
||||
- 业务统计按`SmsMessageRecord.queuedAt`归日;通道尝试及活跃度按现有`COALESCE(submittedAt,createdAt)`归日。跨日补发可能使列表与矩阵日期归属不同,应分别生成,不能先用当天业务列表裁掉当天实际提交、但原消息属于其他日期的事实。
|
||||
- 企业活跃度在统计范围内按messageRecordId去重,通道按messageRecordId+channelId去重;不直接累加通道值得到企业值。规则窗口跨日也须重新去重,不能把每日distinct数简单相加。热力图“30日合计”保持每日展示值之和并标注为日活跃量合计,区别于预警窗口去重人数/条数。
|
||||
- 成功必须按发送尝试完整分段事实判断,缺片/缺回执仍为未知;长短信不能因已到的几个分段都成功而提前成功。旧记录无分段事实时才按既有明确回执兼容;不把上游接受当送达。
|
||||
- 平均到达时长持久化成功耗时总和与样本数,展示时相除;不能平均各组平均值。列表、抽屉、运营商概览及引流三类拆分使用相同generation版本。
|
||||
- 日报保存当次统计维度名称、企业应用归属、运营商、报备适用状态/通过时间与统计规则版本。历史筛选使用报表快照字段,不能inner join当前通过任务导致历史行消失。活动日报逐日记录适用性;尚未通过、不适用、已通过且零发送、未生成必须区分。
|
||||
- 未报备仍指规范正文签名在该企业应用有效签名库中不存在,且原消息无signatureId,不等于缺少通道报备。实时用查询时签名库;近三日用本轮生成时签名库;冻结日保留最后发布判定及名称。此后补登记不得追溯抹除冻结历史。
|
||||
|
||||
## 3. 性能优化方案
|
||||
|
||||
### 3.1 优先消除重复计算
|
||||
|
||||
1. 将逐个签名×通道×运营商反复扫描,改为按统计日有界批次聚合;先限定日期与候选消息/提交,再对这些submitId批量归集分段/回执,避免每个维度重新读取相同事实。
|
||||
2. 一次读取参与检测的规则、通过任务、既有检测键与抑制摘要。已经完成的检测批次直接退出;部分完成只恢复缺失维度,避免昂贵计算后才查存在性。
|
||||
3. 单日活动日报与质量日报复用经核验的尝试分类逻辑,按各自归日维度生成结果。退网“是否活跃”的窗口只需要accepted去重,不为判断阈值重复关联所有回执;送达率由日报单独计算。
|
||||
4. 预警窗口最长支持现有365天,按实际生效规则的最大窗口限制扫描,批量JOIN规则维度后按message/channel去重。企业跨通道、跨日不能直接SUM日报;在测试库比较批量COUNT DISTINCT与适量窗口分组方案后选定。禁止以改成30天窗口偷换规则。超大窗口必要时分阶段物化紧凑去重键,但须先盘点容量,不能本轮默认复制全量短信正文或全部历史明细。
|
||||
5. 批次发布后退网任务引用已完成单日活动版本,并独立保存当时窗口判断及规则版本。刷新日报不更新既有检测决策、不推进旧周期、不重新生成通知。
|
||||
|
||||
### 3.2 索引和SQL计划
|
||||
|
||||
预生产真实索引已有message.queuedAt、submit.messageRecordId,但未见消息的signatureId+carrier组合索引,也未见submit的`COALESCE(submittedAt,createdAt)`表达式时间索引。不能仅因没有索引就断言某一SQL必然全表扫描。
|
||||
|
||||
候选包括消息(signatureId,carrier,id)、提交(COALESCE(submittedAt,createdAt),messageRecordId)、按实际计划需要的通道+有效提交时间,以及日报日期/维度/排序组合。保留表达式原语义,不能直接用createdAt替代submittedAt。候选须通过有代表性数据的`EXPLAIN (ANALYZE, BUFFERS)`验证选择性、loops、临时文件和写入成本后决定,不盲目全部加索引;本轮未执行重查询或建索引。
|
||||
|
||||
大表索引上线采用平台支持的在线建索引流程,明确非事务DDL、失败无效索引检查及重试;具体迁移方式在实施时结合Prisma发布门禁验证,禁止把不允许在事务中的DDL塞进普通事务而绕过错误。不全局扩大work_mem,不默认增加SQL/worker并发。
|
||||
|
||||
### 3.3 页面成本
|
||||
|
||||
- 活跃度改服务端过滤、排序、分页,只传单类维度的当页30格;四TAB仍各自独立,不将两个活跃度结果捆绑返回。
|
||||
- 查询快照元数据和维度使用Map/数据库JOIN,不逐条Array.find造成大集合反复查找;保留已有模块级日期格式化器。
|
||||
- 首屏与列表查询仅取所需字段;若详情改独立请求,必须携带列表generationId以保证详情一致,不能点击详情又扫原始历史短信。
|
||||
- 当天实时查询采用有界日期、分页和超时;失败保留当前TAB最后真实结果并标注其日期/时间,不能把旧值作为新查询成功。跨午夜请求由服务端固定T并回传,前端不自行混合两个自然日结果。
|
||||
|
||||
## 4. 日报生成、冻结和恢复
|
||||
|
||||
建议签名日报任务北京时间03:00启动,依次处理T-3、T-2、T-1,与已存在财务日报错峰;04:00退网检测、08:00通知时点保留。固定时间是拟实施配置,尚未上线;也必须检查与其他后台任务是否重叠。
|
||||
|
||||
生成任务具备耐久状态`pending/running/succeeded/retry_wait/failed`、租约、递增fence和有限重试;按scope+businessDate唯一认领,旧worker过期后不能发布。每个日期在一致性数据库快照下生成候选版本,完成四类校验后短事务切换publishedGenerationId。部分失败保留完整旧版;其他日期可继续,但整轮不得虚报全成功。发布必须再次验证fence、日期是否已冻结和候选水位。
|
||||
|
||||
页面只读published版本;生成中有旧版则显示旧版及刷新中/上次成功时间,没有旧版显示“报表尚未生成”,失败显示失败,不能返回空数组冒充零数据或回退扫描明细。合法零数据日期也必须生成完成清单与0行标记。
|
||||
|
||||
日界线进入T-4后自动禁止覆盖;跨午夜仍在运行的旧任务在发布校验时被拒绝。若T-3最后刷新失败,冻结上一成功版本并显示未达到预期截止时间;若从未成功则保留缺口,不伪造冻结完整。错过整个三日窗口的缺口只能进入显式历史补建流程,不能借“启动补偿”无限回算。
|
||||
|
||||
04:00所需日报未完成时,检测等待该日完成且告警;08:00只发送已完整完成检测的结果,不把半批维度当完整预警。后续恢复沿用当日补偿与幂等,不补发旧日期通知。周期变更、检测行、通知意图在维度事务中一致提交;全批完成标记必须在全部维度校验后置位。通知仍按日期+企业应用聚合及现有抑制规则,重试不能重复创建消息/Webhook。
|
||||
|
||||
日报刷新三天不等于重新做三天的预警判断。`SignatureRetirementDetection`保留历史判断审计,页面活动日报是另一投影,两者截止时间可能不同,页面标明统计口径而不是互相覆盖。
|
||||
|
||||
## 5. 拟新增模型与接口
|
||||
|
||||
以下名称仅为设计,不表示Prisma已有模型:
|
||||
|
||||
| 模型 | 主键/唯一维度与作用 |
|
||||
|---|---|
|
||||
| SignatureAnalyticsDay | businessDate唯一;publishedGenerationId、state、generatedAt、sourceAsOf、frozenAt、schemaVersion、coverage/provenance、rowCounts/checksum;成功零行也记录 |
|
||||
| SignatureAnalyticsRun | scope+businessDate+generationId唯一;owner、leaseUntil、fence、attempt、checkpoint、startedAt/finishedAt、error摘要;每scope/date最多一个有效运行者 |
|
||||
| SignatureQualityDaily | generationId+metricKind+tenant/application/signature+carrier/channel/drainage规范化维度键唯一;区分business与attempt,计数、耗时分子分母、名称快照 |
|
||||
| SignatureActivityDaily | generationId+dimensionType+tenant/application/signature/channelKey/carrier唯一;单日提交、accepted业务数、送达数、适用性及报备快照 |
|
||||
| UnreportedSignatureDaily | generationId+tenantId+applicationId+规范正文签名键唯一;业务数、生成时判定及名称快照 |
|
||||
|
||||
空channel/application使用无碰撞规范键,不能依赖可空字段唯一约束防重复。行均有真实businessDate和generationId外键;普通查询仅取发布版本。预警任务可复用耐久Run机制但scope独立,不与日报共用完成状态。旧版本保留期限和空间预算须在实施/发布阶段核验,不自动清理现有业务记录。
|
||||
|
||||
API建议:
|
||||
|
||||
- 质量与未报备保留现有GET路径和date/keyword/page/pageSize,增加`dataSource=live|report`、`reportState=ready|refreshing|failed|missing`、`mutable/frozen`、businessDate、serverBusinessDate、generatedAt/sourceAsOf、generationId和schemaVersion。数据源由服务器决定,客户端不能要求重算冻结日。
|
||||
- 热力图提供独立版本路径`GET /admin/signature-retirement/activity`,必填dimensionType=enterprise|channel,date为D;企业/应用/签名/通道过滤分别传入,pageSize只接受10/25/50/100。返回单类当页维度、total和D-1至D-30;逐日期带版本/完整性元数据。保留旧heatmap GET只作兼容,最新页面不再调用;记录旧调用后再决定移除,不让旧前端接收截断结果冒充完整数据。
|
||||
- 活跃度筛选、30日排序、总数与当页结果在同一个一致性读事务获取;先在过滤全集排序再LIMIT。缺失日期不可当成0参与已完整合计,须展示“部分日期缺失”。冻日前名称和维度只来自报表。
|
||||
- 日期严格验证真实日历、拒绝未来日;页码为正整数、pageSize有上限,排序白名单。请求取消和旧响应隔离按TAB独立实现。
|
||||
- 沿用运营端鉴权及数据可见范围,在数据库查询前约束tenant/application;不新增匿名查询/生成接口,不以报表缓存绕过权限。真实HTTP401/403、越权筛选和缓存键隔离须专项验收。
|
||||
|
||||
## 6. 历史切换、发布和恢复
|
||||
|
||||
1. 在真实数据规模的隔离测试库建立旧口径对照,包括长短信、补发、跨日和未报备;冻结字段契约,修正差异而非迎合旧错误。
|
||||
2. 增量新增模型/索引与后台生成器,旧接口继续运行;候选生成器先影子计算,不产生通知,完成准确性与负载对照后切换。
|
||||
3. 盘点可支持历史日期的源数据覆盖、报备轨迹、消息/回执保留和磁盘空间。一次性按明确日期清单补建历史日报,先满足热力图至少30日与用户常用查询区间;预警规则窗口另覆盖实际最大365日需求。没有可信历史事实就标记缺口,不把当前报备状态当作过去状态。
|
||||
4. 旧检测快照可作为活动量迁移证据,但不能冒充补齐迟到回执后的完整日报。用原始事实重建的历史报表须记录backfilledAt/sourceAsOf/provenance,不能伪造为当年T+3冻结结果。完整性不足的日期不宣称已完成,历史可查询范围明确展示。
|
||||
5. 迁移期不复制历史周期、不重发历史消息。生成和读取按schemaVersion切换;切换后禁用旧逐维度检测调度入口,防止双跑。先测试环境验收,再另按授权发布预生产。
|
||||
6. 应用回退保留新增表和已发布冻结版本;异常优先暂停新任务、保留只读已发布日报。旧应用会恢复历史实时查询行为,不能称之为满足新冻结规则的等价回退,须在恢复说明中明确影响。不自动恢复数据库或删除候选/旧报表。
|
||||
|
||||
## 7. 验收、观察指标与实施顺序
|
||||
|
||||
详见[功能用例](system-functional-test-cases.md)中`TC-SQA-20260917-01~16`,本地执行覆盖及环境未执行项见第8节。
|
||||
|
||||
- 准确性:真实PostgreSQL逐字段对账;短/长短信、缺片、重复乱序、失败补发、跨通道/跨日去重、未知和迟到回执;旧历史无分段兼容;成功率分母及耗时加权正确。
|
||||
- 冻结:T、T-1、T-3、T-4、跨午夜/跨月/闰日;刷新原子性、版本一致、补登记不改变冻结未报备、名称/通过状态变更不让历史行消失;失败与零数据可区分。
|
||||
- 独立性:四TAB首次/切换/查询/分页/详情;一个TAB失败、慢响应或修改日期不影响另一个;企业请求不含通道维度;真实HTTP与三尺寸浏览器核验,不仅用mock证明功能。
|
||||
- 可靠性:双worker抢占、租约超时接管、旧fence发布被拒、批次部分失败、任务重启、错过冻结窗口、08:00依赖未完成、重复通知去重。报表刷新不写短信/余额/报备/通知状态。
|
||||
- 性能记录:整机及PG/API进程CPU、数据库扫描行/块、SQL耗时/p95、任务耗时、锁等待/临时文件、响应字节、API内存、短信队列延迟;分别标注冷/热缓存和相同业务规模,不把一次结果当容量承诺。
|
||||
- 拟验收目标:相同数据规模,退网原始明细扫描行数下降至少80%,任务耗时下降至少50%;单类热力图每页最多100×30个格,响应规模不随未选维度总数增长。此为测试目标而非已实测收益;不能单凭CPU目标判定成功。若准确性/业务队列恶化立即停止候选压测,不在预生产重新跑整套重任务验证猜测。
|
||||
|
||||
实施分三批:①独立接口/页面查询及精确统计契约;②日报三日刷新/冻结/历史补建与版本化读取;③退网批量聚合/原子任务/依赖编排及性能对照。批次①不宣称已完成报表化,③完成前不宣称CPU问题已解决。
|
||||
|
||||
这是前端、查询后端、数据库模型和后台调度的跨模块改造,主要成本在历史回填、口径对账和故障恢复验收,不能按简单索引补丁处理。实施需API/前端定向与全量回归、类型/构建/质量门禁、真实PG/接口/浏览器;若不修改Gateway无需冒充重做Gateway发布。真实短信、外部通知及压测只在后续明确授权的环境和范围执行。
|
||||
|
||||
## 8. 2026-09-17 本地实现与验收
|
||||
|
||||
### 8.1 实现及设计落地
|
||||
|
||||
- `api/src/signature-analytics/`提供北京时间日期校验、日报聚合、版本读取、后台调度和独立退网批次。03:00后依次刷新T-3/T-2/T-1,60秒补偿检查;04:00检测与08:00通知保留。日报失败不降级实时查询,未生成与合法零结果分开;热力图继续D-1~D-30。
|
||||
- 新增六表:Day发布清单、Generation不可变版本、Run耐久任务、QualityDaily、ActivityDaily、UnreportedDaily。日报行和发布指针以generationId+businessDate复合外键保证日期/版本一致。质量嵌套矩阵和概览保存在同一JSON快照,检索字段独立列化;引流分组保存耗时总和/有效样本数并加权合并,不平均组均值。
|
||||
- 与原建议“候选后短事务切指针”相比,本期采用**每个自然日一个RepeatableRead事务**,聚合、分批250行写入、发布与fence验证一起提交。理由是保证同日各口径取自同一源快照,避免未持久化源水位的断点续算混入不同截面。单SQL90秒、事务120秒、租约300秒;失败重算该日,成功日期不重算,最多5次指数退避。事务上限短于租约,不需要保持长事务跨租约续期。超过此规模应先扩展分阶段水位设计,不能直接无限增大超时。
|
||||
- 退网窗口只扫描accepted提交,批量按实际规则窗口(最长365天)去重,单日送达数复用活动日报。规则与所引用日报版本在首次认领后持久化为checkpoint,重试不改变;检测、周期及冻结通知正文同事务。完成检查前移;日报刷新不修改已有检测/消息。缺昨日当日刷新版本时等待,不消耗检测重试次数。通知沿用原耐久消息/Webhook去重,同进程完成后不每分钟重扫全部消息;重启可补偿,禁止自动补发历史检测。
|
||||
- 两个活动TAB改用`/admin/signature-retirement/activity`,明确dimensionType;服务器过滤、排序、分页和完整性读取处于一致性事务。每页只返回所选维度,最多100×30格;空的越界页仍返回正确总数。旧heatmap接口保留兼容旧页面,当前页面不调用。四TAB查询/取消/错误/日期/筛选各自独立,未报备卡片查询应用新日期。
|
||||
- 普通历史读取只访问日报快照,无当前报备/名称JOIN。报备轨迹可还原时取历史状态与通过时间,旧数据仅有明确approvedAt时保留兼容证据;缺完整历史轨迹不宣称重建了过去所有状态。无报备资格的格子N/A,缺日报的格子显示缺口。旧记录完全无分段审计时保留明确回执兼容;存在分段时按预期总片数判断,缺片不提前成功。
|
||||
- 迁移`20260917030000_signature_analytics`新增表;`20260917031000_signature_submit_effective_at`单独以非事务`CREATE INDEX CONCURRENTLY`建立有效提交时间表达式索引,避免将所有历史源扫描混进三日任务。108项完整迁移已在独立PostgreSQL库执行。线上失败须检查`pg_index.indisvalid`及迁移状态,按发布恢复流程处理;不自动DROP/resolve失败迁移,不绕过门禁。
|
||||
|
||||
### 8.2 历史补建与发布边界
|
||||
|
||||
离线入口`tools/testing/backfill-signature-analytics.mjs`使用已构建API,要求环境变量DATABASE_URL和匹配目标主机的`--host`,逐个明确`--dates=YYYY-MM-DD,...`;默认只检查,明确`--execute=yes`才生成。禁止未来/当天、隐式范围和覆盖已有发布版本。最多366个显式日期,逐日顺序生成;不初始化应用调度、不运行检测/通知,结果标记`backfill-current-source`与实际sourceAsOf,页面标明“事后补建”。本轮只在隔离库验证,线上补建未执行。
|
||||
|
||||
发布时需先核对源数据覆盖、实际报备历史与存储空间,生成影子日报并对账,至少为常用D准备此前30日,再切换页面;缺失日期保持可见缺口。新版本/旧版本表行均保留,不自动清理。回退应用保留新增表与冻结版本,但旧应用将恢复旧历史查询行为,不是业务等价回退。本轮没有推送或部署,也没有声称线上CPU已下降。
|
||||
|
||||
### 8.3 已执行证据及限度
|
||||
|
||||
- 真实PostgreSQL16隔离集群127.0.0.1:16435,空库完整迁移;`verify-signature-analytics.mjs`覆盖日报三日、冻结、跨日去重、2/3/4片、缺片、旧无分段回执、耗时加权、超出页码、重复认领、旧fence拒绝、失败回滚/重试及规则快照。验收数据只在本机专用`cmpp_qa_signature_*`库,脚本拒绝其他主机/库名。
|
||||
- `verify-uplink-matching.mjs`真实事务验证重复事件、应用归属、供应商MO ID保留、并发人工认领和通知存储故障回滚。故障注入仅模拟存储错误;实际数据/事务/通知意图均为PostgreSQL,未调用外部Gateway发送。
|
||||
- 真实完整Nest应用、PostgreSQL和隔离Redis,生产构建页面在1600×1000、1366×768、390×844打开/切TAB/查询/刷新,页面异常0;HTTP正常查询200、匿名401、非法维度/分页400。浏览器认证会话仅内存传递,不保存密码、令牌或storageState。Redis本机为5.0.14.1,有BullMQ建议6.2警告;本轮未将其视为生产队列能力验收。
|
||||
- 前端全量35文件/163项、API全量78套/842项;类型检查、构建、代码结构、格式、ESLint、CSS治理、样式和Bundle门禁记录在testing-progress。Gateway代码未改,不重复冒称实发/协议验收。
|
||||
- `benchmark-signature-analytics.mjs`对照4eb7b16原活动聚合:30000条消息、600维度,本地热缓存旧逐维度10343.912ms,新批量624.940ms,减少93.96%,accepted计数逐维度相等。EXPLAIN原单维度实际源表访问30113行,按600维度估算约1806.78万;新批量60039行。旧总行数是样本外推,不是全批逐SQL实测;均为热缓存,未产生临时磁盘块。这里只证明活动聚合环节,整个日报+365日检测批次的CPU、p95、内存、线上队列影响、真实高密度回执规模仍须部署前/后专项验收,不能宣称全部性能目标已达标。
|
||||
- 本机原始日志、截图和故障证据在忽略目录`.local-data/signature-optimization-20260917/`。初次迁移目录未生成SQL、测试通道缺carriers、断言漏计样本、临时账号缺email等失败已修正并保留原日志,不用最后成功覆盖失败历史。
|
||||
@@ -193,3 +193,11 @@
|
||||
- 第4至10步可回滚到能够读取运营商集合和历史任务的兼容版本;出现双运营商通道后不得回滚到只识别旧单值的版本。
|
||||
- 第11步严格门禁启用后,如出现真实发送资格异常,优先切回兼容双读版本,不回滚或覆盖已经产生的新业务数据。
|
||||
- 任一阶段不得为验收发送、补发或重投真实短信,不修改真实通道账号、密码、启停状态、企业余额或客户连接,除非另获明确授权。
|
||||
|
||||
## 9. 2026-09-17 性能与签名质量日报改造(本地实现,待发布)
|
||||
|
||||
权威增量设计见[签名退网检测与四页查询优化方案](signature-quality-optimization-plan-20260917.md)。实施后替代本文第6节、第7节第15步中检测快照直接作为活动查询、当前通过任务决定历史行及前端全量筛选分页的方式;保留D-1~D-30日期列、原去重单位、规则/抑制和预警历史。四TAB独立查询,日报T实时/T-1~T-3凌晨刷新/T-4起冻结;活动日报与当时预警判断分离。原正文是历史实现描述,本节及专项方案的本地实施状态见下方补充,线上未发布。
|
||||
|
||||
### 2026-09-17 实施状态补充
|
||||
|
||||
上述专项方案已完成本地代码与隔离验收,具体表结构、03:00三日刷新、事务边界、恢复、离线补建及性能测量见专项方案第8节。历史正文保留为旧实现说明;尚未推送或部署,线上依然是旧行为。
|
||||
|
||||
@@ -5606,3 +5606,154 @@ CLIENT-0914-01~07 的模板样式/顺序、文档归属与检索、中文状
|
||||
| TC-OPS-0916-05 | 收尾第12次失败进入needs_review,自动出现在耐久告警;恢复后不自动消失 | 真实PG通过 |
|
||||
|
||||
RC-01/02/04/08/09/10/11目前仅部分本地证据:三段齐段、重复与双进程、事务回滚和过期接管、通知目标数与非零账务、迁移重放;尚不能标整条矩阵通过。RC-03/05/06/07/12的完整组合、网络故障/进程重启、修复前后性能对比须独立补验。无真实运营商流量,未声明容量提升。
|
||||
|
||||
### TC-RC-20260916-13 Gateway即时SubmitResp
|
||||
|
||||
真实TCP通道在收到每个Submit后立即回包,两种协议各500次;全部一次受理,不得丢应答后误报SUBMIT_TIMEOUT。目标环境补测多段消息,核对每段wire提交一次、唯一账单/最终业务通知;保留修复前2/4、1/3段异常与后继记录。本地修改前已复现,修复后重复5轮通过;目标环境补充发布待执行。
|
||||
|
||||
### 2026-09-16 五项整改最终测试证据索引
|
||||
|
||||
TC-RC-20260916-01~12按[测试交付记录](release-20260916-test-completion.md)逐项区分真实目标链路、隔离PG故障验证及未执行场景,不能整组标全部通过。两段/三段/四段、补发成功/失败退款、20HTTP通知及9CMPP目标、503/超时自动恢复、无客户回执目标和无租户通道测试均有真实证据;全部逐断点退出/72小时/历史组合/旧版恢复/等负载性能对照未完成。TC-RC-13修复前真实TCP复现,修复后5000次即时响应及测试环境新增6条18分段无补发通过。五项运营页面检查包含三尺寸、请求失败保留真实快照与恢复、恢复告警不自动消失及人工清除审计。详细计数与测试资源收尾以交付记录为准。
|
||||
|
||||
## 2026-09-17 签名质量日报与退网检测优化验收(本地覆盖,环境项待执行)
|
||||
|
||||
设计见[优化方案](signature-quality-optimization-plan-20260917.md)。以下全部为计划用例,本轮只读取证不等于用例通过;不沿用此前短信测试授权。既有TC-ANALYTICS、TC-SIGNATURE-RETIREMENT保留历史记录,与本轮替代口径冲突时以本节和方案为实施目标。
|
||||
|
||||
| 编号 | 场景 | 验收要求 |
|
||||
|---|---|---|
|
||||
| TC-SQA-20260917-01 | 四TAB切换、不同日期/过滤/分页、慢响应及失败 | 仅当前TAB查询;条件与结果互不覆盖;企业请求不返回通道集合,反之亦然;隐藏TAB不重查 |
|
||||
| TC-SQA-20260917-02 | 未报备TAB编辑日期后分别点顶部/卡片查询,再翻页 | 两个查询入口均应用当前全部草稿条件;翻页只用已应用条件,不串旧日期 |
|
||||
| TC-SQA-20260917-03 | 今天查询质量和未报备 | 真实业务库一致性聚合,响应标记live及截止时间;不触发生成/检测/通知;过午夜由服务端确定自然日 |
|
||||
| TC-SQA-20260917-04 | T-1/T-2/T-3迟到回执后再次凌晨生成 | 三天均更新日报,页面只读发布版本;失败保留旧版并标记;同一版本列表、概览、抽屉一致 |
|
||||
| TC-SQA-20260917-05 | T-4及更早日收到迟到回执/补登记/更名 | 原短信事实可按原业务规则更新,冻结报表内容摘要不变;历史行和名称不依赖当前报备状态 |
|
||||
| TC-SQA-20260917-06 | 热力图D为今天/历史日,跨月年及闰日 | 严格显示D-1~D-30,不出现D当天;每格按真实T判断冻结;未来D拒绝 |
|
||||
| TC-SQA-20260917-07 | 有效零数据、缺报表、无报备资格、部分日期失败 | 四种状态明确区分;缺口不能计作已确认零或触发回查明细;部分30日合计明确不完整 |
|
||||
| TC-SQA-20260917-08 | 短信跨日/跨通道补发,同通道多次accepted | 企业窗口按业务消息去重、通道按消息+通道去重;窗口不简单累加每日distinct数;质量业务日与尝试日分别核对 |
|
||||
| TC-SQA-20260917-09 | 2/3/4段、缺片、乱序重复、失败与历史无分段记录 | 完整成功才算送达;未知保持未知;去重和旧回执兼容正确,耗时分子分母及成功率口径不漂移 |
|
||||
| TC-SQA-20260917-10 | 两worker、租约失效、旧worker恢复、重复执行 | 同scope/date原子认领,旧fence不能发布;批次断点恢复,已完成不重新扫描,周期/快照/意图事务一致 |
|
||||
| TC-SQA-20260917-11 | 候选生成部分失败、发布时跨入T-4、漏跑三日 | 旧版原子保留,跨冻结边界拒绝覆盖;缺口进入明确历史补建流程,不自动无限回算 |
|
||||
| TC-SQA-20260917-12 | 04:00日报未好、08:00检测未完成、历史日报刷新 | 检测/通知按依赖等待且告警,不用半批发通知;恢复幂等,不重发历史消息/Webhook,不改已发正文 |
|
||||
| TC-SQA-20260917-13 | 热力图大数据过滤排序分页及一类TAB失败 | 数据库先过滤并按完整30日合计排序再分页;只返回当页单类维度;另一TAB可独立查询;响应最多100×30格 |
|
||||
| TC-SQA-20260917-14 | 真实接口鉴权/越权、非法日期和分页参数 | 401/403正确,tenant/application可见范围生效;无匿名生成入口;缓存不越权;SQL参数化 |
|
||||
| TC-SQA-20260917-15 | 同规模新旧方案性能与准确性对照 | 所有指标对账;记录扫描行/块、任务耗时、进程CPU、API内存/响应量与队列影响;目标扫描下降80%、耗时下降50%,未达到不得冒称达标 |
|
||||
| TC-SQA-20260917-16 | 历史补建、切换/回退和真实浏览器 | 历史覆盖/来源标明,无伪造冻结时间;不重发预警;三尺寸1600×1000/1366×768/390×844覆盖进入、刷新、路由、筛选、分页、详情、失败与权限;发布按后续授权执行 |
|
||||
|
||||
### 2026-09-17 本地执行覆盖与线上未执行项
|
||||
|
||||
TC-SQA-01~14:真实隔离PG覆盖核心日期/日报/长短信/事务/分页路径,真实HTTP覆盖200/401/400,组件回归与三尺寸浏览器覆盖独立TAB。详细断言见`tools/testing/verify-signature-analytics.mjs`,不能将各用例全部边界都标成通过:真实08:00外部Webhook、旧回退版本、跨午夜运行过程、线上权限配置与365日大批次仍未执行。TC-SQA-15只完成30000消息/600维度活动聚合对照,整机CPU/内存/队列未测;TC-SQA-16只完成隔离历史补建、三尺寸页面,真实环境切换/回退未执行。
|
||||
|
||||
| 用例 | 操作 | 验证结果 |
|
||||
|---|---|---|
|
||||
| TC-MO-20260917-01 | 同手机号同应用多条accepted、共享接入号多应用 | 按应用归并;有唯一发送证据可匹配应用,不强填原短信 |
|
||||
| TC-MO-20260917-02 | 第三条消息属另一应用、其他通道、接收后发送 | 保留真实歧义;通道/时间边界参与过滤,messageId不能跳过证据 |
|
||||
| TC-MO-20260917-03 | 重复事件并发入库 | 真实PG仅一个上行和一份通知意图,供应商ID保留 |
|
||||
| TC-MO-20260917-04 | 两个候选同时认领 | 真实PG仅一个应用成功,另一个拒绝,通知仅一份 |
|
||||
| TC-MO-20260917-05 | 通知存储失败后重试 | 真实PG整笔回滚,事件重试恢复;未执行外部发送 |
|
||||
|
||||
历史159条认领/重新投递、真实供应商MO扩展号码及最终客户收取未执行,不沿用此前测试发送授权。
|
||||
|
||||
|
||||
## 2026-09-17 首页今日回执与收益整改(待执行)
|
||||
|
||||
权威验收矩阵见[首页方案第8节](homepage-receipt-metrics-redesign-20260917.md#8-实施顺序验证与交付边界),HOME0917-01~14均待执行,覆盖四日归组、缺片、整条成功、跨日/重复/补发、网关接收时间、旧业务成功率、价格/成本/返还、按需查询、一致快照、权限、故障恢复、三尺寸和性能。实施后替代旧首页指标顺序、到达率/计收、发送趋势/审核速度及消费排行对应预期;保留历史执行记录。UI图只使用示例,不作为真实API/数据库验收。
|
||||
|
||||
|
||||
### HOME0917 实施验收更新
|
||||
|
||||
最终UI保留企业消费排行并增加今日返还列,替代前述返还模块假设。HOME0917-01~12已通过对应本地真实PG/规则测试与故障注入;HOME0917-13三尺寸真实Nest/PG/Redis页面、按需请求、导出返还、详情、刷新和请求失败真值保留通过。HOME0917-14仅本地2020消息/6000新增片查询样本及110迁移通过,生产规模、线上队列影响和现场回退未执行。证据及边界见[方案第9节](homepage-receipt-metrics-redesign-20260917.md#9-2026-09-17-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。
|
||||
|
||||
|
||||
## 2026-09-17 有效签名唯一性(TC-SIG-UQ-20260917)
|
||||
|
||||
设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。验收脚本 `tools/testing/verify-signature-uniqueness.mjs` 仅允许本机 `cmpp_qa_signature_unique_*` 新库;先在 api 目录完成迁移和构建,设置 SIGNATURE_TEST_DATABASE_URL、SIGNATURE_TEST_REDIS_URL 后执行。
|
||||
|
||||
| 编号 | 场景及预期 |
|
||||
| --- | --- |
|
||||
| 01 | 运营端和客户端新增同企业同应用同名签名返回409;数据库只有一条;未登录401、伪造企业头403、非法签名400。 |
|
||||
| 02 | 同名在不同企业/应用允许;未绑定应用的同名也唯一;自身原名更新允许,改为已占用名称/应用/空应用返回409;跨企业修改失败。 |
|
||||
| 03 | 草稿、待审、通过、驳回占用名称;停用/删除释放;恢复、审核、重新提交不得绕过。 |
|
||||
| 04 | 12路真实HTTP并发新增仅一次201,其余409;直接数据库新增命中绑定和空应用两个唯一索引;真实Prisma适配器P2002转业务冲突。 |
|
||||
| 05 | 导入暂存识别update,审核后原ID、用途、未映射资料、链接保留;暂存后存在同名记录则更新,优先有效记录而非停用重名。 |
|
||||
| 06 | 并发导入同一新名称收敛为同一ID,仅一条有效签名;只对名称冲突重查,其他异常继续报错。 |
|
||||
| 07 | 迁移遇历史有效重复明确失败并回滚;全部历史记录保留,没有留下半套索引;新库全部111迁移成功。 |
|
||||
|
||||
本机真实Nest/PG/Redis覆盖01、02、04~07及03恢复分支;四种有效/两种无效状态和错误分类另由定向单元测试覆盖。线上重复盘点、目标环境迁移及浏览器验收未执行,不等同已发布。
|
||||
|
||||
|
||||
## 2026-09-18 长短信终态与回执归属回归
|
||||
|
||||
| 编号 | 场景与预期 | 本地证据 |
|
||||
|---|---|---|
|
||||
| TC-RC-20260918-01 | 已最终失败后另一失败分片及并发重复到达;选路共一次,终态不重开 | verify-receipt-finality.mjs,真实PG、仅无可用路由边界隔离 |
|
||||
| TC-RC-20260918-02 | 已失败/已退款/已有失败通知后两片成功;消息、账单、通知不变,原始回执及一条尝试异常保留,规范分片不改成功 | 同上,真实PG退款快照 |
|
||||
| TC-RC-20260918-03 | 同业务不同通道尝试复用Msg_Id;仅命中通道的那次分片更新 | 同上,真实PG |
|
||||
| TC-RC-20260918-04 | 同通道某尝试主Msg_Id与另一尝试分片Msg_Id碰撞;拒绝歧义,不写规范回执或分片 | 同上,真实PG |
|
||||
| TC-RC-20260918-05 | 非首片回执先于提交分片元数据;先拒绝关联、补元数据后齐片成功;unknown仍可并发齐片完成 | 同上,真实PG |
|
||||
| TC-RC-20260918-06 | 最终失败后迟到SubmitResult拒绝;不得重开终态或创建补发;旧尝试不得覆盖当前决定 | 同上真实PG;send-chain.service.spec.ts旧尝试单测 |
|
||||
| TC-RC-20260918-07 | 同供应商跨连接唯一匹配、歧义拒绝、身份变更、手机号不符、跨租户关系、历史submitId、候选截断和72小时超时恢复 | receipt-attempt-resolver.spec.ts,12项隔离单测;历史分片补关联并齐片完成另有真实PG用例 |
|
||||
|
||||
既有TC-RC-20260916收尾并发与故障用例继续执行tools/testing/verify-attempt-completion.mjs(真实PG、双OS进程、事务回滚、fence、一次补发Outbox、非零扣退费、通知持久化)。本轮不启动Gateway、Redis消费者或网络通知投递,不以数据库集成代替线上完整短信链路验收;目标环境与线上CPU改善待另行授权发布后验证。
|
||||
|
||||
|
||||
## 2026-09-20 模板拒收指令及运营界面验收
|
||||
|
||||
设计:[模板拒收策略](template-optout-policy-design-20260920.md)。所有写入夹具限本地隔离数据库,不能据此向测试/预生产发送短信。
|
||||
|
||||
| 编号 | 场景 | 预期 |
|
||||
|---|---|---|
|
||||
| TC-OPT-20260920-01 | 四类混合、零提交、统计不一致 | 单条四段合计100%,未知灰色且仅悬停显示数量比例;零提交空条;不一致明确提示而不编造比例 |
|
||||
| TC-OPT-20260920-02 | 超时无回执及明确失败回执 | 当前质量查询分别计入未知/回执失败;历史冻结报表不重算 |
|
||||
| TC-OPT-20260920-03 | 活动通道组仍引用通道,缩减运营商 | 保存成功,组引用保留,被移除运营商不能选该通道,不触发无关重连 |
|
||||
| TC-OPT-20260920-04 | 策略GET/PUT、未登录/客户端、重复/非法/外应用通道 | 鉴权拒绝非法入口,所属应用范围严格校验,有效保存留审计 |
|
||||
| TC-OPT-20260920-05 | 固定/变量模板、direct_send、明确其他模板、无匹配 | 指定模板准确命中,direct_send不绕过策略,其他短信原文不变 |
|
||||
| TC-OPT-20260920-06 | 69→75、71→77、删除跨70字、Unicode代理对 | 同计费单位且同Gateway分片才执行;否则跳过;保护框默认选中且不可取消 |
|
||||
| TC-OPT-20260920-07 | 正文出现指令、末尾重复添加、补发换通道 | 正文不删、添加不叠加、换通道从原文重新计算 |
|
||||
| TC-OPT-20260920-08 | 通道敏感词与增删策略同时存在 | 按各候选真实改写内容筛选通道,保存对应内容摘要 |
|
||||
| TC-OPT-20260920-09 | 单条/微批/补发、事务中断、配置变化 | 消息/Submit/Outbox一致,失败一起回滚,旧命令快照不变化,费用及分片单位不变化 |
|
||||
| TC-OPT-20260920-10 | 短信列表和详情,历史空字段 | 列表真实提交内容,详情原文及尝试快照,历史空字段正常,客户端仅返回自身消息 |
|
||||
| TC-OPT-20260920-11 | API失败、重试、通道组移除后失效规则 | 无假成功,输入保留,显式删除失效规则后可保存 |
|
||||
| TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 |
|
||||
|
||||
代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。
|
||||
|
||||
|
||||
## 2026-09-20 CMPP 协议字段兼容性用例登记
|
||||
|
||||
将 [整改方案第 8、10 节](cmpp-protocol-field-compatibility-remediation-20260920.md) 的 CMPP-FIELD-T01~T18 纳入本表体系,ID 与预期不另行重定义。
|
||||
|
||||
| 用例组 | 执行入口 | 本轮结果与边界 |
|
||||
|---|---|---|
|
||||
| T01~T07 | protocol-uint32.spec.ts、protocol-receipt-intake.spec.ts、tools/testing/verify-protocol-fields.mjs | 数字校验/七字段真实 PG/API/Redis、大小 Msg_Id、单批重复、高值 ACK;本地通过 |
|
||||
| T08~T14 | gateway/third_party/gocmpp/protocol_compatibility_test.go、inbound/upstream protocol_fields_test.go | 版本布局、32 字节号码、序号 0/回绕冲突、CONNECT 完整状态、大包及异常报文;本地 TCP/单元通过 |
|
||||
| T15 | verify-protocol-fields.mjs 迁移演练 | 113 迁移新库成功;21 行小样本非法末表数据整体回滚、锁超时、NULL/旧值/索引保留通过;不是线上重写容量验证 |
|
||||
| T16 | verify-protocol-fields.mjs、verify-receipt-finality.mjs、verify-attempt-completion.mjs | 高值并发去重/迟到相反回执、本机 PG 8 项终态与 11 项完成态回归通过;边界分别验证,未联合运行整套供应商到客户链路 |
|
||||
| T17~T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
|
||||
|
||||
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
|
||||
|
||||
|
||||
## 2026-09-21 未报备签名编码回归
|
||||
|
||||
| 用例 | 场景与预期 |
|
||||
|---|---|
|
||||
| TC-UNREPORTED-ENC-001 | 真实SQL_ASCII、UTF8隔离库提取26种输入:目标签名、中文、ASCII、emoji、换行、引号、长文本完整提取;空签名、前导空白/BOM、未关闭和嵌套括号不提取,保留原规范。 |
|
||||
| TC-UNREPORTED-ENC-002 | 64条【宜都市万商市场投资有限公司】无signatureId消息在本应用无签名、其他应用/企业有同名时仍聚合64;当前应用有效或待审核同名排除,deleted不排除;已有signatureId及无应用记录按原逻辑排除。 |
|
||||
| TC-UNREPORTED-ENC-003 | 北京时间日初包含、次日日初排除、日末包含;实时和日报聚合一致,真实日报持久化与HTTP查询一致;13组按每页10条分两页,三类搜索、空结果、非法参数不改变既有语义。 |
|
||||
| TC-UNREPORTED-ENC-004 | 已冻结日报在源记录改变后普通生成跳过、补建覆盖拒绝,原内容保持;隔离测试不创建任何供应商Submit。 |
|
||||
|
||||
执行:本机PostgreSQL16.14两编码各113项提交内迁移,verify-unreported-signature-encoding.mjs全部通过;真实Nest控制器/服务/PG,不含全局登录鉴权、浏览器及在线发送。完整结果和证据见[本轮记录](unreported-signature-diagnosis-20260921.md)。
|
||||
|
||||
|
||||
## 2026-09-21 六项运营整改用例
|
||||
|
||||
| 编号 | 验收要求 |
|
||||
|---|---|
|
||||
| TC-OPS6-001 | 运营端/客户端签名可搜索;变量仅英文数字,汉字/符号/空格/未闭合/重复均拒绝,数字开头和中文示例可保存。 |
|
||||
| TC-OPS6-002 | 同应用跨签名同名拒绝;trim名称后比较;自编辑正常,跨应用允许;并发只一个成功;软删除可重用,恢复冲突拒绝。 |
|
||||
| TC-OPS6-003 | 首页真实24小时曲线在消费排行上方;北京时间日边界和零桶正确;重复供应商提交不增加业务短信量,刷新仍真实读取。 |
|
||||
| TC-OPS6-004 | 未知筛选匹配submitted/unknown,排除delivered/failed/queued/rejected,真实页面请求unknown且显示提交成功。 |
|
||||
| TC-OPS6-005 | 签名通道质量表头无通道提交列,详情指标不删;报备四个搜索框独立字段AND组合,过滤先于分页且保留虚拟未报备行。 |
|
||||
| TC-OPS6-006 | 移除运营商前明确提示并确认;取消不修改。全国/省网成员均移除,其他组/路由保留且审计完整;事务故障整体回滚,并发成员写入等待后拒绝失配。 |
|
||||
| TC-OPS6-007 | 三种尺寸1600×1000、1366×768、390×844覆盖相关页面、刷新、路由、搜索/校验/确认/取消,无pageerror。 |
|
||||
|
||||
本轮真实本机PG115项迁移、Redis、全Nest认证HTTP和production前端通过;脚本verify-operations-six-fixes.mjs,精确候选前端179/后端935项通过。目标环境未部署未验收,迁移冲突和历史数据量性能仍须发布时核验。详见[执行证据](operations-six-fixes-20260921.md)。
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# 模板拒收指令策略与运营页面修正
|
||||
|
||||
日期:2026-09-20。状态:本地已实施,隔离API/PG及三尺寸浏览器验收通过;未推送、未部署、未进行运营商发送。执行证据见 testing-progress.md。本方案补充发送链路设计,不替代其事务、账务、回执和 Outbox 规则。
|
||||
|
||||
## 业务规则与影响
|
||||
|
||||
- 签名质量列表使用一个四段横向条,按总提交数计算已到达、提交失败、回执失败、未收到回执的占比;未知使用灰色,数量及比例只放悬停提示。保留筛选、分页和详情。零提交显示空轨道。
|
||||
- 通道缩减运营商能力允许保存,保留已有通道组引用及历史报备;选路实时按通道能力过滤,失去可用通道时沿用既有无路由失败处理,不偷偷迁移客户配置。恢复能力后原引用可继续使用。验收发现既有窄屏查询按钮遮挡与运营商选项溢出,同页CSS增加780px以下单列/换行规则;保持桌面及筛选语义不变,所有权清单更新对应已验收摘要。
|
||||
- 运营端企业模板列表增加“拒收指令”配置入口。按模板及应用通道组中的通道选择“保持原文 / 末尾增加 / 末尾删除”,固定指令为 `拒收请回复R`。只删除末尾精确匹配的指令,不删除正文相似字样,不改其他文字或标点。重复增加不叠加。
|
||||
- “避免影响消息分片数”固定选中,后端也不接受关闭。增加和删除都必须同时保持原计费单位和 Gateway 实际编码分片数,否则原文发送并保留跳过原因。不能以本需求修改计费、回执或报表口径。
|
||||
- 仅匹配当前企业、应用下的有效已审核模板;存在明确模板ID时仅使用该模板规则,不串用其他模板。没有模板ID时按精确正文、变量模板匹配(沿用模板匹配规则和排序)。即使 direct_send 绕过模板准入,发送前仍独立识别策略模板;完全不匹配任何模板的短信保持原文。配置不赋予未审核模板发送权限。
|
||||
- 每次选路基于不可变原文产生该通道的候选内容;通道敏感词继续检查实际候选内容。换通道补发重新从原文计算,不能累计增删。已持久化的 Submit/Outbox 使用当时快照,不因配置修改而重写。
|
||||
|
||||
## 数据与接口
|
||||
|
||||
- SmsTemplate 增加 optOutRules JSON(缺省空数组),每项 channelId/action;后台验证动作、重复通道、所属应用的活动通道组成员关系。仅运营端专用 GET/PUT enterprise-templates/:id/opt-out-policy;客户端模板编辑不接受此字段。配置变更留操作审计。
|
||||
- SmsMessageRecord 增加 nullable originalContent;首次改变时保留输入原文,后续永久保留。content 沿用发送内容字段,在提交事务内与 Submit 和 Outbox 一致更新。
|
||||
- SmsSubmitRecord 增加 nullable sentContent 及 contentPolicy JSON,记录每次尝试内容、命中模板/动作和应用或跳过原因。迁移不重写历史短信;旧记录字段为空时维持原展示。
|
||||
- 发送列表展示最近一次提交内容,详情在改写过时另列原始内容;已排队未获得供应商受理不能标称送达。尝试快照用于历史通道发送内容追溯。
|
||||
- 单条、微批和补发统一使用相同改写函数,所有费用及计费单位保持不变;数据库事务失败不留下单独内容改写,Outbox 重发不重新计算策略。
|
||||
|
||||
## 验收与兼容
|
||||
|
||||
验证四类加和、零数据、悬停、通道缩减/恢复/不可路由;策略越权及非法参数、变量模板和 direct_send、无匹配不影响其他短信、69→75跳过/71→77执行、删除跨分片跳过、Unicode与编码边界、多通道补发不叠加、事务失败回滚、Outbox 快照稳定、原文详情及三尺寸页面。
|
||||
|
||||
使用隔离 PostgreSQL、真实 API/Redis和页面验证;不连接运营商发送,不操作线上客户或通道配置。执行定向及全量回归、类型检查、构建和质量门禁。线上验证和部署独立列为未执行。本轮只提交本轮代码与文档,不推送或部署。
|
||||
|
||||
质量分类补充:当前查询及今后生成的日报使用互斥四类,超时无明确失败回执归未知。已发布冻结日报不重算,其历史分类保留。进度条比例均用总提交数,原接口 successRate 字段保留兼容,不改变详情其他既有口径。未改变资金流水、客户回执数量、报表计费单位;本地验证的是生成意图及费用字段、事务一致性,并未执行运营商真实发送和资金结算。每次Submit额外保存一份内容快照用于追溯,归入原提交记录的留存治理范围。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user