Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ad8e61793 | ||
|
|
57b58f1c40 | ||
|
|
530a65de80 | ||
|
|
3357ace7e1 | ||
|
|
94e997ec4d | ||
|
|
ecc3d7a504 | ||
|
|
37ffce40b2 | ||
|
|
4b9127e1ab | ||
|
|
461a65f810 | ||
|
|
ca4f591a13 | ||
|
|
0af671b4ed |
@@ -8,6 +8,8 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
|
||||
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
|
||||
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
|
||||
API_ENABLE_SEND_WORKER=true
|
||||
API_SEND_WORKER_CONCURRENCY=50
|
||||
ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
CREATE TABLE "PhoneFrequencyHit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"ruleId" TEXT,
|
||||
"ruleCode" TEXT NOT NULL,
|
||||
"ruleName" TEXT NOT NULL,
|
||||
"phoneNumber" TEXT NOT NULL,
|
||||
"thresholdValue" INTEGER NOT NULL,
|
||||
"actualValue" INTEGER NOT NULL,
|
||||
"windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||
"windowEndsAt" TIMESTAMP(3) NOT NULL,
|
||||
"generation" INTEGER NOT NULL DEFAULT 0,
|
||||
"action" TEXT NOT NULL DEFAULT 'block',
|
||||
"sourceType" TEXT,
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
"releasedById" TEXT,
|
||||
"releaseReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PhoneFrequencyHit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "PhoneFrequencyState" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"ruleId" TEXT,
|
||||
"ruleCode" TEXT NOT NULL,
|
||||
"phoneNumber" TEXT NOT NULL,
|
||||
"windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||
"windowEndsAt" TIMESTAMP(3) NOT NULL,
|
||||
"count" INTEGER NOT NULL DEFAULT 0,
|
||||
"generation" INTEGER NOT NULL DEFAULT 0,
|
||||
"activeHitId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PhoneFrequencyState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PhoneFrequencyHit_applicationId_ruleCode_phoneNumber_windowStartedAt_generation_key"
|
||||
ON "PhoneFrequencyHit"("applicationId", "ruleCode", "phoneNumber", "windowStartedAt", "generation");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyHit_tenantId_applicationId_createdAt_idx"
|
||||
ON "PhoneFrequencyHit"("tenantId", "applicationId", "createdAt");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyHit_applicationId_phoneNumber_createdAt_idx"
|
||||
ON "PhoneFrequencyHit"("applicationId", "phoneNumber", "createdAt");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyHit_windowEndsAt_releasedAt_idx"
|
||||
ON "PhoneFrequencyHit"("windowEndsAt", "releasedAt");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyHit_ruleId_idx" ON "PhoneFrequencyHit"("ruleId");
|
||||
CREATE INDEX "PhoneFrequencyHit_releasedById_idx" ON "PhoneFrequencyHit"("releasedById");
|
||||
|
||||
CREATE UNIQUE INDEX "PhoneFrequencyState_activeHitId_key" ON "PhoneFrequencyState"("activeHitId");
|
||||
|
||||
CREATE UNIQUE INDEX "PhoneFrequencyState_applicationId_ruleCode_phoneNumber_key"
|
||||
ON "PhoneFrequencyState"("applicationId", "ruleCode", "phoneNumber");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyState_tenantId_applicationId_windowEndsAt_idx"
|
||||
ON "PhoneFrequencyState"("tenantId", "applicationId", "windowEndsAt");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyState_ruleId_idx" ON "PhoneFrequencyState"("ruleId");
|
||||
|
||||
ALTER TABLE "PhoneFrequencyHit"
|
||||
ADD CONSTRAINT "PhoneFrequencyHit_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyHit"
|
||||
ADD CONSTRAINT "PhoneFrequencyHit_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyHit"
|
||||
ADD CONSTRAINT "PhoneFrequencyHit_ruleId_fkey"
|
||||
FOREIGN KEY ("ruleId") REFERENCES "RiskRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyHit"
|
||||
ADD CONSTRAINT "PhoneFrequencyHit_releasedById_fkey"
|
||||
FOREIGN KEY ("releasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyState"
|
||||
ADD CONSTRAINT "PhoneFrequencyState_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyState"
|
||||
ADD CONSTRAINT "PhoneFrequencyState_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyState"
|
||||
ADD CONSTRAINT "PhoneFrequencyState_ruleId_fkey"
|
||||
FOREIGN KEY ("ruleId") REFERENCES "RiskRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyState"
|
||||
ADD CONSTRAINT "PhoneFrequencyState_activeHitId_fkey"
|
||||
FOREIGN KEY ("activeHitId") REFERENCES "PhoneFrequencyHit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "RiskRule" (
|
||||
"id", "tenantId", "applicationId", "code", "name", "description", "metric",
|
||||
"thresholdValue", "action", "status", "priority", "config", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'default-phone-frequency-24h', NULL, NULL, 'PHONE_FREQUENCY_24H',
|
||||
'单号码24小时发送频次', '同一企业应用下,单个号码在北京时间自然日内最多允许10条业务短信。',
|
||||
'phoneFrequencyCount', 10, 'block', 'active', 40,
|
||||
'{"periodSeconds":86400,"timeZone":"Asia/Shanghai","alignment":"fixed"}'::jsonb,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "RiskRule"
|
||||
WHERE "applicationId" IS NULL
|
||||
AND "code" = 'PHONE_FREQUENCY_24H'
|
||||
AND "status" <> 'deleted'
|
||||
);
|
||||
|
||||
INSERT INTO "RiskRule" (
|
||||
"id", "tenantId", "applicationId", "code", "name", "description", "metric",
|
||||
"thresholdValue", "action", "status", "priority", "config", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'default-phone-frequency-5m', NULL, NULL, 'PHONE_FREQUENCY_5M',
|
||||
'单号码5分钟发送频次', '同一企业应用下,单个号码在固定5分钟周期内最多允许5条业务短信。',
|
||||
'phoneFrequencyCount', 5, 'block', 'active', 50,
|
||||
'{"periodSeconds":300,"timeZone":"Asia/Shanghai","alignment":"fixed"}'::jsonb,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "RiskRule"
|
||||
WHERE "applicationId" IS NULL
|
||||
AND "code" = 'PHONE_FREQUENCY_5M'
|
||||
AND "status" <> 'deleted'
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE "PhoneFrequencyWhitelist" (
|
||||
"id" TEXT NOT NULL,
|
||||
"phoneNumber" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"reason" TEXT NOT NULL,
|
||||
"remark" TEXT,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"updatedById" TEXT NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PhoneFrequencyWhitelist_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PhoneFrequencyWhitelist_phoneNumber_key"
|
||||
ON "PhoneFrequencyWhitelist"("phoneNumber");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyWhitelist_status_updatedAt_idx"
|
||||
ON "PhoneFrequencyWhitelist"("status", "updatedAt");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyWhitelist_createdById_idx"
|
||||
ON "PhoneFrequencyWhitelist"("createdById");
|
||||
|
||||
CREATE INDEX "PhoneFrequencyWhitelist_updatedById_idx"
|
||||
ON "PhoneFrequencyWhitelist"("updatedById");
|
||||
|
||||
ALTER TABLE "PhoneFrequencyWhitelist"
|
||||
ADD CONSTRAINT "PhoneFrequencyWhitelist_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PhoneFrequencyWhitelist"
|
||||
ADD CONSTRAINT "PhoneFrequencyWhitelist_updatedById_fkey"
|
||||
FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE "DrainageDetectionRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"pattern" TEXT NOT NULL,
|
||||
"flags" TEXT NOT NULL DEFAULT 'giu',
|
||||
"priority" INTEGER NOT NULL DEFAULT 100,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"description" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "DrainageDetectionRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "DrainageDetectionRule_code_key" ON "DrainageDetectionRule"("code");
|
||||
CREATE INDEX "DrainageDetectionRule_status_priority_idx" ON "DrainageDetectionRule"("status", "priority");
|
||||
|
||||
ALTER TABLE "SmsMessageRecord"
|
||||
ADD COLUMN "hasDrainageContent" BOOLEAN,
|
||||
ADD COLUMN "drainageDetection" JSONB,
|
||||
ADD COLUMN "drainageDetectionVersion" TEXT,
|
||||
ADD COLUMN "drainageEvaluatedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "SmsMessageRecord_hasDrainageContent_queuedAt_idx"
|
||||
ON "SmsMessageRecord"("hasDrainageContent", "queuedAt");
|
||||
|
||||
INSERT INTO "DrainageDetectionRule"
|
||||
("id", "code", "name", "category", "pattern", "flags", "priority", "status", "description", "version", "updatedAt")
|
||||
VALUES
|
||||
('drainage-rule-url', 'URL', 'URL及裸域名', 'url', $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,,;;!!??<>《》]*)?$regex$, 'giu', 10, 'active', '识别协议链接、裸域名、短链接及IP地址链接;邮箱区间由检测器排除', 1, CURRENT_TIMESTAMP),
|
||||
('drainage-rule-mobile', 'MOBILE', '手机号码', 'mobile', $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$, 'giu', 20, 'active', '规范化后识别+86、空格、短横线及中文标点拆分手机号', 1, CURRENT_TIMESTAMP),
|
||||
('drainage-rule-landline', 'LANDLINE', '固定电话号码', 'landline', $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$, 'giu', 30, 'active', '识别区号括号、分隔符和分机号', 1, CURRENT_TIMESTAMP);
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- PostgreSQL standard-conforming strings preserve backslashes literally. The initial
|
||||
-- seed used JavaScript-style escaping, so already-migrated databases need their three
|
||||
-- built-in patterns normalized to the single backslashes expected by RegExp.
|
||||
UPDATE "DrainageDetectionRule"
|
||||
SET
|
||||
"pattern" = $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,,;;!!??<>《》]*)?$regex$,
|
||||
"version" = "version" + 1,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "code" = 'URL';
|
||||
|
||||
UPDATE "DrainageDetectionRule"
|
||||
SET
|
||||
"pattern" = $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$,
|
||||
"version" = "version" + 1,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "code" = 'MOBILE';
|
||||
|
||||
UPDATE "DrainageDetectionRule"
|
||||
SET
|
||||
"pattern" = $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$,
|
||||
"version" = "version" + 1,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "code" = 'LANDLINE';
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE "SmsMessageRecord"
|
||||
ADD COLUMN "cmppRegisteredDelivery" BOOLEAN,
|
||||
ADD COLUMN "timeoutReceiptQueuedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "CmppInboundLongMessageSegment"
|
||||
ADD COLUMN "registeredDelivery" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Historical CMPP submissions were accepted before Registered_Delivery was
|
||||
-- persisted. Preserve their existing receipt-enabled behavior.
|
||||
UPDATE "SmsMessageRecord"
|
||||
SET "cmppRegisteredDelivery" = true
|
||||
WHERE "cmppSubmitSequenceId" IS NOT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "SmsReceiptAnomaly" (
|
||||
"id" TEXT NOT NULL,
|
||||
"anomalyKey" TEXT NOT NULL,
|
||||
"tenantId" TEXT,
|
||||
"applicationId" TEXT,
|
||||
"channelId" TEXT,
|
||||
"messageRecordId" TEXT,
|
||||
"submitRecordId" TEXT,
|
||||
"receiptRecordId" TEXT,
|
||||
"anomalyType" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"previousStatus" TEXT,
|
||||
"incomingStatus" TEXT,
|
||||
"rawStatus" TEXT,
|
||||
"errorCode" TEXT,
|
||||
"detail" JSONB,
|
||||
"occurrenceCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"firstOccurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastOccurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"resolutionNote" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SmsReceiptAnomaly_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SmsReceiptAnomaly_anomalyKey_key" ON "SmsReceiptAnomaly"("anomalyKey");
|
||||
CREATE INDEX "SmsReceiptAnomaly_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("status", "lastOccurredAt");
|
||||
CREATE INDEX "SmsReceiptAnomaly_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("lastOccurredAt");
|
||||
CREATE INDEX "SmsReceiptAnomaly_anomalyType_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("anomalyType", "lastOccurredAt");
|
||||
CREATE INDEX "SmsReceiptAnomaly_tenantId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("tenantId", "status", "lastOccurredAt");
|
||||
CREATE INDEX "SmsReceiptAnomaly_applicationId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("applicationId", "status", "lastOccurredAt");
|
||||
CREATE INDEX "SmsReceiptAnomaly_channelId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("channelId", "status", "lastOccurredAt");
|
||||
CREATE INDEX "SmsReceiptAnomaly_messageRecordId_idx" ON "SmsReceiptAnomaly"("messageRecordId");
|
||||
CREATE INDEX "SmsReceiptAnomaly_submitRecordId_idx" ON "SmsReceiptAnomaly"("submitRecordId");
|
||||
|
||||
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_submitRecordId_fkey" FOREIGN KEY ("submitRecordId") REFERENCES "SmsSubmitRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_receiptRecordId_fkey" FOREIGN KEY ("receiptRecordId") REFERENCES "SmsReceiptRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+182
-22
@@ -32,6 +32,8 @@ model Tenant {
|
||||
riskRules RiskRule[]
|
||||
smsSendTasks SmsSendTask[]
|
||||
riskHitRecords RiskHitRecord[]
|
||||
phoneFrequencyStates PhoneFrequencyState[]
|
||||
phoneFrequencyHits PhoneFrequencyHit[]
|
||||
smsBatchTasks SmsBatchTask[]
|
||||
smsMessageRecords SmsMessageRecord[]
|
||||
smsApiRequests SmsApiRequest[]
|
||||
@@ -45,6 +47,7 @@ model Tenant {
|
||||
cmppConnectionStates CmppConnectionState[]
|
||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
smsReceiptAnomalies SmsReceiptAnomaly[]
|
||||
openApiRequests OpenApiRequest[]
|
||||
httpWebhookEvents HttpWebhookEvent[]
|
||||
cmppInboundLongMessages CmppInboundLongMessage[]
|
||||
@@ -88,13 +91,16 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
roles UserRole[]
|
||||
operationLogs OperationLog[]
|
||||
auditRecords AuditRecord[]
|
||||
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
roles UserRole[]
|
||||
operationLogs OperationLog[]
|
||||
auditRecords AuditRecord[]
|
||||
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
||||
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
||||
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
||||
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -300,6 +306,23 @@ model DrainageField {
|
||||
commonReportFields CommonReportField[]
|
||||
}
|
||||
|
||||
model DrainageDetectionRule {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
category String
|
||||
pattern String
|
||||
flags String @default("giu")
|
||||
priority Int @default(100)
|
||||
status String @default("active")
|
||||
description String?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, priority])
|
||||
}
|
||||
|
||||
model TenantAccount {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -431,6 +454,7 @@ model SmsApplication {
|
||||
connectionStates CmppConnectionState[]
|
||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
receiptAnomalies SmsReceiptAnomaly[]
|
||||
httpConfig SmsApplicationHttpConfig?
|
||||
httpIpAllowlist SmsApplicationHttpIpAllowlist[]
|
||||
httpApiCredentials HttpApiCredential[]
|
||||
@@ -440,6 +464,8 @@ model SmsApplication {
|
||||
dailyUsages SmsApplicationDailyUsage[]
|
||||
inboundLongMessages CmppInboundLongMessage[]
|
||||
riskRules RiskRule[]
|
||||
phoneFrequencyStates PhoneFrequencyState[]
|
||||
phoneFrequencyHits PhoneFrequencyHit[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([status, createdAt])
|
||||
@@ -810,6 +836,7 @@ model SmsChannel {
|
||||
uplinkMessages SmsUplinkMessage[]
|
||||
connectionStates CmppConnectionState[]
|
||||
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
|
||||
receiptAnomalies SmsReceiptAnomaly[]
|
||||
|
||||
@@index([status])
|
||||
@@index([status, createdAt])
|
||||
@@ -1268,9 +1295,11 @@ model RiskRule {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
hits RiskHitRecord[]
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
hits RiskHitRecord[]
|
||||
phoneFrequencyStates PhoneFrequencyState[]
|
||||
phoneFrequencyHits PhoneFrequencyHit[]
|
||||
|
||||
@@unique([applicationId, code])
|
||||
@@index([tenantId, applicationId, status, priority])
|
||||
@@ -1339,6 +1368,85 @@ model RiskHitRecord {
|
||||
@@index([ruleCode])
|
||||
}
|
||||
|
||||
model PhoneFrequencyState {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String
|
||||
ruleId String?
|
||||
ruleCode String
|
||||
phoneNumber String
|
||||
windowStartedAt DateTime
|
||||
windowEndsAt DateTime
|
||||
count Int @default(0)
|
||||
generation Int @default(0)
|
||||
activeHitId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
rule RiskRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull)
|
||||
activeHit PhoneFrequencyHit? @relation("ActivePhoneFrequencyHit", fields: [activeHitId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([applicationId, ruleCode, phoneNumber])
|
||||
@@index([tenantId, applicationId, windowEndsAt])
|
||||
@@index([ruleId])
|
||||
}
|
||||
|
||||
model PhoneFrequencyHit {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String
|
||||
ruleId String?
|
||||
ruleCode String
|
||||
ruleName String
|
||||
phoneNumber String
|
||||
thresholdValue Int
|
||||
actualValue Int
|
||||
windowStartedAt DateTime
|
||||
windowEndsAt DateTime
|
||||
generation Int @default(0)
|
||||
action String @default("block")
|
||||
sourceType String?
|
||||
releasedAt DateTime?
|
||||
releasedById String?
|
||||
releaseReason String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
rule RiskRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull)
|
||||
releasedBy User? @relation("PhoneFrequencyHitReleaser", fields: [releasedById], references: [id])
|
||||
activeForState PhoneFrequencyState? @relation("ActivePhoneFrequencyHit")
|
||||
|
||||
@@unique([applicationId, ruleCode, phoneNumber, windowStartedAt, generation])
|
||||
@@index([tenantId, applicationId, createdAt])
|
||||
@@index([applicationId, phoneNumber, createdAt])
|
||||
@@index([windowEndsAt, releasedAt])
|
||||
@@index([ruleId])
|
||||
@@index([releasedById])
|
||||
}
|
||||
|
||||
model PhoneFrequencyWhitelist {
|
||||
id String @id @default(cuid())
|
||||
phoneNumber String @unique
|
||||
status String @default("active")
|
||||
reason String
|
||||
remark String?
|
||||
createdById String
|
||||
updatedById String
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
createdBy User @relation("PhoneFrequencyWhitelistCreator", fields: [createdById], references: [id])
|
||||
updatedBy User @relation("PhoneFrequencyWhitelistUpdater", fields: [updatedById], references: [id])
|
||||
|
||||
@@index([status, updatedAt])
|
||||
@@index([createdById])
|
||||
@@index([updatedById])
|
||||
}
|
||||
|
||||
model SmsBatchTask {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -1414,6 +1522,10 @@ model SmsMessageRecord {
|
||||
carrier String?
|
||||
province String?
|
||||
content String
|
||||
hasDrainageContent Boolean?
|
||||
drainageDetection Json?
|
||||
drainageDetectionVersion String?
|
||||
drainageEvaluatedAt DateTime?
|
||||
billingUnits Int @default(1)
|
||||
unitPrice BigInt @default(0)
|
||||
amountCents BigInt @default(0)
|
||||
@@ -1423,6 +1535,7 @@ model SmsMessageRecord {
|
||||
gatewayMessageId String?
|
||||
cmppSubmitSequenceId String?
|
||||
cmppSubmitGroupMessageId String?
|
||||
cmppRegisteredDelivery Boolean?
|
||||
clientSrcId String?
|
||||
applicationExtension String?
|
||||
status String @default("queued")
|
||||
@@ -1435,6 +1548,7 @@ model SmsMessageRecord {
|
||||
submittedAt DateTime?
|
||||
deliveredAt DateTime?
|
||||
timeoutAt DateTime?
|
||||
timeoutReceiptQueuedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
@@ -1448,6 +1562,7 @@ model SmsMessageRecord {
|
||||
submitRecords SmsSubmitRecord[]
|
||||
receiptRecords SmsReceiptRecord[]
|
||||
segmentAudits SmsMessageSegmentAudit[]
|
||||
receiptAnomalies SmsReceiptAnomaly[]
|
||||
matchedUplinks SmsUplinkMessage[] @relation("SmsUplinkMatchedMessage")
|
||||
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||
downstreamDeliveries CmppDownstreamDelivery[]
|
||||
@@ -1460,6 +1575,7 @@ model SmsMessageRecord {
|
||||
@@index([phoneNumber])
|
||||
@@index([gatewayMessageId])
|
||||
@@index([drainageInfoId, queuedAt])
|
||||
@@index([hasDrainageContent, queuedAt])
|
||||
}
|
||||
|
||||
model CmppSubmitSession {
|
||||
@@ -1510,6 +1626,7 @@ model SmsSubmitRecord {
|
||||
retryOfSubmitRecord SmsSubmitRecord? @relation("SmsSubmitRetry", fields: [retryOfSubmitRecordId], references: [id])
|
||||
retrySubmit SmsSubmitRecord? @relation("SmsSubmitRetry")
|
||||
segmentAudits SmsMessageSegmentAudit[]
|
||||
receiptAnomalies SmsReceiptAnomaly[]
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([messageRecordId])
|
||||
@@ -1666,14 +1783,15 @@ model CmppInboundLongMessage {
|
||||
}
|
||||
|
||||
model CmppInboundLongMessageSegment {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
segmentIndex Int
|
||||
sequenceId String?
|
||||
content String
|
||||
contentHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
segmentIndex Int
|
||||
sequenceId String?
|
||||
registeredDelivery Boolean @default(true)
|
||||
content String
|
||||
contentHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group CmppInboundLongMessage @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1699,10 +1817,11 @@ model SmsReceiptRecord {
|
||||
deliveredAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id])
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id])
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id])
|
||||
anomalies SmsReceiptAnomaly[]
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([messageId])
|
||||
@@ -1711,6 +1830,47 @@ model SmsReceiptRecord {
|
||||
@@index([channelId, gatewayMessageId, phoneNumber])
|
||||
}
|
||||
|
||||
model SmsReceiptAnomaly {
|
||||
id String @id @default(cuid())
|
||||
anomalyKey String @unique
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
channelId String?
|
||||
messageRecordId String?
|
||||
submitRecordId String?
|
||||
receiptRecordId String?
|
||||
anomalyType String
|
||||
status String @default("pending")
|
||||
previousStatus String?
|
||||
incomingStatus String?
|
||||
rawStatus String?
|
||||
errorCode String?
|
||||
detail Json?
|
||||
occurrenceCount Int @default(1)
|
||||
firstOccurredAt DateTime @default(now())
|
||||
lastOccurredAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
resolutionNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: SetNull)
|
||||
channel SmsChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull)
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id], onDelete: SetNull)
|
||||
submitRecord SmsSubmitRecord? @relation(fields: [submitRecordId], references: [id], onDelete: SetNull)
|
||||
receiptRecord SmsReceiptRecord? @relation(fields: [receiptRecordId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, lastOccurredAt])
|
||||
@@index([lastOccurredAt])
|
||||
@@index([anomalyType, lastOccurredAt])
|
||||
@@index([tenantId, status, lastOccurredAt])
|
||||
@@index([applicationId, status, lastOccurredAt])
|
||||
@@index([channelId, status, lastOccurredAt])
|
||||
@@index([messageRecordId])
|
||||
@@index([submitRecordId])
|
||||
}
|
||||
|
||||
model SmsUplinkMessage {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
|
||||
@@ -26,8 +26,8 @@ export class AdminCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.certifications.list(tenantId, status, keyword);
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
|
||||
return this.certifications.list(tenantId, status, keyword, submittedAtFrom, submittedAtTo);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -38,6 +38,22 @@ describe('CertificationService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters enterprise certification submissions by Shanghai date range', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
await service.list(undefined, 'pending', undefined, '2026-08-01', '2026-08-03');
|
||||
|
||||
expect(prisma.enterpriseCertification.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
submittedAt: {
|
||||
gte: new Date('2026-08-01T00:00:00+08:00'),
|
||||
lte: new Date('2026-08-03T23:59:59.999+08:00'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('submits certification and marks tenant pending', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
|
||||
export interface SubmitCertificationDto {
|
||||
tenantId: string;
|
||||
@@ -20,11 +21,12 @@ export interface ReviewCertificationDto {
|
||||
export class CertificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(tenantId?: string, status?: string, keyword?: string) {
|
||||
async list(tenantId?: string, status?: string, keyword?: string, submittedAtFrom?: string, submittedAtTo?: string) {
|
||||
const records = await this.prisma.enterpriseCertification.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: status && status !== 'all' ? status : undefined,
|
||||
submittedAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
|
||||
OR: keyword ? [
|
||||
{ companyName: { contains: keyword } },
|
||||
{ licenseNo: { contains: keyword } },
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { ChannelConnectionService } from './channel-connection.service';
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelConfigurationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
|
||||
|
||||
listChannels() {
|
||||
return this.prisma.smsChannel.findMany({
|
||||
include: { connectionStates: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
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 = {
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined,
|
||||
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsChannel.findMany({
|
||||
where,
|
||||
include: { connectionStates: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsChannel.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
});
|
||||
if (missingFields.length > 0) {
|
||||
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
const gatewayPort = Number(data.gatewayPort ?? 7890);
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
|
||||
const config = normalizeChannelRuntimeConfig(
|
||||
undefined,
|
||||
data.config,
|
||||
data.desiredConnections,
|
||||
data.windowSize,
|
||||
data.heartbeatIntervalSeconds,
|
||||
data.heartbeatMissThreshold,
|
||||
);
|
||||
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
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 ?? 0,
|
||||
status: data.status ?? 'active',
|
||||
config: config as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (channel.status === 'active') {
|
||||
await this.connection.requestChannelConnection(channel, 'channel_created');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async updateChannel(channelId: string, data: UpdateChannelDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (data.unitPrice !== undefined) {
|
||||
assertMoneyUnits(data.unitPrice, '通道单价');
|
||||
}
|
||||
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
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 connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||
account: data.account ?? channel.account,
|
||||
passwordCipher: data.passwordCipher ?? channel.passwordCipher,
|
||||
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.carrier,
|
||||
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,
|
||||
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 updatedStatus = data.status ?? channel.status;
|
||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||
await this.connection.requestChannelConnection(updated, 'channel_updated');
|
||||
} else if (updatedStatus !== 'active' && channel.status === 'active') {
|
||||
await this.connection.requestChannelDisconnection(updated, 'channel_disabled');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } });
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: `sms_channel.${data.status}`,
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
statusBefore: channel.status,
|
||||
statusAfter: data.status,
|
||||
reason: data.reason,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (data.status === 'active') {
|
||||
await this.connection.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
|
||||
} else if (channel.status === 'active' || data.status === 'deleted') {
|
||||
await this.connection.requestChannelDisconnection(
|
||||
updated,
|
||||
data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled',
|
||||
data.operatorId,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelConnectionService {
|
||||
private readonly logger = new Logger(ChannelConnectionService.name);
|
||||
private gatewayConnectionQueue?: Queue;
|
||||
private gatewaySubmitQueue?: Queue;
|
||||
private redis?: IORedis;
|
||||
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
|
||||
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED !== 'true') {
|
||||
this.connectionTimeoutTimer = setInterval(() => {
|
||||
void this.markTimedOutConnectingChannels().catch((error) => {
|
||||
this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS));
|
||||
this.connectionTimeoutTimer.unref?.();
|
||||
}
|
||||
this.gatewayStartupReconnectTimer = setTimeout(() => {
|
||||
void this.reconnectActiveChannelsAfterGatewayRestart();
|
||||
}, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS));
|
||||
this.gatewayStartupReconnectTimer.unref?.();
|
||||
if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') {
|
||||
this.gatewayReconcileTimer = setInterval(() => {
|
||||
void this.reconcileGatewayConnections().catch((error) => {
|
||||
this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS));
|
||||
this.gatewayReconcileTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.connectionTimeoutTimer) {
|
||||
clearInterval(this.connectionTimeoutTimer);
|
||||
}
|
||||
if (this.gatewayStartupReconnectTimer) {
|
||||
clearTimeout(this.gatewayStartupReconnectTimer);
|
||||
}
|
||||
if (this.gatewayReconcileTimer) {
|
||||
clearInterval(this.gatewayReconcileTimer);
|
||||
}
|
||||
await this.gatewayConnectionQueue?.close();
|
||||
await this.gatewaySubmitQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
listChannelMetrics(channelId: string) {
|
||||
return this.prisma.channelHealthMetric.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { windowStart: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
listChannelConnections(channelId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelConnectionLogs(channelId: string) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const [connectionStates, logs] = await Promise.all([
|
||||
this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.operationLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ resource: 'sms_channel', resourceId: channelId },
|
||||
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
channelId,
|
||||
connectionStates,
|
||||
logs: logs.map((log) => ({
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
event: normalizeLinkEvent(log.action),
|
||||
action: log.action,
|
||||
resourceId: log.resourceId,
|
||||
detail: log.detail,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
listTenantConnections(tenantId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const rawStatus = data.status;
|
||||
const status = normalizeGatewayConnectionStatus(rawStatus);
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application) {
|
||||
throw new BadRequestException('applicationId does not reference an existing application');
|
||||
}
|
||||
if (data.tenantId && data.tenantId !== application.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to tenantId');
|
||||
}
|
||||
data.tenantId = application.tenantId;
|
||||
}
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
status,
|
||||
desiredConnections: data.desiredConnections ?? 1,
|
||||
currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0),
|
||||
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
|
||||
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined,
|
||||
nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined,
|
||||
lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory,
|
||||
lastError: status === 'connected' ? null : data.lastError,
|
||||
};
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
let state;
|
||||
if (existing) {
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload });
|
||||
} else {
|
||||
try {
|
||||
state = await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') {
|
||||
throw error;
|
||||
}
|
||||
const concurrent = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
if (!concurrent) {
|
||||
throw error;
|
||||
}
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload });
|
||||
}
|
||||
}
|
||||
const action = normalizeConnectionAction(
|
||||
['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status,
|
||||
);
|
||||
const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date();
|
||||
const shouldWriteAudit = action !== 'heartbeat'
|
||||
|| !existing?.lastHeartbeatAt
|
||||
|| heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS;
|
||||
if (shouldWriteAudit) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${action}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status,
|
||||
applicationId: state.applicationId,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async markTimedOutConnectingChannels(now = new Date()) {
|
||||
const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`;
|
||||
const states = await this.prisma.cmppConnectionState.findMany({
|
||||
where: {
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
channelId: true,
|
||||
connectionId: true,
|
||||
desiredConnections: true,
|
||||
currentConnections: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
let failed = 0;
|
||||
for (const state of states) {
|
||||
const result = await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
id: state.id,
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
data: {
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
nextReconnectAt: now,
|
||||
lastErrorCategory: 'timeout',
|
||||
lastError,
|
||||
},
|
||||
});
|
||||
if (result.count === 0) {
|
||||
continue;
|
||||
}
|
||||
failed += result.count;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: state.tenantId,
|
||||
action: 'cmpp_connection.failed',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${state.channelId}:${state.connectionId}`,
|
||||
detail: {
|
||||
reason: 'connect_timeout',
|
||||
applicationId: state.applicationId,
|
||||
status: 'failed',
|
||||
previousStatus: 'connecting',
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnectionsBefore: state.currentConnections,
|
||||
currentConnections: 0,
|
||||
timeoutMs,
|
||||
lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { checked: states.length, failed };
|
||||
}
|
||||
|
||||
async requestChannelConnection(
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
},
|
||||
reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
});
|
||||
const data = {
|
||||
applicationId: null,
|
||||
status: 'connecting',
|
||||
desiredConnections,
|
||||
currentConnections: 0,
|
||||
lastError: null,
|
||||
lastReconnectAttemptAt: new Date(),
|
||||
nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)),
|
||||
};
|
||||
let state;
|
||||
if (existing) {
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data });
|
||||
} else {
|
||||
try {
|
||||
state = await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') {
|
||||
throw error;
|
||||
}
|
||||
const concurrent = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: { applicationId: null, channelId: channel.id, connectionId },
|
||||
});
|
||||
if (!concurrent) {
|
||||
throw error;
|
||||
}
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data });
|
||||
}
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.connect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: {
|
||||
reason,
|
||||
status: state.status,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'ConnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
reason,
|
||||
desiredConnections,
|
||||
channel: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
srcId: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
),
|
||||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
),
|
||||
},
|
||||
};
|
||||
const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, {
|
||||
jobId: `gateway-connect-${channel.id}-${command.traceId}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await this.notifyGatewayConnect(command);
|
||||
} finally {
|
||||
if (queuedJob) {
|
||||
await queuedJob.remove().catch((error) => {
|
||||
this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async reconnectActiveChannelsAfterGatewayRestart() {
|
||||
const channels = await this.prisma.smsChannel.findMany({ where: { status: 'active' } });
|
||||
const results = await Promise.allSettled(
|
||||
channels.map((channel) => this.requestChannelConnection(channel, 'gateway_restarted')),
|
||||
);
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const channel = channels[index];
|
||||
this.logger.error(
|
||||
`Failed to restore active CMPP channel ${channel?.code ?? channel?.id ?? index}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileGatewayConnections(now = new Date()) {
|
||||
const channels = await this.prisma.smsChannel.findMany({
|
||||
where: { status: { in: ['active', 'disabled', 'deleted'] } },
|
||||
include: {
|
||||
connectionStates: {
|
||||
where: { applicationId: null },
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
});
|
||||
let reconnectRequested = 0;
|
||||
let disconnectRequested = 0;
|
||||
for (const channel of channels) {
|
||||
const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id));
|
||||
if (channel.status !== 'active') {
|
||||
if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) {
|
||||
await this.withGatewayReconcileLock(channel.id, async () => {
|
||||
await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile');
|
||||
disconnectRequested++;
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
);
|
||||
const heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
);
|
||||
const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000);
|
||||
const connectedAndFresh = state?.status === 'connected'
|
||||
&& state.currentConnections >= desiredConnections
|
||||
&& Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff);
|
||||
const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now;
|
||||
if (!connectedAndFresh && retryDue) {
|
||||
await this.withGatewayReconcileLock(channel.id, async () => {
|
||||
await this.requestChannelConnection(channel, 'automatic_reconnect');
|
||||
reconnectRequested++;
|
||||
});
|
||||
}
|
||||
}
|
||||
return { scanned: channels.length, reconnectRequested, disconnectRequested };
|
||||
}
|
||||
|
||||
async withGatewayReconcileLock(channelId: string, action: () => Promise<void>) {
|
||||
const redis = this.getRedis();
|
||||
const key = `cmpp:gateway:reconcile:${channelId}`;
|
||||
const token = randomUUID();
|
||||
const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX');
|
||||
if (acquired !== 'OK') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
await redis.eval(
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
|
||||
1,
|
||||
key,
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async requestChannelDisconnection(
|
||||
channel: { id: string },
|
||||
reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const now = new Date();
|
||||
await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
data: {
|
||||
status: 'disconnected',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
nextReconnectAt: null,
|
||||
lastErrorCategory: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.disconnect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: { reason } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'DisconnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: now.toISOString(),
|
||||
reason,
|
||||
};
|
||||
const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, {
|
||||
jobId: `gateway-disconnect-${channel.id}-${command.traceId}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await this.notifyGatewayDisconnect(command);
|
||||
} finally {
|
||||
if (queuedJob) {
|
||||
await queuedJob.remove().catch((error) => {
|
||||
this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getGatewayConnectionQueue() {
|
||||
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewayConnectionQueue;
|
||||
}
|
||||
|
||||
getGatewaySubmitQueue() {
|
||||
this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewaySubmitQueue;
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown) {
|
||||
return this.getRedis().xadd(
|
||||
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
|
||||
'*',
|
||||
'messageType',
|
||||
'SubmitCommand',
|
||||
'data',
|
||||
JSON.stringify(command),
|
||||
);
|
||||
}
|
||||
|
||||
async notifyGatewayConnect(command: Record<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async notifyGatewayDisconnect(command: Record<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelCopyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
|
||||
const source = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { reportFields: true },
|
||||
});
|
||||
if (!source) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const nextName = data.name ?? `${source.name}副本`;
|
||||
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
|
||||
|
||||
const copied = await this.prisma.$transaction(async (tx) => {
|
||||
const nextChannel = await tx.smsChannel.create({
|
||||
data: {
|
||||
code: nextCode,
|
||||
name: nextName,
|
||||
carrier: source.carrier,
|
||||
protocol: source.protocol,
|
||||
gatewayHost: source.gatewayHost,
|
||||
gatewayPort: source.gatewayPort,
|
||||
enterpriseCode: source.enterpriseCode,
|
||||
account: source.account,
|
||||
passwordCipher: source.passwordCipher,
|
||||
srcId: source.srcId,
|
||||
sendRegion: source.sendRegion,
|
||||
cmppVersion: source.cmppVersion,
|
||||
rateLimitPerSecond: source.rateLimitPerSecond,
|
||||
unitPrice: source.unitPrice,
|
||||
status: 'disabled',
|
||||
config: source.config as Prisma.InputJsonValue | undefined,
|
||||
reportFields: {
|
||||
create: source.reportFields.map((field) => ({
|
||||
drainageFieldId: field.drainageFieldId,
|
||||
reportType: field.reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: field.required,
|
||||
description: field.description,
|
||||
sortOrder: field.sortOrder,
|
||||
status: field.status,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { reportFields: true },
|
||||
});
|
||||
|
||||
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
|
||||
if (reportMaterials.length > 0) {
|
||||
await tx.signatureReportMaterial.createMany({
|
||||
data: reportMaterials.map((material) => ({
|
||||
signatureId: material.signatureId,
|
||||
channelId: nextChannel.id,
|
||||
fieldCode: material.fieldCode,
|
||||
fieldValue: material.fieldValue,
|
||||
fileObjectId: material.fileObjectId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.copy',
|
||||
resource: 'sms_channel',
|
||||
resourceId: nextChannel.id,
|
||||
detail: {
|
||||
sourceChannelId: source.id,
|
||||
sourceCode: source.code,
|
||||
sourceStatus: source.status,
|
||||
copiedStatus: 'disabled',
|
||||
copiedReportFields: source.reportFields.length,
|
||||
copiedReportMaterials: reportMaterials.length,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return nextChannel;
|
||||
});
|
||||
|
||||
return copied;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { ChannelConfigurationService } from './channel-configuration.service';
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelDeletionService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly configuration: ChannelConfigurationService) {}
|
||||
|
||||
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
|
||||
return this.configuration.changeChannelStatus(channelId, { ...data, status: 'deleted' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelGroupRoutingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
createGroup(data: CreateChannelGroupDto) {
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720);
|
||||
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||
return this.prisma.smsChannelGroup.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier,
|
||||
description: data.description,
|
||||
status: data.status ?? 'active',
|
||||
retryEnabled: data.retryEnabled ?? true,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addGroupItem(data: CreateChannelGroupItemDto) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const groupCarrier = normalizeBusinessCarrier(group.carrier);
|
||||
const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier;
|
||||
if (itemCarrier !== groupCarrier) {
|
||||
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||
}
|
||||
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
||||
throw new BadRequestException('Province route must use a channel with the same sendRegion');
|
||||
}
|
||||
const existing = await this.prisma.smsChannelGroupItem.findFirst({
|
||||
where: { groupId: data.groupId, channelId: data.channelId },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||
}
|
||||
if (data.province) {
|
||||
const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({
|
||||
where: { groupId: data.groupId, province: data.province },
|
||||
});
|
||||
if (existingProvince) {
|
||||
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
|
||||
}
|
||||
} else {
|
||||
const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({
|
||||
where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 },
|
||||
});
|
||||
if (existingPriority) {
|
||||
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
|
||||
}
|
||||
}
|
||||
return this.prisma.smsChannelGroupItem.create({
|
||||
data: {
|
||||
groupId: data.groupId,
|
||||
channelId: data.channelId,
|
||||
carrier: itemCarrier,
|
||||
province: data.province,
|
||||
priority: data.priority ?? 100,
|
||||
weight: data.weight ?? 1,
|
||||
isBackup: data.isBackup ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
|
||||
const current = await this.prisma.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(
|
||||
data.retryTimeLimitMinutes,
|
||||
data.retryTimeLimitHours,
|
||||
current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60,
|
||||
);
|
||||
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
|
||||
const items = data.items ?? [];
|
||||
const channelIds = [...new Set(items.map((item) => item.channelId))];
|
||||
const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } });
|
||||
const channelById = new Map(channels.map((channel) => [channel.id, channel]));
|
||||
validateGroupItems(carrier, items, channelById);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
await tx.smsChannelGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
code: data.code ?? current.code,
|
||||
name: data.name ?? current.name,
|
||||
carrier,
|
||||
description: data.description,
|
||||
status: data.status ?? current.status,
|
||||
retryEnabled: data.retryEnabled ?? current.retryEnabled,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
if (items.length > 0) {
|
||||
await tx.smsChannelGroupItem.createMany({
|
||||
data: items.map((item) => ({
|
||||
groupId,
|
||||
channelId: item.channelId,
|
||||
carrier,
|
||||
province: item.province,
|
||||
priority: item.priority ?? 100,
|
||||
weight: item.weight ?? 1,
|
||||
isBackup: item.isBackup ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const updated = await tx.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel_group.update',
|
||||
resource: 'sms_channel_group',
|
||||
resourceId: groupId,
|
||||
detail: {
|
||||
before: channelGroupAuditSnapshot(current),
|
||||
after: updated ? channelGroupAuditSnapshot(updated) : null,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupId: string) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
status: 'active',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (boundRoute) {
|
||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
||||
}
|
||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
||||
}
|
||||
|
||||
listRouteRules() {
|
||||
return this.prisma.channelRouteRule.findMany({
|
||||
include: { group: true, channel: true },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createRouteRule(data: CreateRouteRuleDto) {
|
||||
if (!data.applicationId) {
|
||||
throw new BadRequestException('applicationId is required for channel group routing');
|
||||
}
|
||||
if (!data.carrier) {
|
||||
throw new BadRequestException('carrier is required for application channel group routing');
|
||||
}
|
||||
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||
if (data.channelId) {
|
||||
throw new BadRequestException('Route rules can only bind channel groups, not single channels');
|
||||
}
|
||||
if (data.province) {
|
||||
throw new BadRequestException('Province routing must be configured inside the channel group');
|
||||
}
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
if (normalizeBusinessCarrier(group.carrier) !== carrier) {
|
||||
throw new BadRequestException('Route rule carrier must match the channel group carrier');
|
||||
}
|
||||
return this.prisma.channelRouteRule.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
groupId: data.groupId,
|
||||
channelId: undefined,
|
||||
carrier,
|
||||
province: undefined,
|
||||
priority: data.priority ?? 100,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelReportingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listReportFields(channelId?: string) {
|
||||
return this.prisma.channelReportField.findMany({
|
||||
where: channelId ? { channelId } : undefined,
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createReportField(data: CreateReportFieldDto) {
|
||||
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
|
||||
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||
if (!field || field.status !== 'active') {
|
||||
throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
}
|
||||
const reportType = normalizeReportType(data.reportType);
|
||||
return this.prisma.channelReportField.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
drainageFieldId: field.id,
|
||||
reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
exportName: data.exportName?.trim() || field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: data.required ?? field.required,
|
||||
description: data.description ?? field.description,
|
||||
sortOrder: data.sortOrder ?? 100,
|
||||
columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80),
|
||||
imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600),
|
||||
imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600),
|
||||
defaultValue: data.defaultValue,
|
||||
transform: data.transform,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) throw new NotFoundException('Channel not found');
|
||||
const ids = data.fields.map((field) => field.drainageFieldId);
|
||||
if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段');
|
||||
const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } });
|
||||
if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
const fieldById = new Map(libraryFields.map((field) => [field.id, field]));
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const oppositeType = reportType === 'signature' ? 'drainage' : 'signature';
|
||||
const [legacyBoth, oppositeFields] = await Promise.all([
|
||||
tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }),
|
||||
tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }),
|
||||
]);
|
||||
const oppositeCodes = new Set(oppositeFields.map((field) => field.code));
|
||||
await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } });
|
||||
for (const legacy of legacyBoth) {
|
||||
if (oppositeCodes.has(legacy.code)) continue;
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
||||
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
||||
}
|
||||
for (const [index, configured] of data.fields.entries()) {
|
||||
const field = fieldById.get(configured.drainageFieldId)!;
|
||||
await tx.channelReportField.create({
|
||||
data: {
|
||||
channelId,
|
||||
drainageFieldId: field.id,
|
||||
reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
exportName: configured.exportName?.trim() || field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: configured.required ?? field.required,
|
||||
description: configured.description ?? field.description,
|
||||
sortOrder: configured.sortOrder ?? (index + 1) * 10,
|
||||
columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80),
|
||||
imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600),
|
||||
imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600),
|
||||
defaultValue: configured.defaultValue,
|
||||
transform: configured.transform,
|
||||
status: configured.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
});
|
||||
}
|
||||
|
||||
listReportMaterials(signatureId?: string, channelId?: string) {
|
||||
return this.prisma.signatureReportMaterial.findMany({
|
||||
where: {
|
||||
signatureId,
|
||||
channelId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
upsertReportMaterial(data: CreateReportMaterialDto) {
|
||||
return this.prisma.signatureReportMaterial.upsert({
|
||||
where: {
|
||||
signatureId_channelId_fieldCode: {
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
fieldCode: data.fieldCode,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
fieldValue: data.fieldValue,
|
||||
fileObjectId: data.fileObjectId,
|
||||
},
|
||||
create: {
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
fieldCode: data.fieldCode,
|
||||
fieldValue: data.fieldValue,
|
||||
fileObjectId: data.fileObjectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
||||
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status,
|
||||
channelId,
|
||||
reportType,
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
},
|
||||
include: {
|
||||
signature: { include: { tenant: true, application: true } },
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (tasks.length === 0) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const channelIds = [...new Set(tasks.map((task) => task.channelId))];
|
||||
const signatureIds = [...new Set(tasks.map((task) => task.signatureId))];
|
||||
const day = currentShanghaiDayRange();
|
||||
const rows = await this.prisma.$queryRaw<ChannelReportDeliveryRow[]>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
submit."channelId" AS channel_id,
|
||||
message."signatureId" AS signature_id,
|
||||
message."drainageInfoId" AS drainage_info_id,
|
||||
submit."submitStatus" AS submit_status,
|
||||
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
||||
CASE
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
THEN segment_summary.completed_at
|
||||
WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at
|
||||
END AS successful_at,
|
||||
CASE
|
||||
WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure'
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success'
|
||||
WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
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,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS delivered_at
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) delivered_receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS failed_at
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND submit."channelId" IN (${Prisma.join(channelIds)})
|
||||
AND message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
)
|
||||
SELECT
|
||||
channel_id AS "channelId",
|
||||
signature_id AS "signatureId",
|
||||
drainage_info_id AS "drainageInfoId",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND submit_status = 'accepted'
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'submit_failed'
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'success'
|
||||
)::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'unknown'
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'failure'
|
||||
)::integer AS "failureCount",
|
||||
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
||||
FROM base
|
||||
GROUP BY channel_id, signature_id, drainage_info_id
|
||||
`);
|
||||
|
||||
return tasks.map((task) => {
|
||||
const taskRows = rows.filter((row) => (
|
||||
row.channelId === task.channelId
|
||||
&& row.signatureId === task.signatureId
|
||||
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
|
||||
));
|
||||
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
||||
return {
|
||||
...task,
|
||||
deliveryStats,
|
||||
lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listReportTasksPage(query: {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
channelId?: string;
|
||||
reportType?: string;
|
||||
keyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: 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 keyword = query.keyword?.trim();
|
||||
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status,
|
||||
channelId: query.channelId,
|
||||
reportType: query.reportType,
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
||||
} : undefined,
|
||||
OR: keyword ? [
|
||||
{ id: { contains: keyword } },
|
||||
{ channel: { name: { contains: keyword } } },
|
||||
{ signature: { name: { contains: keyword } } },
|
||||
{ signature: { tenant: { name: { contains: keyword } } } },
|
||||
{ signature: { application: { name: { contains: keyword } } } },
|
||||
{ drainageInfo: { siteName: { contains: keyword } } },
|
||||
{ drainageInfo: { url: { contains: keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.channelSignatureReportTask.findMany({
|
||||
where,
|
||||
include: {
|
||||
signature: { include: { tenant: true, application: true } },
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.channelSignatureReportTask.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createReportTask(data: CreateReportTaskDto) {
|
||||
const reportType = data.reportType ?? 'signature';
|
||||
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||
if (reportType === 'drainage') {
|
||||
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
||||
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||
}
|
||||
const task = await this.prisma.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
reportType,
|
||||
drainageItemId: undefined,
|
||||
createdById: data.createdById,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending');
|
||||
return task;
|
||||
}
|
||||
|
||||
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
||||
if (!data.items.length) throw new BadRequestException('items is required');
|
||||
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
||||
for (const item of data.items) {
|
||||
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
||||
}
|
||||
const sourceEntry = data.sourceEntry ?? 'report_task';
|
||||
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
|
||||
throw new BadRequestException('unsupported report task source entry');
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
|
||||
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
|
||||
for (const item of data.items) {
|
||||
const reportType = item.reportType ?? 'signature';
|
||||
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
||||
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
||||
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
||||
if (reportType === 'drainage') {
|
||||
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
||||
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
||||
}
|
||||
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
|
||||
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
|
||||
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
||||
}
|
||||
const summaries = [];
|
||||
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
||||
return [...summaries, ...drainageResults];
|
||||
});
|
||||
}
|
||||
|
||||
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||
}) : [];
|
||||
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
||||
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
||||
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
|
||||
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
return [carrier, summarizeReportStatuses(statuses)];
|
||||
}));
|
||||
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||
return { signatureId, reportStatus, carrierReportSummary };
|
||||
}
|
||||
|
||||
async createReportExport(taskId: string, data: CreateReportExportDto) {
|
||||
const task = await this.getReportTaskOrThrow(taskId);
|
||||
const exported = await this.prisma.reportExportFile.create({
|
||||
data: {
|
||||
taskId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
fileName: data.fileName,
|
||||
rowCount: data.rowCount ?? 0,
|
||||
},
|
||||
});
|
||||
await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export');
|
||||
return exported;
|
||||
}
|
||||
|
||||
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
|
||||
const task = await this.getReportTaskOrThrow(taskId);
|
||||
const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined;
|
||||
const rowCount = data.rowCount ?? parsed?.rowCount ?? 0;
|
||||
const successCount = data.successCount ?? parsed?.successCount ?? 0;
|
||||
const failedCount = data.failedCount ?? parsed?.failedCount ?? 0;
|
||||
const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount);
|
||||
const imported = await this.prisma.reportReceiptImport.create({
|
||||
data: {
|
||||
taskId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
fileName: data.fileName,
|
||||
rowCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
status: 'imported',
|
||||
result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
|
||||
if ((task.reportType ?? 'signature') === 'signature') {
|
||||
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
listReportRecords(taskId?: string, channelId?: string) {
|
||||
return this.prisma.channelSignatureReportRecord.findMany({
|
||||
where: { taskId, channelId },
|
||||
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listReportRecordsPage(query: {
|
||||
taskId?: string;
|
||||
channelId?: string;
|
||||
keyword?: string;
|
||||
reportType?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: 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 keyword = query.keyword?.trim();
|
||||
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
||||
taskId: query.taskId,
|
||||
channelId: query.channelId,
|
||||
task: query.reportType ? { reportType: query.reportType } : undefined,
|
||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
||||
} : undefined,
|
||||
OR: keyword ? [
|
||||
{ taskId: { contains: keyword } },
|
||||
{ action: { contains: keyword } },
|
||||
{ reason: { contains: keyword } },
|
||||
{ channel: { name: { contains: keyword } } },
|
||||
{ task: { signature: { name: { contains: keyword } } } },
|
||||
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
||||
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.channelSignatureReportRecord.findMany({
|
||||
where,
|
||||
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.channelSignatureReportRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getReportTaskOrThrow(taskId: string) {
|
||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('Report task not found');
|
||||
}
|
||||
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async updateReportTaskStatus(
|
||||
taskId: string,
|
||||
channelId: string,
|
||||
statusBefore: string,
|
||||
statusAfter: string,
|
||||
action: string,
|
||||
reason?: string,
|
||||
) {
|
||||
await this.prisma.channelSignatureReportTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: statusAfter, reason },
|
||||
});
|
||||
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
||||
}
|
||||
|
||||
recordReportTask(
|
||||
taskId: string,
|
||||
channelId: string,
|
||||
action: string,
|
||||
statusBefore: string | undefined,
|
||||
statusAfter: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return this.prisma.channelSignatureReportRecord.create({
|
||||
data: {
|
||||
taskId,
|
||||
channelId,
|
||||
action,
|
||||
statusBefore,
|
||||
statusAfter,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
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, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { ChannelConnectionService } from './channel-connection.service';
|
||||
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelTestService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
|
||||
|
||||
async testChannel(channelId: string, data: TestChannelDto = {}) {
|
||||
const phoneNumbers = normalizeTestPhones(data);
|
||||
const content = normalizeTestContent(data.content);
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (channel.status !== 'active') {
|
||||
throw new BadRequestException('通道未启用,不能发送测试短信');
|
||||
}
|
||||
const connectedState = channel.connectionStates.find((state) =>
|
||||
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
|
||||
);
|
||||
if (!connectedState) {
|
||||
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const drainageDetection = await detectDrainageContent(this.prisma, content);
|
||||
const results = [];
|
||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const session = await this.prisma.cmppSubmitSession.upsert({
|
||||
where: { sessionNo: `OPEN-${channel.id}` },
|
||||
update: { submitTotal: { increment: 1 } },
|
||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||
});
|
||||
const messageRecord = await this.prisma.smsMessageRecord.create({
|
||||
data: {
|
||||
messageId,
|
||||
phoneNumber,
|
||||
content,
|
||||
...drainageDetection,
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
unitPrice: 0,
|
||||
amountCents: 0,
|
||||
queuePriority: 'normal',
|
||||
channelId: channel.id,
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
},
|
||||
});
|
||||
await this.prisma.smsSubmitRecord.create({
|
||||
data: {
|
||||
messageRecordId: messageRecord.id,
|
||||
channelId: channel.id,
|
||||
sessionId: session.id,
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits,
|
||||
},
|
||||
});
|
||||
const command = buildChannelTestSubmitCommand({
|
||||
channel,
|
||||
content,
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
testNo,
|
||||
attempt: index,
|
||||
accessNo: data.accessNo,
|
||||
});
|
||||
await this.connection.getGatewaySubmitQueue().add('submit-command', command);
|
||||
const streamMessageId = await this.connection.publishGatewaySubmitCommand(command);
|
||||
results.push({
|
||||
phoneNumber,
|
||||
messageRecordId: messageRecord.id,
|
||||
submitId,
|
||||
streamMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.test_submit',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channel.id,
|
||||
detail: {
|
||||
testNo,
|
||||
phoneTotal: phoneNumbers.length,
|
||||
messageRecordIds: results.map((item) => item.messageRecordId),
|
||||
connectionId: connectedState.connectionId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
channelId,
|
||||
status: 'submit_queued',
|
||||
testNo,
|
||||
submitted: results.length,
|
||||
messages: results,
|
||||
queuedAt: createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/** Stable request contracts shared by the channel controller and R5 domains. */
|
||||
|
||||
export interface CreateChannelDto {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier?: string;
|
||||
sendRegion?: string;
|
||||
protocol?: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort?: number;
|
||||
enterpriseCode?: string;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion?: string;
|
||||
rateLimitPerSecond?: number;
|
||||
unitPrice?: number;
|
||||
status?: string;
|
||||
desiredConnections?: number;
|
||||
windowSize?: number;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
heartbeatMissThreshold?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateChannelDto = Partial<CreateChannelDto>;
|
||||
|
||||
export interface CreateChannelGroupDto {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
}
|
||||
|
||||
export interface CreateChannelGroupItemDto {
|
||||
groupId: string;
|
||||
channelId: string;
|
||||
carrier?: string;
|
||||
province?: string;
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
isBackup?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateChannelGroupDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
carrier?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
|
||||
}
|
||||
|
||||
export interface CreateRouteRuleDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
groupId: string;
|
||||
channelId?: string;
|
||||
carrier?: string;
|
||||
province?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateReportFieldDto {
|
||||
channelId: string;
|
||||
drainageFieldId: string;
|
||||
reportType: 'signature' | 'drainage' | 'both';
|
||||
code?: string;
|
||||
name?: string;
|
||||
fieldType?: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
exportName?: string;
|
||||
columnWidth?: number;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
defaultValue?: string;
|
||||
transform?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ReplaceReportFieldsDto {
|
||||
fields: Array<Omit<CreateReportFieldDto, 'channelId' | 'reportType'>>;
|
||||
}
|
||||
|
||||
export interface CreateReportMaterialDto {
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
fieldCode: string;
|
||||
fieldValue?: string;
|
||||
fileObjectId?: string;
|
||||
}
|
||||
|
||||
export interface CreateReportTaskDto {
|
||||
tenantId: string;
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
reportType?: 'signature' | 'drainage';
|
||||
drainageItemId?: string;
|
||||
createdById?: string;
|
||||
}
|
||||
|
||||
export interface ChangeReportTaskStatusesDto {
|
||||
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||
}
|
||||
|
||||
export interface CreateReportExportDto {
|
||||
fileObjectId?: string;
|
||||
fileName: string;
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
export interface CreateReceiptImportDto {
|
||||
fileObjectId?: string;
|
||||
fileName: string;
|
||||
fileContent?: string;
|
||||
delimiter?: ',' | '\t';
|
||||
rowCount?: number;
|
||||
successCount?: number;
|
||||
failedCount?: number;
|
||||
statusAfter?: string;
|
||||
reason?: string;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpsertConnectionStateDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId: string;
|
||||
connectionId: string;
|
||||
status: string;
|
||||
desiredConnections?: number;
|
||||
currentConnections?: number;
|
||||
lastConnectedAt?: string;
|
||||
lastDisconnectedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
reconnectCount?: number;
|
||||
lastReconnectAttemptAt?: string;
|
||||
nextReconnectAt?: string;
|
||||
lastErrorCategory?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface ChangeChannelStatusDto {
|
||||
status: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CopyChannelDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface TestChannelDto {
|
||||
phoneNumber?: string;
|
||||
phones?: string[] | string;
|
||||
content?: string;
|
||||
accessNo?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import {
|
||||
ChannelsService,
|
||||
ChangeChannelStatusDto,
|
||||
CopyChannelDto,
|
||||
CreateChannelDto,
|
||||
@@ -22,7 +21,8 @@ import {
|
||||
UpsertConnectionStateDto,
|
||||
UpdateChannelDto,
|
||||
UpdateChannelGroupDto,
|
||||
} from './channels.service';
|
||||
} from './channels.contracts';
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
@ApiTags('channels')
|
||||
@Controller('admin')
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
|
||||
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
||||
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||
|
||||
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
|
||||
export const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
|
||||
|
||||
export const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
|
||||
|
||||
export const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
|
||||
|
||||
export const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
|
||||
|
||||
export const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
|
||||
|
||||
export const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
|
||||
|
||||
export const DEFAULT_CMPP_VERSION = '2.0';
|
||||
|
||||
export function normalizeTestPhones(data: TestChannelDto) {
|
||||
const rawPhones = Array.isArray(data.phones)
|
||||
? data.phones
|
||||
: String(data.phoneNumber ?? data.phones ?? '').split(/[,\n,\s]+/u);
|
||||
const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean);
|
||||
const uniquePhones = Array.from(new Set(phones));
|
||||
if (uniquePhones.length === 0) {
|
||||
throw new BadRequestException('请填写测试手机号');
|
||||
}
|
||||
if (uniquePhones.length > 10) {
|
||||
throw new BadRequestException('测试手机号最多允许 10 个');
|
||||
}
|
||||
for (const phone of uniquePhones) {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException(`手机号格式不正确:${phone}`);
|
||||
}
|
||||
}
|
||||
return uniquePhones;
|
||||
}
|
||||
|
||||
export function normalizeTestContent(content?: string) {
|
||||
const normalized = (content ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('请填写测试短信内容');
|
||||
}
|
||||
if (normalized.length > 1000) {
|
||||
throw new BadRequestException('测试短信内容不能超过 1000 字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function calculateBillingUnits(content: string) {
|
||||
return Math.max(1, Math.ceil([...content].length / 67));
|
||||
}
|
||||
|
||||
export function buildChannelTestSubmitCommand({
|
||||
channel,
|
||||
content,
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
testNo,
|
||||
attempt,
|
||||
accessNo,
|
||||
}: {
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
};
|
||||
content: string;
|
||||
phoneNumber: string;
|
||||
messageId: string;
|
||||
submitId: string;
|
||||
testNo: string;
|
||||
attempt: number;
|
||||
accessNo?: string;
|
||||
}) {
|
||||
const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId;
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: randomUUID(),
|
||||
messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId: 'platform-channel-test',
|
||||
applicationId: 'admin-channel-test',
|
||||
taskId: testNo,
|
||||
submitId,
|
||||
queuePriority: 'normal',
|
||||
phoneNumber,
|
||||
content,
|
||||
signature: 'CHANNEL_TEST',
|
||||
templateId: 'admin-channel-test',
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
route: {
|
||||
channelCode: channel.code,
|
||||
cmppAccountCode: channel.account,
|
||||
priority: attempt,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
|
||||
srcId,
|
||||
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
),
|
||||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
),
|
||||
},
|
||||
retry: { attempt: 0, maxAttempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
return config[key as keyof typeof config];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) {
|
||||
const value = getConfigValue(config, key);
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === 'connected') {
|
||||
return 'connected';
|
||||
}
|
||||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'heartbeat';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export function normalizeCmppVersion(version?: string) {
|
||||
const normalized = (version ?? DEFAULT_CMPP_VERSION).trim();
|
||||
if (normalized === '2.0' || normalized === '3.0') {
|
||||
return normalized;
|
||||
}
|
||||
throw new BadRequestException('cmppVersion must be 2.0 or 3.0');
|
||||
}
|
||||
|
||||
export function normalizeGatewayConnectionStatus(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['connecting', 'connect_requested'].includes(normalized)) {
|
||||
return 'connecting';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function defaultChannelConnectionId(channelId: string) {
|
||||
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
|
||||
}
|
||||
|
||||
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||
const value = Number(config.desiredConnections);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export type ChannelConnectionSettings = {
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
cmppVersion: string;
|
||||
config?: Prisma.JsonValue | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getRuntimeConfigInteger(
|
||||
config: Prisma.JsonValue | Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback;
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function channelConnectionSettingsChanged(
|
||||
before: ChannelConnectionSettings,
|
||||
after: ChannelConnectionSettings,
|
||||
) {
|
||||
return before.gatewayHost !== after.gatewayHost
|
||||
|| before.gatewayPort !== after.gatewayPort
|
||||
|| before.account !== after.account
|
||||
|| before.passwordCipher !== after.passwordCipher
|
||||
|| before.cmppVersion !== after.cmppVersion
|
||||
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
|
||||
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|
||||
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
|
||||
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
|
||||
}
|
||||
|
||||
export function channelGroupAuditSnapshot(group: {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
retryEnabled: boolean;
|
||||
retryTimeLimitMinutes: number;
|
||||
items?: Array<{
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
priority: number;
|
||||
weight: number;
|
||||
isBackup: boolean;
|
||||
channel?: { code?: string; name?: string } | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
code: group.code,
|
||||
name: group.name,
|
||||
carrier: group.carrier,
|
||||
description: group.description ?? null,
|
||||
status: group.status,
|
||||
retryEnabled: group.retryEnabled,
|
||||
retryTimeLimitMinutes: group.retryTimeLimitMinutes,
|
||||
items: (group.items ?? []).map((item) => ({
|
||||
channelId: item.channelId,
|
||||
channelCode: item.channel?.code ?? null,
|
||||
channelName: item.channel?.name ?? null,
|
||||
carrier: item.carrier ?? null,
|
||||
province: item.province ?? null,
|
||||
priority: item.priority,
|
||||
weight: item.weight,
|
||||
isBackup: item.isBackup,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannelRuntimeConfig(
|
||||
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
|
||||
incomingConfig?: Record<string, unknown> | null,
|
||||
desiredConnections?: number,
|
||||
windowSize?: number,
|
||||
heartbeatIntervalSeconds?: number,
|
||||
heartbeatMissThreshold?: number,
|
||||
) {
|
||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||
? existingConfig as Record<string, unknown>
|
||||
: {};
|
||||
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
|
||||
? incomingConfig
|
||||
: {};
|
||||
const base = { ...existing, ...incoming };
|
||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
);
|
||||
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||||
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
);
|
||||
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
|
||||
base.serviceId = normalizeCmppServiceId(base.serviceId);
|
||||
base.longMessageReceiptMode = normalizeLongMessageReceiptMode(base.longMessageReceiptMode);
|
||||
return base;
|
||||
}
|
||||
|
||||
export function normalizeLongMessageReceiptMode(value: unknown) {
|
||||
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
||||
if (!['per_segment', 'message_level'].includes(normalized)) {
|
||||
throw new BadRequestException('longMessageReceiptMode must be per_segment or message_level');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeCmppServiceId(value: unknown) {
|
||||
const normalized = String(value ?? 'SMS').trim() || 'SMS';
|
||||
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
|
||||
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeChannelRateLimit(value: unknown) {
|
||||
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
|
||||
if (normalized > 2000) {
|
||||
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeExtensionDigits(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 0;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) {
|
||||
throw new BadRequestException('extensionDigits must be an integer between 0 and 20');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
throw new BadRequestException('Receipt file is empty');
|
||||
}
|
||||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||||
const firstCells = splitReceiptLine(lines[0], separator);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
const header = hasHeader ? firstCells : [];
|
||||
const rows = hasHeader ? lines.slice(1) : lines;
|
||||
const statusIndex = findReceiptStatusIndex(header);
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const resultRows = rows.map((line, index) => {
|
||||
const cells = splitReceiptLine(line, separator);
|
||||
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
|
||||
const normalizedStatus = normalizeReceiptStatus(rawStatus);
|
||||
if (normalizedStatus === 'success') {
|
||||
successCount += 1;
|
||||
} else {
|
||||
failedCount += 1;
|
||||
}
|
||||
return {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
phone: cells[0] ?? '',
|
||||
status: normalizedStatus,
|
||||
rawStatus,
|
||||
raw: cells,
|
||||
};
|
||||
});
|
||||
return {
|
||||
rowCount: resultRows.length,
|
||||
successCount,
|
||||
failedCount,
|
||||
result: {
|
||||
delimiter: separator === '\t' ? 'tab' : 'comma',
|
||||
hasHeader,
|
||||
rows: resultRows,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function splitReceiptLine(line: string, delimiter: ',' | '\t') {
|
||||
if (delimiter === '\t') {
|
||||
return line.split('\t').map((cell) => stripReceiptCell(cell));
|
||||
}
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
cells.push(stripReceiptCell(current));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
cells.push(stripReceiptCell(current));
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function stripReceiptCell(value: string) {
|
||||
return value.trim().replace(/^"|"$/g, '').trim();
|
||||
}
|
||||
|
||||
export function findReceiptStatusIndex(header: string[]) {
|
||||
if (header.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
return index >= 0 ? index : Math.max(0, header.length - 1);
|
||||
}
|
||||
|
||||
export function normalizeReceiptStatus(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
|
||||
return 'success';
|
||||
}
|
||||
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
|
||||
if (rowCount <= 0 || successCount <= 0) {
|
||||
return 'failed';
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return 'partial';
|
||||
}
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
export type ChannelReportDeliveryRow = {
|
||||
channelId: string;
|
||||
signatureId: string;
|
||||
drainageInfoId: string | null;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
lastSuccessfulSentAt: Date | null;
|
||||
};
|
||||
|
||||
export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
|
||||
const total = sumReportDelivery(rows, 'total');
|
||||
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
|
||||
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
|
||||
const successCount = sumReportDelivery(rows, 'successCount');
|
||||
const unknownCount = sumReportDelivery(rows, 'unknownCount');
|
||||
const failureCount = sumReportDelivery(rows, 'failureCount');
|
||||
return {
|
||||
total,
|
||||
acceptedCount,
|
||||
submitFailureCount,
|
||||
submitFailureRate: percentage(submitFailureCount, total),
|
||||
successCount,
|
||||
successRate: percentage(successCount, acceptedCount),
|
||||
unknownCount,
|
||||
unknownRate: percentage(unknownCount, acceptedCount),
|
||||
failureCount,
|
||||
failureRate: percentage(failureCount, acceptedCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
|
||||
ChannelReportDeliveryRow,
|
||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||||
>) {
|
||||
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
||||
}
|
||||
|
||||
export function percentage(count: number, total: number) {
|
||||
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
|
||||
}
|
||||
|
||||
export function latestDate(values: Array<Date | null>) {
|
||||
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
|
||||
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
|
||||
}
|
||||
|
||||
export function currentShanghaiDayRange(now = new Date()) {
|
||||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
|
||||
const localDate = shifted.toISOString().slice(0, 10);
|
||||
const startAt = new Date(`${localDate}T00:00:00+08:00`);
|
||||
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
||||
}
|
||||
|
||||
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
|
||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||
}
|
||||
|
||||
export function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
const normalized = normalizeChannelCarrier(carrier);
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||||
throw new BadRequestException('carrier must be mobile, unicom, or telecom');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeChannelCarrier(carrier?: string | null) {
|
||||
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';
|
||||
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
|
||||
const normalized = normalizeChannelCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === groupCarrier;
|
||||
}
|
||||
|
||||
export function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
||||
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
|
||||
}
|
||||
|
||||
export function validateGroupItems(
|
||||
groupCarrier: string,
|
||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
|
||||
) {
|
||||
const channelIds = new Set<string>();
|
||||
const provinces = new Set<string>();
|
||||
const nationalPriorities = new Set<number>();
|
||||
for (const item of items) {
|
||||
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
|
||||
if (itemCarrier !== groupCarrier) {
|
||||
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
|
||||
}
|
||||
const channel = channels.get(item.channelId);
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (channelIds.has(item.channelId)) {
|
||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||
}
|
||||
channelIds.add(item.channelId);
|
||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||
}
|
||||
if (item.province) {
|
||||
const province = normalizeRegion(item.province);
|
||||
if (provinces.has(province)) {
|
||||
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
|
||||
}
|
||||
provinces.add(province);
|
||||
if (!isRegionCompatible(channel.sendRegion, item.province)) {
|
||||
throw new BadRequestException('Province route must use a channel with the same sendRegion');
|
||||
}
|
||||
} else {
|
||||
const priority = item.priority ?? 100;
|
||||
if (nationalPriorities.has(priority)) {
|
||||
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
|
||||
}
|
||||
nationalPriorities.add(priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReportType(value?: string) {
|
||||
if (value === 'signature' || value === 'drainage' || value === 'both') return value;
|
||||
throw new BadRequestException('reportType must be signature, drainage or both');
|
||||
}
|
||||
|
||||
export function summarizeReportStatuses(statuses: string[]) {
|
||||
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
let status = 'pending';
|
||||
if (approved === statuses.length) status = 'approved';
|
||||
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
|
||||
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
|
||||
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
|
||||
return { status, approved, total: statuses.length };
|
||||
}
|
||||
|
||||
export function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connect_requested')) {
|
||||
return '连接请求';
|
||||
}
|
||||
if (action.includes('connected')) {
|
||||
return '连接成功';
|
||||
}
|
||||
if (action.includes('heartbeat')) {
|
||||
return '心跳';
|
||||
}
|
||||
if (action.includes('reconnecting')) {
|
||||
return '重连';
|
||||
}
|
||||
if (action.includes('disconnected')) {
|
||||
return '断开';
|
||||
}
|
||||
if (action.includes('failed')) {
|
||||
return '连接失败';
|
||||
}
|
||||
if (action.includes('copy')) {
|
||||
return '复制';
|
||||
}
|
||||
if (action.includes('deleted')) {
|
||||
return '删除';
|
||||
}
|
||||
return '更新';
|
||||
}
|
||||
@@ -104,6 +104,9 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]),
|
||||
},
|
||||
drainageDetectionRule: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
createMany: jest.fn(),
|
||||
@@ -582,6 +585,7 @@ describe('ChannelsService', () => {
|
||||
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
|
||||
await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20');
|
||||
await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters');
|
||||
await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow('longMessageReceiptMode must be per_segment or message_level');
|
||||
});
|
||||
|
||||
it('updates CMPP channel configuration without requiring password changes', async () => {
|
||||
@@ -683,6 +687,20 @@ describe('ChannelsService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('persists a message-level long-message receipt mode without requesting a reconnect', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.updateChannel('channel-1', { config: { longMessageReceiptMode: 'message_level' } });
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }),
|
||||
}),
|
||||
}));
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid channel update ports', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { shanghaiDateRange } from './shanghai-date-range';
|
||||
|
||||
describe('shanghaiDateRange', () => {
|
||||
it('builds an inclusive Asia/Shanghai day range', () => {
|
||||
expect(shanghaiDateRange('2026-08-01', '2026-08-03')).toEqual({
|
||||
gte: new Date('2026-08-01T00:00:00+08:00'),
|
||||
lte: new Date('2026-08-03T23:59:59.999+08:00'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed or reversed ranges', () => {
|
||||
expect(() => shanghaiDateRange('2026/08/01', undefined)).toThrow(BadRequestException);
|
||||
expect(() => shanghaiDateRange('2026-02-31', undefined)).toThrow('日期无效');
|
||||
expect(() => shanghaiDateRange('2026-08-03', '2026-08-01')).toThrow('开始日期不能晚于结束日期');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function parseBoundary(value: string | undefined, endOfDay: boolean) {
|
||||
if (!value) return undefined;
|
||||
if (!DATE_PATTERN.test(value)) throw new BadRequestException('日期格式必须为 YYYY-MM-DD');
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const calendarDate = new Date(Date.UTC(year, month - 1, day));
|
||||
if (calendarDate.getUTCFullYear() !== year || calendarDate.getUTCMonth() !== month - 1 || calendarDate.getUTCDate() !== day) {
|
||||
throw new BadRequestException('日期无效');
|
||||
}
|
||||
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00'}+08:00`);
|
||||
if (Number.isNaN(parsed.getTime())) throw new BadRequestException('日期无效');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Converts UI calendar dates to an inclusive Asia/Shanghai database range. */
|
||||
export function shanghaiDateRange(from?: string, to?: string) {
|
||||
const gte = parseBoundary(from, false);
|
||||
const lte = parseBoundary(to, true);
|
||||
if (gte && lte && gte > lte) throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
return gte || lte ? { gte, lte } : undefined;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
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 {
|
||||
CreateCommonReportFieldDto,
|
||||
CreateBlacklistDto,
|
||||
CreateDrainageFieldDto,
|
||||
UpsertDrainageDetectionRuleDto,
|
||||
TestDrainageDetectionDto,
|
||||
CreatePhoneCarrierRuleDto,
|
||||
CreatePhoneSegmentDto,
|
||||
CreateSensitiveWordDto,
|
||||
@@ -125,6 +128,31 @@ export class DictionariesController {
|
||||
return this.dictionaries.deleteDrainageField(id);
|
||||
}
|
||||
|
||||
@Get('drainage-detection-rules')
|
||||
listDrainageDetectionRules(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listDrainageDetectionRules({ keyword, status });
|
||||
}
|
||||
|
||||
@Post('drainage-detection-rules/test')
|
||||
testDrainageDetection(@Body() body: TestDrainageDetectionDto) {
|
||||
return this.dictionaries.testDrainageDetection(body);
|
||||
}
|
||||
|
||||
@Post('drainage-detection-rules')
|
||||
createDrainageDetectionRule(@Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.dictionaries.createDrainageDetectionRule({ ...body, operatorId });
|
||||
}
|
||||
|
||||
@Put('drainage-detection-rules/:id')
|
||||
updateDrainageDetectionRule(@Param('id') id: string, @Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.dictionaries.updateDrainageDetectionRule(id, { ...body, operatorId });
|
||||
}
|
||||
|
||||
@Post('drainage-detection-rules/:id/status')
|
||||
changeDrainageDetectionRuleStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.dictionaries.changeDrainageDetectionRuleStatus(id, { ...body, operatorId });
|
||||
}
|
||||
|
||||
@Get('common-report-fields')
|
||||
listCommonReportFields() {
|
||||
return this.dictionaries.listCommonReportFields();
|
||||
|
||||
@@ -2,6 +2,11 @@ import { BadRequestException, ConflictException, Injectable, Optional } from '@n
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||
import {
|
||||
detectDrainageContentWithRules,
|
||||
invalidateDrainageDetectionRuleCache,
|
||||
validateDrainageDetectionPattern,
|
||||
} from '../send-chain/drainage-content-detection';
|
||||
|
||||
export interface CreatePhoneSegmentDto {
|
||||
prefix: string;
|
||||
@@ -58,6 +63,23 @@ export interface CreateDrainageFieldDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpsertDrainageDetectionRuleDto {
|
||||
code: string;
|
||||
name: string;
|
||||
category: 'url' | 'mobile' | 'landline';
|
||||
pattern: string;
|
||||
flags?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
description?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface TestDrainageDetectionDto {
|
||||
content: string;
|
||||
rule?: UpsertDrainageDetectionRuleDto;
|
||||
}
|
||||
|
||||
export interface CreateCommonReportFieldDto {
|
||||
drainageFieldId: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
@@ -374,6 +396,97 @@ export class DictionariesService {
|
||||
});
|
||||
}
|
||||
|
||||
listDrainageDetectionRules(query: { keyword?: string; status?: string } = {}) {
|
||||
const keyword = query.keyword?.trim();
|
||||
return this.prisma.drainageDetectionRule.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
OR: keyword ? [
|
||||
{ code: { contains: keyword, mode: 'insensitive' } },
|
||||
{ name: { contains: keyword, mode: 'insensitive' } },
|
||||
{ description: { contains: keyword, mode: 'insensitive' } },
|
||||
] : undefined,
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) {
|
||||
this.validateDrainageDetectionRule(data);
|
||||
const created = await this.prisma.drainageDetectionRule.create({
|
||||
data: {
|
||||
code: data.code.trim().toUpperCase(),
|
||||
name: data.name.trim(),
|
||||
category: data.category,
|
||||
pattern: data.pattern,
|
||||
flags: data.flags ?? 'giu',
|
||||
priority: data.priority ?? 100,
|
||||
status: data.status ?? 'active',
|
||||
description: data.description?.trim() || null,
|
||||
},
|
||||
});
|
||||
invalidateDrainageDetectionRuleCache();
|
||||
await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.create', 'drainage_detection_rule', created.id, { code: created.code });
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateDrainageDetectionRule(id: string, data: UpsertDrainageDetectionRuleDto) {
|
||||
this.validateDrainageDetectionRule(data);
|
||||
const updated = await this.prisma.drainageDetectionRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
code: data.code.trim().toUpperCase(),
|
||||
name: data.name.trim(),
|
||||
category: data.category,
|
||||
pattern: data.pattern,
|
||||
flags: data.flags ?? 'giu',
|
||||
priority: data.priority ?? 100,
|
||||
status: data.status ?? 'active',
|
||||
description: data.description?.trim() || null,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
invalidateDrainageDetectionRuleCache();
|
||||
await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.update', 'drainage_detection_rule', id, { code: updated.code, version: updated.version });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeDrainageDetectionRuleStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status === 'inactive' ? 'inactive' : 'active';
|
||||
const updated = await this.prisma.drainageDetectionRule.update({
|
||||
where: { id },
|
||||
data: { status, version: { increment: 1 } },
|
||||
});
|
||||
invalidateDrainageDetectionRuleCache();
|
||||
await this.writeOperationLog(data.operatorId, `drainage_detection_rule.${status}`, 'drainage_detection_rule', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async testDrainageDetection(data: TestDrainageDetectionDto) {
|
||||
if (!data.content?.trim()) throw new BadRequestException('测试短信内容不能为空');
|
||||
const rules = data.rule
|
||||
? [{
|
||||
id: 'preview',
|
||||
code: data.rule.code?.trim().toUpperCase() || 'PREVIEW',
|
||||
name: data.rule.name?.trim() || '预览规则',
|
||||
category: data.rule.category,
|
||||
pattern: data.rule.pattern,
|
||||
flags: data.rule.flags ?? 'giu',
|
||||
priority: data.rule.priority ?? 100,
|
||||
version: 1,
|
||||
}]
|
||||
: await this.prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: { priority: 'asc' } });
|
||||
if (data.rule) this.validateDrainageDetectionRule(data.rule);
|
||||
return detectDrainageContentWithRules(data.content, rules);
|
||||
}
|
||||
|
||||
private validateDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) {
|
||||
if (!data.code?.trim() || !data.name?.trim()) throw new BadRequestException('规则编码和名称不能为空');
|
||||
if (!['url', 'mobile', 'landline'].includes(data.category)) throw new BadRequestException('规则类型仅支持 URL、手机号或固话');
|
||||
if (data.status && !['active', 'inactive'].includes(data.status)) throw new BadRequestException('规则状态不正确');
|
||||
validateDrainageDetectionPattern(data.pattern, data.flags ?? 'giu');
|
||||
}
|
||||
|
||||
listCommonReportFields() {
|
||||
return this.prisma.commonReportField.findMany({
|
||||
include: { drainageField: true },
|
||||
|
||||
@@ -11,6 +11,21 @@ describe('OpenApiService', () => {
|
||||
expect(decryptSecret(encrypted)).toBe('customer-secret');
|
||||
});
|
||||
|
||||
it('returns the configured public HTTPS origin for customer integration parameters', async () => {
|
||||
const previous = process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||
process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/';
|
||||
const prisma = {
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
|
||||
};
|
||||
try {
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }));
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||
else process.env.HTTP_API_PUBLIC_ORIGIN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('replays a completed request for the same idempotency key and body', async () => {
|
||||
const prisma = {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
|
||||
|
||||
@@ -68,6 +68,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
return {
|
||||
applicationId,
|
||||
applicationName: application.name,
|
||||
publicOrigin: httpApiPublicOrigin(),
|
||||
config: application.httpConfig,
|
||||
ipAllowlist: application.httpIpAllowlist.map((item) => item.ipCidr),
|
||||
};
|
||||
@@ -86,7 +87,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }),
|
||||
...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []),
|
||||
]);
|
||||
return { applicationId, config, ipAllowlist };
|
||||
return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist };
|
||||
}
|
||||
|
||||
async listCredentials(applicationId: string, tenantId?: string) {
|
||||
@@ -419,6 +420,18 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
function httpApiPublicOrigin() {
|
||||
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
|
||||
if (!configured) return undefined;
|
||||
const url = new URL(configured);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
||||
// This value is copied into customer integration parameters, so fail closed instead of
|
||||
// publishing an insecure or path-dependent endpoint when deployment config is wrong.
|
||||
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址');
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function normalizeOpenApiFailure(error: unknown) {
|
||||
if (error instanceof HttpException) {
|
||||
const value = error.getResponse();
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AdminOperationsController {
|
||||
@Query('queuedAtFrom') queuedAtFrom?: string,
|
||||
@Query('queuedAtTo') queuedAtTo?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('hasDrainage') hasDrainage?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
@@ -59,6 +60,7 @@ export class AdminOperationsController {
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
status,
|
||||
hasDrainage,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
@@ -76,6 +78,7 @@ export class AdminOperationsController {
|
||||
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
|
||||
@Query('queuedAtTo') queuedAtTo: string | undefined,
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('hasDrainage') hasDrainage: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
const exported = await this.operations.exportMessages({
|
||||
@@ -89,6 +92,7 @@ export class AdminOperationsController {
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
status,
|
||||
hasDrainage,
|
||||
});
|
||||
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
|
||||
@@ -123,6 +127,11 @@ export class AdminOperationsController {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('pending-audits')
|
||||
pendingAudits(@Query('tenantId') tenantId?: string) {
|
||||
return this.operations.pendingAudits(tenantId);
|
||||
}
|
||||
|
||||
@Get('dashboard/statistics')
|
||||
dashboardStatistics(@Query('tenantId') tenantId?: string) {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
@@ -216,6 +225,29 @@ export class AdminOperationsController {
|
||||
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
|
||||
}
|
||||
|
||||
@Get('receipt-anomalies')
|
||||
receiptAnomalies(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('anomalyType') anomalyType?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.listReceiptAnomalies({
|
||||
tenantId,
|
||||
applicationId,
|
||||
channelId,
|
||||
anomalyType,
|
||||
status,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('downstream-deliveries')
|
||||
downstreamDeliveries(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Stable controller/query contracts extracted in R2.
|
||||
|
||||
export interface MessageQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
channelKeyword?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
carrier?: string;
|
||||
status?: string;
|
||||
hasDrainage?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface TraceQuery extends MessageQuery {
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface OperationLogQuery {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitDeadLetterQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ReceiptAnomalyQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
anomalyType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryDashboardQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamRecoveryStatusQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
state?: string;
|
||||
failureCategory?: string;
|
||||
keyword?: string;
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MessageSegmentAuditQuery {
|
||||
messageId?: string;
|
||||
messageRecordId?: string;
|
||||
}
|
||||
|
||||
export interface SignatureQualityQuery {
|
||||
date?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts';
|
||||
|
||||
// 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 }
|
||||
: {};
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
batchTaskId: query.taskId,
|
||||
messageId: query.messageId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
...carrierWhere(query.carrier),
|
||||
...statusWhere,
|
||||
...(query.hasDrainage === 'true' ? { hasDrainageContent: true }
|
||||
: query.hasDrainage === 'false' ? { hasDrainageContent: false }
|
||||
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null }
|
||||
: {}),
|
||||
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||
...(query.queuedAtFrom || query.queuedAtTo ? {
|
||||
queuedAt: {
|
||||
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
|
||||
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const recognizedCarrierValues = [
|
||||
'mobile', 'cmcc', '移动', '中国移动',
|
||||
'unicom', 'cucc', '联通', '中国联通',
|
||||
'telecom', 'ctcc', '电信', '中国电信',
|
||||
];
|
||||
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
|
||||
if (!carrier) return {};
|
||||
// Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized.
|
||||
if (carrier === 'unknown') {
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ carrier: null },
|
||||
{ carrier: { notIn: recognizedCarrierValues } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const valuesByCarrier: Record<string, string[]> = {
|
||||
mobile: ['mobile', 'cmcc', '移动', '中国移动'],
|
||||
unicom: ['unicom', 'cucc', '联通', '中国联通'],
|
||||
telecom: ['telecom', 'ctcc', '电信', '中国电信'],
|
||||
};
|
||||
return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {};
|
||||
}
|
||||
export function startOfShanghaiDay(value: string) {
|
||||
return new Date(`${value}T00:00:00+08:00`);
|
||||
}
|
||||
export function endOfShanghaiDay(value: string) {
|
||||
return new Date(`${value}T23:59:59.999+08:00`);
|
||||
}
|
||||
export function qualityBusinessDay(value?: string) {
|
||||
const key = value || shanghaiDateKey();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) {
|
||||
throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD');
|
||||
}
|
||||
const startAt = startOfShanghaiDay(key);
|
||||
if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) {
|
||||
throw new BadRequestException('统计日期无效');
|
||||
}
|
||||
return {
|
||||
key,
|
||||
startAt,
|
||||
endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
export function shanghaiDateKey(value = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(value);
|
||||
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
|
||||
}
|
||||
export function normalizeGroupBy(groupBy?: string) {
|
||||
if (groupBy === 'tenant' || groupBy === 'tenantId') {
|
||||
return 'tenantId';
|
||||
}
|
||||
if (groupBy === 'application' || groupBy === 'applicationId') {
|
||||
return 'applicationId';
|
||||
}
|
||||
return 'channelId';
|
||||
}
|
||||
export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
|
||||
return {
|
||||
tenantId,
|
||||
createdAt: { gte: since },
|
||||
OR: [
|
||||
{ transactionType: 'refunded' },
|
||||
{ transactionType: 'released', relatedType: 'sms_message_record' },
|
||||
],
|
||||
};
|
||||
}
|
||||
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
|
||||
if (!range || range === 'all') {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (range === '7d') {
|
||||
date.setDate(date.getDate() - 6);
|
||||
} else if (range === '30d') {
|
||||
date.setDate(date.getDate() - 29);
|
||||
}
|
||||
return { gte: date };
|
||||
}
|
||||
export function downstreamAlertPendingMinutes() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : 10;
|
||||
}
|
||||
export function downstreamAlertRecentFailedHours() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1);
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
export function downstreamAlertWindows(now = new Date()) {
|
||||
return {
|
||||
now,
|
||||
stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000),
|
||||
recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000),
|
||||
};
|
||||
}
|
||||
export function downstreamAlertWhere(
|
||||
scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput,
|
||||
window: ReturnType<typeof downstreamAlertWindows>,
|
||||
): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
return {
|
||||
AND: [
|
||||
scopedWhere,
|
||||
{
|
||||
OR: [
|
||||
stalledPendingWhere(window.stalledPendingAt),
|
||||
{ status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } },
|
||||
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
return {
|
||||
status: 'pending',
|
||||
OR: [
|
||||
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
|
||||
{ lastRetriedAt: { lte: cutoff } },
|
||||
],
|
||||
};
|
||||
}
|
||||
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
|
||||
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
|
||||
createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined,
|
||||
};
|
||||
}
|
||||
export function parseDateBoundary(value?: string, endOfDay = false) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false);
|
||||
const updatedAtTo = parseDateBoundary(query.updatedAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
state: query.state && query.state !== 'all' ? query.state : undefined,
|
||||
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
|
||||
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ account: { contains: query.keyword } },
|
||||
{ gatewayInstanceId: { contains: query.keyword } },
|
||||
{ lastError: { contains: query.keyword } },
|
||||
{ lastSkipReason: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
}
|
||||
export function escapeCsvCell(value: string) {
|
||||
let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (/^[=+\-@]/.test(normalized)) {
|
||||
normalized = `'${normalized}`;
|
||||
}
|
||||
if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) {
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
export function formatCsvDate(value?: Date | string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
export function formatExportTimestamp(date: Date) {
|
||||
const parts = [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
String(date.getHours()).padStart(2, '0'),
|
||||
String(date.getMinutes()).padStart(2, '0'),
|
||||
String(date.getSeconds()).padStart(2, '0'),
|
||||
];
|
||||
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
||||
}
|
||||
export function clientApplicationView(application?: Record<string, any> | null) {
|
||||
if (!application) return null;
|
||||
return { id: application.id, name: application.name };
|
||||
}
|
||||
export function clientReceiptView(receipt: Record<string, any>) {
|
||||
return {
|
||||
id: receipt.id,
|
||||
messageId: receipt.messageId,
|
||||
receiptStatus: receipt.receiptStatus,
|
||||
rawStatus: receipt.rawStatus,
|
||||
errorCode: receipt.errorCode ?? null,
|
||||
errorMessage: receipt.errorMessage ?? null,
|
||||
deliveredAt: receipt.deliveredAt,
|
||||
createdAt: receipt.createdAt,
|
||||
};
|
||||
}
|
||||
export function clientMessageView(message: Record<string, any>) {
|
||||
return {
|
||||
id: message.id,
|
||||
batchTaskId: message.batchTaskId ?? null,
|
||||
applicationId: message.applicationId ?? null,
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
carrier: message.carrier ?? null,
|
||||
province: message.province ?? null,
|
||||
content: message.content,
|
||||
billingUnits: message.billingUnits,
|
||||
amountCents: moneyToNumber(message.amountCents),
|
||||
status: message.status,
|
||||
submitStatus: message.submitStatus ?? null,
|
||||
receiptStatus: message.receiptStatus ?? null,
|
||||
errorCode: message.errorCode ?? null,
|
||||
errorMessage: message.errorMessage ?? null,
|
||||
queuedAt: message.queuedAt,
|
||||
submittedAt: message.submittedAt ?? null,
|
||||
deliveredAt: message.deliveredAt ?? null,
|
||||
application: clientApplicationView(message.application),
|
||||
receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [],
|
||||
};
|
||||
}
|
||||
export function clientBatchTaskView(task: Record<string, any>) {
|
||||
return {
|
||||
id: task.id,
|
||||
taskNo: task.taskNo,
|
||||
applicationId: task.applicationId ?? null,
|
||||
templateId: task.templateId ?? null,
|
||||
content: task.content,
|
||||
category: task.category ?? null,
|
||||
phoneTotal: task.phoneTotal,
|
||||
status: task.status,
|
||||
auditStatus: task.auditStatus ?? null,
|
||||
reviewReason: task.reviewReason ?? null,
|
||||
rejectReason: task.rejectReason ?? null,
|
||||
progressTotal: task.progressTotal,
|
||||
progressSent: task.progressSent ?? 0,
|
||||
progressDelivered: task.progressDelivered ?? 0,
|
||||
progressFailed: task.progressFailed ?? 0,
|
||||
submittedTotal: task.submittedTotal ?? 0,
|
||||
successTotal: task.successTotal ?? 0,
|
||||
failedTotal: task.failedTotal ?? 0,
|
||||
unknownTotal: task.unknownTotal ?? 0,
|
||||
timeoutTotal: task.timeoutTotal ?? 0,
|
||||
scheduledAt: task.scheduledAt ?? null,
|
||||
canceledAt: task.canceledAt ?? null,
|
||||
createdAt: task.createdAt,
|
||||
application: clientApplicationView(task.application),
|
||||
messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [],
|
||||
};
|
||||
}
|
||||
export function clientUplinkView(message: Record<string, any>) {
|
||||
return {
|
||||
id: message.id,
|
||||
applicationId: message.applicationId ?? null,
|
||||
messageRecordId: message.messageRecordId ?? null,
|
||||
messageId: message.messageId ?? null,
|
||||
phoneNumber: message.phoneNumber,
|
||||
destId: message.destId,
|
||||
content: message.content,
|
||||
matchStatus: message.matchStatus,
|
||||
matchReason: message.matchReason ?? null,
|
||||
receivedAt: message.receivedAt,
|
||||
createdAt: message.createdAt,
|
||||
application: clientApplicationView(message.application),
|
||||
messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null,
|
||||
};
|
||||
}
|
||||
export function clientAccountView(account: Record<string, any>) {
|
||||
return {
|
||||
id: account.id,
|
||||
tenantId: account.tenantId,
|
||||
balanceCents: moneyToNumber(account.balanceCents),
|
||||
creditCents: moneyToNumber(account.creditCents),
|
||||
status: account.status,
|
||||
updatedAt: account.updatedAt,
|
||||
tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null,
|
||||
};
|
||||
}
|
||||
export function clientRechargeView(order: Record<string, any>) {
|
||||
return {
|
||||
id: order.id,
|
||||
orderNo: order.orderNo,
|
||||
amountCents: moneyToNumber(order.amountCents),
|
||||
status: order.status,
|
||||
payMethod: order.payMethod,
|
||||
remark: order.remark ?? null,
|
||||
createdAt: order.createdAt,
|
||||
completedAt: order.completedAt ?? null,
|
||||
};
|
||||
}
|
||||
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
|
||||
return groups.reduce(
|
||||
(summary, group) => {
|
||||
const count = group._count._all;
|
||||
summary.total += count;
|
||||
summary.amountCents += moneyToNumber(group._sum.amountCents);
|
||||
summary.billingUnits += group._sum.billingUnits ?? 0;
|
||||
if (group.status === 'delivered') {
|
||||
summary.delivered += count;
|
||||
} else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) {
|
||||
summary.failed += count;
|
||||
} else if (group.status === 'unknown') {
|
||||
summary.unknown += count;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{ total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 },
|
||||
);
|
||||
}
|
||||
export function groupDownstreamByType(
|
||||
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
|
||||
) {
|
||||
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
|
||||
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'awaiting_ack') {
|
||||
current.awaitingAck += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'unconfirmed') {
|
||||
current.unconfirmed += item._count._all;
|
||||
} else if (item.status === 'rejected') {
|
||||
current.rejected += item._count._all;
|
||||
}
|
||||
accumulator[item.deliveryType] = current;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
export function groupDownstreamByApplication(
|
||||
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
||||
applicationMap: Map<string, string>,
|
||||
applicationAlertMap: Map<string, number>,
|
||||
) {
|
||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
|
||||
groups.forEach((item) => {
|
||||
const current = summaryMap.get(item.applicationId) ?? {
|
||||
applicationId: item.applicationId,
|
||||
name: applicationMap.get(item.applicationId) ?? item.applicationId,
|
||||
pending: 0,
|
||||
awaitingAck: 0,
|
||||
failed: 0,
|
||||
unconfirmed: 0,
|
||||
rejected: 0,
|
||||
delivered: 0,
|
||||
alertCount: 0,
|
||||
};
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'awaiting_ack') {
|
||||
current.awaitingAck += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'unconfirmed') {
|
||||
current.unconfirmed += item._count._all;
|
||||
} else if (item.status === 'rejected') {
|
||||
current.rejected += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
}
|
||||
current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0;
|
||||
summaryMap.set(item.applicationId, current);
|
||||
});
|
||||
return [...summaryMap.values()];
|
||||
}
|
||||
export function positiveInteger(value: number | undefined, fallback: number) {
|
||||
const normalized = Number(value);
|
||||
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
|
||||
}
|
||||
export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput {
|
||||
const error: Prisma.OperationLogWhereInput = {
|
||||
OR: [
|
||||
{ action: { contains: 'failed' } },
|
||||
{ action: { contains: 'reject' } },
|
||||
{ detail: { path: ['result'], string_contains: 'fail' } },
|
||||
{ detail: { path: ['status'], string_contains: 'fail' } },
|
||||
],
|
||||
};
|
||||
const warning: Prisma.OperationLogWhereInput = {
|
||||
OR: [
|
||||
{ action: { contains: 'warning' } },
|
||||
{ action: { contains: 'risk' } },
|
||||
],
|
||||
};
|
||||
const success: Prisma.OperationLogWhereInput = {
|
||||
OR: [
|
||||
{ action: { contains: 'approve' } },
|
||||
{ action: { contains: 'recharge' } },
|
||||
{ action: { contains: 'connected' } },
|
||||
],
|
||||
};
|
||||
if (level === 'error') {
|
||||
return error;
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return { AND: [{ NOT: error }, warning] };
|
||||
}
|
||||
if (level === 'success') {
|
||||
return { AND: [{ NOT: error }, { NOT: warning }, success] };
|
||||
}
|
||||
if (level === 'info') {
|
||||
return { NOT: { OR: [error, warning, success] } };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
|
||||
const detail = (log.detail ?? {}) as Record<string, unknown>;
|
||||
const result = String(detail.result ?? detail.status ?? '');
|
||||
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
|
||||
? 'error'
|
||||
: log.action.includes('warning') || log.action.includes('risk')
|
||||
? 'warning'
|
||||
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
|
||||
? 'success'
|
||||
: 'info';
|
||||
return {
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
level,
|
||||
tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'),
|
||||
module: log.resource,
|
||||
operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system',
|
||||
action: log.action,
|
||||
resourceId: log.resourceId ?? '',
|
||||
detail,
|
||||
ip: log.ipAddress ?? '',
|
||||
userAgent: log.userAgent ?? '',
|
||||
};
|
||||
}
|
||||
export function sanitizeGatewaySubmitException(
|
||||
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
|
||||
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
|
||||
) {
|
||||
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
|
||||
return {
|
||||
...record,
|
||||
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
|
||||
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
|
||||
channel: channel ? {
|
||||
id: channel.id,
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
status: channel.status,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
} : null,
|
||||
rawPayloadAvailable: Boolean(rawPayload),
|
||||
commandPayload: redactGatewayCommandValue(commandPayload),
|
||||
messageState: messageState ?? null,
|
||||
};
|
||||
}
|
||||
export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactGatewayCommandValue(item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const redacted: Record<string, Prisma.JsonValue | null> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
redacted[key] = [
|
||||
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
|
||||
'token', 'apikey', 'accesskey', 'secretkey',
|
||||
].includes(normalizedKey)
|
||||
? '[REDACTED]'
|
||||
: redactGatewayCommandValue(child as Prisma.JsonValue);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ function createPrismaMock() {
|
||||
user: {
|
||||
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
|
||||
},
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '企业A' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||
count: jest.fn().mockResolvedValue(3),
|
||||
@@ -50,6 +53,7 @@ function createPrismaMock() {
|
||||
},
|
||||
enterpriseCertification: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'certification-1' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
@@ -92,6 +96,26 @@ function createPrismaMock() {
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
|
||||
},
|
||||
smsReceiptAnomaly: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'receipt-anomaly-1',
|
||||
anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1',
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
status: 'pending',
|
||||
occurrenceCount: 1,
|
||||
firstOccurredAt: new Date('2026-08-06T01:00:00.000Z'),
|
||||
lastOccurredAt: new Date('2026-08-06T01:00:00.000Z'),
|
||||
tenant: { name: '租户A' },
|
||||
application: { name: '应用A' },
|
||||
channel: { name: '通道A' },
|
||||
messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' },
|
||||
submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' },
|
||||
receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' },
|
||||
}]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }),
|
||||
},
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'recover-1',
|
||||
@@ -206,6 +230,7 @@ describe('OperationsService', () => {
|
||||
queuedAtFrom: '2026-07-01',
|
||||
queuedAtTo: '2026-07-02',
|
||||
status: 'delivered',
|
||||
hasDrainage: 'true',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
||||
@@ -218,6 +243,7 @@ describe('OperationsService', () => {
|
||||
phoneNumber: '13800000001',
|
||||
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
|
||||
status: 'delivered',
|
||||
hasDrainageContent: true,
|
||||
content: { contains: '验证码', mode: 'insensitive' },
|
||||
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
|
||||
queuedAt: {
|
||||
@@ -388,6 +414,22 @@ describe('OperationsService', () => {
|
||||
|
||||
expect(dashboard.gatewayConnections).toEqual([]);
|
||||
expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]);
|
||||
expect(dashboard.clientOverview).toEqual({
|
||||
enterpriseName: '企业A',
|
||||
certificationStatus: 'certified',
|
||||
signatureCount: 1,
|
||||
pendingBatchTaskCount: 3,
|
||||
});
|
||||
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', sourceType: 'client', status: 'pending_review' },
|
||||
});
|
||||
expect(prisma.enterpriseCertification.findFirst).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', status: 'approved' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.smsSignature.count).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
});
|
||||
expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/);
|
||||
});
|
||||
|
||||
@@ -480,6 +522,10 @@ describe('OperationsService', () => {
|
||||
updatedAt: { gte: expect.any(Date) },
|
||||
},
|
||||
});
|
||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||
expect(hourlyTrendQuery.sql).toContain(
|
||||
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
|
||||
);
|
||||
expect(prisma.accountTransaction.aggregate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: 'tenant-1',
|
||||
@@ -558,6 +604,7 @@ describe('OperationsService', () => {
|
||||
|
||||
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
drainageSignatures: [],
|
||||
summary: {
|
||||
total: 5,
|
||||
successCount: 3,
|
||||
@@ -585,6 +632,27 @@ describe('OperationsService', () => {
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('returns pending audit counts without running the full dashboard aggregation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.pendingAudits('tenant-1')).resolves.toEqual({
|
||||
enterpriseCertifications: 1,
|
||||
smsAudits: 2,
|
||||
templates: 1,
|
||||
signatures: 1,
|
||||
drainageInfos: 0,
|
||||
total: 5,
|
||||
});
|
||||
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
|
||||
expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
|
||||
expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
|
||||
expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending' } });
|
||||
expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending_review' } });
|
||||
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid send quality dates', async () => {
|
||||
const service = new OperationsService(createPrismaMock() as never);
|
||||
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
|
||||
@@ -615,6 +683,7 @@ describe('OperationsService', () => {
|
||||
channelId: 'channel-1',
|
||||
channelName: '通道一',
|
||||
carrier: 'mobile',
|
||||
drainageState: 'with',
|
||||
total: 4,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 0,
|
||||
@@ -629,6 +698,7 @@ describe('OperationsService', () => {
|
||||
channelId: 'channel-2',
|
||||
channelName: '通道二',
|
||||
carrier: 'telecom',
|
||||
drainageState: 'without',
|
||||
total: 2,
|
||||
acceptedCount: 1,
|
||||
submitFailureCount: 1,
|
||||
@@ -689,6 +759,10 @@ describe('OperationsService', () => {
|
||||
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
|
||||
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
|
||||
],
|
||||
drainageBreakdowns: [
|
||||
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
|
||||
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
|
||||
],
|
||||
})],
|
||||
total: 12,
|
||||
page: 2,
|
||||
@@ -851,6 +925,49 @@ describe('OperationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns paginated receipt anomalies with status summary', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.listReceiptAnomalies({
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'channel-1',
|
||||
status: 'pending',
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
keyword: 'MSG-1',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
summary: {
|
||||
pending: 1,
|
||||
resolved: 0,
|
||||
ignored: 0,
|
||||
oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'),
|
||||
},
|
||||
}));
|
||||
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'channel-1',
|
||||
status: 'pending',
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
}),
|
||||
orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }],
|
||||
take: 10,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
channel: { select: { id: true, code: true, name: true, status: true } },
|
||||
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
|
||||
submitRecord: { select: { submitId: true, submitStatus: true } },
|
||||
receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns paginated downstream deliveries', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsDashboardQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const businessDay = qualityBusinessDay();
|
||||
const sinceToday = businessDay.startAt;
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const todayMessageWhereClause = {
|
||||
...messageWhereClause,
|
||||
queuedAt: { gte: sinceToday, lt: businessDay.endAt },
|
||||
};
|
||||
const [
|
||||
taskCount,
|
||||
messageGroups,
|
||||
todayMessageGroups,
|
||||
uplinkCount,
|
||||
billingAggregate,
|
||||
transactionAggregate,
|
||||
connectionGroups,
|
||||
pendingAudits,
|
||||
tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
enterpriseSpendRows,
|
||||
downstreamPendingCount,
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
downstreamStalledPendingCount,
|
||||
downstreamStalledAckCount,
|
||||
downstreamRecentFailedCount,
|
||||
hourlySendRows,
|
||||
auditSpeedRows,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: messageWhereClause,
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: todayMessageWhereClause,
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.accountTransaction.aggregate({
|
||||
where: returnedTransactionWhere(sinceToday, query.tenantId),
|
||||
_sum: { amountCents: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppConnectionState.groupBy({
|
||||
by: ['status'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
_sum: { currentConnections: true, desiredConnections: true },
|
||||
}),
|
||||
this.pendingAudits(query.tenantId),
|
||||
this.prisma.tenantAccount.findMany({
|
||||
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.smsBatchTask.findMany({
|
||||
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||
include: { application: true, messages: { take: 1, include: { channel: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.rechargeOrder.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
payMethod: 'manual_topup',
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
todaySpendCents: bigint;
|
||||
balanceCents: bigint;
|
||||
creditCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents",
|
||||
account."balanceCents" AS "balanceCents",
|
||||
account."creditCents" AS "creditCents"
|
||||
FROM "TenantAccount" account
|
||||
JOIN "Tenant" tenant ON tenant.id = account."tenantId"
|
||||
LEFT JOIN "SmsBillingRecord" billing
|
||||
ON billing."tenantId" = tenant.id
|
||||
AND billing."createdAt" >= ${businessDay.startAt}
|
||||
AND billing."createdAt" < ${businessDay.endAt}
|
||||
WHERE tenant.status <> 'deleted'
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'pending' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'failed' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'delivered' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt),
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: 'awaiting_ack',
|
||||
ackDeadlineAt: { lte: downstreamAlertWindow.now },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
hour: number;
|
||||
submittedCount: bigint;
|
||||
successCount: bigint;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
EXTRACT(
|
||||
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
|
||||
)::integer AS hour,
|
||||
COUNT(*)::bigint AS "submittedCount",
|
||||
COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`),
|
||||
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
|
||||
this.prisma.$queryRaw<Array<{
|
||||
category: string;
|
||||
count: bigint;
|
||||
averageProcessingMs: bigint | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH review_samples AS (
|
||||
SELECT
|
||||
'enterpriseCertifications'::text AS category,
|
||||
certification."submittedAt" AS "submittedAt",
|
||||
certification."reviewedAt" AS "reviewedAt"
|
||||
FROM "EnterpriseCertification" certification
|
||||
WHERE certification."reviewedAt" >= ${businessDay.startAt}
|
||||
AND certification."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'smsAudits'::text,
|
||||
task."createdAt",
|
||||
task."reviewedAt"
|
||||
FROM "SmsSendTask" task
|
||||
WHERE task."reviewedAt" >= ${businessDay.startAt}
|
||||
AND task."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'drainageInfos'::text,
|
||||
drainage."submittedAt",
|
||||
drainage."reviewedAt"
|
||||
FROM "SmsDrainageInfo" drainage
|
||||
WHERE drainage."reviewedAt" >= ${businessDay.startAt}
|
||||
AND drainage."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CASE review."targetType"
|
||||
WHEN 'sms_signature' THEN 'signatures'
|
||||
WHEN 'sms_template' THEN 'templates'
|
||||
END,
|
||||
submission."createdAt",
|
||||
review."createdAt"
|
||||
FROM "AuditRecord" review
|
||||
JOIN LATERAL (
|
||||
SELECT pending."createdAt"
|
||||
FROM "AuditRecord" pending
|
||||
WHERE pending."targetType" = review."targetType"
|
||||
AND pending."targetId" = review."targetId"
|
||||
AND pending."statusAfter" = 'pending'
|
||||
AND pending."createdAt" <= review."createdAt"
|
||||
ORDER BY pending."createdAt" DESC
|
||||
LIMIT 1
|
||||
) submission ON true
|
||||
WHERE review."targetType" IN ('sms_signature', 'sms_template')
|
||||
AND review."statusBefore" = 'pending'
|
||||
AND review."statusAfter" IN ('approved', 'rejected')
|
||||
AND review."createdAt" >= ${businessDay.startAt}
|
||||
AND review."createdAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null})
|
||||
)
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*)::bigint AS count,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs"
|
||||
FROM review_samples
|
||||
WHERE "reviewedAt" >= "submittedAt"
|
||||
GROUP BY category
|
||||
`),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||
const row = hourlyRowsByHour.get(hour);
|
||||
return {
|
||||
hour,
|
||||
label: `${String(hour).padStart(2, '0')}:00`,
|
||||
submittedCount: Number(row?.submittedCount ?? 0),
|
||||
successCount: Number(row?.successCount ?? 0),
|
||||
};
|
||||
});
|
||||
const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row]));
|
||||
const auditProcessingSpeed = [
|
||||
['enterpriseCertifications', '企业认证'],
|
||||
['smsAudits', '短信审核'],
|
||||
['templates', '模板'],
|
||||
['signatures', '签名'],
|
||||
['drainageInfos', '引流信息'],
|
||||
].map(([category, label]) => {
|
||||
const row = auditSpeedByCategory.get(category);
|
||||
return {
|
||||
category,
|
||||
label,
|
||||
count: Number(row?.count ?? 0),
|
||||
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
|
||||
};
|
||||
});
|
||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||
return {
|
||||
taskCount,
|
||||
messageStatus: messageGroups,
|
||||
today: {
|
||||
sent: todayTotals.total,
|
||||
delivered: todayTotals.delivered,
|
||||
failed: todayTotals.failed,
|
||||
unknown: todayTotals.unknown,
|
||||
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
|
||||
spendCents: todayTotals.amountCents,
|
||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
},
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
transactions: transactionAggregate,
|
||||
gatewayConnections: connectionGroups,
|
||||
pendingAuditCount: pendingAudits.total,
|
||||
pendingAudits,
|
||||
hourlySendTrend,
|
||||
auditProcessingSpeed,
|
||||
downstreamDeliverySummary: {
|
||||
pending: downstreamPendingCount,
|
||||
failed: downstreamFailedCount,
|
||||
delivered: downstreamDeliveredCount,
|
||||
stalledPending: downstreamStalledPendingCount,
|
||||
stalledAck: downstreamStalledAckCount,
|
||||
recentFailed: downstreamRecentFailedCount,
|
||||
alertCount: downstreamAlertCount,
|
||||
},
|
||||
accounts: tenantAccounts,
|
||||
enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({
|
||||
tenantId: row.tenantId,
|
||||
tenantName: row.tenantName,
|
||||
todaySpendCents: moneyToNumber(row.todaySpendCents),
|
||||
balanceCents: moneyToNumber(row.balanceCents),
|
||||
creditCents: moneyToNumber(row.creditCents),
|
||||
})),
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
};
|
||||
}
|
||||
async clientDashboard(query: { tenantId?: string }) {
|
||||
const tenantId = query.tenantId;
|
||||
const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([
|
||||
this.dashboard(query),
|
||||
tenantId
|
||||
? this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true } })
|
||||
: Promise.resolve(null),
|
||||
tenantId
|
||||
? this.prisma.enterpriseCertification.findFirst({
|
||||
where: { tenantId, status: 'approved' },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
tenantId
|
||||
? this.prisma.smsSignature.count({
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
tenantId
|
||||
? this.prisma.smsBatchTask.count({
|
||||
where: { tenantId, sourceType: 'client', status: 'pending_review' },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
return {
|
||||
taskCount: dashboard.taskCount,
|
||||
messageStatus: dashboard.messageStatus,
|
||||
today: dashboard.today,
|
||||
uplinkCount: dashboard.uplinkCount,
|
||||
billing: dashboard.billing,
|
||||
transactions: dashboard.transactions,
|
||||
gatewayConnections: [],
|
||||
pendingAuditCount: dashboard.pendingAuditCount,
|
||||
pendingAudits: dashboard.pendingAudits,
|
||||
hourlySendTrend: dashboard.hourlySendTrend,
|
||||
auditProcessingSpeed: dashboard.auditProcessingSpeed,
|
||||
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
|
||||
accounts: dashboard.accounts.map(clientAccountView),
|
||||
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
|
||||
recentTasks: dashboard.recentTasks.map(clientBatchTaskView),
|
||||
recentRecharges: dashboard.recentRecharges.map(clientRechargeView),
|
||||
clientOverview: {
|
||||
enterpriseName: tenant?.name ?? null,
|
||||
certificationStatus: approvedCertification ? 'certified' : 'uncertified',
|
||||
signatureCount,
|
||||
pendingBatchTaskCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
pendingAudits(tenantId?: string) {
|
||||
return Promise.all([
|
||||
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
||||
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
|
||||
templates,
|
||||
signatures,
|
||||
drainageInfos,
|
||||
enterpriseCertifications,
|
||||
smsAudits,
|
||||
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, ReceiptAnomalyQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 downstream query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsDownstreamQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
OR: query.keyword ? [
|
||||
{ streamMessageId: { contains: query.keyword } },
|
||||
{ traceId: { contains: query.keyword } },
|
||||
{ messageId: { contains: query.keyword } },
|
||||
{ submitId: { contains: query.keyword } },
|
||||
{ failureCode: { contains: query.keyword } },
|
||||
{ failureMessage: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
...baseWhere,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
};
|
||||
const [items, total, statusGroups, oldestPending] = await Promise.all([
|
||||
this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.count({ where }),
|
||||
this.prisma.gatewaySubmitDeadLetter.groupBy({
|
||||
by: ['status'],
|
||||
where: baseWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.findFirst({
|
||||
where: { ...baseWhere, status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
|
||||
const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value));
|
||||
const messageStates = messageIds.length > 0
|
||||
? await this.prisma.smsMessageRecord.findMany({
|
||||
where: { messageId: { in: messageIds } },
|
||||
select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true },
|
||||
})
|
||||
: [];
|
||||
const messageStateById = new Map(messageStates.map((item) => [item.messageId, item]));
|
||||
return {
|
||||
items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
pending: statusCounts.get('pending') ?? 0,
|
||||
requeueing: statusCounts.get('requeueing') ?? 0,
|
||||
requeued: statusCounts.get('requeued') ?? 0,
|
||||
resolved: statusCounts.get('resolved') ?? 0,
|
||||
oldestPendingAt: oldestPending?.createdAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
async listReceiptAnomalies(query: ReceiptAnomalyQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const baseWhere: Prisma.SmsReceiptAnomalyWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
anomalyType: query.anomalyType && query.anomalyType !== 'all' ? query.anomalyType : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ anomalyKey: { contains: query.keyword } },
|
||||
{ rawStatus: { contains: query.keyword } },
|
||||
{ errorCode: { contains: query.keyword } },
|
||||
{ messageRecord: { messageId: { contains: query.keyword } } },
|
||||
{ submitRecord: { submitId: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const where: Prisma.SmsReceiptAnomalyWhereInput = {
|
||||
...baseWhere,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
};
|
||||
const [items, total, statusGroups, oldestPending] = await Promise.all([
|
||||
this.prisma.smsReceiptAnomaly.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
channel: { select: { id: true, code: true, name: true, status: true } },
|
||||
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
|
||||
submitRecord: { select: { submitId: true, submitStatus: true } },
|
||||
receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } },
|
||||
},
|
||||
orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsReceiptAnomaly.count({ where }),
|
||||
this.prisma.smsReceiptAnomaly.groupBy({
|
||||
by: ['status'],
|
||||
where: baseWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.smsReceiptAnomaly.findFirst({
|
||||
where: { ...baseWhere, status: 'pending' },
|
||||
orderBy: { firstOccurredAt: 'asc' },
|
||||
select: { firstOccurredAt: true },
|
||||
}),
|
||||
]);
|
||||
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
pending: statusCounts.get('pending') ?? 0,
|
||||
resolved: statusCounts.get('resolved') ?? 0,
|
||||
ignored: statusCounts.get('ignored') ?? 0,
|
||||
oldestPendingAt: oldestPending?.firstOccurredAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where: Prisma.CmppDownstreamDeliveryWhereInput = {
|
||||
...downstreamDeliveryScopedWhere(query),
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ messageId: { contains: query.keyword } },
|
||||
{ payload: { path: ['account'], string_contains: query.keyword } },
|
||||
{ payload: { path: ['phoneNumber'], string_contains: query.keyword } },
|
||||
{ lastError: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: true,
|
||||
attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
|
||||
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt),
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['deliveryType', 'status'],
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['applicationId', 'status'],
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['applicationId'],
|
||||
where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow),
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: 0,
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: { gte: 1, lte: 3 },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: { gte: 4 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))];
|
||||
const applications: Array<{ id: string; name: string }> = applicationIds.length > 0
|
||||
? await this.prisma.smsApplication.findMany({
|
||||
where: { id: { in: applicationIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
|
||||
const applicationAlertMap = new Map<string, number>(
|
||||
applicationAlertGroups.map((item) => [item.applicationId, item._count._all]),
|
||||
);
|
||||
const groupedByType = groupDownstreamByType(typeGroups);
|
||||
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total,
|
||||
pending,
|
||||
awaitingAck,
|
||||
delivered,
|
||||
failed,
|
||||
unconfirmed,
|
||||
rejected,
|
||||
stalledPending,
|
||||
stalledAck,
|
||||
recentFailed,
|
||||
alertCount: stalledPending + stalledAck + recentFailed,
|
||||
},
|
||||
typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({
|
||||
deliveryType,
|
||||
total: groupedByType[deliveryType]?.total ?? 0,
|
||||
pending: groupedByType[deliveryType]?.pending ?? 0,
|
||||
awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0,
|
||||
delivered: groupedByType[deliveryType]?.delivered ?? 0,
|
||||
failed: groupedByType[deliveryType]?.failed ?? 0,
|
||||
unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0,
|
||||
rejected: groupedByType[deliveryType]?.rejected ?? 0,
|
||||
})),
|
||||
retryBuckets: [
|
||||
{ label: '0次', count: retryZero },
|
||||
{ label: '1-3次', count: retryLow },
|
||||
{ label: '4次及以上', count: retryHigh },
|
||||
],
|
||||
topApplications: groupedByApplication
|
||||
.sort((left, right) => (
|
||||
right.alertCount - left.alertCount
|
||||
|| right.failed - left.failed
|
||||
|| right.pending - left.pending
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
))
|
||||
.slice(0, 5),
|
||||
};
|
||||
}
|
||||
async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where = downstreamRecoveryStatusWhere(query);
|
||||
const now = new Date();
|
||||
const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([
|
||||
recoveryStatuses.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
recoveryStatuses.count({ where }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'running' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'success' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'failed' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }),
|
||||
recoveryStatuses.count({
|
||||
where: {
|
||||
...where,
|
||||
nextRetryAt: { gt: now },
|
||||
},
|
||||
}),
|
||||
recoveryStatuses.groupBy({
|
||||
by: ['failureCategory'],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
total,
|
||||
running: runningCount,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
waitingConnection: waitingConnectionCount,
|
||||
backoff: backoffCount,
|
||||
failureCategories: categoryGroups
|
||||
.filter((item) => item.failureCategory)
|
||||
.map((item) => ({
|
||||
category: String(item.failureCategory),
|
||||
count: item._count?._all ?? 0,
|
||||
}))
|
||||
.sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)),
|
||||
},
|
||||
};
|
||||
}
|
||||
async listMessageSegmentAudits(query: MessageSegmentAuditQuery) {
|
||||
const segmentAudits = (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
if (!query.messageId && !query.messageRecordId) {
|
||||
return [];
|
||||
}
|
||||
return segmentAudits.findMany({
|
||||
where: {
|
||||
messageRecordId: query.messageRecordId,
|
||||
messageRecord: query.messageId ? { messageId: query.messageId } : undefined,
|
||||
},
|
||||
include: { channel: true, submitRecord: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
async getDownstreamRecoveryStatus(id: string) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const item = await recoveryStatuses.findUnique({
|
||||
where: { id },
|
||||
include: { tenant: true, application: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException('Recovery status not found');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const where = downstreamRecoveryStatusWhere(query);
|
||||
const items = await recoveryStatuses.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
|
||||
take: 5000,
|
||||
});
|
||||
const rows = [
|
||||
[
|
||||
'账号',
|
||||
'企业',
|
||||
'应用',
|
||||
'Gateway实例',
|
||||
'恢复状态',
|
||||
'锁持有实例',
|
||||
'锁过期时间',
|
||||
'失败分类',
|
||||
'尝试次数',
|
||||
'最后尝试时间',
|
||||
'恢复成功时间',
|
||||
'恢复失败时间',
|
||||
'下次恢复时间',
|
||||
'最后错误',
|
||||
'最后跳过原因',
|
||||
'创建时间',
|
||||
'更新时间',
|
||||
],
|
||||
...items.map((item) => [
|
||||
item.account ?? '',
|
||||
item.tenant?.name ?? '',
|
||||
item.application?.name ?? '',
|
||||
item.gatewayInstanceId ?? '',
|
||||
item.state ?? '',
|
||||
(item as { lockOwner?: string | null }).lockOwner ?? '',
|
||||
formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt),
|
||||
(item as { failureCategory?: string | null }).failureCategory ?? '',
|
||||
String(item.attemptCount ?? 0),
|
||||
formatCsvDate(item.lastAttemptAt),
|
||||
formatCsvDate(item.lastSuccessAt),
|
||||
formatCsvDate(item.lastFailureAt),
|
||||
formatCsvDate(item.nextRetryAt),
|
||||
item.lastError ?? '',
|
||||
item.lastSkipReason ?? '',
|
||||
formatCsvDate(item.createdAt),
|
||||
formatCsvDate(item.updatedAt),
|
||||
]),
|
||||
];
|
||||
|
||||
return {
|
||||
fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`,
|
||||
content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'),
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
private gatewayDownstreamRecoveryStatusDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
count: (args: Record<string, unknown>) => Promise<number>;
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
groupBy: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 logs query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsLogQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
|
||||
const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.operationLog.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.operationLog.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
async systemLogs(query: OperationLogQuery) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 10));
|
||||
const where: Prisma.OperationLogWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
userId: query.userId,
|
||||
createdAt: createdAtRange(query.range),
|
||||
resource: query.module && query.module !== 'all' ? query.module : undefined,
|
||||
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ action: { contains: query.keyword } },
|
||||
{ resource: { contains: query.keyword } },
|
||||
{ resourceId: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ user: { displayName: { contains: query.keyword } } },
|
||||
{ user: { username: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total, modules] = await Promise.all([
|
||||
this.prisma.operationLog.findMany({
|
||||
where,
|
||||
include: { tenant: true, user: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.operationLog.count({ where }),
|
||||
this.prisma.operationLog.groupBy({
|
||||
by: ['resource'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
orderBy: { resource: 'asc' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: items.map((item) => normalizeOperationLog(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
modules: modules.map((item) => item.resource),
|
||||
};
|
||||
}
|
||||
async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) {
|
||||
const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined;
|
||||
const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId };
|
||||
const where: Prisma.OperationLogWhereInput = {
|
||||
tenantId: effectiveQuery.tenantId,
|
||||
userId: effectiveQuery.userId,
|
||||
createdAt: createdAtRange(effectiveQuery.range),
|
||||
resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined,
|
||||
AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined,
|
||||
OR: effectiveQuery.keyword ? [
|
||||
{ action: { contains: effectiveQuery.keyword } },
|
||||
{ resource: { contains: effectiveQuery.keyword } },
|
||||
{ resourceId: { contains: effectiveQuery.keyword } },
|
||||
{ tenant: { name: { contains: effectiveQuery.keyword } } },
|
||||
{ user: { displayName: { contains: effectiveQuery.keyword } } },
|
||||
{ user: { username: { contains: effectiveQuery.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const rows = await this.prisma.operationLog.findMany({
|
||||
where,
|
||||
include: { tenant: true, user: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 10_001,
|
||||
});
|
||||
const truncated = rows.length > 10_000;
|
||||
const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog);
|
||||
const clientExport = Boolean(clientUserId);
|
||||
const headers = clientExport
|
||||
? ['时间', '级别', '模块', '操作人', '动作', '资源ID']
|
||||
: ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP'];
|
||||
const values = exportedRows.map((item) => clientExport
|
||||
? [item.time, item.level, item.module, item.operator, item.action, item.resourceId]
|
||||
: [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]);
|
||||
return {
|
||||
operationId: randomUUID(),
|
||||
status: 'completed' as const,
|
||||
fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`,
|
||||
recordCount: exportedRows.length,
|
||||
truncated,
|
||||
content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'),
|
||||
filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range },
|
||||
};
|
||||
}
|
||||
private async resolveClientTenantId(userId: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
|
||||
select: { tenantId: true },
|
||||
});
|
||||
if (!user?.tenantId) throw new NotFoundException('Client tenant not found');
|
||||
return user.tenantId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsMessageQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
return this.prisma.smsBatchTask.findMany({
|
||||
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
|
||||
include: { apiRequests: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
const items = await this.listBatchTasks(query);
|
||||
return items.map(clientBatchTaskView);
|
||||
}
|
||||
listMessages(query: MessageQuery) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: { include: { channel: true, channelGroup: true } },
|
||||
receiptRecords: { include: { channel: true } },
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
async listMessagesPage(query: MessageQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25)));
|
||||
const where = messageWhere(query);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
channel: { select: { id: true, name: true, srcId: true } },
|
||||
submitRecords: {
|
||||
select: {
|
||||
id: true,
|
||||
submitId: true,
|
||||
channelId: true,
|
||||
channelGroupId: true,
|
||||
channelGroupName: true,
|
||||
gatewayMessageId: true,
|
||||
submitStatus: true,
|
||||
submittedAt: true,
|
||||
createdAt: true,
|
||||
channel: { select: { id: true, name: true } },
|
||||
channelGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
receiptRecords: {
|
||||
select: {
|
||||
id: true,
|
||||
messageId: true,
|
||||
gatewayMessageId: true,
|
||||
receiptStatus: true,
|
||||
rawStatus: true,
|
||||
errorCode: true,
|
||||
errorMessage: true,
|
||||
deliveredAt: true,
|
||||
createdAt: true,
|
||||
channelId: true,
|
||||
channel: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsMessageRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
async exportMessages(query: MessageQuery) {
|
||||
const items = await this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
select: {
|
||||
messageId: true,
|
||||
queuedAt: true,
|
||||
phoneNumber: true,
|
||||
province: true,
|
||||
carrier: true,
|
||||
billingUnits: true,
|
||||
amountCents: true,
|
||||
status: true,
|
||||
submitStatus: true,
|
||||
deliveredAt: true,
|
||||
content: true,
|
||||
hasDrainageContent: true,
|
||||
tenant: { select: { name: true } },
|
||||
application: { select: { name: true } },
|
||||
channel: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'],
|
||||
...items.map((item) => [
|
||||
item.messageId,
|
||||
item.tenant?.name ?? '',
|
||||
item.application?.name ?? '',
|
||||
item.queuedAt.toISOString(),
|
||||
item.phoneNumber,
|
||||
item.province ?? '',
|
||||
item.carrier ?? '',
|
||||
String(item.billingUnits),
|
||||
String(moneyToNumber(item.amountCents)),
|
||||
item.channel?.name ?? '',
|
||||
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
|
||||
item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测',
|
||||
item.deliveredAt?.toISOString() ?? '',
|
||||
item.content,
|
||||
]),
|
||||
];
|
||||
return {
|
||||
fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`,
|
||||
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
|
||||
};
|
||||
}
|
||||
async listClientMessages(query: MessageQuery) {
|
||||
const items = await this.listMessages(query);
|
||||
return items.map(clientMessageView);
|
||||
}
|
||||
async listClientMessagesPage(query: MessageQuery) {
|
||||
const result = await this.listMessagesPage(query);
|
||||
return { ...result, items: result.items.map(clientMessageView) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsQualityQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async statistics(query: { tenantId?: string; groupBy?: string }) {
|
||||
const groupBy = normalizeGroupBy(query.groupBy);
|
||||
if (groupBy === 'tenantId') {
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['tenantId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
if (groupBy === 'applicationId') {
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['applicationId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['channelId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
async sendQuality(date?: string) {
|
||||
const day = qualityBusinessDay(date);
|
||||
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
|
||||
this.prisma.$queryRaw<Array<{
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
submitFailureRate: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
unknownRate: number;
|
||||
failureRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
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.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.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
|
||||
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
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,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM classified
|
||||
GROUP BY channel_id
|
||||
ORDER BY COUNT(*) DESC, channel_id
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
hasDrainage: boolean;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
(message."hasDrainageContent" IS TRUE) AS has_drainage,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id,
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
base.has_drainage AS "hasDrainage",
|
||||
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",
|
||||
CASE
|
||||
WHEN COUNT(*) 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')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM base
|
||||
JOIN "SmsSignature" signature ON signature.id = base.signature_id
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
||||
ORDER BY "successCount" DESC, total DESC, signature.name
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT message.status, message."receiptStatus" AS receipt_status
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
AND COALESCE(message.status, '') <> 'rejected'
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate"
|
||||
FROM base
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."receiptStatus" AS receipt_status
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."applicationId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
AND COALESCE(message.status, '') <> 'rejected'
|
||||
)
|
||||
SELECT
|
||||
application.id AS "applicationId",
|
||||
application.name AS "applicationName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate"
|
||||
FROM base
|
||||
JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
JOIN "Tenant" tenant ON tenant.id = application."tenantId"
|
||||
GROUP BY application.id, application.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, application.name
|
||||
`),
|
||||
]);
|
||||
const signatures = aggregateSignatureRows(signatureSplits);
|
||||
const drainageSignatures = signatureSplits.filter((item) => item.hasDrainage);
|
||||
const summary = summaryRows[0] ?? {
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
unknownCount: 0,
|
||||
failureCount: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
|
||||
}
|
||||
async signatureQuality(query: SignatureQualityQuery) {
|
||||
const day = qualityBusinessDay(query.date);
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
|
||||
const keyword = query.keyword?.trim() || null;
|
||||
const keywordPattern = keyword ? `%${keyword}%` : null;
|
||||
const summaries = await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
applicationNames: string | null;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
rowCount: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
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",
|
||||
CASE
|
||||
WHEN COUNT(*) 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')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
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
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
WHERE (
|
||||
${keyword}::text IS NULL
|
||||
OR signature.name ILIKE ${keywordPattern}
|
||||
OR tenant.name ILIKE ${keywordPattern}
|
||||
OR application.name ILIKE ${keywordPattern}
|
||||
)
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, signature.name
|
||||
LIMIT ${pageSize}
|
||||
OFFSET ${(page - 1) * pageSize}
|
||||
`);
|
||||
const signatureIds = summaries.map((item) => item.signatureId);
|
||||
const drainageBreakdowns = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
drainageState: 'with' | 'without' | 'unknown';
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
CASE
|
||||
WHEN message."hasDrainageContent" IS TRUE THEN 'with'
|
||||
WHEN message."hasDrainageContent" IS FALSE THEN 'without'
|
||||
ELSE 'unknown'
|
||||
END AS drainage_state,
|
||||
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.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.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
|
||||
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
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,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
signature_id AS "signatureId",
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
carrier,
|
||||
drainage_state AS "drainageState",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')
|
||||
* 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
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
|
||||
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
|
||||
`);
|
||||
const carrierOverview = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
carrier: string;
|
||||
businessMessageCount: number;
|
||||
finalSuccessCount: number;
|
||||
finalSuccessRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
message."signatureId" AS "signatureId",
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
COUNT(*)::integer AS "businessMessageCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE message.status = 'delivered'
|
||||
OR message."receiptStatus" = 'delivered'
|
||||
)::integer AS "finalSuccessCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (
|
||||
WHERE message.status = 'delivered'
|
||||
OR message."receiptStatus" = 'delivered'
|
||||
) * 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "finalSuccessRate",
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END
|
||||
))::integer AS "averageArrivalMs"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown')
|
||||
ORDER BY message."signatureId", COUNT(*) DESC, carrier
|
||||
`);
|
||||
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
||||
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
|
||||
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
|
||||
return {
|
||||
...summary,
|
||||
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
|
||||
carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId),
|
||||
breakdowns: signatureBreakdowns,
|
||||
drainageBreakdowns: signatureDrainageBreakdowns,
|
||||
};
|
||||
});
|
||||
return {
|
||||
date: day.key,
|
||||
items,
|
||||
total: summaries[0]?.rowCount ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type SignatureSplitRow = {
|
||||
id: string;
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
hasDrainage: boolean;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
};
|
||||
|
||||
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
|
||||
const grouped = new Map<string, SignatureSplitRow[]>();
|
||||
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
|
||||
return [...grouped.values()].map((parts) => {
|
||||
const first = parts[0];
|
||||
const total = parts.reduce((sum, item) => sum + item.total, 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);
|
||||
return {
|
||||
...first,
|
||||
id: first.signatureId,
|
||||
hasDrainage: false,
|
||||
total,
|
||||
acceptedCount,
|
||||
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
|
||||
successCount,
|
||||
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
|
||||
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
|
||||
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
|
||||
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
|
||||
};
|
||||
}).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName));
|
||||
}
|
||||
|
||||
type DrainageBreakdownRow = {
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
drainageState: 'with' | 'without' | 'unknown';
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
};
|
||||
|
||||
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
|
||||
const grouped = new Map<string, DrainageBreakdownRow[]>();
|
||||
rows.forEach((row) => {
|
||||
const key = `${row.signatureId}\u0000${row.channelId}\u0000${row.carrier}`;
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), row]);
|
||||
});
|
||||
return [...grouped.values()].map((parts) => {
|
||||
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);
|
||||
return {
|
||||
signatureId: first.signatureId,
|
||||
channelId: first.channelId,
|
||||
channelName: first.channelName,
|
||||
carrier: first.carrier,
|
||||
total: parts.reduce((sum, item) => sum + item.total, 0),
|
||||
acceptedCount,
|
||||
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
|
||||
successCount,
|
||||
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
|
||||
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
|
||||
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
|
||||
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 trace query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsTraceQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
auditSummary(query: { tenantId?: string }) {
|
||||
return this.prisma.operationLog.groupBy({
|
||||
by: ['action', 'resource'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { action: 'desc' } },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
async trace(query: TraceQuery) {
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
...messageWhere(query),
|
||||
messageId: query.messageId,
|
||||
},
|
||||
include: {
|
||||
batchTask: { include: { apiRequests: true } },
|
||||
submitRecords: { include: { session: true } },
|
||||
receiptRecords: true,
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const messageIds = messages.map((message) => message.messageId);
|
||||
const [billingRecords, uplinks] = await Promise.all([
|
||||
this.prisma.smsBillingRecord.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
taskId: query.taskId,
|
||||
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { messages, billingRecords, uplinks };
|
||||
}
|
||||
async reconciliation(query: { tenantId?: string; taskId?: string }) {
|
||||
const [messages, billing, transactions] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.aggregate({
|
||||
where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId, taskId: query.taskId },
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.accountTransaction.aggregate({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined,
|
||||
relatedId: query.taskId,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true },
|
||||
}),
|
||||
]);
|
||||
const messageAmount = moneyToNumber(messages._sum.amountCents);
|
||||
const billingAmount = moneyToNumber(billing._sum.amountCents);
|
||||
const transactionAmount = moneyToNumber(transactions._sum.amountCents);
|
||||
return {
|
||||
messages,
|
||||
billing,
|
||||
transactions,
|
||||
diff: {
|
||||
messageVsBillingAmountCents: messageAmount - billingAmount,
|
||||
billingVsTransactionAmountCents: billingAmount + transactionAmount,
|
||||
messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsUplinkQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
channelId: query.channelId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
|
||||
take: query.pageSize ?? 500,
|
||||
});
|
||||
}
|
||||
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
|
||||
const items = await this.listUplinkMessages(query);
|
||||
return items.map(clientUplinkView);
|
||||
}
|
||||
async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) {
|
||||
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.SmsUplinkMessageWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
channelId: query.channelId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
receivedAt: query.startTime || query.endTime ? {
|
||||
gte: query.startTime ? new Date(query.startTime) : undefined,
|
||||
lte: query.endTime ? new Date(query.endTime) : undefined,
|
||||
} : undefined,
|
||||
};
|
||||
const [rawItems, total] = await Promise.all([
|
||||
this.listUplinkMessages({ ...query, page, pageSize }),
|
||||
this.prisma.smsUplinkMessage.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: clientView ? rawItems.map(clientUplinkView) : rawItems,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
async monitor(query: { tenantId?: string; channelId?: string }) {
|
||||
const where = messageWhere(query);
|
||||
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }),
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: { submitRecords: true, receiptRecords: true },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.smsReceiptRecord.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
|
||||
]);
|
||||
return {
|
||||
byStatus,
|
||||
recentMessages,
|
||||
recentReceipts,
|
||||
recentUplinks: recentUplinks.slice(0, 20),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import { ReportBatchOperationService } from './batch-operation.service';
|
||||
import { ReportChannelExportService } from './channel-export.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportBatchGenerationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {}
|
||||
|
||||
async listBatches(query: PagedQuery = {}) {
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
const where: Prisma.ReportMaterialBatchWhereInput = {
|
||||
createdAt: dateRange(query.startAt, query.endAt),
|
||||
batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||
};
|
||||
const [batches, total] = await Promise.all([
|
||||
this.prisma.reportMaterialBatch.findMany({
|
||||
where,
|
||||
include: {
|
||||
exportFiles: {
|
||||
include: {
|
||||
items: { include: { task: { select: { id: true, status: true } } } },
|
||||
},
|
||||
},
|
||||
items: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportMaterialBatch.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: batches.map((batch) => {
|
||||
const reportItems = batch.exportFiles.flatMap((file) => file.items);
|
||||
const reportTotal = reportItems.length;
|
||||
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
|
||||
return {
|
||||
...batch,
|
||||
reportTotal,
|
||||
successCount,
|
||||
successRate: reportTotal ? successCount / reportTotal : 0,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async createBatch(data: CreateReportBatchDto) {
|
||||
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
||||
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
|
||||
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
|
||||
const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
|
||||
if (claimed.replayed) return claimed.result;
|
||||
|
||||
let preflight: Awaited<ReturnType<ReportBatchGenerationService['preflightBatch']>>;
|
||||
try {
|
||||
preflight = await this.preflightBatch({ items: uniqueItems });
|
||||
} catch (error) {
|
||||
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
|
||||
throw error;
|
||||
}
|
||||
if (preflight.eligibleTargetCount === 0) {
|
||||
await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
|
||||
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
|
||||
}
|
||||
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
|
||||
const batch = await this.prisma.reportMaterialBatch.create({
|
||||
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length },
|
||||
});
|
||||
try {
|
||||
const prepared = [];
|
||||
for (const inspection of eligibleInspections) {
|
||||
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
|
||||
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
|
||||
}
|
||||
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
|
||||
for (const item of prepared) {
|
||||
for (const channel of item.channels) {
|
||||
const current = channelMap.get(channel.id) ?? [];
|
||||
current.push({ ...item, channels: [channel] });
|
||||
channelMap.set(channel.id, current);
|
||||
}
|
||||
}
|
||||
const exportedFiles = [];
|
||||
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
|
||||
let failedTargetCount = 0;
|
||||
for (const [channelId, items] of channelMap) {
|
||||
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
|
||||
exportedFiles.push(result.file);
|
||||
failedTargetCount += result.incompleteBatchItemIds.length;
|
||||
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
|
||||
}
|
||||
for (const item of prepared) {
|
||||
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
|
||||
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
|
||||
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
|
||||
}
|
||||
const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
|
||||
const result = {
|
||||
...completed,
|
||||
operationId: claimed.operationId,
|
||||
replayed: false,
|
||||
result: {
|
||||
successCount: preflight.eligibleTargetCount - failedTargetCount,
|
||||
skippedCount: preflight.skippedTargetCount,
|
||||
failedCount: failedTargetCount,
|
||||
items: preflight.items,
|
||||
},
|
||||
};
|
||||
await this.operations.completeBatchOperation(claimed.operationId, batch.id, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
|
||||
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
|
||||
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
||||
for (const item of data.items) {
|
||||
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
|
||||
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
|
||||
}
|
||||
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
|
||||
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
eligible: items.some((item) => item.eligible),
|
||||
eligibleItemCount: items.filter((item) => item.eligible).length,
|
||||
blockedItemCount: items.filter((item) => !item.eligible).length,
|
||||
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
|
||||
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
|
||||
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
|
||||
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
||||
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
|
||||
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过');
|
||||
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : [];
|
||||
const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
|
||||
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()];
|
||||
const snapshot = selected.reportType === 'signature'
|
||||
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
|
||||
: { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
|
||||
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
|
||||
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
|
||||
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
|
||||
}
|
||||
|
||||
async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
|
||||
if (!signature) throw new NotFoundException('签名不存在');
|
||||
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
||||
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
|
||||
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
|
||||
const blockedReasons: string[] = [];
|
||||
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
|
||||
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
|
||||
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
|
||||
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
|
||||
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion})`);
|
||||
if (selected.reportType === 'drainage') {
|
||||
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
|
||||
else {
|
||||
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
|
||||
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
|
||||
}
|
||||
}
|
||||
const snapshot = selected.reportType === 'signature'
|
||||
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
|
||||
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
|
||||
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : [];
|
||||
const channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
|
||||
for (const route of routes) {
|
||||
if (route.group.status !== 'active') continue;
|
||||
for (const entry of route.group.items) {
|
||||
if (entry.channel.status !== 'active') continue;
|
||||
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
|
||||
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
|
||||
channelCarriers.set(entry.channel.id, current);
|
||||
}
|
||||
}
|
||||
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
|
||||
const previous = await this.prisma.reportMaterialBatchItem.findMany({
|
||||
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
|
||||
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const priorKeys = new Map<string, string>();
|
||||
for (const item of previous) {
|
||||
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
|
||||
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
|
||||
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
|
||||
}
|
||||
}
|
||||
const targets: ReportBatchTarget[] = [];
|
||||
for (const { channel, carriers } of channelCarriers.values()) {
|
||||
const carrier = [...carriers].sort().join(',');
|
||||
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
|
||||
const targetReasons = [...blockedReasons];
|
||||
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
|
||||
else {
|
||||
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
|
||||
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
|
||||
}
|
||||
const duplicateBatchId = priorKeys.get(businessKey);
|
||||
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
|
||||
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
|
||||
}
|
||||
return {
|
||||
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
|
||||
reportType: selected.reportType,
|
||||
signatureId: signature.id,
|
||||
drainageItemId: drainageInfo?.id,
|
||||
materialVersion,
|
||||
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
|
||||
tenantName: signature.tenant.name,
|
||||
applicationId: signature.applicationId ?? undefined,
|
||||
applicationName: signature.application?.name ?? '未指定应用',
|
||||
eligible: targets.some((target) => target.eligible),
|
||||
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportBatchOperationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`;
|
||||
const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } });
|
||||
if (existing) {
|
||||
const detail = jsonRecord(existing.detail);
|
||||
if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' });
|
||||
if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } };
|
||||
throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' });
|
||||
}
|
||||
const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } });
|
||||
return { operationId: operation.id, replayed: false as const, result: null };
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
}
|
||||
|
||||
async completeBatchOperation(operationId: string, batchId: string, result: Record<string, unknown>) {
|
||||
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } });
|
||||
}
|
||||
|
||||
async failBatchOperation(operationId: string, message: string, batchId?: string) {
|
||||
const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } });
|
||||
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import type { ReportBatchGenerationService } from './batch-generation.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportChannelExportService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) throw new NotFoundException('通道不存在');
|
||||
const reportTypes = [...new Set(items.map((item) => item.reportType))];
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
|
||||
const incompleteBatchItemIds: string[] = [];
|
||||
let totalRows = 0;
|
||||
for (const reportType of reportTypes) {
|
||||
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.properties.defaultRowHeight = 22;
|
||||
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
|
||||
styleHeader(sheet.getRow(1));
|
||||
for (const item of items.filter((current) => current.reportType === reportType)) {
|
||||
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
|
||||
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
||||
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
|
||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
|
||||
const task = existingTask
|
||||
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
|
||||
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
|
||||
if (missingReason) {
|
||||
incompleteBatchItemIds.push(item.batchItem.id);
|
||||
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
|
||||
continue;
|
||||
}
|
||||
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
|
||||
totalRows += 1;
|
||||
let targetHeight = 22;
|
||||
for (const [index, value] of values.entries()) {
|
||||
if (!isFileRef(value)) continue;
|
||||
const downloaded = await this.files.getDownload(value.fileObjectId);
|
||||
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
|
||||
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
|
||||
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
|
||||
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
|
||||
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
|
||||
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
|
||||
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
|
||||
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
|
||||
}
|
||||
row.height = targetHeight;
|
||||
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
|
||||
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
|
||||
}
|
||||
}
|
||||
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
|
||||
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
|
||||
const empty = workbook.addWorksheet('无可导出数据');
|
||||
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
|
||||
empty.getColumn(1).width = 64;
|
||||
}
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
|
||||
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
|
||||
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
|
||||
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
|
||||
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
|
||||
}
|
||||
|
||||
recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
|
||||
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportImportParserService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
listImportProfiles(reportType?: 'signature' | 'drainage') {
|
||||
return this.prisma.reportMaterialImportProfile.findMany({
|
||||
where: { reportType, status: 'active' },
|
||||
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async saveImportProfile(data: CreateImportProfileDto) {
|
||||
validateProfile(data);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const profile = data.id
|
||||
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
|
||||
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
|
||||
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
|
||||
await tx.reportMaterialImportProfileColumn.createMany({
|
||||
data: data.columns.map((column, index) => ({
|
||||
profileId: profile.id,
|
||||
sourceHeader: column.sourceHeader,
|
||||
sourceHeaderPath: column.sourceHeaderPath,
|
||||
sourceColumnIndex: column.sourceColumnIndex,
|
||||
targetFieldCode: column.targetFieldCode,
|
||||
targetKind: column.targetKind,
|
||||
fieldType: column.fieldType,
|
||||
required: column.required ?? false,
|
||||
transform: column.transform,
|
||||
sortOrder: column.sortOrder ?? (index + 1) * 10,
|
||||
})),
|
||||
});
|
||||
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
|
||||
});
|
||||
}
|
||||
|
||||
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
|
||||
if (!options.tenantId) throw new BadRequestException('tenantId is required');
|
||||
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
|
||||
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||
const workbook = await loadWorkbook(file.buffer);
|
||||
assertSafeWorkbook(workbook);
|
||||
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
|
||||
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
|
||||
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
|
||||
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
|
||||
const headerRowCount = clamp(options.headerRowCount, 1, 5);
|
||||
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
const columnCount = Math.min(worksheet.columnCount, 200);
|
||||
const columns = Array.from({ length: columnCount }, (_, offset) => {
|
||||
const sourceColumnIndex = offset + 1;
|
||||
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
|
||||
const sourceHeaderPath = [...new Set(parts)].join('/');
|
||||
return {
|
||||
sourceColumnIndex,
|
||||
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
|
||||
sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`,
|
||||
sourceHeaderPath,
|
||||
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
|
||||
};
|
||||
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
|
||||
const previewRows = [];
|
||||
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
|
||||
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
|
||||
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
|
||||
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
|
||||
}
|
||||
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
|
||||
const profileMappings = profile?.columns.map((column) => ({
|
||||
sourceHeader: column.sourceHeader,
|
||||
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
|
||||
sourceColumnIndex: column.sourceColumnIndex,
|
||||
targetFieldCode: column.targetFieldCode,
|
||||
targetKind: column.targetKind as ImportMapping['targetKind'],
|
||||
fieldType: column.fieldType as ImportMapping['fieldType'],
|
||||
required: column.required,
|
||||
transform: column.transform ?? undefined,
|
||||
sortOrder: column.sortOrder,
|
||||
}));
|
||||
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
|
||||
const batch = await this.prisma.reportMaterialImportBatch.create({
|
||||
data: {
|
||||
tenantId: options.tenantId,
|
||||
applicationId: options.applicationId,
|
||||
profileId: options.profileId,
|
||||
fileObjectId: sourceFile.id,
|
||||
fileName: sourceFile.fileName,
|
||||
reportType: options.reportType,
|
||||
sheetName: worksheet.name,
|
||||
headerRowCount,
|
||||
dataStartRow,
|
||||
mapping: suggestedMappings as Prisma.InputJsonValue,
|
||||
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
|
||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id,
|
||||
detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import { ReportImportParserService } from './import-parser.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportImportReviewService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly importParser: ReportImportParserService) {}
|
||||
|
||||
async commitImport(batchId: string, data: ImportCommitDto) {
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException('导入批次不存在');
|
||||
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
|
||||
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
|
||||
if (data.profile) await this.importParser.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
const workbook = await loadWorkbook(content);
|
||||
assertSafeWorkbook(workbook);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
||||
let successCount = 0;
|
||||
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
||||
const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = [];
|
||||
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
|
||||
const values: Record<string, unknown> = {};
|
||||
try {
|
||||
for (const mapping of data.mappings) {
|
||||
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
|
||||
if (image && mapping.fieldType !== 'string') {
|
||||
const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, {
|
||||
originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
|
||||
mimetype: imageContentType(image.extension),
|
||||
size: image.buffer.length,
|
||||
buffer: image.buffer,
|
||||
});
|
||||
values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType };
|
||||
} else {
|
||||
values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform);
|
||||
}
|
||||
}
|
||||
if (!Object.values(values).some(hasValue)) continue;
|
||||
for (const mapping of data.mappings.filter((item) => item.required)) {
|
||||
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
|
||||
}
|
||||
const staged = batch.reportType === 'signature'
|
||||
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
|
||||
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
stagedItems.push({
|
||||
batchId,
|
||||
rowNumber,
|
||||
reportType: batch.reportType,
|
||||
operation: staged.operation,
|
||||
targetId: staged.targetId,
|
||||
status: 'pending_review',
|
||||
payload: staged.payload as Prisma.InputJsonValue,
|
||||
originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined,
|
||||
});
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '导入失败';
|
||||
failures.push({ rowNumber, reason });
|
||||
stagedItems.push({
|
||||
batchId,
|
||||
rowNumber,
|
||||
reportType: batch.reportType,
|
||||
operation: 'invalid',
|
||||
status: 'invalid',
|
||||
payload: values as Prisma.InputJsonValue,
|
||||
errorMessage: reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems });
|
||||
return tx.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: successCount ? 'pending_review' : 'failed',
|
||||
mapping: data.mappings as Prisma.InputJsonValue,
|
||||
result: { failures } as Prisma.InputJsonValue,
|
||||
successCount,
|
||||
failedCount: failures.length,
|
||||
},
|
||||
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
||||
});
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id,
|
||||
detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) {
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
const where: Prisma.ReportMaterialImportBatchWhereInput = {
|
||||
reportType: query.reportType,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
createdAt: dateRange(query.startAt, query.endAt),
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ fileName: { contains: query.keyword.trim() } },
|
||||
{ id: { contains: query.keyword.trim() } },
|
||||
] : undefined,
|
||||
};
|
||||
const [batches, total] = await Promise.all([
|
||||
this.prisma.reportMaterialImportBatch.findMany({
|
||||
where,
|
||||
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportMaterialImportBatch.count({ where }),
|
||||
]);
|
||||
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))];
|
||||
const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))];
|
||||
const reviewerIds = [...new Set(batches.flatMap((batch) => [
|
||||
batch.reviewedById,
|
||||
...batch.items.map((item) => item.reviewedById),
|
||||
]).filter((id): id is string => Boolean(id)))];
|
||||
const [tenants, applications, reviewers] = await Promise.all([
|
||||
tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [],
|
||||
applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [],
|
||||
reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [],
|
||||
]);
|
||||
const tenantById = new Map(tenants.map((item) => [item.id, item]));
|
||||
const applicationById = new Map(applications.map((item) => [item.id, item]));
|
||||
const reviewerById = new Map(reviewers.map((item) => [item.id, item]));
|
||||
return {
|
||||
items: batches.map((batch) => ({
|
||||
...batch,
|
||||
tenant: tenantById.get(batch.tenantId) ?? null,
|
||||
application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null,
|
||||
reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null,
|
||||
items: batch.items.map((item) => ({
|
||||
...item,
|
||||
reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null,
|
||||
})),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
|
||||
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
|
||||
if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision');
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } },
|
||||
});
|
||||
if (!batch) throw new NotFoundException('导入审核批次不存在');
|
||||
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
|
||||
let approvedCount = 0;
|
||||
let rejectedCount = 0;
|
||||
const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = [];
|
||||
for (const item of batch.items) {
|
||||
if (data.decision === 'reject') {
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() },
|
||||
});
|
||||
rejectedCount += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const targetId = await this.applyImportItem(batch, item, data.reviewerId);
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null },
|
||||
});
|
||||
approvedCount += 1;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '导入审核应用失败';
|
||||
failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason });
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
const counts = await this.prisma.reportMaterialImportItem.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const countByStatus = new Map(counts.map((item) => [item.status, item._count._all]));
|
||||
const pendingCount = countByStatus.get('pending_review') ?? 0;
|
||||
const totalApproved = countByStatus.get('approved') ?? 0;
|
||||
const totalRejected = countByStatus.get('rejected') ?? 0;
|
||||
const totalInvalid = countByStatus.get('invalid') ?? 0;
|
||||
const status = pendingCount
|
||||
? 'partially_reviewed'
|
||||
: totalApproved && (totalRejected || totalInvalid)
|
||||
? 'partially_approved'
|
||||
: totalApproved
|
||||
? 'approved'
|
||||
: totalRejected
|
||||
? 'rejected'
|
||||
: 'failed';
|
||||
await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status,
|
||||
reviewedById: pendingCount ? undefined : data.reviewerId,
|
||||
reviewedAt: pendingCount ? undefined : new Date(),
|
||||
completedAt: pendingCount ? undefined : new Date(),
|
||||
},
|
||||
});
|
||||
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
|
||||
}
|
||||
|
||||
async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const name = mappedCoreValue(mappings, values, 'signatureName');
|
||||
if (!name) throw new Error('缺少短信签名');
|
||||
const purpose = mappedCoreValue(mappings, values, 'purpose');
|
||||
const signatureReportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
targetId: existing?.id,
|
||||
payload: {
|
||||
tenantId,
|
||||
applicationId,
|
||||
name,
|
||||
purpose,
|
||||
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues },
|
||||
},
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
applicationId: existing.applicationId,
|
||||
name: existing.name,
|
||||
purpose: existing.purpose,
|
||||
drainageInfo: existing.drainageInfo,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
|
||||
const siteName = mappedCoreValue(mappings, values, 'siteName');
|
||||
const url = mappedCoreValue(mappings, values, 'url');
|
||||
if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL');
|
||||
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } });
|
||||
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
|
||||
const remark = mappedCoreValue(mappings, values, 'remark');
|
||||
const reportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } });
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
targetId: existing?.id,
|
||||
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues },
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
siteName: existing.siteName,
|
||||
url: existing.url,
|
||||
remark: existing.remark,
|
||||
reportValues: existing.reportValues,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async applyImportItem(
|
||||
batch: { tenantId: string; applicationId: string | null; reportType: string },
|
||||
item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },
|
||||
reviewerId: string,
|
||||
) {
|
||||
const payload = jsonRecord(item.payload);
|
||||
if (item.reportType === 'signature') {
|
||||
const name = String(payload.name ?? '');
|
||||
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
|
||||
const body = {
|
||||
applicationId,
|
||||
name,
|
||||
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
|
||||
drainageInfo: jsonRecord(payload.drainageInfo),
|
||||
};
|
||||
let targetId = item.targetId;
|
||||
if (targetId) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
|
||||
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
|
||||
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
|
||||
} else {
|
||||
const duplicate = await this.prisma.smsSignature.findFirst({
|
||||
where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
|
||||
});
|
||||
if (duplicate) {
|
||||
targetId = duplicate.id;
|
||||
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body });
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
|
||||
return targetId;
|
||||
}
|
||||
const signatureId = String(payload.signatureId ?? '');
|
||||
const siteName = String(payload.siteName ?? '');
|
||||
const url = String(payload.url ?? '');
|
||||
const body = {
|
||||
siteName,
|
||||
url,
|
||||
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
|
||||
reportValues: jsonRecord(payload.reportValues),
|
||||
};
|
||||
let targetId = item.targetId;
|
||||
if (targetId) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } });
|
||||
if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改');
|
||||
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
} else {
|
||||
const duplicate = await this.prisma.smsDrainageInfo.findFirst({
|
||||
where: { signatureId, url, auditStatus: { not: 'deleted' } },
|
||||
});
|
||||
if (duplicate) {
|
||||
targetId = duplicate.id;
|
||||
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` });
|
||||
return targetId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import { ReportPendingQueryService } from './pending-query.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportOfficialExportService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {}
|
||||
|
||||
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'CMPP短信平台';
|
||||
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
const headers = reportType === 'signature'
|
||||
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
|
||||
: ['短信签名', '站点名称', 'URL', '备注', '网站截图'];
|
||||
sheet.addRow(headers);
|
||||
sheet.addRow(reportType === 'signature'
|
||||
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
|
||||
: ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
sheet.columns.forEach((column) => { column.width = 24; });
|
||||
sheet.getRow(2).height = 48;
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
|
||||
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { fileName, content };
|
||||
}
|
||||
|
||||
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
|
||||
const items = await this.pending.findPendingItems(query);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
for (const item of items) sheet.addRow([
|
||||
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
|
||||
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
|
||||
]);
|
||||
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; });
|
||||
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
|
||||
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportPendingQueryService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
|
||||
const items = await this.findPendingItems(query);
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
return {
|
||||
items: items.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
|
||||
const changedAt = dateRange(query.startAt, query.endAt);
|
||||
const keyword = query.keyword?.trim();
|
||||
const [signatures, drainageInfos] = await Promise.all([
|
||||
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
pendingReport: true,
|
||||
auditStatus: 'approved',
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
reportChangedAt: changedAt,
|
||||
OR: keyword ? [
|
||||
{ name: { contains: keyword } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
{ application: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
pendingReport: true,
|
||||
auditStatus: 'approved',
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
reportChangedAt: changedAt,
|
||||
OR: keyword ? [
|
||||
{ siteName: { contains: keyword } },
|
||||
{ url: { contains: keyword } },
|
||||
{ signature: { name: { contains: keyword } } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
{ application: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, application: true, signature: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return [
|
||||
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
|
||||
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
|
||||
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Stable request, query and internal data contracts for report-material domains. */
|
||||
|
||||
export type ImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
sourceColumnIndex: number;
|
||||
targetFieldCode: string;
|
||||
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
|
||||
fieldType: 'string' | 'image' | 'file';
|
||||
required?: boolean;
|
||||
transform?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export interface CreateImportProfileDto {
|
||||
id?: string;
|
||||
name: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
sheetName?: string;
|
||||
headerRowCount?: number;
|
||||
dataStartRow?: number;
|
||||
status?: string;
|
||||
columns: ImportMapping[];
|
||||
}
|
||||
|
||||
export interface ImportCommitDto {
|
||||
mappings: ImportMapping[];
|
||||
profile?: CreateImportProfileDto;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface ReviewImportItemsDto {
|
||||
decision: 'approve' | 'reject';
|
||||
itemIds?: string[];
|
||||
reason?: string;
|
||||
reviewerId?: string;
|
||||
}
|
||||
|
||||
export type PagedQuery = {
|
||||
keyword?: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export interface CreateReportBatchDto {
|
||||
createdById?: string;
|
||||
idempotencyKey?: string;
|
||||
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>;
|
||||
}
|
||||
|
||||
export type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string };
|
||||
|
||||
export type ReportBatchInspection = {
|
||||
id: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
signatureId: string;
|
||||
drainageItemId?: string;
|
||||
materialVersion: number;
|
||||
name: string;
|
||||
tenantName: string;
|
||||
applicationId?: string;
|
||||
applicationName: string;
|
||||
eligible: boolean;
|
||||
blockedReasons: string[];
|
||||
targets: ReportBatchTarget[];
|
||||
};
|
||||
|
||||
export type AnalyzeImportOptions = {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
sheetName?: string;
|
||||
headerRowCount: number;
|
||||
dataStartRow: number;
|
||||
profileId?: string;
|
||||
operatorId?: string;
|
||||
};
|
||||
|
||||
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||
@@ -3,7 +3,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService, ReviewImportItemsDto } from './report-materials.service';
|
||||
import { ReportMaterialsService } from './report-materials.service';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
|
||||
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
||||
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CreateImportProfileDto, EmbeddedImage, ImportMapping } from './report-materials.contracts';
|
||||
|
||||
/** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */
|
||||
export function profileData(data: CreateImportProfileDto) {
|
||||
return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' };
|
||||
}
|
||||
|
||||
export function validateProfile(data: CreateImportProfileDto) {
|
||||
if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空');
|
||||
if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段');
|
||||
const indexes = data.columns.map((column) => column.sourceColumnIndex);
|
||||
if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射');
|
||||
}
|
||||
|
||||
export async function loadWorkbook(buffer: Buffer) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as never);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
export function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
|
||||
for (const worksheet of workbook.worksheets) {
|
||||
worksheet.eachRow((row) => row.eachCell((cell) => {
|
||||
const value = cell.value;
|
||||
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}
|
||||
const text = typeof value === 'string' ? value.trimStart() : '';
|
||||
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function safeSpreadsheetText(value: unknown) {
|
||||
const text = value == null ? '' : String(value);
|
||||
return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
export function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
|
||||
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
|
||||
if (!getImages) return [];
|
||||
return getImages.call(worksheet).flatMap((drawing) => {
|
||||
const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId);
|
||||
if (!image) return [];
|
||||
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1;
|
||||
const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1;
|
||||
const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
|
||||
return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] {
|
||||
return columns.flatMap((column, index) => {
|
||||
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
|
||||
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
|
||||
if (!core && !column.imageCount) return [];
|
||||
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }];
|
||||
});
|
||||
}
|
||||
|
||||
export function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] {
|
||||
const used = new Set<number>();
|
||||
return profileColumns.flatMap((profileColumn) => {
|
||||
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
|
||||
const header = normalizeHeader(profileColumn.sourceHeader);
|
||||
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath)
|
||||
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header);
|
||||
if (!source) return [];
|
||||
used.add(source.sourceColumnIndex);
|
||||
return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }];
|
||||
});
|
||||
}
|
||||
|
||||
export function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
||||
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
||||
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
||||
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
||||
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true };
|
||||
if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true };
|
||||
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
|
||||
|
||||
export function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
|
||||
|
||||
export function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
|
||||
|
||||
export function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); }
|
||||
|
||||
export function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); }
|
||||
|
||||
export function dateRange(startAt?: string, endAt?: string) {
|
||||
const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined;
|
||||
const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined;
|
||||
if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效');
|
||||
if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效');
|
||||
return start || end ? { gte: start, lte: end } : undefined;
|
||||
}
|
||||
|
||||
export function cellText(cell: ExcelJS.Cell) {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value);
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
|
||||
if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim();
|
||||
if ('richText' in value) return value.richText.map((item) => item.text).join('').trim();
|
||||
if ('text' in value) return String(value.text).trim();
|
||||
return cell.text.trim();
|
||||
}
|
||||
|
||||
export function transformValue(value: string, transform?: string) {
|
||||
if (!transform || transform === 'trim') return value.trim();
|
||||
if (transform === 'digits') return value.replace(/\D/g, '');
|
||||
if (transform === 'uppercase') return value.trim().toUpperCase();
|
||||
if (transform === 'lowercase') return value.trim().toLowerCase();
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) {
|
||||
const mapping = mappings.find((item) => item.targetKind === kind);
|
||||
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
|
||||
}
|
||||
|
||||
export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
|
||||
}
|
||||
|
||||
export function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
|
||||
export function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
|
||||
|
||||
export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; }
|
||||
|
||||
export function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
|
||||
const values = jsonRecord(snapshot.values);
|
||||
if (hasValue(values[code])) return values[code];
|
||||
const signature = jsonRecord(snapshot.signature);
|
||||
const drainage = jsonRecord(snapshot.drainage);
|
||||
const aliases: Record<string, unknown> = {
|
||||
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name,
|
||||
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName,
|
||||
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark,
|
||||
};
|
||||
if (hasValue(aliases[code])) return aliases[code];
|
||||
const semantic = normalizeHeader(`${code}/${name ?? ''}`);
|
||||
if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name;
|
||||
if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose;
|
||||
if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName;
|
||||
if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName;
|
||||
if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName;
|
||||
if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url;
|
||||
if (/备注|说明|remark/.test(semantic)) return drainage.remark;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyExportTransform(value: unknown, transform?: string | null) {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return transformValue(text, transform ?? undefined);
|
||||
}
|
||||
|
||||
export function styleHeader(row: ExcelJS.Row) {
|
||||
row.height = 28;
|
||||
row.eachCell((cell) => {
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } };
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
|
||||
|
||||
export function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
|
||||
|
||||
export function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
|
||||
|
||||
export function normalizeBatchIdempotencyKey(value?: string) {
|
||||
const key = value?.trim();
|
||||
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
|
||||
return key;
|
||||
}
|
||||
|
||||
export function jsonStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
}
|
||||
|
||||
export function jsonSafe(value: unknown): Prisma.InputJsonValue {
|
||||
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
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 { SendChainService } from '../send-chain/send-chain.service';
|
||||
@@ -8,11 +8,20 @@ import {
|
||||
ReviewSmsTaskDto,
|
||||
RiskReviewService,
|
||||
} from './risk-review.service';
|
||||
import {
|
||||
CreatePhoneFrequencyWhitelistDto,
|
||||
PhoneFrequencyService,
|
||||
UpdatePhoneFrequencyWhitelistDto,
|
||||
} from './phone-frequency.service';
|
||||
|
||||
@ApiTags('risk-review')
|
||||
@Controller('admin/risk-review')
|
||||
export class AdminRiskReviewController {
|
||||
constructor(private readonly riskReview: RiskReviewService, private readonly sendChain: SendChainService) {}
|
||||
constructor(
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly sendChain: SendChainService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
) {}
|
||||
|
||||
@Get('rules')
|
||||
listRules(@Query('applicationId') applicationId?: string) {
|
||||
@@ -34,9 +43,88 @@ export class AdminRiskReviewController {
|
||||
return this.riskReview.listHits(tenantId, taskId);
|
||||
}
|
||||
|
||||
@Get('phone-frequency-hits')
|
||||
listPhoneFrequencyHits(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: 'active' | 'expired' | 'released',
|
||||
@Query('createdAtFrom') createdAtFrom?: string,
|
||||
@Query('createdAtTo') createdAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.phoneFrequency.listHits({
|
||||
tenantId,
|
||||
applicationId,
|
||||
phoneNumber,
|
||||
status,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
page: Number(page ?? 1),
|
||||
pageSize: Number(pageSize ?? 20),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('phone-frequency-hits/:id/release')
|
||||
releasePhoneFrequencyHit(
|
||||
@Param('id') hitId: string,
|
||||
@Body() body: { reason?: string },
|
||||
@CurrentSessionUserId() reviewerId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.releaseHit(hitId, reviewerId, body.reason);
|
||||
}
|
||||
|
||||
@Get('phone-frequency-whitelist')
|
||||
listPhoneFrequencyWhitelist(
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: 'active' | 'inactive' | 'deleted',
|
||||
@Query('updatedAtFrom') updatedAtFrom?: string,
|
||||
@Query('updatedAtTo') updatedAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.phoneFrequency.listWhitelist({
|
||||
phoneNumber,
|
||||
keyword,
|
||||
status,
|
||||
updatedAtFrom,
|
||||
updatedAtTo,
|
||||
page: Number(page ?? 1),
|
||||
pageSize: Number(pageSize ?? 20),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('phone-frequency-whitelist')
|
||||
createPhoneFrequencyWhitelist(
|
||||
@Body() body: CreatePhoneFrequencyWhitelistDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.createWhitelist(body, operatorId);
|
||||
}
|
||||
|
||||
@Put('phone-frequency-whitelist/:id')
|
||||
updatePhoneFrequencyWhitelist(
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdatePhoneFrequencyWhitelistDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.updateWhitelist(id, body, operatorId);
|
||||
}
|
||||
|
||||
@Delete('phone-frequency-whitelist/:id')
|
||||
deletePhoneFrequencyWhitelist(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason?: string },
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.deleteWhitelist(id, operatorId, body.reason);
|
||||
}
|
||||
|
||||
@Get('tasks')
|
||||
listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.riskReview.listTasks(tenantId, status);
|
||||
listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
|
||||
return this.riskReview.listTasks(tenantId, status, submittedAtFrom, submittedAtTo);
|
||||
}
|
||||
|
||||
@Get('tasks/pending')
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { PhoneFrequencyService, fixedShanghaiWindow } from './phone-frequency.service';
|
||||
|
||||
describe('PhoneFrequencyService', () => {
|
||||
it('aligns five-minute cycles and natural days in Asia/Shanghai', () => {
|
||||
const requestedAt = new Date('2026-07-30T16:07:42.000Z');
|
||||
|
||||
expect(fixedShanghaiWindow(requestedAt, 300)).toEqual({
|
||||
startAt: new Date('2026-07-30T16:05:00.000Z'),
|
||||
endAt: new Date('2026-07-30T16:10:00.000Z'),
|
||||
});
|
||||
expect(fixedShanghaiWindow(requestedAt, 86400)).toEqual({
|
||||
startAt: new Date('2026-07-30T16:00:00.000Z'),
|
||||
endAt: new Date('2026-07-31T16:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a persistent hit and rejects the threshold-exceeding phone only', async () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'state-24h',
|
||||
phoneNumber: '13800000001',
|
||||
count: 2,
|
||||
generation: 0,
|
||||
activeHitId: null,
|
||||
windowStartedAt: new Date('2026-07-29T16:00:00.000Z'),
|
||||
windowEndsAt: new Date('2026-07-30T16:00:00.000Z'),
|
||||
}])
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'state-5m',
|
||||
phoneNumber: '13800000001',
|
||||
count: 6,
|
||||
generation: 0,
|
||||
activeHitId: null,
|
||||
windowStartedAt: new Date('2026-07-30T01:00:00.000Z'),
|
||||
windowEndsAt: new Date('2026-07-30T01:05:00.000Z'),
|
||||
}]),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
phoneFrequencyHit: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
phoneFrequencyWhitelist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
riskRule: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'rule-24h',
|
||||
applicationId: null,
|
||||
code: 'PHONE_FREQUENCY_24H',
|
||||
name: '单号码24小时发送频次',
|
||||
thresholdValue: 10,
|
||||
action: 'block',
|
||||
priority: 40,
|
||||
config: { periodSeconds: 86400 },
|
||||
},
|
||||
{
|
||||
id: 'rule-5m',
|
||||
applicationId: null,
|
||||
code: 'PHONE_FREQUENCY_5M',
|
||||
name: '单号码5分钟发送频次',
|
||||
thresholdValue: 5,
|
||||
action: 'block',
|
||||
priority: 50,
|
||||
config: { periodSeconds: 300 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
$transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new PhoneFrequencyService(prisma as never, riskReview as never);
|
||||
|
||||
const rejected = await service.reserve(
|
||||
'tenant-1',
|
||||
'application-1',
|
||||
['13800000001'],
|
||||
'client',
|
||||
new Date('2026-07-30T01:03:00.000Z'),
|
||||
);
|
||||
|
||||
expect(rejected.get('13800000001')).toEqual(expect.objectContaining({
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: expect.stringContaining('当前第6条'),
|
||||
}));
|
||||
expect(tx.phoneFrequencyHit.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
applicationId: 'application-1',
|
||||
phoneNumber: '13800000001',
|
||||
ruleCode: 'PHONE_FREQUENCY_5M',
|
||||
thresholdValue: 5,
|
||||
actualValue: 6,
|
||||
})],
|
||||
});
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bypasses both frequency rules for active platform-level whitelist phones', async () => {
|
||||
const tx = {
|
||||
phoneFrequencyWhitelist: {
|
||||
findMany: jest.fn().mockResolvedValue([{ phoneNumber: '13800000001' }]),
|
||||
},
|
||||
$queryRaw: jest.fn(),
|
||||
};
|
||||
const prisma = {
|
||||
riskRule: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'rule-5m',
|
||||
applicationId: null,
|
||||
code: 'PHONE_FREQUENCY_5M',
|
||||
name: '单号码5分钟发送频次',
|
||||
thresholdValue: 5,
|
||||
action: 'block',
|
||||
priority: 50,
|
||||
config: { periodSeconds: 300 },
|
||||
}]),
|
||||
},
|
||||
$transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new PhoneFrequencyService(prisma as never, riskReview as never);
|
||||
|
||||
const rejected = await service.reserve(
|
||||
'tenant-1',
|
||||
'application-1',
|
||||
['13800000001'],
|
||||
'client',
|
||||
new Date('2026-07-30T01:03:00.000Z'),
|
||||
);
|
||||
|
||||
expect(rejected.size).toBe(0);
|
||||
expect(tx.phoneFrequencyWhitelist.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
phoneNumber: { in: ['13800000001'] },
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from './risk-review.service';
|
||||
|
||||
const PHONE_FREQUENCY_RULE_CODES = ['PHONE_FREQUENCY_24H', 'PHONE_FREQUENCY_5M'] as const;
|
||||
const FREQUENCY_WRITE_CHUNK_SIZE = 1000;
|
||||
|
||||
type FrequencyRule = {
|
||||
id: string;
|
||||
applicationId: string | null;
|
||||
code: string;
|
||||
name: string;
|
||||
thresholdValue: number;
|
||||
action: string;
|
||||
priority: number;
|
||||
config: unknown;
|
||||
};
|
||||
|
||||
type FrequencyStateRow = {
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
count: number;
|
||||
generation: number;
|
||||
activeHitId: string | null;
|
||||
windowStartedAt: Date;
|
||||
windowEndsAt: Date;
|
||||
};
|
||||
|
||||
export interface PhoneFrequencyHitQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
status?: 'active' | 'expired' | 'released';
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PhoneFrequencyWhitelistQuery {
|
||||
phoneNumber?: string;
|
||||
keyword?: string;
|
||||
status?: 'active' | 'inactive' | 'deleted';
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreatePhoneFrequencyWhitelistDto {
|
||||
phoneNumber: string;
|
||||
reason: string;
|
||||
remark?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export type UpdatePhoneFrequencyWhitelistDto = Partial<CreatePhoneFrequencyWhitelistDto>;
|
||||
|
||||
export interface PhoneFrequencyRejection {
|
||||
code: 'PHONE_FREQUENCY_LIMIT';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhoneFrequencyService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 为一次初始业务短信提交占用频次。调用方只传尚未被格式或黑名单拒绝的号码;
|
||||
* 长短信分片、通道重试和补发不会进入这里,因此同一业务号码只计一次。
|
||||
*/
|
||||
async reserve(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
phones: string[],
|
||||
sourceType?: string,
|
||||
requestedAt = new Date(),
|
||||
) {
|
||||
if (!applicationId) return new Map<string, PhoneFrequencyRejection>();
|
||||
const normalizedPhones = [...new Set(phones.map((phone) => phone.trim()).filter(Boolean))].sort();
|
||||
if (normalizedPhones.length === 0) return new Map<string, PhoneFrequencyRejection>();
|
||||
|
||||
await this.riskReview.ensureDefaultRules();
|
||||
const rules = await this.effectiveRules(applicationId);
|
||||
if (rules.length === 0) return new Map<string, PhoneFrequencyRejection>();
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const rejected = new Map<string, PhoneFrequencyRejection>();
|
||||
// 平台级白名单只截断号码频控链路;调用 reserve 之前已执行的格式、黑名单等校验不受影响。
|
||||
const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, normalizedPhones);
|
||||
const controlledPhones = normalizedPhones.filter((phone) => !whitelistedPhones.has(phone));
|
||||
if (controlledPhones.length === 0) return rejected;
|
||||
for (const rule of rules) {
|
||||
const window = fixedShanghaiWindow(requestedAt, readPeriodSeconds(rule));
|
||||
// 分块限制 SQL 参数数量,但两条规则的全部分块仍在同一事务中提交或回滚。
|
||||
for (const phoneChunk of chunks(controlledPhones, FREQUENCY_WRITE_CHUNK_SIZE)) {
|
||||
const states = await this.upsertStates(tx, {
|
||||
tenantId,
|
||||
applicationId,
|
||||
phones: phoneChunk,
|
||||
rule,
|
||||
window,
|
||||
});
|
||||
const newTriggers = states.filter((state) => state.activeHitId === null && state.count > rule.thresholdValue);
|
||||
const hitByStateId = new Map<string, string>();
|
||||
if (newTriggers.length > 0) {
|
||||
const hitRows = newTriggers.map((state) => {
|
||||
const hitId = randomUUID();
|
||||
hitByStateId.set(state.id, hitId);
|
||||
return {
|
||||
id: hitId,
|
||||
tenantId,
|
||||
applicationId,
|
||||
ruleId: rule.id,
|
||||
ruleCode: rule.code,
|
||||
ruleName: rule.name,
|
||||
phoneNumber: state.phoneNumber,
|
||||
thresholdValue: Math.floor(rule.thresholdValue),
|
||||
actualValue: state.count,
|
||||
windowStartedAt: state.windowStartedAt,
|
||||
windowEndsAt: state.windowEndsAt,
|
||||
generation: state.generation,
|
||||
action: 'block',
|
||||
sourceType,
|
||||
};
|
||||
});
|
||||
await tx.phoneFrequencyHit.createMany({ data: hitRows });
|
||||
await this.attachActiveHits(tx, hitByStateId);
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (state.activeHitId === null && state.count <= rule.thresholdValue) continue;
|
||||
const reason = `${rule.name}命中:本周期最多${Math.floor(rule.thresholdValue)}条,当前第${state.count}条,周期${formatWindow(state.windowStartedAt, state.windowEndsAt)}`;
|
||||
const existing = rejected.get(state.phoneNumber);
|
||||
rejected.set(state.phoneNumber, {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: existing ? `${existing.reason};${reason}` : reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rejected;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async listHits(query: PhoneFrequencyHitQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
const now = new Date();
|
||||
if (query.status && !['active', 'expired', 'released'].includes(query.status)) {
|
||||
throw new BadRequestException('号码频次触发记录状态无效');
|
||||
}
|
||||
const createdAtFrom = parseOptionalDate(query.createdAtFrom, '开始时间');
|
||||
const createdAtTo = parseOptionalDate(query.createdAtTo, '结束时间');
|
||||
if (createdAtFrom && createdAtTo && createdAtFrom > createdAtTo) {
|
||||
throw new BadRequestException('开始时间不能晚于结束时间');
|
||||
}
|
||||
const where: Prisma.PhoneFrequencyHitWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber?.trim() ? { contains: query.phoneNumber.trim() } : undefined,
|
||||
createdAt: createdAtFrom || createdAtTo ? {
|
||||
gte: createdAtFrom,
|
||||
lte: createdAtTo,
|
||||
} : undefined,
|
||||
...(query.status === 'active' ? { releasedAt: null, windowEndsAt: { gt: now } } : {}),
|
||||
...(query.status === 'expired' ? { releasedAt: null, windowEndsAt: { lte: now } } : {}),
|
||||
...(query.status === 'released' ? { releasedAt: { not: null } } : {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.phoneFrequencyHit.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
releasedBy: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.phoneFrequencyHit.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async releaseHit(hitId: string, reviewerId: string | undefined, reason?: string) {
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!reviewerId) throw new BadRequestException('解除操作需要有效的运营登录会话');
|
||||
if (!normalizedReason) throw new BadRequestException('解除并清零时必须填写原因');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 与并发 reserve 串行:解除先锁住当前活跃状态,再同时清零计数和断开命中关联。
|
||||
const [lockedState] = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
|
||||
SELECT state.id
|
||||
FROM "PhoneFrequencyState" state
|
||||
WHERE state."activeHitId" = ${hitId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const hit = await tx.phoneFrequencyHit.findUnique({ where: { id: hitId } });
|
||||
if (!hit) throw new NotFoundException('号码频次触发记录不存在');
|
||||
if (hit.releasedAt) {
|
||||
return tx.phoneFrequencyHit.findUnique({
|
||||
where: { id: hitId },
|
||||
include: { tenant: true, application: true, releasedBy: true },
|
||||
});
|
||||
}
|
||||
const releasedAt = new Date();
|
||||
if (lockedState) {
|
||||
await tx.phoneFrequencyState.update({
|
||||
where: { id: lockedState.id },
|
||||
data: {
|
||||
count: 0,
|
||||
generation: { increment: 1 },
|
||||
activeHitId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
const released = await tx.phoneFrequencyHit.update({
|
||||
where: { id: hitId },
|
||||
data: {
|
||||
releasedAt,
|
||||
releasedById: reviewerId,
|
||||
releaseReason: normalizedReason,
|
||||
},
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
releasedBy: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
tenantId: hit.tenantId,
|
||||
userId: reviewerId,
|
||||
action: 'phone_frequency.release',
|
||||
resource: 'phone_frequency_hit',
|
||||
resourceId: hitId,
|
||||
detail: {
|
||||
applicationId: hit.applicationId,
|
||||
phoneNumber: hit.phoneNumber,
|
||||
ruleCode: hit.ruleCode,
|
||||
countReset: Boolean(lockedState),
|
||||
reason: normalizedReason,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return released;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async listWhitelist(query: PhoneFrequencyWhitelistQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
if (query.status && !['active', 'inactive', 'deleted'].includes(query.status)) {
|
||||
throw new BadRequestException('号码频控白名单状态无效');
|
||||
}
|
||||
const updatedAtFrom = parseOptionalDate(query.updatedAtFrom, '开始时间');
|
||||
const updatedAtTo = parseOptionalDate(query.updatedAtTo, '结束时间');
|
||||
if (updatedAtFrom && updatedAtTo && updatedAtFrom > updatedAtTo) {
|
||||
throw new BadRequestException('开始时间不能晚于结束时间');
|
||||
}
|
||||
const keyword = query.keyword?.trim();
|
||||
const phoneNumber = query.phoneNumber?.trim();
|
||||
const where: Prisma.PhoneFrequencyWhitelistWhereInput = {
|
||||
status: query.status ?? { not: 'deleted' },
|
||||
phoneNumber: phoneNumber ? { contains: phoneNumber } : undefined,
|
||||
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
|
||||
OR: keyword ? [
|
||||
{ phoneNumber: { contains: keyword } },
|
||||
{ reason: { contains: keyword, mode: 'insensitive' } },
|
||||
{ remark: { contains: keyword, mode: 'insensitive' } },
|
||||
] : undefined,
|
||||
};
|
||||
const include = {
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
updatedBy: { select: { id: true, username: true, displayName: true } },
|
||||
} as const;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.phoneFrequencyWhitelist.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.phoneFrequencyWhitelist.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createWhitelist(data: CreatePhoneFrequencyWhitelistDto, operatorId: string | undefined) {
|
||||
const normalized = normalizeWhitelistInput(data, false);
|
||||
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.phoneFrequencyWhitelist.findUnique({
|
||||
where: { phoneNumber: normalized.phoneNumber },
|
||||
});
|
||||
if (existing && existing.status !== 'deleted') {
|
||||
throw new BadRequestException('该号码已存在于号码频控白名单');
|
||||
}
|
||||
const entry = existing
|
||||
? await tx.phoneFrequencyWhitelist.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...normalized,
|
||||
deletedAt: null,
|
||||
updatedById: operatorId,
|
||||
},
|
||||
})
|
||||
: await tx.phoneFrequencyWhitelist.create({
|
||||
data: {
|
||||
...normalized,
|
||||
createdById: operatorId,
|
||||
updatedById: operatorId,
|
||||
},
|
||||
});
|
||||
const reset = normalized.status === 'active'
|
||||
? await this.resetFrequencyStates(tx, [normalized.phoneNumber], operatorId, '号码加入平台级频控白名单')
|
||||
: { stateCount: 0, hitCount: 0 };
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: existing ? 'phone_frequency_whitelist.restore' : 'phone_frequency_whitelist.create',
|
||||
resource: 'phone_frequency_whitelist',
|
||||
resourceId: entry.id,
|
||||
detail: {
|
||||
after: normalized,
|
||||
reset,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return tx.phoneFrequencyWhitelist.findUnique({
|
||||
where: { id: entry.id },
|
||||
include: whitelistUserInclude,
|
||||
});
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async updateWhitelist(
|
||||
id: string,
|
||||
data: UpdatePhoneFrequencyWhitelistDto,
|
||||
operatorId: string | undefined,
|
||||
) {
|
||||
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
|
||||
if (!data || Object.keys(data).length === 0) throw new BadRequestException('没有需要修改的白名单字段');
|
||||
const normalized = normalizeWhitelistInput(data, true);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`
|
||||
SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE
|
||||
`);
|
||||
const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } });
|
||||
if (!existing || existing.status === 'deleted') {
|
||||
throw new NotFoundException('号码频控白名单记录不存在');
|
||||
}
|
||||
const nextPhone = normalized.phoneNumber ?? existing.phoneNumber;
|
||||
const nextStatus = normalized.status ?? existing.status;
|
||||
if (nextPhone !== existing.phoneNumber) {
|
||||
const duplicate = await tx.phoneFrequencyWhitelist.findUnique({ where: { phoneNumber: nextPhone } });
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException(
|
||||
duplicate.status === 'deleted'
|
||||
? '该号码存在已删除的白名单历史记录,请直接重新新增该号码以恢复记录'
|
||||
: '该号码已存在于号码频控白名单',
|
||||
);
|
||||
}
|
||||
}
|
||||
const shouldReset = nextPhone !== existing.phoneNumber || nextStatus !== existing.status;
|
||||
const reset = shouldReset
|
||||
? await this.resetFrequencyStates(
|
||||
tx,
|
||||
[existing.phoneNumber, nextPhone],
|
||||
operatorId,
|
||||
'平台级频控白名单号码或状态发生变更',
|
||||
)
|
||||
: { stateCount: 0, hitCount: 0 };
|
||||
const entry = await tx.phoneFrequencyWhitelist.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...normalized,
|
||||
updatedById: operatorId,
|
||||
},
|
||||
include: whitelistUserInclude,
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'phone_frequency_whitelist.update',
|
||||
resource: 'phone_frequency_whitelist',
|
||||
resourceId: id,
|
||||
detail: {
|
||||
before: whitelistAuditValue(existing),
|
||||
after: whitelistAuditValue(entry),
|
||||
reset,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return entry;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async deleteWhitelist(id: string, operatorId: string | undefined, reason?: string) {
|
||||
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!normalizedReason) throw new BadRequestException('删除白名单时必须填写原因');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`
|
||||
SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE
|
||||
`);
|
||||
const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } });
|
||||
if (!existing || existing.status === 'deleted') {
|
||||
throw new NotFoundException('号码频控白名单记录不存在');
|
||||
}
|
||||
const reset = await this.resetFrequencyStates(
|
||||
tx,
|
||||
[existing.phoneNumber],
|
||||
operatorId,
|
||||
`删除平台级频控白名单:${normalizedReason}`,
|
||||
);
|
||||
const entry = await tx.phoneFrequencyWhitelist.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: new Date(),
|
||||
updatedById: operatorId,
|
||||
},
|
||||
include: whitelistUserInclude,
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'phone_frequency_whitelist.delete',
|
||||
resource: 'phone_frequency_whitelist',
|
||||
resourceId: id,
|
||||
detail: {
|
||||
phoneNumber: existing.phoneNumber,
|
||||
reason: normalizedReason,
|
||||
reset,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return entry;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
private async findActiveWhitelistedPhones(tx: Prisma.TransactionClient, phones: string[]) {
|
||||
const result = new Set<string>();
|
||||
for (const phoneChunk of chunks(phones, FREQUENCY_WRITE_CHUNK_SIZE)) {
|
||||
const rows = await tx.phoneFrequencyWhitelist.findMany({
|
||||
where: { phoneNumber: { in: phoneChunk }, status: 'active', deletedAt: null },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
for (const row of rows) result.add(row.phoneNumber);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async resetFrequencyStates(
|
||||
tx: Prisma.TransactionClient,
|
||||
phones: string[],
|
||||
operatorId: string,
|
||||
releaseReason: string,
|
||||
) {
|
||||
// 白名单状态变化按号码跨应用清零;历史命中保留,只解除当前仍与状态关联的活跃命中。
|
||||
const normalizedPhones = [...new Set(phones)].sort();
|
||||
const states = await tx.phoneFrequencyState.findMany({
|
||||
where: { phoneNumber: { in: normalizedPhones } },
|
||||
select: { id: true, activeHitId: true },
|
||||
});
|
||||
const activeHitIds = states.flatMap((state) => state.activeHitId ? [state.activeHitId] : []);
|
||||
const releasedAt = new Date();
|
||||
const released = activeHitIds.length > 0
|
||||
? await tx.phoneFrequencyHit.updateMany({
|
||||
where: { id: { in: activeHitIds }, releasedAt: null },
|
||||
data: { releasedAt, releasedById: operatorId, releaseReason },
|
||||
})
|
||||
: { count: 0 };
|
||||
const reset = states.length > 0
|
||||
? await tx.phoneFrequencyState.updateMany({
|
||||
where: { id: { in: states.map((state) => state.id) } },
|
||||
data: { count: 0, generation: { increment: 1 }, activeHitId: null },
|
||||
})
|
||||
: { count: 0 };
|
||||
return { stateCount: reset.count, hitCount: released.count };
|
||||
}
|
||||
|
||||
private async effectiveRules(applicationId: string): Promise<FrequencyRule[]> {
|
||||
const rules = await this.prisma.riskRule.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
code: { in: [...PHONE_FREQUENCY_RULE_CODES] },
|
||||
OR: [{ applicationId: null }, { applicationId }],
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const byCode = new Map<string, FrequencyRule>();
|
||||
for (const rule of rules) {
|
||||
if (rule.applicationId || !byCode.has(rule.code)) byCode.set(rule.code, rule);
|
||||
}
|
||||
return [...byCode.values()].sort((left, right) => left.priority - right.priority);
|
||||
}
|
||||
|
||||
private upsertStates(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
phones: string[];
|
||||
rule: FrequencyRule;
|
||||
window: { startAt: Date; endAt: Date };
|
||||
},
|
||||
) {
|
||||
const values = input.phones.map((phone) => Prisma.sql`(${randomUUID()}, ${phone})`);
|
||||
// ON CONFLICT 对同一应用、规则、号码取得行锁,保证并发越过阈值时只有一个首次命中者。
|
||||
return tx.$queryRaw<FrequencyStateRow[]>(Prisma.sql`
|
||||
WITH input("id", "phoneNumber") AS (
|
||||
VALUES ${Prisma.join(values)}
|
||||
)
|
||||
INSERT INTO "PhoneFrequencyState" (
|
||||
"id", "tenantId", "applicationId", "ruleId", "ruleCode", "phoneNumber",
|
||||
"windowStartedAt", "windowEndsAt", "count", "generation", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
input.id, ${input.tenantId}, ${input.applicationId}, ${input.rule.id}, ${input.rule.code},
|
||||
input."phoneNumber", ${input.window.startAt}, ${input.window.endAt}, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM input
|
||||
ON CONFLICT ("applicationId", "ruleCode", "phoneNumber")
|
||||
DO UPDATE SET
|
||||
"ruleId" = EXCLUDED."ruleId",
|
||||
"windowStartedAt" = EXCLUDED."windowStartedAt",
|
||||
"windowEndsAt" = EXCLUDED."windowEndsAt",
|
||||
"count" = CASE
|
||||
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 1
|
||||
WHEN "PhoneFrequencyState"."activeHitId" IS NOT NULL THEN "PhoneFrequencyState"."count"
|
||||
ELSE "PhoneFrequencyState"."count" + 1
|
||||
END,
|
||||
"generation" = CASE
|
||||
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 0
|
||||
ELSE "PhoneFrequencyState"."generation"
|
||||
END,
|
||||
"activeHitId" = CASE
|
||||
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN NULL
|
||||
ELSE "PhoneFrequencyState"."activeHitId"
|
||||
END,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
RETURNING
|
||||
"id", "phoneNumber", "count", "generation", "activeHitId", "windowStartedAt", "windowEndsAt"
|
||||
`);
|
||||
}
|
||||
|
||||
private async attachActiveHits(tx: Prisma.TransactionClient, hitByStateId: Map<string, string>) {
|
||||
if (hitByStateId.size === 0) return;
|
||||
const values = [...hitByStateId].map(([stateId, hitId]) => Prisma.sql`(${stateId}, ${hitId})`);
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE "PhoneFrequencyState" state
|
||||
SET "activeHitId" = updates."hitId", "updatedAt" = CURRENT_TIMESTAMP
|
||||
FROM (VALUES ${Prisma.join(values)}) AS updates("stateId", "hitId")
|
||||
WHERE state.id = updates."stateId"
|
||||
AND state."activeHitId" IS NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
const whitelistUserInclude = {
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
updatedBy: { select: { id: true, username: true, displayName: true } },
|
||||
} as const;
|
||||
|
||||
function normalizeWhitelistInput(
|
||||
input: CreatePhoneFrequencyWhitelistDto | UpdatePhoneFrequencyWhitelistDto,
|
||||
partial: boolean,
|
||||
) {
|
||||
const result: {
|
||||
phoneNumber?: string;
|
||||
reason?: string;
|
||||
remark?: string | null;
|
||||
status?: 'active' | 'inactive';
|
||||
} = {};
|
||||
if (!partial || input.phoneNumber !== undefined) {
|
||||
const phoneNumber = normalizeMainlandPhone(input.phoneNumber);
|
||||
if (!phoneNumber) throw new BadRequestException('请输入有效的中国大陆11位手机号码');
|
||||
result.phoneNumber = phoneNumber;
|
||||
}
|
||||
if (!partial || input.reason !== undefined) {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason) throw new BadRequestException('白名单用途说明不能为空');
|
||||
if (reason.length > 200) throw new BadRequestException('白名单用途说明不能超过200个字符');
|
||||
result.reason = reason;
|
||||
}
|
||||
if (input.remark !== undefined) {
|
||||
const remark = input.remark?.trim() ?? '';
|
||||
if (remark.length > 500) throw new BadRequestException('白名单备注不能超过500个字符');
|
||||
result.remark = remark || null;
|
||||
}
|
||||
const status = input.status ?? (partial ? undefined : 'active');
|
||||
if (status !== undefined && !['active', 'inactive'].includes(status)) {
|
||||
throw new BadRequestException('白名单状态无效');
|
||||
}
|
||||
if (status) result.status = status;
|
||||
return result as {
|
||||
phoneNumber: string;
|
||||
reason: string;
|
||||
remark?: string | null;
|
||||
status: 'active' | 'inactive';
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMainlandPhone(value: string | undefined) {
|
||||
const compact = value?.trim().replace(/[\s-]/g, '') ?? '';
|
||||
const withoutCountryCode = compact.startsWith('+86')
|
||||
? compact.slice(3)
|
||||
: compact.startsWith('86') && compact.length === 13
|
||||
? compact.slice(2)
|
||||
: compact;
|
||||
return /^1\d{10}$/.test(withoutCountryCode) ? withoutCountryCode : undefined;
|
||||
}
|
||||
|
||||
function whitelistAuditValue(entry: {
|
||||
phoneNumber: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
remark: string | null;
|
||||
deletedAt: Date | null;
|
||||
}) {
|
||||
return {
|
||||
phoneNumber: entry.phoneNumber,
|
||||
status: entry.status,
|
||||
reason: entry.reason,
|
||||
remark: entry.remark,
|
||||
deletedAt: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function readPeriodSeconds(rule: FrequencyRule) {
|
||||
const config = rule.config && typeof rule.config === 'object' && !Array.isArray(rule.config)
|
||||
? rule.config as Record<string, unknown>
|
||||
: {};
|
||||
const fallback = rule.code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60;
|
||||
const value = Number(config.periodSeconds ?? fallback);
|
||||
return Number.isInteger(value) && value >= 60 && value <= 24 * 60 * 60 && 24 * 60 * 60 % value === 0
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function fixedShanghaiWindow(value: Date, periodSeconds: number) {
|
||||
const shanghaiOffsetMs = 8 * 60 * 60 * 1000;
|
||||
const shifted = value.getTime() + shanghaiOffsetMs;
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const localDayStart = Math.floor(shifted / dayMs) * dayMs;
|
||||
const periodMs = periodSeconds * 1000;
|
||||
const localWindowStart = localDayStart + Math.floor((shifted - localDayStart) / periodMs) * periodMs;
|
||||
return {
|
||||
startAt: new Date(localWindowStart - shanghaiOffsetMs),
|
||||
endAt: new Date(localWindowStart - shanghaiOffsetMs + periodMs),
|
||||
};
|
||||
}
|
||||
|
||||
function formatWindow(startAt: Date, endAt: Date) {
|
||||
const formatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatter.format(startAt)}至${formatter.format(endAt)}`;
|
||||
}
|
||||
|
||||
function chunks<T>(items: T[], size: number) {
|
||||
const result: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
result.push(items.slice(index, index + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseOptionalDate(value: string | undefined, label: string) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException(`${label}格式无效`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AdminRiskReviewController } from './admin-risk-review.controller';
|
||||
import { ClientRiskReviewController } from './client-risk-review.controller';
|
||||
import { RiskReviewService } from './risk-review.service';
|
||||
import { PhoneFrequencyService } from './phone-frequency.service';
|
||||
import { SendChainModule } from '../send-chain/send-chain.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
||||
controllers: [AdminRiskReviewController, ClientRiskReviewController],
|
||||
providers: [RiskReviewService],
|
||||
exports: [RiskReviewService],
|
||||
providers: [RiskReviewService, PhoneFrequencyService],
|
||||
exports: [RiskReviewService, PhoneFrequencyService],
|
||||
})
|
||||
export class RiskReviewModule {}
|
||||
|
||||
@@ -61,6 +61,18 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('RiskReviewService', () => {
|
||||
it('keeps phone-frequency periods fixed and rejects manual-review actions', () => {
|
||||
const service = new RiskReviewService(createPrismaMock() as never);
|
||||
|
||||
expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 }))
|
||||
.toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改');
|
||||
expect(() => service['validateRuleInput']({
|
||||
code: 'PHONE_FREQUENCY_24H',
|
||||
thresholdValue: 10,
|
||||
action: 'manual_review',
|
||||
})).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
|
||||
});
|
||||
|
||||
it('includes the sending enterprise and application in SMS review rows', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findMany.mockResolvedValue([]);
|
||||
@@ -76,6 +88,23 @@ describe('RiskReviewService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('filters SMS review tasks by their submission time', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findMany.mockResolvedValue([]);
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
await service.listTasks(undefined, 'pending_review', '2026-08-01', '2026-08-03');
|
||||
|
||||
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
createdAt: {
|
||||
gte: new Date('2026-08-01T00:00:00+08:00'),
|
||||
lte: new Date('2026-08-03T23:59:59.999+08:00'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('groups identical CMPP template mismatches into a deterministic short review window', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
|
||||
export interface CreateRiskRuleDto {
|
||||
tenantId?: string;
|
||||
@@ -87,6 +88,26 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
|
||||
action: 'manual_review',
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
code: 'PHONE_FREQUENCY_24H',
|
||||
name: '单号码24小时发送频次',
|
||||
description: '同一企业应用下,单个号码在北京时间自然日内最多允许10条业务短信。',
|
||||
metric: 'phoneFrequencyCount',
|
||||
thresholdValue: 10,
|
||||
action: 'block',
|
||||
priority: 40,
|
||||
config: { periodSeconds: 24 * 60 * 60, timeZone: 'Asia/Shanghai', alignment: 'fixed' },
|
||||
},
|
||||
{
|
||||
code: 'PHONE_FREQUENCY_5M',
|
||||
name: '单号码5分钟发送频次',
|
||||
description: '同一企业应用下,单个号码在固定5分钟周期内最多允许5条业务短信。',
|
||||
metric: 'phoneFrequencyCount',
|
||||
thresholdValue: 5,
|
||||
action: 'block',
|
||||
priority: 50,
|
||||
config: { periodSeconds: 5 * 60, timeZone: 'Asia/Shanghai', alignment: 'fixed' },
|
||||
},
|
||||
];
|
||||
|
||||
const RULE_DEFINITIONS = new Map(DEFAULT_RULES.map((rule) => [rule.code, rule]));
|
||||
@@ -129,7 +150,7 @@ export class RiskReviewService {
|
||||
description: definition.description,
|
||||
metric: definition.metric!,
|
||||
thresholdValue: data.thresholdValue,
|
||||
action: data.action ?? 'manual_review',
|
||||
action: isPhoneFrequencyRule(data.code) ? 'block' : data.action ?? 'manual_review',
|
||||
status: data.status ?? 'active',
|
||||
priority: data.priority ?? definition.priority ?? 100,
|
||||
config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined,
|
||||
@@ -179,11 +200,12 @@ export class RiskReviewService {
|
||||
});
|
||||
}
|
||||
|
||||
listTasks(tenantId?: string, status?: string) {
|
||||
listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) {
|
||||
return this.prisma.smsSendTask.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status,
|
||||
createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
|
||||
...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}),
|
||||
...(!status ? {
|
||||
OR: [
|
||||
@@ -468,7 +490,7 @@ export class RiskReviewService {
|
||||
return rejected;
|
||||
}
|
||||
|
||||
private async ensureDefaultRules() {
|
||||
async ensureDefaultRules() {
|
||||
for (const rule of DEFAULT_RULES) {
|
||||
const exists = await this.prisma.riskRule.findFirst({
|
||||
where: { applicationId: null, code: rule.code, status: { not: 'deleted' } },
|
||||
@@ -556,6 +578,12 @@ export class RiskReviewService {
|
||||
if (!Number.isFinite(data.thresholdValue) || data.thresholdValue < 0) {
|
||||
throw new BadRequestException('风控阈值必须是大于等于0的有效数字');
|
||||
}
|
||||
if (
|
||||
isPhoneFrequencyRule(data.code)
|
||||
&& (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review')
|
||||
) {
|
||||
throw new BadRequestException('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
|
||||
}
|
||||
if (data.action && !['block', 'manual_review'].includes(data.action)) {
|
||||
throw new BadRequestException('风控处理动作无效');
|
||||
}
|
||||
@@ -581,6 +609,18 @@ export class RiskReviewService {
|
||||
}
|
||||
|
||||
private normalizeRuleConfig(code: string, config?: Record<string, unknown> | null) {
|
||||
if (code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M') {
|
||||
const defaultPeriodSeconds = code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60;
|
||||
const periodSeconds = Number(config?.periodSeconds ?? defaultPeriodSeconds);
|
||||
if (periodSeconds !== defaultPeriodSeconds) {
|
||||
throw new BadRequestException('号码频次周期首版固定为24小时自然日或5分钟,不允许修改');
|
||||
}
|
||||
return {
|
||||
periodSeconds: defaultPeriodSeconds,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
alignment: 'fixed',
|
||||
};
|
||||
}
|
||||
if (code !== 'NON_WORKING_MARKETING_BULK') {
|
||||
return config ?? undefined;
|
||||
}
|
||||
@@ -599,6 +639,10 @@ export class RiskReviewService {
|
||||
}
|
||||
}
|
||||
|
||||
function isPhoneFrequencyRule(code: string) {
|
||||
return code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M';
|
||||
}
|
||||
|
||||
function ratio(count: number, total: number) {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { SendChainService, TimeoutUnknownDto } from './send-chain.service';
|
||||
import { TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('send-chain')
|
||||
@Controller('admin/send')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto, SendChainService } from './send-chain.service';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('client-send-chain')
|
||||
@Controller('client/send')
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
||||
|
||||
describe('queueFinalReceiptDeliveries', () => {
|
||||
it('queues one HTTP event and one CMPP receipt for each registered client fragment', async () => {
|
||||
const prisma = {
|
||||
cmppInboundLongMessage: {
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
messageId: 'MSG-GROUP',
|
||||
segmentTotal: 3,
|
||||
segments: [
|
||||
{ segmentIndex: 1, sequenceId: '101', registeredDelivery: true },
|
||||
{ segmentIndex: 2, sequenceId: '102', registeredDelivery: false },
|
||||
{ segmentIndex: 3, sequenceId: '103', registeredDelivery: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
const queue = jest.fn().mockResolvedValue({ id: 'queued' });
|
||||
|
||||
await queueFinalReceiptDeliveries(prisma as never, queue, {
|
||||
message: {
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
cmppSubmitGroupMessageId: 'MSG-GROUP',
|
||||
},
|
||||
payload: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
segmentPayloads: {
|
||||
1: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
3: { receiptStatus: 'undelivered', rawStatus: 'REJECTD' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queue).toHaveBeenCalledTimes(3);
|
||||
expect(queue).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
queueHttpWebhook: true,
|
||||
queueCmppDelivery: false,
|
||||
}));
|
||||
expect(queue).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
receiptDedupeKey: 'receipt:record-1:segment:1',
|
||||
queueHttpWebhook: false,
|
||||
payload: expect.objectContaining({ submitSequenceId: 101, clientSegmentIndex: 1 }),
|
||||
}));
|
||||
expect(queue).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
receiptDedupeKey: 'receipt:record-1:segment:3',
|
||||
payload: expect.objectContaining({ submitSequenceId: 103, clientSegmentIndex: 3, receiptStatus: 'undelivered', rawStatus: 'REJECTD' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('queues only the message-level HTTP event when the submission did not originate from CMPP', async () => {
|
||||
const prisma = { cmppInboundLongMessage: { findFirst: jest.fn() } };
|
||||
const queue = jest.fn().mockResolvedValue({ id: 'queued' });
|
||||
|
||||
await queueFinalReceiptDeliveries(prisma as never, queue, {
|
||||
message: {
|
||||
id: 'record-http',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-HTTP',
|
||||
phoneNumber: '13800000001',
|
||||
},
|
||||
payload: { receiptStatus: 'undelivered', rawStatus: 'EXPIRED' },
|
||||
});
|
||||
|
||||
expect(queue).toHaveBeenCalledTimes(1);
|
||||
expect(queue).toHaveBeenCalledWith(expect.objectContaining({
|
||||
queueHttpWebhook: true,
|
||||
queueCmppDelivery: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type FinalReceiptMessage = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
cmppRegisteredDelivery?: boolean | null;
|
||||
};
|
||||
|
||||
export type DownstreamDeliveryQueueRequest = {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
messageRecordId?: string | null;
|
||||
messageId?: string | null;
|
||||
deliveryType: 'receipt' | 'uplink';
|
||||
payload: Record<string, unknown>;
|
||||
receiptDedupeKey?: string;
|
||||
queueHttpWebhook?: boolean;
|
||||
queueCmppDelivery?: boolean;
|
||||
propagateHttpQueueError?: boolean;
|
||||
};
|
||||
|
||||
type ClientReceiptTarget = {
|
||||
segmentIndex: number;
|
||||
segmentTotal: number;
|
||||
submitSequenceId: number;
|
||||
submitGroupMessageId: string;
|
||||
registeredDelivery: boolean;
|
||||
};
|
||||
|
||||
async function resolveClientReceiptTargets(
|
||||
prisma: PrismaService,
|
||||
message: FinalReceiptMessage,
|
||||
): Promise<ClientReceiptTarget[]> {
|
||||
if (message.cmppSubmitGroupMessageId) {
|
||||
const group = await prisma.cmppInboundLongMessage.findFirst({
|
||||
where: { messageId: message.cmppSubmitGroupMessageId },
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
});
|
||||
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 = 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,
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one business-level HTTP callback and one CMPP status report for every
|
||||
* original client fragment that requested Registered_Delivery. Internal retry,
|
||||
* refund and billing remain message-level; only protocol delivery is expanded.
|
||||
*/
|
||||
export async function queueFinalReceiptDeliveries(
|
||||
prisma: PrismaService,
|
||||
queue: (request: DownstreamDeliveryQueueRequest) => Promise<unknown>,
|
||||
data: {
|
||||
message: FinalReceiptMessage;
|
||||
payload: Record<string, unknown>;
|
||||
segmentPayloads?: Record<number, Record<string, unknown>>;
|
||||
propagateHttpQueueError?: boolean;
|
||||
},
|
||||
) {
|
||||
const { message } = data;
|
||||
if (!message.tenantId || !message.applicationId) {
|
||||
return { queued: false, cmppTargetCount: 0 };
|
||||
}
|
||||
|
||||
// HTTP submissions have one client message identity, so their webhook stays
|
||||
// message-level even when the carrier internally split the SMS into segments.
|
||||
await queue({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: data.payload,
|
||||
queueHttpWebhook: true,
|
||||
queueCmppDelivery: false,
|
||||
propagateHttpQueueError: data.propagateHttpQueueError,
|
||||
});
|
||||
|
||||
const targets = (await resolveClientReceiptTargets(prisma, message))
|
||||
.filter((target) => target.registeredDelivery);
|
||||
for (const target of targets) {
|
||||
const isSingleFragment = target.segmentTotal === 1;
|
||||
await queue({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
...data.payload,
|
||||
...data.segmentPayloads?.[target.segmentIndex],
|
||||
submitSequenceId: target.submitSequenceId,
|
||||
submitGroupMessageId: target.submitGroupMessageId,
|
||||
clientSegmentIndex: target.segmentIndex,
|
||||
clientSegmentTotal: target.segmentTotal,
|
||||
},
|
||||
receiptDedupeKey: isSingleFragment
|
||||
? `receipt:${message.id}`
|
||||
: `receipt:${message.id}:segment:${target.segmentIndex}`,
|
||||
queueHttpWebhook: false,
|
||||
queueCmppDelivery: true,
|
||||
});
|
||||
}
|
||||
return { queued: true, cmppTargetCount: targets.length };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { detectDrainageContentWithRules, type DrainageDetectionRuleSnapshot } from './drainage-content-detection';
|
||||
|
||||
const rules: DrainageDetectionRuleSnapshot[] = [
|
||||
{ id: 'url', code: 'URL', name: 'URL', category: 'url', priority: 10, version: 1, flags: 'giu', pattern: '(?:https?:\\/\\/)?(?:www\\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,24}|(?:\\d{1,3}\\.){3}\\d{1,3})(?::\\d{1,5})?(?:\\/[^\\s,,;;!!??<>《》]*)?' },
|
||||
{ id: 'mobile', code: 'MOBILE', name: '手机', category: 'mobile', priority: 20, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])' },
|
||||
{ id: 'landline', code: 'LANDLINE', name: '固话', category: 'landline', priority: 30, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?(?:\\(0[0-9]{2,3}\\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])' },
|
||||
];
|
||||
|
||||
describe('drainage content detection', () => {
|
||||
test.each([
|
||||
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
|
||||
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
|
||||
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
|
||||
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
|
||||
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
|
||||
['短横线手机号', '电话 138-0013-8000', 'mobile'],
|
||||
['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'],
|
||||
])('%s', (_name, content, category) => {
|
||||
const result = detectDrainageContentWithRules(content, rules);
|
||||
expect(result.hasDrainageContent).toBe(true);
|
||||
expect((result.drainageDetection as { matches: Array<{ category: string }> }).matches.some((item) => item.category === category)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify an email address as drainage information', () => {
|
||||
expect(detectDrainageContentWithRules('联系邮箱 service@example.com,谢谢', rules).hasDrainageContent).toBe(false);
|
||||
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps original offsets for record-page highlighting', () => {
|
||||
const content = '📨详情请看 example。com/path,谢谢';
|
||||
const result = detectDrainageContentWithRules(content, rules);
|
||||
const [match] = (result.drainageDetection as { matches: Array<{ start: number; end: number }> }).matches;
|
||||
expect(content.slice(match.start, match.end)).toContain('example。com/path');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type DrainageDetectionCategory = 'url' | 'mobile' | 'landline' | string;
|
||||
|
||||
export type DrainageDetectionRuleSnapshot = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: DrainageDetectionCategory;
|
||||
pattern: string;
|
||||
flags: string;
|
||||
priority: number;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type DrainageDetectionMatch = {
|
||||
ruleId: string;
|
||||
ruleCode: string;
|
||||
ruleName: string;
|
||||
category: DrainageDetectionCategory;
|
||||
text: string;
|
||||
normalizedText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type DrainageDetectionResult = {
|
||||
hasDrainageContent: boolean;
|
||||
drainageDetection: Prisma.InputJsonValue;
|
||||
drainageDetectionVersion: string;
|
||||
drainageEvaluatedAt: Date;
|
||||
};
|
||||
|
||||
type NormalizedContent = {
|
||||
text: string;
|
||||
sourceStarts: number[];
|
||||
sourceEnds: number[];
|
||||
};
|
||||
|
||||
const RULE_CACHE_TTL_MS = 30_000;
|
||||
const MAX_PATTERN_LENGTH = 1_000;
|
||||
const MAX_CONTENT_LENGTH = 20_000;
|
||||
const MAX_MATCHES = 50;
|
||||
|
||||
let cachedRules: { expiresAt: number; rules: DrainageDetectionRuleSnapshot[] } | undefined;
|
||||
|
||||
export function invalidateDrainageDetectionRuleCache() {
|
||||
cachedRules = undefined;
|
||||
}
|
||||
|
||||
export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
|
||||
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空');
|
||||
if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
|
||||
if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
|
||||
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复');
|
||||
}
|
||||
// 可配置规则会运行在发送入口,禁止容易造成灾难性回溯或跨文本引用的结构。
|
||||
if (/\\[1-9]/.test(pattern) || /\(\?<([=!])/.test(pattern) || /\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) {
|
||||
throw new BadRequestException('表达式包含不安全的回溯、后行断言或嵌套量词');
|
||||
}
|
||||
try {
|
||||
// 强制全局匹配,避免配置遗漏 g 后只能识别首个命中。
|
||||
new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`);
|
||||
} catch {
|
||||
throw new BadRequestException('识别表达式格式不正确');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
|
||||
let text = '';
|
||||
const sourceStarts: number[] = [];
|
||||
const sourceEnds: number[] = [];
|
||||
let sourceIndex = 0;
|
||||
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
|
||||
const sourceEnd = sourceIndex + sourceChar.length;
|
||||
let normalized = sourceChar.normalize('NFKC')
|
||||
.replace(/[.。]/g, '.')
|
||||
.replace(/[:﹕]/g, ':')
|
||||
.replace(/[/]/g, '/')
|
||||
.replace(/[()]/g, (char) => char === '(' ? '(' : ')')
|
||||
.replace(/[+]/g, '+');
|
||||
if (category === 'url') {
|
||||
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
|
||||
normalized = normalized.replace(/\s+/gu, '').replace(/。/g, '.');
|
||||
} else if (category === 'mobile' || category === 'landline') {
|
||||
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
|
||||
normalized = normalized.replace(/[\s\-‐‑‒–—―.。·,,、]/gu, '');
|
||||
}
|
||||
for (const char of normalized) {
|
||||
text += char;
|
||||
// RegExp.index 使用 UTF-16 code unit,映射数组必须采用相同计数方式,避免表情符号导致高亮偏移。
|
||||
for (let codeUnit = 0; codeUnit < char.length; codeUnit += 1) {
|
||||
sourceStarts.push(sourceIndex);
|
||||
sourceEnds.push(sourceEnd);
|
||||
}
|
||||
}
|
||||
sourceIndex = sourceEnd;
|
||||
}
|
||||
return { text, sourceStarts, sourceEnds };
|
||||
}
|
||||
|
||||
function sourceRange(normalized: NormalizedContent, start: number, end: number) {
|
||||
const safeStart = Math.max(0, Math.min(start, normalized.sourceStarts.length - 1));
|
||||
const safeEnd = Math.max(safeStart, Math.min(end - 1, normalized.sourceEnds.length - 1));
|
||||
return {
|
||||
start: normalized.sourceStarts[safeStart] ?? 0,
|
||||
end: normalized.sourceEnds[safeEnd] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function emailRanges(normalized: NormalizedContent) {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
|
||||
for (const match of normalized.text.matchAll(email)) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function overlaps(start: number, end: number, range: { start: number; end: number }) {
|
||||
return start < range.end && end > range.start;
|
||||
}
|
||||
|
||||
export function detectDrainageContentWithRules(
|
||||
content: string,
|
||||
rules: DrainageDetectionRuleSnapshot[],
|
||||
evaluatedAt = new Date(),
|
||||
): DrainageDetectionResult {
|
||||
const matches: DrainageDetectionMatch[] = [];
|
||||
const normalizedByCategory = new Map<string, NormalizedContent>();
|
||||
const emailNormalized = normalizeContent(content, 'url');
|
||||
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
|
||||
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
|
||||
validateDrainageDetectionPattern(rule.pattern, rule.flags);
|
||||
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
|
||||
normalizedByCategory.set(rule.category, normalized);
|
||||
const regex = new RegExp(rule.pattern, rule.flags.includes('g') ? rule.flags : `${rule.flags}g`);
|
||||
for (const match of normalized.text.matchAll(regex)) {
|
||||
const captured = match[1] || match[0];
|
||||
const capturedOffset = match[0].indexOf(captured);
|
||||
const normalizedStart = match.index + Math.max(0, capturedOffset);
|
||||
const normalizedEnd = normalizedStart + captured.length;
|
||||
const range = sourceRange(normalized, normalizedStart, normalizedEnd);
|
||||
if (range.end <= range.start) continue;
|
||||
// 邮箱整体不是引流信息;不仅排除其中的域名,也排除数字本地部分被电话规则误识别。
|
||||
if (originalEmailRanges.some((emailRange) => overlaps(range.start, range.end, emailRange))) continue;
|
||||
const candidate: DrainageDetectionMatch = {
|
||||
ruleId: rule.id,
|
||||
ruleCode: rule.code,
|
||||
ruleName: rule.name,
|
||||
category: rule.category,
|
||||
text: content.slice(range.start, range.end),
|
||||
normalizedText: captured,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
};
|
||||
if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
if (matches.length >= MAX_MATCHES) break;
|
||||
}
|
||||
if (matches.length >= MAX_MATCHES) break;
|
||||
}
|
||||
matches.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const versionSource = rules
|
||||
.map((rule) => `${rule.code}:${rule.version}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const drainageDetectionVersion = createHash('sha256').update(versionSource).digest('hex').slice(0, 16);
|
||||
return {
|
||||
hasDrainageContent: matches.length > 0,
|
||||
drainageDetection: {
|
||||
matches,
|
||||
categories: [...new Set(matches.map((item) => item.category))],
|
||||
ruleCount: rules.length,
|
||||
truncated: content.length > MAX_CONTENT_LENGTH || matches.length >= MAX_MATCHES,
|
||||
} as Prisma.InputJsonValue,
|
||||
drainageDetectionVersion,
|
||||
drainageEvaluatedAt: evaluatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function activeRules(prisma: PrismaService) {
|
||||
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
|
||||
const rules = await prisma.drainageDetectionRule.findMany({
|
||||
where: { status: 'active' },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
pattern: true,
|
||||
flags: true,
|
||||
priority: true,
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
cachedRules = { rules, expiresAt: Date.now() + RULE_CACHE_TTL_MS };
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function detectDrainageContent(prisma: PrismaService, content: string) {
|
||||
return detectDrainageContentWithRules(content, await activeRules(prisma));
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
GatewaySubmitResultDto,
|
||||
GatewaySubmitSegmentResultDto,
|
||||
GatewayUplinkEventDto,
|
||||
SendChainService,
|
||||
} from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service';
|
||||
} from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||
|
||||
@ApiTags('gateway-events')
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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';
|
||||
|
||||
|
||||
/**
|
||||
* R10 accounting implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendAccountingService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
batchTaskId: string;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number | bigint;
|
||||
amountCents: number | bigint;
|
||||
}) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
const unitPrice = moneyToNumber(message.unitPrice);
|
||||
const billingUnits = message.billingUnits ?? 0;
|
||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
||||
if (exists?.billingStatus === 'charged') {
|
||||
return;
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge-release:${message.messageId}`,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
||||
});
|
||||
}
|
||||
const transaction = await this.billing.charge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: '提交成功扣费',
|
||||
});
|
||||
const data = {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
taskId: message.batchTaskId,
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
contentLength: [...message.content].length,
|
||||
billingUnits,
|
||||
unitPrice,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
transactionId: transaction.id,
|
||||
};
|
||||
if (exists) {
|
||||
await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data });
|
||||
return;
|
||||
}
|
||||
await this.prisma.smsBillingRecord.create({ data });
|
||||
}
|
||||
|
||||
async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
if (charged) {
|
||||
return;
|
||||
}
|
||||
const released = await this.prisma.accountTransaction.findFirst({
|
||||
where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' },
|
||||
});
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-reservation-release:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: `${remark}: ${message.messageId}`,
|
||||
});
|
||||
}
|
||||
|
||||
async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
|
||||
if (refunded) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
if (!charged) {
|
||||
return;
|
||||
}
|
||||
const transaction = await this.billing.refund({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-refund:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark,
|
||||
});
|
||||
await this.prisma.smsBillingRecord.updateMany({
|
||||
where: { messageId: message.messageId },
|
||||
data: { billingStatus: 'refunded', transactionId: transaction.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import { detectDrainageContent } from './drainage-content-detection';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendBatchEntryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
const phones = [...new Set(data.phones ?? [])];
|
||||
const schedule = parseSchedule(data);
|
||||
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
|
||||
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
|
||||
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
|
||||
this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
|
||||
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
|
||||
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
||||
detectDrainageContent(this.prisma, data.content),
|
||||
]);
|
||||
const risk = messageClassification.rejectionReason
|
||||
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
|
||||
: await this.riskReview.evaluateTask({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
phones,
|
||||
variables: messageClassification.variables ?? data.variables,
|
||||
createdById: data.createdById,
|
||||
sourceType: data.sourceType ?? 'client',
|
||||
});
|
||||
let frequencyRejectedAll = false;
|
||||
let frequencyBatchReason: string | undefined;
|
||||
if (risk.status !== 'rejected' && sendablePhones.length > 0) {
|
||||
const frequencyRejections = await this.phoneFrequency.reserve(
|
||||
data.tenantId,
|
||||
data.applicationId,
|
||||
sendablePhones,
|
||||
data.sourceType ?? 'client',
|
||||
);
|
||||
for (const [phone, rejection] of frequencyRejections) {
|
||||
phoneRejections.set(phone, rejection);
|
||||
}
|
||||
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
|
||||
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
|
||||
frequencyBatchReason = frequencyRejectedAll
|
||||
? [...frequencyRejections.values()][0]?.reason
|
||||
: undefined;
|
||||
}
|
||||
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
|
||||
await this.prisma.smsSendTask.update({
|
||||
where: { id: risk.task.id },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
riskDecision: 'block',
|
||||
reviewReason: null,
|
||||
rejectReason: frequencyBatchReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
taskId: risk.task?.id,
|
||||
content: data.content,
|
||||
phoneCount: sendablePhones.length,
|
||||
unitPrice,
|
||||
});
|
||||
const batchStatus = frequencyRejectedAll
|
||||
? 'rejected'
|
||||
: risk.status === 'approved' && sendablePhones.length === 0
|
||||
? 'failed'
|
||||
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
|
||||
const shouldReserveBalance = batchStatus === 'ready';
|
||||
if (risk.status === 'approved') {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('企业账户余额不足');
|
||||
}
|
||||
}
|
||||
if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) {
|
||||
await this.facade.reserveDailySendQuota(data.applicationId, sendablePhones.length);
|
||||
}
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceType: data.sourceType ?? 'client',
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
phoneTotal: phones.length,
|
||||
status: batchStatus,
|
||||
riskTaskId: risk.task?.id,
|
||||
auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved',
|
||||
reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null,
|
||||
rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null,
|
||||
progressTotal: phones.length,
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
createdById: data.createdById,
|
||||
},
|
||||
});
|
||||
if (shouldReserveBalance && billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '发送任务创建冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsApiRequest.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
batchTaskId: task.id,
|
||||
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceIp: data.sourceIp,
|
||||
userAgent: data.userAgent,
|
||||
payloadSummary: {
|
||||
phoneTotal: phones.length,
|
||||
contentLength: [...data.content].length,
|
||||
category: data.category,
|
||||
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: schedule.scheduledAt?.toISOString(),
|
||||
},
|
||||
status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted',
|
||||
},
|
||||
});
|
||||
if (phones.length > 0) {
|
||||
await this.prisma.smsMessageRecord.createMany({
|
||||
data: phones.map((phone) => {
|
||||
const rejection = phoneRejections.get(phone);
|
||||
const status = rejection
|
||||
? 'submit_failed'
|
||||
: batchStatus === 'ready'
|
||||
? 'queued'
|
||||
: batchStatus === 'scheduled'
|
||||
? 'scheduled'
|
||||
: batchStatus;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
batchTaskId: task.id,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
signatureId: messageClassification.signatureId,
|
||||
drainageInfoId: messageClassification.drainageInfoId,
|
||||
reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
|
||||
messageId: `MSG-${randomUUID()}`,
|
||||
clientMessageId: data.clientMessageId,
|
||||
phoneNumber: phone,
|
||||
content: data.content,
|
||||
...drainageDetection,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: rejection ? 0 : billing.unitPrice,
|
||||
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
queuePriority,
|
||||
clientSrcId: accessNumber.clientSrcId,
|
||||
applicationExtension: accessNumber.applicationExtension,
|
||||
status,
|
||||
submitStatus: rejection ? 'rejected' : undefined,
|
||||
errorCode: rejection?.code,
|
||||
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (batchStatus === 'ready' && sendablePhones.length > 0) {
|
||||
await this.facade.enqueueBatchTask(task.id);
|
||||
} else if (batchStatus === 'failed') {
|
||||
await this.facade.refreshTaskProgress(task.id);
|
||||
}
|
||||
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
|
||||
}
|
||||
|
||||
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
|
||||
if (!data.applicationId) {
|
||||
throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
|
||||
}
|
||||
const template = await this.facade.resolveInboundTemplateCandidate(data.applicationId, data.content);
|
||||
if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板');
|
||||
}
|
||||
const variables = matchTemplateContent(template.content, data.content);
|
||||
if (variables === null) {
|
||||
throw new BadRequestException('短信内容与已审核模板不匹配');
|
||||
}
|
||||
return this.facade.createBatchTask({
|
||||
...data,
|
||||
templateId: template.id,
|
||||
variables,
|
||||
sourceType: 'api',
|
||||
});
|
||||
}
|
||||
|
||||
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
const task = await this.prisma.smsBatchTask.findFirst({
|
||||
where: { id: taskId, tenantId, sourceType },
|
||||
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async previewImport(data: ImportPreviewDto) {
|
||||
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
|
||||
if (sizeBytes > 20 * 1024 * 1024) {
|
||||
throw new BadRequestException('导入文件不能超过 20MB');
|
||||
}
|
||||
const rows = parseImportRows(data.content, data.delimiter);
|
||||
const phones: string[] = [];
|
||||
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
|
||||
const requiredVariables = data.requiredVariables ?? [];
|
||||
const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
}) : [];
|
||||
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber));
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.phoneNumber) {
|
||||
errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' });
|
||||
continue;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' });
|
||||
continue;
|
||||
}
|
||||
if (seen.has(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' });
|
||||
continue;
|
||||
}
|
||||
if (blacklist.has(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' });
|
||||
continue;
|
||||
}
|
||||
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
|
||||
if (missingVariables.length > 0) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` });
|
||||
continue;
|
||||
}
|
||||
seen.add(row.phoneNumber);
|
||||
phones.push(row.phoneNumber);
|
||||
}
|
||||
return {
|
||||
fileName: data.fileName,
|
||||
encoding: data.encoding ?? 'utf8',
|
||||
totalRows: rows.length,
|
||||
validCount: phones.length,
|
||||
errorCount: errors.length,
|
||||
phones,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
const preview = await this.facade.previewImport({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
content: data.importContent,
|
||||
requiredVariables: data.requiredVariables,
|
||||
});
|
||||
if (preview.validCount === 0) {
|
||||
throw new BadRequestException('导入文件没有可发送号码');
|
||||
}
|
||||
return this.facade.createBatchTask({ ...data, phones: preview.phones });
|
||||
}
|
||||
|
||||
async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||
if (!applicationId) {
|
||||
return 0;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, customerUnitPrice: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return 0;
|
||||
}
|
||||
return moneyToNumber(application.customerUnitPrice);
|
||||
}
|
||||
|
||||
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||
if (!applicationId) {
|
||||
return 'normal';
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, queuePriority: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return 'normal';
|
||||
}
|
||||
return normalizeQueuePriority(application.queuePriority);
|
||||
}
|
||||
|
||||
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
if (!applicationId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
return {
|
||||
clientSrcId: application.cmppClientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveTemplateMessageClassification(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
templateId: string | undefined,
|
||||
content: string,
|
||||
) {
|
||||
if (templateId) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId
|
||||
|| template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
const variables = matchTemplateContent(template.content, content);
|
||||
if (variables === null) {
|
||||
throw new BadRequestException('短信内容与选定的审核模板不匹配');
|
||||
}
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(template.signatureId, content);
|
||||
return {
|
||||
signatureId: template.signatureId,
|
||||
drainageInfoId: drainage?.id,
|
||||
variables,
|
||||
// 引流资料只做关联与监控,报备审核状态不参与本期发送决策。
|
||||
rejectionReason: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!applicationId) {
|
||||
throw new BadRequestException('自由内容短信必须关联企业应用');
|
||||
}
|
||||
const [application, signature] = await Promise.all([
|
||||
this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, templateMismatchMode: true },
|
||||
}),
|
||||
this.facade.resolveInboundSignatureCandidate(applicationId, content),
|
||||
]);
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
throw new BadRequestException('短信应用不存在或不属于当前企业');
|
||||
}
|
||||
if (!signature) {
|
||||
throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头');
|
||||
}
|
||||
if (application.templateMismatchMode !== 'direct_send') {
|
||||
throw new BadRequestException('当前应用未允许无模板自由内容直接发送');
|
||||
}
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, content);
|
||||
return {
|
||||
signatureId: signature.id,
|
||||
drainageInfoId: drainage?.id,
|
||||
variables: undefined,
|
||||
rejectionReason: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
const rejected = new Map<string, { code: string; reason: string }>();
|
||||
for (const phone of phones) {
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' });
|
||||
}
|
||||
}
|
||||
const validPhones = phones.filter((phone) => !rejected.has(phone));
|
||||
if (validPhones.length === 0) {
|
||||
return rejected;
|
||||
}
|
||||
const [globalHits, enterpriseHits] = await Promise.all([
|
||||
this.prisma.globalBlacklist.findMany({
|
||||
where: { phoneNumber: { in: validPhones }, status: 'active' },
|
||||
select: { phoneNumber: true, reason: true },
|
||||
}),
|
||||
applicationId
|
||||
? this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' },
|
||||
select: { phoneNumber: true, reason: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
for (const hit of globalHits) {
|
||||
rejected.set(hit.phoneNumber, {
|
||||
code: 'GLOBAL_BLACKLIST',
|
||||
reason: hit.reason?.trim() || '号码命中平台黑名单',
|
||||
});
|
||||
}
|
||||
for (const hit of enterpriseHits) {
|
||||
rejected.set(hit.phoneNumber, {
|
||||
code: 'ENTERPRISE_BLACKLIST',
|
||||
reason: hit.reason?.trim() || '号码命中企业应用黑名单',
|
||||
});
|
||||
}
|
||||
return rejected;
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
throw new BadRequestException('企业客户不存在或已停用');
|
||||
}
|
||||
if (tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('企业认证未通过,不能发送短信');
|
||||
}
|
||||
if (!applicationId) {
|
||||
return;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
|
||||
throw new BadRequestException('短信应用不存在或已停用');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('短信应用接口未开通,不能发送短信');
|
||||
}
|
||||
if (!templateId) {
|
||||
return;
|
||||
}
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信签名未审核通过');
|
||||
}
|
||||
}
|
||||
|
||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
if (!result.reserved) {
|
||||
throw new HttpException({
|
||||
code: 'DAILY_SEND_LIMIT_EXCEEDED',
|
||||
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
|
||||
dailyLimit: result.dailyLimit,
|
||||
requestedCount,
|
||||
}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
|
||||
throw new BadRequestException('发送号码数量必须为正整数');
|
||||
}
|
||||
const usageDate = shanghaiDateKey();
|
||||
const reservationId = randomUUID();
|
||||
const rows = await this.prisma.$queryRaw<Array<{ dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
|
||||
WITH application_limit AS (
|
||||
SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit"
|
||||
FROM "SmsApplication"
|
||||
WHERE id = ${applicationId}
|
||||
), reservation AS (
|
||||
INSERT INTO "SmsApplicationDailyUsage" (
|
||||
id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW()
|
||||
FROM application_limit
|
||||
WHERE ${requestedCount} <= "dailyLimit"
|
||||
ON CONFLICT ("applicationId", "usageDate") DO UPDATE
|
||||
SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount",
|
||||
"updatedAt" = NOW()
|
||||
WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount"
|
||||
<= (SELECT "dailyLimit" FROM application_limit)
|
||||
RETURNING "usedCount"
|
||||
)
|
||||
SELECT application_limit."dailyLimit", reservation."usedCount"
|
||||
FROM application_limit
|
||||
LEFT JOIN reservation ON TRUE
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
throw new NotFoundException('短信应用不存在');
|
||||
}
|
||||
return {
|
||||
dailyLimit: Number(rows[0].dailyLimit),
|
||||
usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount),
|
||||
reserved: rows[0].usedCount != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// R8 contract-only declarations. Runtime behavior remains in SendChainService.
|
||||
|
||||
export interface CreateBatchTaskDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
phones: string[];
|
||||
sendMode?: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
createdById?: string;
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
sourceType?: 'client' | 'api' | 'cmpp';
|
||||
clientMessageId?: string;
|
||||
}
|
||||
|
||||
export type CreateHttpBatchTaskDto = Omit<CreateBatchTaskDto, 'templateId' | 'variables' | 'sourceType'>;
|
||||
|
||||
export interface GatewayInboundAuthDto {
|
||||
account: string;
|
||||
password?: string;
|
||||
authSource?: string;
|
||||
timestamp?: number;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export interface GatewayInboundSubmitDto {
|
||||
account: string;
|
||||
phoneNumber?: string;
|
||||
phoneNumbers?: string[];
|
||||
content: string;
|
||||
srcId?: string;
|
||||
destId?: string;
|
||||
sequenceId?: number;
|
||||
registeredDelivery?: number;
|
||||
remoteIp?: string;
|
||||
longMessage?: {
|
||||
reference: number;
|
||||
total: number;
|
||||
index: number;
|
||||
format: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GatewayInboundSingleSubmitResult {
|
||||
accepted: boolean;
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
taskId: string;
|
||||
messageId: string;
|
||||
messageRecordId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitResultDto {
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
submitId?: string;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId: string;
|
||||
submitStatus: 'accepted' | 'rejected' | 'timeout';
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
segments?: Array<{
|
||||
segmentTotal?: number;
|
||||
segmentIndex?: number;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId?: string;
|
||||
submitStatus?: 'accepted' | 'rejected' | 'timeout' | string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitSegmentResultDto {
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
submitId?: string;
|
||||
segmentTotal: number;
|
||||
segmentIndex: number;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId?: string;
|
||||
submitStatus: 'accepted' | 'rejected' | 'timeout' | string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayReceiptEventDto {
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId: string;
|
||||
phoneNumber?: string;
|
||||
receiptStatus: 'delivered' | 'undelivered' | 'unknown';
|
||||
rawStatus: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
deliveredAt?: string;
|
||||
connectionId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayUplinkEventDto {
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
sequenceId?: number;
|
||||
phoneNumber: string;
|
||||
destId: string;
|
||||
content: string;
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
export type UplinkMatchCandidateInput = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
messageRecordId?: string;
|
||||
matchSource: 'access_number' | 'phone_window';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export interface GatewayPendingDeliveryQueryDto {
|
||||
account: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamSentDto {
|
||||
id: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentDto {
|
||||
result: number;
|
||||
acknowledgedAt?: string;
|
||||
}
|
||||
|
||||
export type GatewayDownstreamFailureType =
|
||||
| 'send_failed'
|
||||
| 'ack_timeout'
|
||||
| 'ack_rejected'
|
||||
| 'ack_invalid'
|
||||
| 'connection_lost'
|
||||
| 'unrecoverable'
|
||||
| 'queue_timeout';
|
||||
|
||||
export type GatewayControlDeliveryResult = {
|
||||
sent?: boolean;
|
||||
delivered?: boolean;
|
||||
retryable?: boolean;
|
||||
reasonCode?: string;
|
||||
errorMessage?: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
};
|
||||
|
||||
export interface GatewaySubmitDeadLetterDto {
|
||||
streamMessageId: string;
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
submitId?: string;
|
||||
failureCode: string;
|
||||
failureMessage: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
commandPayload?: Record<string, unknown>;
|
||||
rawPayload?: string;
|
||||
deadLetteredAt?: string;
|
||||
}
|
||||
|
||||
export interface RequeueGatewaySubmitExceptionDto {
|
||||
confirmedNotSubmitted?: boolean;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamRecoveryStatusDto {
|
||||
account: string;
|
||||
gatewayInstanceId?: string;
|
||||
state: string;
|
||||
lockOwner?: string;
|
||||
lockExpiresAt?: string;
|
||||
lastAttemptAt?: string;
|
||||
lastSuccessAt?: string;
|
||||
lastFailureAt?: string;
|
||||
nextRetryAt?: string;
|
||||
attemptCount?: number;
|
||||
failureCategory?: string;
|
||||
lastError?: string;
|
||||
lastSkipReason?: string;
|
||||
}
|
||||
|
||||
export interface TimeoutUnknownDto {
|
||||
olderThanHours?: number;
|
||||
}
|
||||
|
||||
export interface ImportPreviewDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
encoding?: 'utf8' | 'gbk';
|
||||
delimiter?: ',' | '\t';
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface ConfirmImportDto extends CreateBatchTaskDto {
|
||||
importContent: string;
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface SendJob {
|
||||
messageRecordId: string;
|
||||
}
|
||||
|
||||
export type QueuePriority = 'normal' | 'priority';
|
||||
|
||||
export type RoutedChannel = {
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
account: string;
|
||||
srcId: string;
|
||||
rateLimitPerSecond: number;
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
carrier?: string | null;
|
||||
sendRegion: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
passwordCipher: string;
|
||||
cmppVersion: string;
|
||||
config?: unknown;
|
||||
};
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
routeScope: 'province' | 'national';
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
aggregateReceiptSegmentState,
|
||||
isSameUpstreamEndpointIdentity,
|
||||
receiptEventKey,
|
||||
selectChannelCandidate,
|
||||
} from './send-chain.helpers';
|
||||
|
||||
const connected = [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }];
|
||||
|
||||
describe('send-chain pure policies', () => {
|
||||
it('prefers an approved online province channel while preserving priority order', () => {
|
||||
const items = [
|
||||
{ channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'province', carrier: 'mobile', province: '安徽省', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
province: '安徽省',
|
||||
excludedChannelIds: new Set(),
|
||||
approvedChannelIds: new Set(['national', 'province']),
|
||||
})?.channelId).toBe('province');
|
||||
});
|
||||
|
||||
it('falls back to an approved online national channel', () => {
|
||||
const items = [
|
||||
{ channelId: 'offline', carrier: 'mobile', province: '安徽', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: [] } },
|
||||
{ channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'all', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
province: '安徽',
|
||||
excludedChannelIds: new Set(),
|
||||
approvedChannelIds: new Set(['offline', 'national']),
|
||||
})?.channelId).toBe('national');
|
||||
});
|
||||
|
||||
it('does not select excluded or unreported channels', () => {
|
||||
const items = [
|
||||
{ channelId: 'excluded', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'unreported', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
excludedChannelIds: new Set(['excluded']),
|
||||
approvedChannelIds: new Set(['excluded']),
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a segmented message non-terminal until all receipts arrive', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:00:00Z') }],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
new Date('2026-07-31T00:01:00Z'),
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: false, segmentTotal: 2, status: 'submitted' });
|
||||
});
|
||||
|
||||
it('marks all delivered segments successful at the latest receipt time', () => {
|
||||
const latest = new Date('2026-07-31T00:02:00Z');
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:01:00Z') },
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: latest },
|
||||
],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-2', receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
latest,
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: true, segmentTotal: 2, status: 'delivered', deliveredAt: latest });
|
||||
});
|
||||
|
||||
it('lets a failed segment decide the terminal message result', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered' },
|
||||
{ segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'ERR' },
|
||||
],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-3', receiptStatus: 'undelivered', rawStatus: 'REJECTD' },
|
||||
new Date('2026-07-31T00:03:00Z'),
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: true, status: 'failed', receiptStatus: 'undelivered', errorCode: 'ERR' });
|
||||
});
|
||||
|
||||
it('normalizes upstream endpoint identity without weakening port or version equality', () => {
|
||||
expect(isSameUpstreamEndpointIdentity(
|
||||
{ account: ' acct ', gatewayHost: 'SMSC.EXAMPLE', gatewayPort: 7890, protocol: 'cmpp', cmppVersion: '2.0' },
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
)).toBe(true);
|
||||
expect(isSameUpstreamEndpointIdentity(
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7891, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('generates a stable receipt event key and changes it with logical channel identity', () => {
|
||||
const event = {
|
||||
channelId: 'physical',
|
||||
gatewayMessageId: 'gw-4',
|
||||
phoneNumber: '13800000000',
|
||||
receiptStatus: 'delivered' as const,
|
||||
rawStatus: 'DELIVRD',
|
||||
};
|
||||
expect(receiptEventKey(event, 'logical')).toBe(receiptEventKey({ ...event }, 'logical'));
|
||||
expect(receiptEventKey(event, 'logical')).not.toBe(receiptEventKey(event, 'other'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
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.
|
||||
|
||||
export const SEND_QUEUE = 'sms.send.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_RETRY_DELAY_MS = 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS = 30 * 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_MAX_RETRIES = 10;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72;
|
||||
|
||||
export const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
||||
|
||||
export const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
export function longMessageReceiptMode(config: unknown): 'per_segment' | 'message_level' {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return 'per_segment';
|
||||
return (config as Record<string, unknown>).longMessageReceiptMode === 'message_level'
|
||||
? 'message_level'
|
||||
: 'per_segment';
|
||||
}
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000;
|
||||
|
||||
export const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000;
|
||||
|
||||
export const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS = 30;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS = 72;
|
||||
|
||||
export const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
export const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
priority: 1,
|
||||
normal: 100,
|
||||
};
|
||||
|
||||
export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
|
||||
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
|
||||
}
|
||||
|
||||
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
|
||||
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function statusFromRisk(status: string, scheduled: boolean) {
|
||||
if (status === 'rejected') {
|
||||
return 'rejected';
|
||||
}
|
||||
if (status === 'pending_review') {
|
||||
return 'pending_review';
|
||||
}
|
||||
if (scheduled) {
|
||||
return 'scheduled';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
export function parseSchedule(data: CreateBatchTaskDto) {
|
||||
if (data.sendMode !== 'scheduled' && !data.scheduledAt) {
|
||||
return { scheduledAt: null };
|
||||
}
|
||||
if (!data.scheduledAt) {
|
||||
throw new BadRequestException('定时发送必须提供 scheduledAt');
|
||||
}
|
||||
const scheduledAt = new Date(data.scheduledAt);
|
||||
if (Number.isNaN(scheduledAt.getTime())) {
|
||||
throw new BadRequestException('scheduledAt 时间格式无效');
|
||||
}
|
||||
if (scheduledAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('scheduledAt 必须晚于当前时间');
|
||||
}
|
||||
return { scheduledAt };
|
||||
}
|
||||
|
||||
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asDateOrNull(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function downstreamRetryDelayMs(retryCount = 1) {
|
||||
const base = downstreamRetryBaseDelayMs();
|
||||
const max = downstreamRetryMaxDelayMs();
|
||||
const attempt = Math.max(1, Math.floor(retryCount));
|
||||
const delay = base * Math.pow(2, Math.max(0, attempt - 1));
|
||||
return Math.min(delay, max);
|
||||
}
|
||||
|
||||
export function downstreamAckTimeoutMs() {
|
||||
const configured = Number(process.env.CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS ?? 30);
|
||||
return Math.max(5, Number.isFinite(configured) ? configured : 30) * 1000;
|
||||
}
|
||||
|
||||
export function downstreamRetryBaseDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
export function downstreamRetryMaxDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
||||
}
|
||||
|
||||
export function downstreamMaxRetries() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_MAX_RETRIES ?? DEFAULT_DOWNSTREAM_MAX_RETRIES);
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_DOWNSTREAM_MAX_RETRIES;
|
||||
}
|
||||
|
||||
export function downstreamPendingTimeoutHours() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS ?? DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS;
|
||||
}
|
||||
|
||||
export function downstreamControlFailureMessage(result: GatewayControlDeliveryResult) {
|
||||
const reason = String(result.errorMessage ?? '').trim();
|
||||
const code = String(result.reasonCode ?? '').trim();
|
||||
if (reason && code) return `${reason} (${code})`;
|
||||
if (reason) return reason;
|
||||
if (code) return `Gateway 未完成下游投递 (${code})`;
|
||||
return 'Gateway 未完成下游投递,等待自动重试';
|
||||
}
|
||||
|
||||
export function parseImportRows(content: string, delimiter?: ',' | '\t') {
|
||||
const normalized = content.replace(/^\uFEFF/, '');
|
||||
const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstDelimiter = delimiter ?? (lines[0].includes(',') ? ',' : '\t');
|
||||
const firstCells = splitImportLine(lines[0], firstDelimiter);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'phoneNumber', 'mobile', '手机号'].includes(cell));
|
||||
const headers = hasHeader ? firstCells : ['phoneNumber'];
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
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),
|
||||
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
|
||||
variables: {},
|
||||
};
|
||||
headers.forEach((header, cellIndex) => {
|
||||
if (!['phone', 'phoneNumber', 'mobile', '手机号'].includes(header)) {
|
||||
row.variables[header] = cells[cellIndex] ?? '';
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export function splitImportLine(line: string, delimiter: ',' | '\t') {
|
||||
return line.split(delimiter).map((cell) => cell.trim().replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
export function cellByHeader(headers: string[], cells: string[], candidates: string[]) {
|
||||
const index = headers.findIndex((header) => candidates.includes(header));
|
||||
return index >= 0 ? cells[index] : undefined;
|
||||
}
|
||||
|
||||
export function normalizeCarrier(carrier?: string | null) {
|
||||
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';
|
||||
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
|
||||
return value || 'mobile';
|
||||
}
|
||||
|
||||
export function normalizeQueuePriority(queuePriority?: string | null): QueuePriority {
|
||||
return queuePriority === 'priority' ? 'priority' : 'normal';
|
||||
}
|
||||
|
||||
export function getPositiveConfigInteger(config: unknown, key: string, fallback: number) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
if (Number.isInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
||||
const normalized = normalizeCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === targetCarrier;
|
||||
}
|
||||
|
||||
export function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
export function matchTemplateContent(templateContent: string, actualContent: string) {
|
||||
if (templateContent === actualContent) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g;
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
let pattern = '^';
|
||||
for (const match of templateContent.matchAll(tokenPattern)) {
|
||||
const index = match.index ?? 0;
|
||||
pattern += escapeRegularExpression(templateContent.slice(cursor, index));
|
||||
pattern += '([\\s\\S]+?)';
|
||||
names.push(match[1]);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`;
|
||||
const matched = new RegExp(pattern, 'u').exec(actualContent);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
const variables: Record<string, string> = {};
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const name = names[index];
|
||||
const value = matched[index + 1];
|
||||
if (variables[name] !== undefined && variables[name] !== value) {
|
||||
return null;
|
||||
}
|
||||
variables[name] = value;
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function escapeRegularExpression(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
|
||||
}
|
||||
|
||||
export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) {
|
||||
if (!province) {
|
||||
return false;
|
||||
}
|
||||
const target = normalizeRegion(province);
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
return itemProvince === target || sendRegion === target;
|
||||
}
|
||||
|
||||
export function validateInboundApplicationSrcId(
|
||||
srcId: string | undefined,
|
||||
application: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
cmppClientSrcId?: string | null;
|
||||
},
|
||||
) {
|
||||
const submittedSrcId = srcId?.trim() ?? '';
|
||||
const applicationExtension = application.cmppApplicationExtension?.trim() ?? '';
|
||||
if (!applicationExtension) {
|
||||
return submittedSrcId || null;
|
||||
}
|
||||
|
||||
const fillPrefix = application.cmppAccessNumberFillEnabled
|
||||
? 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}`);
|
||||
}
|
||||
return submittedSrcId;
|
||||
}
|
||||
|
||||
export function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) {
|
||||
const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`;
|
||||
if (upstreamSrcId.length > 21) {
|
||||
throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits');
|
||||
}
|
||||
return upstreamSrcId;
|
||||
}
|
||||
|
||||
export function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
|
||||
return value === 'accepted' || value === 'rejected' || value === 'timeout' ? value : 'timeout';
|
||||
}
|
||||
|
||||
export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['receiptStatus'] {
|
||||
return value === 'delivered' || value === 'undelivered' || value === 'unknown' ? value : 'unknown';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
export function shanghaiDateKey(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
export function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
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')),
|
||||
]))
|
||||
.digest('base64');
|
||||
return expected === data.authSource;
|
||||
}
|
||||
if (!data.password) {
|
||||
return false;
|
||||
}
|
||||
return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash;
|
||||
}
|
||||
|
||||
export function octetString(value: string, fixedLength: number) {
|
||||
if (value.length === fixedLength) {
|
||||
return value;
|
||||
}
|
||||
if (value.length > fixedLength) {
|
||||
return value.slice(value.length - fixedLength);
|
||||
}
|
||||
return value + '\0'.repeat(fixedLength - value.length);
|
||||
}
|
||||
|
||||
export function hasRecoveryAuditStateChanged(
|
||||
previous: Record<string, unknown> | null,
|
||||
current: Record<string, unknown>,
|
||||
) {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
|
||||
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
|
||||
}
|
||||
|
||||
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const explicit = String(data.failureCategory ?? '').trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (data.state === 'success' || data.state === 'running') {
|
||||
return null;
|
||||
}
|
||||
if (data.lastSkipReason === 'backoff') {
|
||||
return 'backoff';
|
||||
}
|
||||
if (data.lastSkipReason === 'locked') {
|
||||
return 'lock_contended';
|
||||
}
|
||||
if (data.lastSkipReason === 'lock_lost') {
|
||||
return 'lock_lost';
|
||||
}
|
||||
if (data.state === 'waiting_connection') {
|
||||
return 'client_disconnected';
|
||||
}
|
||||
if (data.state === 'partial') {
|
||||
return 'partial_delivery_failed';
|
||||
}
|
||||
if (data.state === 'failed' && data.lastError) {
|
||||
return 'flush_failed';
|
||||
}
|
||||
return data.state ? 'unknown' : null;
|
||||
}
|
||||
|
||||
export type ChannelCandidate = {
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
channel: {
|
||||
carrier?: string | null;
|
||||
sendRegion?: string | null;
|
||||
status: string;
|
||||
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve database priority order while preferring matching province routes
|
||||
* over national fallbacks. Filtering remains deterministic and side-effect free.
|
||||
*/
|
||||
export function selectChannelCandidate<T extends ChannelCandidate>(
|
||||
items: T[],
|
||||
options: {
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
forceNational?: boolean;
|
||||
excludedChannelIds: ReadonlySet<string>;
|
||||
approvedChannelIds: ReadonlySet<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),
|
||||
);
|
||||
const provinceCandidates = options.forceNational
|
||||
? []
|
||||
: eligible.filter((item) => isProvinceChannel(item, options.province));
|
||||
const nationalCandidates = eligible.filter((item) => isNationalChannel(item));
|
||||
return [...provinceCandidates, ...nationalCandidates].find((item) => isChannelSendAvailable(item.channel));
|
||||
}
|
||||
|
||||
export type ReceiptSegmentAudit = {
|
||||
segmentTotal?: number | null;
|
||||
receiptStatus?: string | null;
|
||||
rawStatus?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
deliveredAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate one message's terminal state without reading or writing storage.
|
||||
* A failed segment wins; success requires every expected segment to be delivered.
|
||||
*/
|
||||
export function aggregateReceiptSegmentState(
|
||||
audits: ReceiptSegmentAudit[],
|
||||
billingUnits: number | null | undefined,
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
) {
|
||||
if (audits.length === 0) {
|
||||
const status = data.receiptStatus === 'delivered'
|
||||
? 'delivered'
|
||||
: data.receiptStatus === 'unknown'
|
||||
? 'unknown'
|
||||
: 'failed';
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal: 1,
|
||||
status,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
const segmentTotal = Math.max(
|
||||
1,
|
||||
Number(billingUnits ?? 1),
|
||||
...audits.map((audit) => Number(audit.segmentTotal ?? 1)),
|
||||
);
|
||||
const received = audits.filter((audit) => Boolean(audit.receiptStatus));
|
||||
const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? ''));
|
||||
if (failed) {
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'failed',
|
||||
receiptStatus: failed.receiptStatus ?? 'undelivered',
|
||||
rawStatus: failed.rawStatus ?? data.rawStatus,
|
||||
errorCode: failed.errorCode ?? data.errorCode,
|
||||
errorMessage: failed.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: failed.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
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);
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'delivered',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? undefined,
|
||||
errorMessage: undefined,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
if (received.length >= segmentTotal) {
|
||||
const latest = received[received.length - 1];
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'unknown',
|
||||
receiptStatus: 'unknown',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? data.errorCode,
|
||||
errorMessage: latest.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
terminal: false,
|
||||
segmentTotal,
|
||||
status: 'submitted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -23,6 +23,9 @@ function createPrismaMock() {
|
||||
submitId: 'SUB-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
channelId: 'channel-1',
|
||||
cmppSubmitSequenceId: '101',
|
||||
cmppSubmitGroupMessageId: null,
|
||||
cmppRegisteredDelivery: true,
|
||||
template: { signature: { id: 'sig-1', name: '签名' } },
|
||||
};
|
||||
const channel = {
|
||||
@@ -109,8 +112,12 @@ function createPrismaMock() {
|
||||
smsDrainageInfo: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
drainageDetectionRule: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
smsSendTask: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
@@ -191,6 +198,9 @@ function createPrismaMock() {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsReceiptAnomaly: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'receipt-anomaly-1', status: 'pending' }),
|
||||
},
|
||||
smsUplinkMessage: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
@@ -374,10 +384,19 @@ function createService(
|
||||
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
|
||||
const phoneFrequency = {
|
||||
reserve: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
const service = new SendChainService(
|
||||
prisma as never,
|
||||
billing,
|
||||
riskReview,
|
||||
phoneFrequency as never,
|
||||
openApi as never,
|
||||
);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
return { service, prisma, billing, riskReview, phoneFrequency };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
@@ -451,6 +470,94 @@ describe('SendChainService', () => {
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('rejects only phones that hit application frequency rules and excludes them from billing', async () => {
|
||||
const { service, prisma, billing, phoneFrequency } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000002', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
});
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000002'],
|
||||
});
|
||||
|
||||
expect(phoneFrequency.reserve).toHaveBeenCalledWith(
|
||||
'tenant-1',
|
||||
'app-1',
|
||||
['13800000001', '13800000002'],
|
||||
'client',
|
||||
);
|
||||
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 }));
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800000002',
|
||||
status: 'submit_failed',
|
||||
submitStatus: 'rejected',
|
||||
errorCode: 'PHONE_FREQUENCY_LIMIT',
|
||||
amountCents: 0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => {
|
||||
const { service, prisma, riskReview, phoneFrequency } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
status: 'pending_review',
|
||||
reason: '命中人工审核规则',
|
||||
task: { id: 'review-task-1' },
|
||||
});
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000001', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(prisma.smsSendTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'review-task-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
riskDecision: 'block',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
auditStatus: 'rejected',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the review task id on every message waiting for manual review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
@@ -590,7 +697,7 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
|
||||
it.each(['pending', 'rejected'])('does not block a matched %s drainage URL and still preserves the matched resource', async (auditStatus) => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
|
||||
@@ -607,14 +714,14 @@ describe('SendChainService', () => {
|
||||
})).resolves.toBeDefined();
|
||||
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
|
||||
data: expect.objectContaining({ status: 'ready', rejectReason: null }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
|
||||
drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined,
|
||||
})],
|
||||
});
|
||||
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
||||
@@ -2478,6 +2585,18 @@ describe('SendChainService', () => {
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
status: 'submitted',
|
||||
billingUnits: 2,
|
||||
cmppSubmitSequenceId: '501',
|
||||
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-1',
|
||||
cmppRegisteredDelivery: true,
|
||||
});
|
||||
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
|
||||
id: 'long-group-receipt-1',
|
||||
messageId: 'MSG-LONG-GROUP-1',
|
||||
segmentTotal: 2,
|
||||
segments: [
|
||||
{ segmentIndex: 1, sequenceId: '501', registeredDelivery: true },
|
||||
{ segmentIndex: 2, sequenceId: '502', registeredDelivery: true },
|
||||
],
|
||||
});
|
||||
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
||||
id: 'submit-long',
|
||||
@@ -2523,10 +2642,141 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-long' },
|
||||
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, {
|
||||
data: expect.objectContaining({
|
||||
dedupeKey: 'receipt:record-long:segment:1',
|
||||
payload: expect.objectContaining({ submitSequenceId: 501, clientSegmentIndex: 1, clientSegmentTotal: 2 }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(2, {
|
||||
data: expect.objectContaining({
|
||||
dedupeKey: 'receipt:record-long:segment:2',
|
||||
payload: expect.objectContaining({ submitSequenceId: 502, clientSegmentIndex: 2, clientSegmentTotal: 2 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and sends only one downstream final receipt under concurrent completion', async () => {
|
||||
it('treats one delivered receipt as the whole long-message success only for a message-level receipt channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||||
id: 'channel-1',
|
||||
config: { longMessageReceiptMode: 'message_level' },
|
||||
});
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-message-level',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-MESSAGE-LEVEL',
|
||||
submitId: 'SUB-MESSAGE-LEVEL',
|
||||
phoneNumber: '13127620092',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
||||
status: 'submitted',
|
||||
billingUnits: 2,
|
||||
});
|
||||
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
||||
id: 'submit-message-level',
|
||||
submitId: 'SUB-MESSAGE-LEVEL',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'segment-1', receiptStatus: 'delivered' },
|
||||
{ id: 'segment-2', receiptStatus: null },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', compensationType: 'supplier_message_level_receipt', deliveredAt: new Date() },
|
||||
]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-MESSAGE-LEVEL',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-MESSAGE-LEVEL-1',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
messageRecordId: 'record-message-level',
|
||||
submitRecordId: 'submit-message-level',
|
||||
receiptStatus: null,
|
||||
}),
|
||||
data: expect.objectContaining({
|
||||
receiptStatus: 'delivered',
|
||||
compensationType: 'supplier_message_level_receipt',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'record-message-level' },
|
||||
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('records a receipt anomaly when a message-level success is followed by a failure for the same attempt', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||||
id: 'channel-1',
|
||||
config: { longMessageReceiptMode: 'message_level' },
|
||||
});
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-conflict',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-CONFLICT',
|
||||
submitId: 'SUB-CONFLICT',
|
||||
phoneNumber: '13127620092',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-CONFLICT-1',
|
||||
status: 'delivered',
|
||||
billingUnits: 2,
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
||||
id: 'segment-conflict-2',
|
||||
messageRecordId: 'record-conflict',
|
||||
submitRecordId: 'submit-conflict',
|
||||
submitId: 'SUB-CONFLICT',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-CONFLICT-2',
|
||||
segmentIndex: 2,
|
||||
segmentTotal: 2,
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
|
||||
]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-CONFLICT',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-CONFLICT-2',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
errorCode: 'SP_CONFLICT',
|
||||
});
|
||||
|
||||
expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' },
|
||||
create: expect.objectContaining({
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
previousStatus: 'delivered',
|
||||
incomingStatus: 'undelivered',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
}));
|
||||
expect(billing.refund).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => {
|
||||
const { service, prisma } = createService();
|
||||
let claimedDelivery: Record<string, unknown> | null = null;
|
||||
prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => {
|
||||
@@ -2592,6 +2842,18 @@ describe('SendChainService', () => {
|
||||
billingUnits: 2,
|
||||
amountCents: 6,
|
||||
unitPrice: 3,
|
||||
cmppSubmitSequenceId: '601',
|
||||
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-FAIL',
|
||||
cmppRegisteredDelivery: true,
|
||||
});
|
||||
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
|
||||
id: 'long-group-receipt-fail',
|
||||
messageId: 'MSG-LONG-GROUP-FAIL',
|
||||
segmentTotal: 2,
|
||||
segments: [
|
||||
{ segmentIndex: 1, sequenceId: '601', registeredDelivery: true },
|
||||
{ segmentIndex: 2, sequenceId: '602', registeredDelivery: true },
|
||||
],
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
||||
id: 'segment-2',
|
||||
@@ -2656,12 +2918,9 @@ describe('SendChainService', () => {
|
||||
}),
|
||||
});
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
messageRecordId: 'record-long',
|
||||
deliveryType: 'receipt',
|
||||
status: 'pending',
|
||||
}),
|
||||
data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending' }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3703,8 +3962,8 @@ describe('SendChainService', () => {
|
||||
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 },
|
||||
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 },
|
||||
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, status: 'submitted', cmppSubmitSequenceId: '701', cmppRegisteredDelivery: true, timeoutAt: null },
|
||||
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-2', phoneNumber: '13900000002', amountCents: 3, billingUnits: 1, status: 'unknown', cmppSubmitSequenceId: '702', cmppRegisteredDelivery: true, timeoutAt: null },
|
||||
]);
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' })
|
||||
@@ -3714,10 +3973,12 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: { not: null },
|
||||
status: { in: ['submitted', 'unknown'] },
|
||||
submittedAt: { lte: expect.any(Date) },
|
||||
OR: [
|
||||
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } },
|
||||
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
|
||||
],
|
||||
},
|
||||
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
||||
select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: true }),
|
||||
take: 10000,
|
||||
});
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
@@ -3725,9 +3986,95 @@ describe('SendChainService', () => {
|
||||
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
|
||||
});
|
||||
expect(billing.refund).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
dedupeKey: 'receipt:record-1',
|
||||
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 701 }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
|
||||
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) };
|
||||
const { service } = createService(prisma, openApi);
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
||||
id: 'record-http-timeout',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: null,
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-HTTP-TIMEOUT',
|
||||
phoneNumber: '13800000001',
|
||||
amountCents: 0,
|
||||
billingUnits: 1,
|
||||
status: 'submitted',
|
||||
cmppSubmitSequenceId: null,
|
||||
cmppSubmitGroupMessageId: null,
|
||||
cmppRegisteredDelivery: null,
|
||||
timeoutAt: null,
|
||||
}]);
|
||||
|
||||
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 });
|
||||
|
||||
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
messageRecordId: 'record-http-timeout',
|
||||
eventType: 'receipt',
|
||||
payload: expect.objectContaining({
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'EXPIRED',
|
||||
errorCode: 'RECEIPT_TIMEOUT',
|
||||
}),
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null },
|
||||
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers timeout refund and downstream queueing when the prior scan stopped before setting the outbox marker', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' });
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
||||
id: 'record-timeout-recovery',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: null,
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-TIMEOUT-RECOVERY',
|
||||
phoneNumber: '13800000001',
|
||||
amountCents: 3,
|
||||
billingUnits: 1,
|
||||
status: 'timeout',
|
||||
cmppSubmitSequenceId: '703',
|
||||
cmppSubmitGroupMessageId: null,
|
||||
cmppRegisteredDelivery: true,
|
||||
timeoutAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
}]);
|
||||
|
||||
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 });
|
||||
|
||||
expect(billing.refund).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
dedupeKey: 'receipt:record-timeout-recovery',
|
||||
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 703 }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'record-timeout-recovery', status: 'timeout', timeoutReceiptQueuedAt: null },
|
||||
data: { timeoutReceiptQueuedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
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 { downstreamPendingTimeoutHours } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import { SendAccountingService } from './send-accounting.service';
|
||||
import { SendDownstreamDeliveryService } from './send-downstream-delivery.service';
|
||||
import { SendDownstreamStateService } from './send-downstream-state.service';
|
||||
import { SendGatewayResultService } from './send-gateway-result.service';
|
||||
import { SendReceiptService } from './send-receipt.service';
|
||||
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;
|
||||
|
||||
/**
|
||||
* R10 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
export class SendCompletionService {
|
||||
private readonly gatewayResult: SendGatewayResultService;
|
||||
private readonly receipt: SendReceiptService;
|
||||
private readonly retry: SendRetryService;
|
||||
private readonly accounting: SendAccountingService;
|
||||
private readonly downstreamState: SendDownstreamStateService;
|
||||
private readonly downstreamDelivery: SendDownstreamDeliveryService;
|
||||
private readonly timeout: SendTimeoutService;
|
||||
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
billing: BillingService,
|
||||
openApi: OpenApiService | undefined,
|
||||
facade: SendCompletionFacade,
|
||||
callbacks: SendCompletionCallbacks = {},
|
||||
) {
|
||||
this.gatewayResult = new SendGatewayResultService(prisma, billing, openApi, facade, callbacks);
|
||||
this.receipt = new SendReceiptService(prisma, billing, openApi, facade, callbacks);
|
||||
this.retry = new SendRetryService(prisma, billing, openApi, facade, callbacks);
|
||||
this.accounting = new SendAccountingService(prisma, billing, openApi, facade, callbacks);
|
||||
this.downstreamState = new SendDownstreamStateService(prisma, billing, openApi, facade, callbacks);
|
||||
this.downstreamDelivery = new SendDownstreamDeliveryService(prisma, billing, openApi, facade, callbacks);
|
||||
this.timeout = new SendTimeoutService(prisma, billing, openApi, facade, callbacks);
|
||||
}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
return this.gatewayResult.handleSubmitSegmentResult(data);
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
return this.gatewayResult.handleSubmitResult(data);
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
return this.gatewayResult.resolveSubmitRecordForGatewayResult(messageRecordId, data);
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return this.gatewayResult.smsMessageSegmentAuditDelegate();
|
||||
}
|
||||
|
||||
async recordSubmitSegments(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewaySubmitResultDto,
|
||||
submittedAt: Date,
|
||||
) {
|
||||
return this.gatewayResult.recordSubmitSegments(message, data, submittedAt);
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
return this.gatewayResult.findMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
}
|
||||
|
||||
async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
return this.gatewayResult.requireMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
return this.receipt.intakeReceipt(data);
|
||||
}
|
||||
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
return this.receipt.processPendingUpstreamReceiptInbox(limit);
|
||||
}
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
return this.receipt.processUpstreamReceiptInboxRecord(id);
|
||||
}
|
||||
|
||||
async runUpstreamReceiptInboxScan() {
|
||||
return this.receipt.runUpstreamReceiptInboxScan();
|
||||
}
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return this.receipt.handleReceipt(data, incomingIdentity);
|
||||
}
|
||||
|
||||
async recordReceiptSegment(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
) {
|
||||
return this.receipt.recordReceiptSegment(message, data, deliveredAt, submitRecordId);
|
||||
}
|
||||
|
||||
async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
return this.receipt.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId);
|
||||
}
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return this.receipt.resolveReceiptMessage(data, incomingIdentity);
|
||||
}
|
||||
|
||||
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
|
||||
return this.retry.recordGatewaySubmitDeadLetter(data);
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
return this.retry.recoverStaleGatewaySubmitRequeues(now);
|
||||
}
|
||||
|
||||
async retryMessageIfAllowed(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuedAt?: Date;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
},
|
||||
reason: string,
|
||||
sourceSubmitRecordId?: string,
|
||||
) {
|
||||
return this.retry.retryMessageIfAllowed(message, reason, sourceSubmitRecordId);
|
||||
}
|
||||
|
||||
async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
batchTaskId: string;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number | bigint;
|
||||
amountCents: number | bigint;
|
||||
}) {
|
||||
return this.accounting.chargeAcceptedMessage(message);
|
||||
}
|
||||
|
||||
async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.accounting.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.accounting.refundMessage(message, remark);
|
||||
}
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
return this.downstreamState.listPendingDownstreamDeliveries(data);
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryDelivered(id: string) {
|
||||
return this.downstreamState.markDownstreamDeliveryDelivered(id);
|
||||
}
|
||||
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
return this.downstreamState.markDownstreamDeliverySent(data);
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
return this.downstreamState.acknowledgeDownstreamDelivery(data);
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(
|
||||
id: string,
|
||||
errorMessage?: string,
|
||||
failureType: GatewayDownstreamFailureType = 'send_failed',
|
||||
attempt?: GatewayDownstreamSentDto,
|
||||
) {
|
||||
return this.downstreamState.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt);
|
||||
}
|
||||
|
||||
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
return this.downstreamState.recordGatewayDownstreamRecoveryStatus(data);
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
return this.downstreamState.requeueDownstreamDelivery(id);
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
return this.downstreamState.recoverStaleDownstreamManualRequeues(now);
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
return this.downstreamState.batchRequeueDownstreamDeliveries(ids);
|
||||
}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
return this.downstreamDelivery.handleUplink(data);
|
||||
}
|
||||
|
||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||
return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
||||
}
|
||||
|
||||
async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
|
||||
return this.downstreamDelivery.queueAndTryDownstreamDelivery(data);
|
||||
}
|
||||
|
||||
async resolveUplinkMatch(
|
||||
data: GatewayUplinkEventDto,
|
||||
channel: { id: string; srcId?: string | null },
|
||||
): Promise<{
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: string;
|
||||
matchStatus: string;
|
||||
matchReason: string;
|
||||
candidates: UplinkMatchCandidateInput[];
|
||||
}> {
|
||||
return this.downstreamDelivery.resolveUplinkMatch(data, channel);
|
||||
}
|
||||
|
||||
async recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
cmppRegisteredDelivery?: boolean | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.downstreamDelivery.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
async postGatewayControl(path: string, payload: unknown) {
|
||||
return this.downstreamDelivery.postGatewayControl(path, payload);
|
||||
}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
return this.timeout.markUnknownTimeout(data);
|
||||
}
|
||||
|
||||
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
|
||||
return this.timeout.markExpiredDownstreamDeliveries(olderThanHours);
|
||||
}
|
||||
|
||||
async runReceiptTimeoutScan() {
|
||||
return this.timeout.runReceiptTimeoutScan();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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, UplinkMatchCandidateInput, 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 { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
|
||||
|
||||
|
||||
/**
|
||||
* R10 downstreamDelivery implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendDownstreamDeliveryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
}
|
||||
const match = await this.facade.resolveUplinkMatch(data, channel);
|
||||
const record = await this.prisma.smsUplinkMessage.create({
|
||||
data: {
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
channelId: data.channelId,
|
||||
messageId: data.messageId,
|
||||
sequenceId: data.sequenceId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
matchStatus: match.matchStatus,
|
||||
matchReason: match.matchReason,
|
||||
receivedAt: data.receivedAt ? new Date(data.receivedAt) : new Date(),
|
||||
},
|
||||
});
|
||||
if (match.candidates.length > 0) {
|
||||
await this.prisma.smsUplinkMatchCandidate.createMany({
|
||||
data: match.candidates.map((candidate) => ({
|
||||
uplinkMessageId: record.id,
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
matchSource: candidate.matchSource,
|
||||
confidence: candidate.confidence,
|
||||
reason: candidate.reason,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
if (match.tenantId && match.applicationId) {
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
deliveryType: 'uplink',
|
||||
payload: {
|
||||
messageId: data.messageId,
|
||||
applicationId: match.applicationId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
receivedAt: record.receivedAt.toISOString(),
|
||||
uplinkMessageId: record.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
|
||||
where: { id: candidateId, uplinkMessageId },
|
||||
include: {
|
||||
application: { select: { id: true, name: true, cmppAccount: true } },
|
||||
messageRecord: { select: { id: true, messageId: true, content: true } },
|
||||
uplinkMessage: true,
|
||||
},
|
||||
});
|
||||
if (!candidate) {
|
||||
throw new NotFoundException('Uplink match candidate not found');
|
||||
}
|
||||
if (candidate.status === 'rejected') {
|
||||
throw new BadRequestException('该候选已被排除,不能认领');
|
||||
}
|
||||
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
|
||||
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
|
||||
}
|
||||
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,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
matchStatus: 'matched',
|
||||
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
|
||||
},
|
||||
}),
|
||||
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,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
deliveryType: 'uplink',
|
||||
payload: {
|
||||
messageId,
|
||||
applicationId: candidate.applicationId,
|
||||
phoneNumber: candidate.uplinkMessage.phoneNumber,
|
||||
destId: candidate.uplinkMessage.destId,
|
||||
content: candidate.uplinkMessage.content,
|
||||
receivedAt: candidate.uplinkMessage.receivedAt.toISOString(),
|
||||
manualClaim: true,
|
||||
uplinkMessageId,
|
||||
},
|
||||
});
|
||||
return this.prisma.smsUplinkMessage.findUnique({
|
||||
where: { id: updatedUplink.id },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
|
||||
if (!data.applicationId) {
|
||||
return null;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: {
|
||||
cmppAccount: true,
|
||||
interfaceEnabled: true,
|
||||
status: true,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
|
||||
if (deliveryAllowed && data.queueHttpWebhook !== false) {
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
|
||||
eventType: data.deliveryType,
|
||||
payload: data.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (data.propagateHttpQueueError) throw error;
|
||||
}
|
||||
}
|
||||
if (data.queueCmppDelivery === false) {
|
||||
return null;
|
||||
}
|
||||
if (application?.interfaceEnabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
|
||||
? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`
|
||||
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
|
||||
? `uplink:${data.payload.uplinkMessageId}`
|
||||
: null;
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await this.prisma.cmppDownstreamDelivery.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
dedupeKey,
|
||||
deliveryType: data.deliveryType,
|
||||
payload,
|
||||
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
|
||||
? application?.downstreamUplinkRetryEnabled ?? true
|
||||
: application?.downstreamReceiptRetryEnabled ?? true),
|
||||
status: deliveryAllowed ? 'pending' : 'abandoned',
|
||||
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
dedupeKey
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
const existing = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { dedupeKey },
|
||||
});
|
||||
if (existing) {
|
||||
this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({
|
||||
deliveryType: data.deliveryType,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
dedupeKey,
|
||||
deliveryId: existing.id,
|
||||
})}`);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!deliveryAllowed) {
|
||||
return delivery;
|
||||
}
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(
|
||||
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
|
||||
{ deliveryId: delivery.id, ...payload },
|
||||
) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
await this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
|
||||
return delivery;
|
||||
} else {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
{ id: delivery.id, ...result },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed');
|
||||
}
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async resolveUplinkMatch(
|
||||
data: GatewayUplinkEventDto,
|
||||
channel: { id: string; srcId?: string | null },
|
||||
): Promise<{
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: 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: [] };
|
||||
}
|
||||
|
||||
async recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
cmppRegisteredDelivery?: boolean | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
if (!message.tenantId || !message.applicationId) return null;
|
||||
const existing = await this.prisma.smsReceiptRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
|
||||
});
|
||||
if (existing) return existing;
|
||||
const deliveredAt = new Date();
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt },
|
||||
});
|
||||
const gatewayMessageId = `PLATFORM:${message.messageId}`;
|
||||
const receipt = await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'),
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
await queueFinalReceiptDeliveries(
|
||||
this.prisma,
|
||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
||||
{
|
||||
message,
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: `PLATFORM:${message.messageId}`,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
deliveredAt: deliveredAt.toISOString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async postGatewayControl(path: string, payload: unknown) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, '');
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Gateway control ${path} returned ${response.status}${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
return response.json().catch(() => ({}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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';
|
||||
|
||||
|
||||
/**
|
||||
* R10 downstreamState implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendDownstreamStateService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const expired of expiredAcknowledgements) {
|
||||
await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout');
|
||||
}
|
||||
return this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
applicationId: application.id,
|
||||
status: 'pending',
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.min(Math.max(data.limit ?? 100, 1), 500),
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryDelivered(id: string) {
|
||||
return this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
deliveredAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
const sentAt = asDateOrNull(data.sentAt) ?? new Date();
|
||||
const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs());
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
const attemptKey = downstreamDeliveryAttemptKey(data);
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
},
|
||||
create: {
|
||||
deliveryId: data.id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
},
|
||||
});
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
|
||||
const acknowledgedMessageId = String(data.messageId ?? '').trim();
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
const attemptKey = downstreamDeliveryAttemptKey(data);
|
||||
const acknowledgementAccepted = data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0';
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackDeadlineAt: null,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
: data.result === 0
|
||||
? 'CMPP_DELIVER_RESP Msg_Id=0'
|
||||
: `CMPP_DELIVER_RESP result=${data.result}`,
|
||||
},
|
||||
create: {
|
||||
deliveryId: data.id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
: data.result === 0
|
||||
? 'CMPP_DELIVER_RESP Msg_Id=0'
|
||||
: `CMPP_DELIVER_RESP result=${data.result}`,
|
||||
},
|
||||
});
|
||||
if (acknowledgementAccepted) {
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
acknowledgedAt,
|
||||
deliveredAt: acknowledgedAt,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
acknowledgedAt,
|
||||
ackResult: data.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, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(
|
||||
id: string,
|
||||
errorMessage?: string,
|
||||
failureType: GatewayDownstreamFailureType = 'send_failed',
|
||||
attempt?: GatewayDownstreamSentDto,
|
||||
) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'delivered') {
|
||||
return delivery;
|
||||
}
|
||||
if (failureType === 'queue_timeout' && delivery.status !== 'pending') {
|
||||
return delivery;
|
||||
}
|
||||
const retryCount = (delivery.retryCount ?? 0) + 1;
|
||||
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();
|
||||
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
|
||||
if (attempt && (attempt.connectionId || attempt.sequenceId || attempt.messageId)) {
|
||||
const attemptKey = downstreamDeliveryAttemptKey({ ...attempt, id });
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
status: 'failed',
|
||||
connectionId: attempt.connectionId,
|
||||
sequenceId: attempt.sequenceId,
|
||||
messageId: attempt.messageId,
|
||||
failureType,
|
||||
errorMessage: errorMessage ?? 'downstream delivery failed',
|
||||
ackDeadlineAt: null,
|
||||
},
|
||||
create: {
|
||||
deliveryId: id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: attempt.connectionId,
|
||||
sequenceId: attempt.sequenceId,
|
||||
messageId: attempt.messageId,
|
||||
status: 'failed',
|
||||
sentAt: asDateOrNull(attempt.sentAt),
|
||||
failureType,
|
||||
errorMessage: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: finalFailure ? finalStatus : 'pending',
|
||||
retryCount,
|
||||
nextRetryAt: finalFailure ? null : new Date(Date.now() + downstreamRetryDelayMs(retryCount)),
|
||||
ackDeadlineAt: null,
|
||||
lastError: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
if (finalFailure) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId,
|
||||
action: 'gateway.downstream_delivery_failed',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
deliveryType: updated.deliveryType,
|
||||
applicationId: updated.applicationId,
|
||||
messageId: updated.messageId,
|
||||
retryCount,
|
||||
failureType,
|
||||
retryEnabled: updated.retryEnabled,
|
||||
errorMessage: updated.lastError,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const account = String(data.account ?? '').trim();
|
||||
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 previous = await recoveryStatuses.findUnique({
|
||||
where: { account },
|
||||
select: {
|
||||
state: true,
|
||||
gatewayInstanceId: true,
|
||||
lockOwner: true,
|
||||
failureCategory: true,
|
||||
lastError: true,
|
||||
lastSkipReason: true,
|
||||
},
|
||||
});
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: account },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
});
|
||||
const failureCategory = normalizeRecoveryFailureCategory(data);
|
||||
const updated = await recoveryStatuses.upsert({
|
||||
where: { account },
|
||||
update: {
|
||||
tenantId: application?.tenantId ?? null,
|
||||
applicationId: application?.id ?? null,
|
||||
gatewayInstanceId: data.gatewayInstanceId ?? null,
|
||||
state: data.state,
|
||||
lockOwner: data.lockOwner ?? null,
|
||||
lockExpiresAt: asDateOrNull(data.lockExpiresAt),
|
||||
lastAttemptAt: asDateOrNull(data.lastAttemptAt),
|
||||
lastSuccessAt: asDateOrNull(data.lastSuccessAt),
|
||||
lastFailureAt: asDateOrNull(data.lastFailureAt),
|
||||
nextRetryAt: asDateOrNull(data.nextRetryAt),
|
||||
attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0,
|
||||
failureCategory,
|
||||
lastError: data.lastError ?? null,
|
||||
lastSkipReason: data.lastSkipReason ?? null,
|
||||
},
|
||||
create: {
|
||||
account,
|
||||
tenantId: application?.tenantId,
|
||||
applicationId: application?.id,
|
||||
gatewayInstanceId: data.gatewayInstanceId,
|
||||
state: data.state,
|
||||
lockOwner: data.lockOwner,
|
||||
lockExpiresAt: asDateOrNull(data.lockExpiresAt),
|
||||
lastAttemptAt: asDateOrNull(data.lastAttemptAt),
|
||||
lastSuccessAt: asDateOrNull(data.lastSuccessAt),
|
||||
lastFailureAt: asDateOrNull(data.lastFailureAt),
|
||||
nextRetryAt: asDateOrNull(data.nextRetryAt),
|
||||
attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0,
|
||||
failureCategory,
|
||||
lastError: data.lastError,
|
||||
lastSkipReason: data.lastSkipReason,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
},
|
||||
});
|
||||
const normalizedUpdated = updated as typeof updated & {
|
||||
failureCategory?: string | null;
|
||||
lockOwner?: string | null;
|
||||
lockExpiresAt?: Date | null;
|
||||
};
|
||||
if (hasRecoveryAuditStateChanged(previous, updated)) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
action: 'gateway.downstream_recovery_status_changed',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
account,
|
||||
previousState: previous?.state ?? null,
|
||||
state: updated.state,
|
||||
gatewayInstanceId: updated.gatewayInstanceId,
|
||||
lockOwner: normalizedUpdated.lockOwner,
|
||||
attemptCount: updated.attemptCount,
|
||||
nextRetryAt: updated.nextRetryAt,
|
||||
failureCategory: normalizedUpdated.failureCategory,
|
||||
applicationId: updated.applicationId,
|
||||
applicationName: application?.name,
|
||||
lastError: updated.lastError,
|
||||
lastSkipReason: updated.lastSkipReason,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { id },
|
||||
include: { application: { select: { cmppAccount: true } } },
|
||||
});
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'awaiting_ack') {
|
||||
throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投');
|
||||
}
|
||||
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
||||
if (!payload) {
|
||||
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
||||
}
|
||||
const path =
|
||||
delivery.deliveryType === 'receipt'
|
||||
? '/downstream/receipt'
|
||||
: delivery.deliveryType === 'uplink'
|
||||
? '/downstream/uplink'
|
||||
: null;
|
||||
if (!path) {
|
||||
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
deliveryId: delivery.id,
|
||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||
...payload,
|
||||
};
|
||||
const retriedAt = new Date();
|
||||
const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: {
|
||||
id: delivery.id,
|
||||
status: delivery.status,
|
||||
updatedAt: delivery.updatedAt,
|
||||
},
|
||||
data: {
|
||||
status: 'manual_requeueing',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: retriedAt,
|
||||
nextRetryAt: null,
|
||||
sentAt: null,
|
||||
acknowledgedAt: null,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: null,
|
||||
ackSequenceId: null,
|
||||
ackMessageId: null,
|
||||
connectionId: null,
|
||||
deliveredAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
previousStatus: delivery.status,
|
||||
previousRetryCount: delivery.retryCount,
|
||||
manualRetryCount: (delivery.manualRetryCount ?? 0) + 1,
|
||||
lastRetriedAt: retriedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
}
|
||||
return this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
);
|
||||
} catch (error) {
|
||||
return this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
error instanceof Error ? error.message : 'Gateway control delivery failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 stale = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
|
||||
select: { id: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 500,
|
||||
});
|
||||
let recovered = 0;
|
||||
for (const delivery of stale) {
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt },
|
||||
data: {
|
||||
status: 'pending',
|
||||
nextRetryAt: null,
|
||||
lastError: '人工重投进程中断,已恢复为待投递',
|
||||
},
|
||||
});
|
||||
recovered += updated.count;
|
||||
}
|
||||
return { recovered };
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new BadRequestException('请选择至少一条下游投递记录');
|
||||
}
|
||||
const results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }> = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
await this.facade.requeueDownstreamDelivery(id);
|
||||
results.push({ id, status: 'success' });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id,
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : '批量重投失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: uniqueIds.length,
|
||||
successCount: results.filter((item) => item.status === 'success').length,
|
||||
failedCount: results.filter((item) => item.status === 'failed').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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';
|
||||
|
||||
|
||||
/**
|
||||
* R10 gatewayResult implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendGatewayResultService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||
const effectiveSubmitId = submitRecord.submitId;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.facade.recordSubmitSegments(message, {
|
||||
messageId: data.messageId,
|
||||
channelId: data.channelId,
|
||||
submitId: effectiveSubmitId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId ?? '',
|
||||
submitStatus: normalizeSubmitStatus(data.submitStatus),
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
segments: [{
|
||||
segmentTotal: data.segmentTotal,
|
||||
segmentIndex: data.segmentIndex,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
}],
|
||||
}, submittedAt);
|
||||
if (data.gatewayMessageId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: submitRecord.id,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { submitId: data.submitId },
|
||||
});
|
||||
if (
|
||||
!exact ||
|
||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||
(exact.channelId && exact.channelId !== data.channelId)
|
||||
) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
submitId: data.submitId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
||||
);
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult without submitId cannot be matched uniquely',
|
||||
);
|
||||
}
|
||||
this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
|
||||
const effectiveData = { ...data, submitId: submitRecord.submitId };
|
||||
const batchTask = message.batchTaskId
|
||||
? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } })
|
||||
: null;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: { id: submitRecord.id },
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (message.submitId && effectiveData.submitId !== message.submitId) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
await this.facade.chargeAcceptedMessage(businessMessage);
|
||||
const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
if (latest?.status === 'failed') {
|
||||
await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款');
|
||||
}
|
||||
} else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
|
||||
submitRecord.id,
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
}
|
||||
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
|
||||
const updated = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: data.submitStatus === 'accepted'
|
||||
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
||||
: { id: message.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0 && data.submitStatus === 'accepted') {
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: message.id, gatewayMessageId: null },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) {
|
||||
await this.facade.recordCmppFailureReceipt(
|
||||
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
|
||||
data.errorCode || 'SUBMIT',
|
||||
data.errorMessage || (data.submitStatus === 'timeout' ? '上游提交超时' : '上游拒绝短信'),
|
||||
);
|
||||
}
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [
|
||||
{ submitId: effectiveData.submitId },
|
||||
data.messageId ? { messageId: data.messageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>,
|
||||
},
|
||||
data: {
|
||||
status: 'resolved',
|
||||
resolvedAt: submittedAt,
|
||||
resolvedStatus: data.submitStatus,
|
||||
},
|
||||
});
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||
if (!exact
|
||||
|| (exact.messageRecordId && exact.messageRecordId !== messageRecordId)
|
||||
|| (exact.channelId && exact.channelId !== data.channelId)) {
|
||||
throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel');
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
OR: [
|
||||
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
|
||||
{ gatewayMessageId: null },
|
||||
].filter(Boolean) as Array<{ gatewayMessageId?: string | null }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
}
|
||||
|
||||
async recordSubmitSegments(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewaySubmitResultDto,
|
||||
submittedAt: Date,
|
||||
) {
|
||||
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
|
||||
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 }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`;
|
||||
const attempt = submitRecord
|
||||
? Math.max(0, await this.prisma.smsSubmitRecord.count({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
createdAt: { lte: submitRecord.createdAt },
|
||||
},
|
||||
}) - 1)
|
||||
: 0;
|
||||
const fallbackSegments = [{
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: data.submittedAt,
|
||||
}];
|
||||
const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments;
|
||||
const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1)));
|
||||
await Promise.all(segments.map((segment, index) => {
|
||||
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
||||
const status = segment.submitStatus ?? data.submitStatus;
|
||||
return segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId,
|
||||
segmentIndex,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
segmentIndex,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
|
||||
Boolean,
|
||||
) as Array<{
|
||||
messageId?: string;
|
||||
gatewayMessageId?: string;
|
||||
}>;
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findFirst({
|
||||
where: {
|
||||
OR: conditions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const message = await this.facade.findMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 gatewaySubmit implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendGatewaySubmitService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private redis?: IORedis;
|
||||
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
|
||||
private gatewayQueue?: Queue;
|
||||
private worker?: Worker<SendJob>;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
if (task.status === 'canceled') {
|
||||
throw new BadRequestException('SMS batch task is canceled');
|
||||
}
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: taskId, status: 'queued' },
|
||||
select: { id: true, queuePriority: true },
|
||||
take: 100000,
|
||||
});
|
||||
const queue = this.facade.getSendQueue();
|
||||
for (const message of messages) {
|
||||
const queuePriority = normalizeQueuePriority(message.queuePriority);
|
||||
await queue.add('send-message', { messageRecordId: message.id }, {
|
||||
jobId: message.id,
|
||||
attempts: 3,
|
||||
priority: BULLMQ_PRIORITY[queuePriority],
|
||||
});
|
||||
}
|
||||
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||
return { taskId, enqueued: messages.length };
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.worker) {
|
||||
return { status: 'already_started' };
|
||||
}
|
||||
const connection = bullmqConnection();
|
||||
this.worker = new Worker<SendJob>(
|
||||
SEND_QUEUE,
|
||||
async (job) => this.facade.processSendJob(job.data),
|
||||
{ connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) },
|
||||
);
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
async processSendJob(job: SendJob) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id: job.messageRecordId },
|
||||
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
|
||||
});
|
||||
if (!message || message.status !== 'queued') {
|
||||
return { skipped: true };
|
||||
}
|
||||
if (!message.tenantId || !message.batchTaskId) {
|
||||
return { skipped: true, reason: 'standalone channel test message' };
|
||||
}
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage(businessMessage);
|
||||
return await this.facade.submitMessageToGateway(businessMessage, routed, 0);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', errorMessage: reason },
|
||||
});
|
||||
await this.releaseMessageReservation(businessMessage, reason);
|
||||
if (message.batchTask?.sourceType === 'cmpp') {
|
||||
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
|
||||
} else {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
}
|
||||
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||
}
|
||||
}
|
||||
|
||||
async submitMessageToGateway(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
routed: RoutedChannel,
|
||||
attempt: number,
|
||||
retryOfSubmitRecordId?: string,
|
||||
) {
|
||||
const channel = routed.channel;
|
||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||
await this.facade.ensureSignatureReportedForChannel(message, channel.id);
|
||||
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const session = await tx.cmppSubmitSession.upsert({
|
||||
where: { sessionNo: `OPEN-${channel.id}` },
|
||||
update: { submitTotal: { increment: 1 } },
|
||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||
});
|
||||
await tx.smsSubmitRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
channelId: channel.id,
|
||||
channelGroupId: routed.groupId,
|
||||
channelGroupName: routed.groupName,
|
||||
sessionId: session.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice ?? 0,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
|
||||
},
|
||||
});
|
||||
await tx.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
carrier: routed.carrier,
|
||||
province: routed.province,
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
receiptStatus: null,
|
||||
errorCode: null,
|
||||
errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (retryOfSubmitRecordId) {
|
||||
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
channelId: channel.id,
|
||||
})}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
retryOfSubmitRecordId
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
return {
|
||||
submitted: false,
|
||||
duplicateRetry: true,
|
||||
messageRecordId: message.id,
|
||||
channelId: existingRetry.channelId,
|
||||
attempt,
|
||||
submitId: existingRetry.submitId,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: randomUUID(),
|
||||
messageId: message.messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? 'unknown',
|
||||
taskId: message.batchTaskId,
|
||||
submitId,
|
||||
queuePriority: normalizeQueuePriority(message.queuePriority),
|
||||
phoneNumber: message.phoneNumber,
|
||||
content: message.content,
|
||||
signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS',
|
||||
templateId: message.templateId ?? 'unknown',
|
||||
billingUnits: message.billingUnits,
|
||||
route: {
|
||||
channelCode: channel.code,
|
||||
cmppAccountCode: channel.account,
|
||||
priority: attempt,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
carrier: routed.carrier,
|
||||
province: routed.province ?? undefined,
|
||||
scope: routed.routeScope,
|
||||
groupId: routed.groupId,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
? String(channel.config.serviceId)
|
||||
: 'SMS',
|
||||
srcId: upstreamSrcId,
|
||||
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
|
||||
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
|
||||
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
|
||||
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
|
||||
},
|
||||
retry: { attempt, maxAttempts: 1 },
|
||||
};
|
||||
await this.facade.getGatewayQueue().add('submit-command', command);
|
||||
await this.facade.publishGatewaySubmitCommand(command);
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
|
||||
}
|
||||
|
||||
async selectChannelForMessage(
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||
): Promise<RoutedChannel> {
|
||||
if (!message.applicationId) {
|
||||
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
||||
}
|
||||
const hasPersistedRouting = Boolean(message.carrier);
|
||||
const [carrier, province] = hasPersistedRouting
|
||||
? [normalizeCarrier(message.carrier), message.province ?? null]
|
||||
: await Promise.all([
|
||||
this.facade.identifyCarrier(message.phoneNumber),
|
||||
this.facade.identifyProvince(message.phoneNumber),
|
||||
]);
|
||||
if (!hasPersistedRouting) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { carrier, province },
|
||||
});
|
||||
}
|
||||
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId, carrier);
|
||||
const excluded = new Set(options.excludeChannelIds ?? []);
|
||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
|
||||
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
|
||||
select: { channelId: true },
|
||||
});
|
||||
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
|
||||
const selected = selectChannelCandidate(route.group.items, {
|
||||
carrier,
|
||||
province,
|
||||
forceNational: options.forceNational,
|
||||
excludedChannelIds: excluded,
|
||||
approvedChannelIds,
|
||||
});
|
||||
if (!selected) {
|
||||
throw new NotFoundException('无已报备通过且在线的可用通道');
|
||||
}
|
||||
return {
|
||||
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
|
||||
carrier,
|
||||
province,
|
||||
groupId: route.groupId,
|
||||
groupName: route.group.name,
|
||||
routeScope: isNationalChannel(selected) ? 'national' : 'province',
|
||||
};
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
const route = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
status: 'active',
|
||||
tenantId,
|
||||
applicationId,
|
||||
carrier,
|
||||
channelId: null,
|
||||
province: null,
|
||||
},
|
||||
include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
if (!route) {
|
||||
throw new NotFoundException('企业应用未配置对应运营商通道组');
|
||||
}
|
||||
if (route.group.status !== 'active') {
|
||||
throw new BadRequestException('企业应用绑定的通道组已停用');
|
||||
}
|
||||
if (normalizeCarrier(route.group.carrier) !== carrier) {
|
||||
throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致');
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.phoneRouting.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
async ensureSignatureReportedForChannel(
|
||||
message: {
|
||||
id: string;
|
||||
templateId?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
channelId: string,
|
||||
) {
|
||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||
if (!signatureId) {
|
||||
throw new BadRequestException('短信签名未配置,不能提交到通道');
|
||||
}
|
||||
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
|
||||
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!reportTask) {
|
||||
throw new BadRequestException('短信签名未在最终通道报备通过');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
if (direct || !message.templateId) return direct;
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } });
|
||||
return template?.signature?.id ?? null;
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
const redis = this.facade.getRedis();
|
||||
for (;;) {
|
||||
const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`;
|
||||
const count = await redis.incr(bucket);
|
||||
if (count === 1) {
|
||||
await redis.expire(bucket, 2);
|
||||
}
|
||||
if (count <= Math.max(1, tps)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string) {
|
||||
const groups = await this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchTaskId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const count = (statuses: string[]) =>
|
||||
groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0);
|
||||
const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0);
|
||||
const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']);
|
||||
const successTotal = count(['delivered']);
|
||||
const failedTotal = count(['submit_failed', 'failed']);
|
||||
const unknownTotal = count(['unknown']);
|
||||
const timeoutTotal = count(['timeout']);
|
||||
const doneTotal = successTotal + failedTotal + timeoutTotal;
|
||||
const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued';
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: batchTaskId },
|
||||
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
|
||||
});
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
if (!this.sendQueue) {
|
||||
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.sendQueue;
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
if (!this.gatewayQueue) {
|
||||
this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.gatewayQueue;
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
const redis = this.facade.getRedis();
|
||||
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
|
||||
const payload = JSON.stringify(command);
|
||||
if (!idempotencyKey) {
|
||||
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
|
||||
}
|
||||
const result = await redis.eval(
|
||||
`local existing = redis.call('GET', KEYS[2])
|
||||
if existing then return existing end
|
||||
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
|
||||
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
|
||||
return streamId`,
|
||||
2,
|
||||
stream,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
|
||||
);
|
||||
return typeof result === 'string' ? result : String(result ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
import { detectDrainageContent } from './drainage-content-detection';
|
||||
|
||||
/**
|
||||
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendInboundEntryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
cmppRegisteredDelivery?: boolean | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (application.tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('Enterprise certification is not approved');
|
||||
}
|
||||
if (!matchesApplicationSecret(data, application.secretHash)) {
|
||||
throw new BadRequestException('CMPP account or password is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
tenantId: application.tenantId,
|
||||
account: application.cmppAccount,
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
passwordCipher: application.secretHash,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
status: 'authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) {
|
||||
throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1');
|
||||
}
|
||||
const phoneNumbers = data.phoneNumbers?.length
|
||||
? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim())
|
||||
: data.phoneNumber
|
||||
? [data.phoneNumber.trim()]
|
||||
: [];
|
||||
if (phoneNumbers.length === 0) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) {
|
||||
throw new BadRequestException('CMPP account is disabled for new submissions');
|
||||
}
|
||||
if (data.longMessage) {
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
validateInboundApplicationSrcId(data.srcId, application);
|
||||
const collection = await this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers);
|
||||
if (collection.response) {
|
||||
return collection.response;
|
||||
}
|
||||
if (!collection.complete) {
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
fragmentPending: true,
|
||||
receivedSegments: collection.receivedSegments,
|
||||
segmentTotal: data.longMessage.total,
|
||||
phoneCount: phoneNumbers.length,
|
||||
messages: phoneNumbers.map((phoneNumber) => ({
|
||||
phoneNumber,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
})),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await this.facade.recoverCompletedInboundLongMessageResponse(
|
||||
collection.messageId,
|
||||
phoneNumbers,
|
||||
) ?? await this.facade.submitCompleteInboundMessage({
|
||||
...data,
|
||||
content: collection.content,
|
||||
sequenceId: collection.sequenceId,
|
||||
registeredDelivery: collection.registeredDelivery ? 1 : 0,
|
||||
longMessage: undefined,
|
||||
}, phoneNumbers, application, collection.messageId);
|
||||
await this.prisma.cmppInboundLongMessage.update({
|
||||
where: { id: collection.groupId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
await this.prisma.cmppInboundLongMessage.update({
|
||||
where: { id: collection.groupId },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return this.facade.submitCompleteInboundMessage(data, phoneNumbers, application);
|
||||
}
|
||||
|
||||
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
|
||||
const existing = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
cmppSubmitGroupMessageId: messageId,
|
||||
phoneNumber: { in: phoneNumbers },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
batchTaskId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
status: true,
|
||||
errorCode: true,
|
||||
},
|
||||
});
|
||||
const byPhone = new Map(existing.map((item) => [item.phoneNumber, item]));
|
||||
const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber));
|
||||
if (ordered.some((item) => !item)) {
|
||||
return null;
|
||||
}
|
||||
const messages = ordered.map((item, index) => ({
|
||||
phoneNumber: phoneNumbers[index],
|
||||
messageId: item!.messageId,
|
||||
messageRecordId: item!.id,
|
||||
taskId: item!.batchTaskId ?? '',
|
||||
status: item!.status,
|
||||
}));
|
||||
const first = ordered[0]!;
|
||||
const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT');
|
||||
return {
|
||||
accepted: !dailyLimitRejected,
|
||||
tenantId: first.tenantId ?? '',
|
||||
applicationId: first.applicationId ?? '',
|
||||
taskId: first.batchTaskId ?? '',
|
||||
messageId: first.messageId,
|
||||
messageRecordId: first.id,
|
||||
status: dailyLimitRejected ? 'rejected' : 'accepted',
|
||||
result: dailyLimitRejected ? 8 : undefined,
|
||||
phoneCount: messages.length,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
async submitCompleteInboundMessage(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
) {
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const persisted = requestedGroupMessageId
|
||||
? await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
cmppSubmitGroupMessageId: requestedGroupMessageId,
|
||||
phoneNumber: { in: phoneNumbers },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
batchTaskId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
status: true,
|
||||
errorCode: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
|
||||
const phoneRejections = await this.facade.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers);
|
||||
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => (
|
||||
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
|
||||
)).length;
|
||||
const dailyQuota = missingPhoneCount > 0
|
||||
? await this.facade.tryReserveDailySendQuota(application.id, missingPhoneCount)
|
||||
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
|
||||
const dailyLimitRejection = dailyQuota.reserved
|
||||
? undefined
|
||||
: {
|
||||
code: 'DAILY_LIMIT',
|
||||
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`,
|
||||
};
|
||||
|
||||
const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
|
||||
const submissions = phoneNumbers.map((phoneNumber, index) => ({
|
||||
phoneNumber,
|
||||
persisted: persistedByPhone.get(phoneNumber),
|
||||
receiptRejection: phoneRejections.get(phoneNumber),
|
||||
messageId: persistedByPhone.get(phoneNumber)?.messageId
|
||||
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
|
||||
}));
|
||||
const results: GatewayInboundSingleSubmitResult[] = [];
|
||||
const concurrency = 10;
|
||||
for (let offset = 0; offset < submissions.length; offset += concurrency) {
|
||||
const batch = submissions.slice(offset, offset + concurrency);
|
||||
results.push(...await Promise.all(batch.map((submission) => submission.persisted
|
||||
? Promise.resolve({
|
||||
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
|
||||
tenantId: submission.persisted.tenantId ?? application.tenantId,
|
||||
applicationId: submission.persisted.applicationId ?? application.id,
|
||||
taskId: submission.persisted.batchTaskId ?? '',
|
||||
messageId: submission.persisted.messageId,
|
||||
messageRecordId: submission.persisted.id,
|
||||
status: submission.persisted.status,
|
||||
})
|
||||
: this.facade.submitInboundSingleMessage({
|
||||
...data,
|
||||
phoneNumber: submission.phoneNumber,
|
||||
phoneNumbers: undefined,
|
||||
}, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
|
||||
}
|
||||
const first = results[0];
|
||||
return {
|
||||
...first,
|
||||
result: dailyLimitRejection ? 8 : undefined,
|
||||
phoneCount: results.length,
|
||||
messages: results.map((result, index) => ({
|
||||
phoneNumber: phoneNumbers[index],
|
||||
messageId: result.messageId,
|
||||
messageRecordId: result.messageRecordId,
|
||||
taskId: result.taskId,
|
||||
status: result.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async collectInboundLongMessageFragment(
|
||||
data: GatewayInboundSubmitDto,
|
||||
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
const fragment = data.longMessage;
|
||||
if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535
|
||||
|| !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255
|
||||
|| !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total
|
||||
|| !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) {
|
||||
throw new BadRequestException('CMPP long message fragment metadata is invalid');
|
||||
}
|
||||
const groupKey = createHash('sha256').update(JSON.stringify({
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
srcId: data.srcId?.trim() ?? '',
|
||||
phoneNumbers,
|
||||
reference: fragment.reference,
|
||||
total: fragment.total,
|
||||
format: fragment.format,
|
||||
})).digest('hex');
|
||||
const contentHash = createHash('sha256').update(data.content).digest('hex');
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS,
|
||||
300,
|
||||
) * 1000);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`;
|
||||
await tx.cmppInboundLongMessage.updateMany({
|
||||
where: {
|
||||
groupKey,
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: { status: 'expired', completedAt: now },
|
||||
});
|
||||
|
||||
const recent = await tx.cmppInboundLongMessage.findFirst({
|
||||
where: {
|
||||
groupKey,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (recent && ['completed', 'rejected'].includes(recent.status)
|
||||
&& matchingRecentSegment?.contentHash === contentHash
|
||||
&& matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) {
|
||||
return {
|
||||
complete: recent.status === 'completed',
|
||||
groupId: recent.id,
|
||||
messageId: recent.messageId,
|
||||
receivedSegments: recent.segments.length,
|
||||
response: recent.response as any,
|
||||
content: recent.segments.map((item) => item.content).join(''),
|
||||
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
|
||||
registeredDelivery: recent.segments[0]?.registeredDelivery ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null;
|
||||
if (!group) {
|
||||
group = await tx.cmppInboundLongMessage.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
groupKey,
|
||||
account: data.account,
|
||||
srcId: data.srcId?.trim() || null,
|
||||
phoneNumbers,
|
||||
concatReference: fragment.reference,
|
||||
segmentTotal: fragment.total,
|
||||
msgFmt: fragment.format,
|
||||
messageId: `MSG-${randomUUID()}`,
|
||||
expiresAt,
|
||||
},
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
});
|
||||
}
|
||||
if (group.status === 'processing') {
|
||||
const processingStaleMs = positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
|
||||
DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
|
||||
) * 1000;
|
||||
const complete = group.segments.length === fragment.total
|
||||
&& group.segments.every((item, index) => item.segmentIndex === index + 1);
|
||||
if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) {
|
||||
await tx.cmppInboundLongMessage.update({
|
||||
where: { id: group.id },
|
||||
data: { status: 'processing', expiresAt },
|
||||
});
|
||||
return {
|
||||
complete: true,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: null,
|
||||
content: group.segments.map((item) => item.content).join(''),
|
||||
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
|
||||
registeredDelivery: group.segments[0]?.registeredDelivery ?? true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
complete: false,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: group.response as any,
|
||||
content: '',
|
||||
sequenceId: undefined,
|
||||
registeredDelivery: true,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (existing && (existing.contentHash !== contentHash
|
||||
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId))
|
||||
|| existing.registeredDelivery !== (data.registeredDelivery !== 0))) {
|
||||
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
|
||||
}
|
||||
if (!existing) {
|
||||
await tx.cmppInboundLongMessageSegment.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
segmentIndex: fragment.index,
|
||||
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
registeredDelivery: data.registeredDelivery !== 0,
|
||||
content: data.content,
|
||||
contentHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
const segments = await tx.cmppInboundLongMessageSegment.findMany({
|
||||
where: { groupId: group.id },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
const complete = segments.length === fragment.total
|
||||
&& segments.every((item, index) => item.segmentIndex === index + 1);
|
||||
if (complete) {
|
||||
await tx.cmppInboundLongMessage.update({
|
||||
where: { id: group.id },
|
||||
data: { status: 'processing', expiresAt },
|
||||
});
|
||||
}
|
||||
return {
|
||||
complete,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: segments.length,
|
||||
response: null,
|
||||
content: complete ? segments.map((item) => item.content).join('') : '',
|
||||
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
|
||||
registeredDelivery: segments[0]?.registeredDelivery ?? true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async expireInboundLongMessages(now = new Date()) {
|
||||
return this.prisma.cmppInboundLongMessage.updateMany({
|
||||
where: {
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: {
|
||||
status: 'expired',
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submitInboundSingleMessage(
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
||||
const template = await this.facade.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = moneyToNumber(application.customerUnitPrice);
|
||||
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
content: data.content,
|
||||
phoneCount: 1,
|
||||
unitPrice,
|
||||
});
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: template?.id,
|
||||
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceType: 'cmpp',
|
||||
content: data.content,
|
||||
phoneTotal: 1,
|
||||
status: synchronousRejection ? 'rejected' : 'validating',
|
||||
auditStatus: synchronousRejection ? 'rejected' : undefined,
|
||||
rejectReason: synchronousRejection?.reason,
|
||||
progressTotal: 1,
|
||||
},
|
||||
});
|
||||
await this.prisma.smsApiRequest.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
batchTaskId: task.id,
|
||||
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceIp: data.remoteIp,
|
||||
userAgent: 'cmpp-gateway',
|
||||
payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account },
|
||||
status: synchronousRejection ? 'rejected' : 'accepted',
|
||||
},
|
||||
});
|
||||
const drainageDetection = await detectDrainageContent(this.prisma, data.content);
|
||||
const message = await this.prisma.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
batchTaskId: task.id,
|
||||
applicationId: application.id,
|
||||
templateId: template?.id,
|
||||
messageId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
content: data.content,
|
||||
...drainageDetection,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: receiptRejection ? 0 : billing.unitPrice,
|
||||
amountCents: receiptRejection ? 0 : billing.amountCents,
|
||||
queuePriority,
|
||||
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
cmppSubmitGroupMessageId: submitGroupMessageId,
|
||||
cmppRegisteredDelivery: data.registeredDelivery !== 0,
|
||||
clientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
status: synchronousRejection ? 'rejected' : 'validating',
|
||||
errorCode: synchronousRejection?.code,
|
||||
errorMessage: synchronousRejection?.reason,
|
||||
},
|
||||
});
|
||||
|
||||
if (synchronousRejection) {
|
||||
return {
|
||||
accepted: false,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
taskId: task.id,
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
status: 'rejected',
|
||||
};
|
||||
}
|
||||
|
||||
const reject = async (code: string, reason: string) => {
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, code, reason);
|
||||
};
|
||||
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
|
||||
const drainageInfoId = drainage?.id;
|
||||
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: options.templateId,
|
||||
content: data.content,
|
||||
variables: options.templateId ? templateVariables : undefined,
|
||||
phoneNumber: data.phoneNumber,
|
||||
sourceType: 'cmpp',
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
return;
|
||||
}
|
||||
if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
reviewTaskId: risk.task?.id,
|
||||
signatureId: options.signatureId,
|
||||
drainageInfoId,
|
||||
},
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
return;
|
||||
}
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', signatureId: options.signatureId, drainageInfoId },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||
});
|
||||
await this.facade.enqueueBatchTask(task.id);
|
||||
};
|
||||
if (receiptRejection) {
|
||||
await reject(receiptRejection.code, receiptRejection.reason);
|
||||
} else if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
await reject('ACCOUNT', '企业或短信应用已停用');
|
||||
} else if (!application.interfaceEnabled) {
|
||||
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
|
||||
} else if (application.tenant.certificationStatus !== 'approved') {
|
||||
await reject('CERT', '企业认证未通过');
|
||||
} else if (!template && application.templateMismatchMode === 'manual_review') {
|
||||
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content);
|
||||
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
content: data.content,
|
||||
phoneNumber: data.phoneNumber,
|
||||
sourceType: 'cmpp',
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
} else {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
} else {
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 模板不匹配待审核短信冻结',
|
||||
});
|
||||
}
|
||||
const reviewTask = risk.status === 'pending_review' && risk.task
|
||||
? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id)
|
||||
: await this.riskReview.aggregateTemplateMismatch({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
messageRecordId: message.id,
|
||||
signatureId: signature.id,
|
||||
content: data.content,
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
riskTaskId: reviewTask?.id,
|
||||
auditStatus: 'pending',
|
||||
reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!template && application.templateMismatchMode === 'direct_send') {
|
||||
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
await queueAfterRiskChecks({ signatureId: signature.id });
|
||||
}
|
||||
} else if (!template) {
|
||||
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
||||
} else if (template.auditStatus !== 'approved') {
|
||||
await reject('TEMPLATE', '短信模板尚未审核通过');
|
||||
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
await reject('SIGNATURE', '短信签名尚未审核通过');
|
||||
} else {
|
||||
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
|
||||
}
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
taskId: task.id,
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
status: 'accepted',
|
||||
};
|
||||
}
|
||||
|
||||
async evaluateRiskWithPhoneFrequency(input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}) {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: input.tenantId,
|
||||
applicationId: input.applicationId,
|
||||
templateId: input.templateId,
|
||||
content: input.content,
|
||||
variables: input.variables,
|
||||
phones: [input.phoneNumber],
|
||||
sourceType: input.sourceType,
|
||||
});
|
||||
if (risk.status === 'rejected') return risk;
|
||||
const frequencyRejections = await this.phoneFrequency.reserve(
|
||||
input.tenantId,
|
||||
input.applicationId,
|
||||
[input.phoneNumber],
|
||||
input.sourceType,
|
||||
);
|
||||
const rejection = frequencyRejections.get(input.phoneNumber);
|
||||
return rejection
|
||||
? { ...risk, status: 'rejected' as const, reason: rejection.reason }
|
||||
: risk;
|
||||
}
|
||||
|
||||
findInboundApplication(account: string) {
|
||||
return this.prisma.smsApplication.findFirst({
|
||||
where: { cmppAccount: account },
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
const exact = await this.prisma.smsTemplate.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
content,
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved' },
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (exact) return exact;
|
||||
const variableTemplates = await this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
applicationId,
|
||||
content: { contains: '${' },
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved' },
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
|
||||
}
|
||||
|
||||
resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
const match = content.match(/^【[^】]+】/);
|
||||
if (!match?.[0]) return null;
|
||||
return this.prisma.smsSignature.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
name: match[0],
|
||||
auditStatus: 'approved',
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
|
||||
if (!signatureId) return undefined;
|
||||
const candidates = await this.prisma.smsDrainageInfo.findMany({
|
||||
where: { signatureId, auditStatus: { not: 'deleted' } },
|
||||
select: { id: true, url: true, auditStatus: true, updatedAt: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const matches = candidates
|
||||
.map((item) => ({ ...item, normalizedUrl: item.url.trim() }))
|
||||
.filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl))
|
||||
.sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime());
|
||||
if (matches.length === 0) return undefined;
|
||||
const longestLength = matches[0].normalizedUrl.length;
|
||||
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
|
||||
if (longestMatches.length !== 1) {
|
||||
throw new BadRequestException({
|
||||
code: 'DRAINAGE_MATCH_AMBIGUOUS',
|
||||
message: '短信内容同时匹配多条等长引流地址,无法确定报备资料',
|
||||
drainageInfoIds: longestMatches.map((item) => item.id),
|
||||
});
|
||||
}
|
||||
const matched = longestMatches[0];
|
||||
return { id: matched.id, auditStatus: matched.auditStatus };
|
||||
}
|
||||
|
||||
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: messageRecordId },
|
||||
data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' },
|
||||
});
|
||||
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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, longMessageReceiptMode } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
||||
|
||||
|
||||
/**
|
||||
* R10 receipt implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendReceiptService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private upstreamReceiptInboxScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: data.channelId },
|
||||
select: {
|
||||
id: true,
|
||||
account: true,
|
||||
gatewayHost: true,
|
||||
gatewayPort: true,
|
||||
protocol: true,
|
||||
cmppVersion: true,
|
||||
},
|
||||
});
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
}
|
||||
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,
|
||||
status: 'pending',
|
||||
nextRetryAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (['pending', 'retrying'].includes(inbox.status)) {
|
||||
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
|
||||
}
|
||||
return { accepted: true, inboxId: inbox.id, status: inbox.status };
|
||||
}
|
||||
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(
|
||||
now.getTime()
|
||||
- positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
);
|
||||
const candidates = await this.prisma.upstreamReceiptInbox.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
status: { in: ['pending', 'retrying'] },
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }],
|
||||
},
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ receivedAt: 'asc' }, { id: 'asc' }],
|
||||
take: Math.min(Math.max(limit, 1), 500),
|
||||
select: { id: true },
|
||||
});
|
||||
let processed = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (await this.facade.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1;
|
||||
}
|
||||
return { scanned: candidates.length, processed };
|
||||
}
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
const staleBefore = new Date(
|
||||
Date.now()
|
||||
- positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
);
|
||||
const claimed = await this.prisma.upstreamReceiptInbox.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ status: { in: ['pending', 'retrying'] } },
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null },
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } });
|
||||
if (!inbox) return false;
|
||||
try {
|
||||
const message = await this.facade.handleReceipt({
|
||||
messageId: inbox.provisionalMessageId ?? undefined,
|
||||
channelId: inbox.incomingChannelId,
|
||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||
sequenceId: inbox.sequenceId ?? undefined,
|
||||
gatewayMessageId: inbox.gatewayMessageId,
|
||||
phoneNumber: inbox.phoneNumber ?? undefined,
|
||||
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
|
||||
rawStatus: inbox.rawStatus,
|
||||
errorCode: inbox.errorCode ?? undefined,
|
||||
errorMessage: inbox.errorMessage ?? undefined,
|
||||
deliveredAt: inbox.deliveredAt.toISOString(),
|
||||
}, {
|
||||
account: inbox.upstreamAccount,
|
||||
gatewayHost: inbox.upstreamHost,
|
||||
gatewayPort: inbox.upstreamPort,
|
||||
protocol: inbox.protocol,
|
||||
cmppVersion: inbox.protocolVersion,
|
||||
});
|
||||
const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId;
|
||||
const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined;
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'matched',
|
||||
matchedMessageRecordId: matchedMessageRecordId ?? null,
|
||||
matchedChannelId: matchedChannelId ?? null,
|
||||
lastError: null,
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const maxAttempts = positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
);
|
||||
const maxAgeHours = positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
);
|
||||
const exhausted = inbox.attemptCount >= maxAttempts
|
||||
|| inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000;
|
||||
const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8));
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: exhausted ? 'unmatched' : 'retrying',
|
||||
nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs),
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
processedAt: exhausted ? new Date() : null,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async runUpstreamReceiptInboxScan() {
|
||||
if (this.upstreamReceiptInboxScanRunning) return;
|
||||
this.upstreamReceiptInboxScanRunning = true;
|
||||
try {
|
||||
const result = await this.facade.processPendingUpstreamReceiptInbox();
|
||||
if (result.processed > 0) {
|
||||
this.logger.log(`Matched ${result.processed}/${result.scanned} pending upstream receipts`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Upstream receipt inbox scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.upstreamReceiptInboxScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
|
||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||
const receiptKey = receiptEventKey(data, logicalChannelId);
|
||||
const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
});
|
||||
if (existingReceipt?.messageRecord) {
|
||||
return existingReceipt.messageRecord;
|
||||
}
|
||||
const message = resolved.message;
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
if (resolved.submitRecordId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: resolved.submitRecordId,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
sequenceId: data.sequenceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
let receiptRecordId: string | undefined;
|
||||
try {
|
||||
const createdReceipt = await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey,
|
||||
channelId: logicalChannelId,
|
||||
messageId: resolved.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
|
||||
sequenceId: data.sequenceId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
receiptRecordId = createdReceipt.id;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
});
|
||||
if (duplicate?.messageRecord) return duplicate.messageRecord;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||
const receiptMode = Number(message.billingUnits ?? 1) > 1
|
||||
? await this.getLongMessageReceiptMode(logicalChannelId)
|
||||
: 'per_segment';
|
||||
if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') {
|
||||
await this.applyMessageLevelSuccess(
|
||||
message,
|
||||
logicalReceipt,
|
||||
deliveredAt,
|
||||
resolved.submitRecordId,
|
||||
resolved.submitId,
|
||||
);
|
||||
}
|
||||
const aggregate = await this.facade.aggregateReceiptSegments(
|
||||
message,
|
||||
logicalReceipt,
|
||||
deliveredAt,
|
||||
resolved.submitRecordId,
|
||||
resolved.submitId,
|
||||
);
|
||||
if (!aggregate.terminal) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = aggregate.status;
|
||||
const isCurrentAttempt =
|
||||
(!message.channelId || message.channelId === logicalChannelId)
|
||||
&& (
|
||||
!message.gatewayMessageId
|
||||
|| message.gatewayMessageId === data.gatewayMessageId
|
||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
||||
);
|
||||
if (!isCurrentAttempt) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
if (status === 'failed' && message.status === 'delivered') {
|
||||
if (receiptMode === 'message_level') {
|
||||
// A delivered result may already have been exposed to the customer and settled.
|
||||
// Preserve that terminal decision; the contradictory late receipt is evidence for operations, not a second state transition.
|
||||
await this.recordReceiptConflict({
|
||||
message,
|
||||
submitRecordId: resolved.submitRecordId,
|
||||
submitId: resolved.submitId,
|
||||
receiptRecordId,
|
||||
receiptKey,
|
||||
data: logicalReceipt,
|
||||
});
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
'回执失败补发',
|
||||
resolved.submitRecordId,
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.refundMessage(businessMessage, '最终失败退款');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: logicalChannelId,
|
||||
gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
receiptRawStatus: aggregate.rawStatus,
|
||||
status,
|
||||
errorCode: aggregate.errorCode,
|
||||
errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus),
|
||||
deliveredAt: aggregate.deliveredAt,
|
||||
},
|
||||
});
|
||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||
await queueFinalReceiptDeliveries(
|
||||
this.prisma,
|
||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
||||
{
|
||||
message,
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
rawStatus: aggregate.rawStatus,
|
||||
errorCode: aggregate.errorCode,
|
||||
deliveredAt: aggregate.deliveredAt.toISOString(),
|
||||
},
|
||||
segmentPayloads: Object.fromEntries(
|
||||
aggregate.segments
|
||||
.filter((segment) => segment.receiptStatus)
|
||||
.map((segment) => [segment.segmentIndex, {
|
||||
receiptStatus: segment.receiptStatus,
|
||||
rawStatus: segment.rawStatus,
|
||||
errorCode: segment.errorCode,
|
||||
deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(),
|
||||
}]),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
private async getLongMessageReceiptMode(channelId?: string | null) {
|
||||
if (!channelId) return 'per_segment' as const;
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
select: { config: true },
|
||||
});
|
||||
return longMessageReceiptMode(channel?.config);
|
||||
}
|
||||
|
||||
private async applyMessageLevelSuccess(
|
||||
message: { id: string; channelId?: string | null; submitId?: string | null },
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId)
|
||||
&& (!message.submitId || message.submitId === submitId);
|
||||
if (!belongsToCurrentAttempt) return;
|
||||
const attemptWhere = submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
: submitId
|
||||
? { messageRecordId: message.id, submitId }
|
||||
: null;
|
||||
if (!attemptWhere) return;
|
||||
const segments = await this.prisma.smsMessageSegmentAudit.findMany({
|
||||
where: attemptWhere,
|
||||
select: { id: true, receiptStatus: true },
|
||||
});
|
||||
if (segments.length <= 1) return;
|
||||
if (segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))) {
|
||||
return;
|
||||
}
|
||||
// This supplier contract reports one message-level success for a multipart SMS.
|
||||
// Mark only missing segments as inferred so the raw receipt remains singular and auditable.
|
||||
await this.prisma.smsMessageSegmentAudit.updateMany({
|
||||
where: { ...attemptWhere, receiptStatus: null },
|
||||
data: {
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
errorMessage: data.errorMessage ?? null,
|
||||
compensationType: 'supplier_message_level_receipt',
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async recordReceiptConflict(input: {
|
||||
message: { id: string; tenantId?: string | null; applicationId?: string | null; status: string; messageId: string };
|
||||
submitRecordId?: string;
|
||||
submitId?: string;
|
||||
receiptRecordId?: string;
|
||||
receiptKey: string;
|
||||
data: GatewayReceiptEventDto;
|
||||
}) {
|
||||
// One logical conflict per message attempt keeps repeated supplier packets auditable
|
||||
// without creating an unbounded queue of operationally identical anomalies.
|
||||
const anomalyKey = `aggregate-receipt-conflict:${input.message.id}:${input.submitId ?? input.submitRecordId ?? 'unknown'}`;
|
||||
const occurredAt = new Date();
|
||||
const detail = {
|
||||
messageId: input.message.messageId,
|
||||
submitId: input.submitId,
|
||||
receiptKey: input.receiptKey,
|
||||
gatewayMessageId: input.data.gatewayMessageId,
|
||||
phoneNumber: input.data.phoneNumber,
|
||||
reason: 'message_level_success_followed_by_failure',
|
||||
};
|
||||
await this.prisma.smsReceiptAnomaly.upsert({
|
||||
where: { anomalyKey },
|
||||
update: {
|
||||
status: 'pending',
|
||||
receiptRecordId: input.receiptRecordId,
|
||||
incomingStatus: input.data.receiptStatus,
|
||||
rawStatus: input.data.rawStatus,
|
||||
errorCode: input.data.errorCode ?? null,
|
||||
detail,
|
||||
occurrenceCount: { increment: 1 },
|
||||
lastOccurredAt: occurredAt,
|
||||
resolvedAt: null,
|
||||
resolutionNote: null,
|
||||
},
|
||||
create: {
|
||||
anomalyKey,
|
||||
tenantId: input.message.tenantId,
|
||||
applicationId: input.message.applicationId,
|
||||
channelId: input.data.channelId,
|
||||
messageRecordId: input.message.id,
|
||||
submitRecordId: input.submitRecordId,
|
||||
receiptRecordId: input.receiptRecordId,
|
||||
anomalyType: 'aggregate_success_then_failure',
|
||||
previousStatus: input.message.status,
|
||||
incomingStatus: input.data.receiptStatus,
|
||||
rawStatus: input.data.rawStatus,
|
||||
errorCode: input.data.errorCode ?? null,
|
||||
detail,
|
||||
firstOccurredAt: occurredAt,
|
||||
lastOccurredAt: occurredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async recordReceiptSegment(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
) {
|
||||
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
|
||||
const updated = await segmentAudits.updateMany({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
data: {
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
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' },
|
||||
});
|
||||
await segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`,
|
||||
segmentIndex: 1,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`,
|
||||
attempt: 0,
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: submitRecord?.submitStatus ?? 'accepted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
compensationType: 'receipt_recovered',
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const audits = await this.facade.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
: submitId
|
||||
? { messageRecordId: message.id, submitId }
|
||||
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
return {
|
||||
...aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt),
|
||||
segments: audits,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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';
|
||||
|
||||
|
||||
/**
|
||||
* R10 retry implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendRetryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
|
||||
const createdAt = data.deadLetteredAt ? new Date(data.deadLetteredAt) : new Date();
|
||||
return this.prisma.gatewaySubmitDeadLetter.upsert({
|
||||
where: { streamMessageId: data.streamMessageId },
|
||||
update: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
channelId: data.channelId,
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
},
|
||||
create: {
|
||||
streamMessageId: data.streamMessageId,
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
channelId: data.channelId,
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!deadLetter) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (deadLetter.status !== 'pending') {
|
||||
throw new BadRequestException('该提交异常当前状态不允许重新入队');
|
||||
}
|
||||
if (!data.confirmedNotSubmitted) {
|
||||
throw new BadRequestException('请确认运营商未接收该短信后再重新入队');
|
||||
}
|
||||
const reason = String(data.reason ?? '').trim();
|
||||
if (reason.length < 5 || reason.length > 500) {
|
||||
throw new BadRequestException('请填写5至500字的重新入队原因');
|
||||
}
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand');
|
||||
}
|
||||
if (deadLetter.manualRetryCount >= 3) {
|
||||
throw new BadRequestException('该提交异常已达到人工重新入队次数上限');
|
||||
}
|
||||
const message = deadLetter.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
|
||||
: null;
|
||||
if (message && (
|
||||
message.submitStatus === 'accepted'
|
||||
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|
||||
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
|
||||
)) {
|
||||
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
|
||||
}
|
||||
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
|
||||
if (!commandChannelId) {
|
||||
throw new BadRequestException('该提交异常缺少通道信息');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: commandChannelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel || channel.status !== 'active') {
|
||||
throw new BadRequestException('原通道不存在或已停用,不能重新入队');
|
||||
}
|
||||
if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) {
|
||||
throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道');
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'pending' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
let retryStreamMessageId: string;
|
||||
try {
|
||||
const publishedStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!publishedStreamMessageId) {
|
||||
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
||||
}
|
||||
retryStreamMessageId = publishedStreamMessageId;
|
||||
} catch (error) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (finalized.count !== 1 && updated.status !== 'resolved') {
|
||||
throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
userId: data.operatorId,
|
||||
action: 'gateway.submit_dead_letter_requeue',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
streamMessageId: updated.streamMessageId,
|
||||
retryStreamMessageId,
|
||||
submitId: updated.submitId,
|
||||
messageId: updated.messageId,
|
||||
reason,
|
||||
confirmedNotSubmitted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where: { status: 'requeueing', updatedAt: { lt: staleCutoff } },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
let recovered = 0;
|
||||
let failed = 0;
|
||||
for (const deadLetter of stale) {
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'requeue_recovering' },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
try {
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
const retryStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号');
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (finalized.count === 1) {
|
||||
recovered += 1;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: deadLetter.tenantId ?? undefined,
|
||||
action: 'gateway.submit_dead_letter_requeue_recovered',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: deadLetter.id,
|
||||
detail: { retryStreamMessageId, requeueKey },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return { recovered, failed };
|
||||
}
|
||||
|
||||
async retryMessageIfAllowed(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuedAt?: Date;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
},
|
||||
reason: string,
|
||||
sourceSubmitRecordId?: string,
|
||||
) {
|
||||
const attempts = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: { messageRecordId: message.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
|
||||
let sourceAttempt = sourceSubmitRecordId
|
||||
? attempts.find((attempt) => attempt.id === sourceSubmitRecordId)
|
||||
: attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1];
|
||||
if (!sourceAttempt && sourceSubmitRecordId) {
|
||||
sourceAttempt = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { id: sourceSubmitRecordId },
|
||||
}) ?? undefined;
|
||||
}
|
||||
if (!sourceAttempt || sourceAttempt.messageRecordId !== message.id) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
sourceSubmitRecordId,
|
||||
sourceMessageRecordId: sourceAttempt?.messageRecordId,
|
||||
error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing',
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId: sourceAttempt.id },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId: sourceAttempt.id,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
|
||||
this.logger.log(`sms_retry_route_started ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
if (ageMinutes >= 72 * 60) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason: 'maximum_message_age_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const retryCarrier = message.carrier
|
||||
? normalizeCarrier(message.carrier)
|
||||
: await this.facade.identifyCarrier(message.phoneNumber);
|
||||
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier);
|
||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: route.groupId,
|
||||
reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
retryTimeLimitMinutes,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage({ ...message, carrier: retryCarrier }, {
|
||||
forceNational: true,
|
||||
excludeChannelIds: attemptedChannelIds,
|
||||
});
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { errorMessage: reason },
|
||||
});
|
||||
const retried = await this.facade.submitMessageToGateway(
|
||||
message,
|
||||
routed,
|
||||
attempts.length,
|
||||
sourceAttempt.id,
|
||||
);
|
||||
this.logger.log(`sms_retry_route_selected ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: routed.groupId,
|
||||
channelId: routed.channel.id,
|
||||
attempt: attempts.length,
|
||||
})}`);
|
||||
return retried;
|
||||
} catch (error) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 reviewContinuation implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendReviewContinuationService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
|
||||
const reviewTask = await this.prisma.smsSendTask.findUnique({
|
||||
where: { id: reviewTaskId },
|
||||
});
|
||||
if (!reviewTask) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const messageRecords = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
status: 'pending_review',
|
||||
OR: [
|
||||
{ reviewTaskId },
|
||||
{ batchTask: { riskTaskId: reviewTaskId } },
|
||||
],
|
||||
},
|
||||
include: { batchTask: true },
|
||||
});
|
||||
if (messageRecords.length === 0) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const batchTaskIds = new Set<string>();
|
||||
for (const message of messageRecords) {
|
||||
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
|
||||
if (decision === 'approved') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', errorCode: null, errorMessage: null },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: message.batchTaskId },
|
||||
data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null },
|
||||
});
|
||||
batchTaskIds.add(message.batchTaskId);
|
||||
} else {
|
||||
await this.releaseMessageReservation(
|
||||
message as typeof message & { tenantId: string; batchTaskId: string },
|
||||
'模板不匹配人工审核驳回释放冻结',
|
||||
);
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: message.batchTaskId },
|
||||
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason);
|
||||
}
|
||||
}
|
||||
for (const batchTaskId of batchTaskIds) {
|
||||
await this.facade.enqueueBatchTask(batchTaskId);
|
||||
}
|
||||
return { reviewTaskId, decision, affected: messageRecords.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 scheduledDispatch implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendScheduledDispatchService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private scheduledDispatchScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.SMS_SCHEDULED_DISPATCH_STALE_MS,
|
||||
DEFAULT_SCHEDULED_DISPATCH_STALE_MS,
|
||||
));
|
||||
const tasks = await this.prisma.smsBatchTask.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'scheduled', scheduledAt: { lte: now } },
|
||||
{ status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } },
|
||||
],
|
||||
},
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
});
|
||||
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
|
||||
for (const task of tasks) {
|
||||
const candidateStatus = task.status || 'scheduled';
|
||||
const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching';
|
||||
const claimed = await this.prisma.smsBatchTask.updateMany({
|
||||
where: {
|
||||
id: task.id,
|
||||
status: candidateStatus,
|
||||
...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }),
|
||||
},
|
||||
data: { status: claimedStatus },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
let reservationEstablished = false;
|
||||
let dispatchPrepared = false;
|
||||
try {
|
||||
await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
|
||||
select: { id: true, amountCents: true, billingUnits: true },
|
||||
take: 100000,
|
||||
});
|
||||
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
|
||||
const existingReservation = await this.prisma.accountTransaction.findFirst({
|
||||
where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id },
|
||||
select: { id: true },
|
||||
});
|
||||
reservationEstablished = Boolean(existingReservation);
|
||||
if (!reservationEstablished) {
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: task.tenantId,
|
||||
amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '定时任务到点冻结',
|
||||
});
|
||||
reservationEstablished = true;
|
||||
}
|
||||
}
|
||||
dispatchPrepared = true;
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
});
|
||||
const enqueued = await this.facade.enqueueBatchTask(task.id);
|
||||
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
|
||||
if (reservationEstablished || dispatchPrepared) {
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` },
|
||||
});
|
||||
results.push({ taskId: task.id, status: 'retrying', reason });
|
||||
continue;
|
||||
}
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'rejected', errorMessage: reason },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'failed', rejectReason: reason },
|
||||
});
|
||||
results.push({ taskId: task.id, status: 'failed', reason });
|
||||
}
|
||||
}
|
||||
return { dispatched: results.filter((result) => result.status === 'queued').length, results };
|
||||
}
|
||||
|
||||
async runScheduledDispatchScan() {
|
||||
if (this.scheduledDispatchScanRunning) return;
|
||||
this.scheduledDispatchScanRunning = true;
|
||||
try {
|
||||
await this.facade.dispatchDueScheduledTasks();
|
||||
} catch (error) {
|
||||
this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
this.scheduledDispatchScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SendBatchEntryService } from './send-batch-entry.service';
|
||||
import { SendGatewaySubmitService } from './send-gateway-submit.service';
|
||||
import { SendInboundEntryService } from './send-inbound-entry.service';
|
||||
import { SendReviewContinuationService } from './send-review-continuation.service';
|
||||
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
|
||||
|
||||
|
||||
export type SendSubmissionCallbacks = {
|
||||
releaseMessageReservation: (
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) => Promise<void>;
|
||||
recordCmppFailureReceipt: (
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
cmppRegisteredDelivery?: boolean | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
export class SendSubmissionService {
|
||||
private readonly batchEntry: SendBatchEntryService;
|
||||
private readonly inboundEntry: SendInboundEntryService;
|
||||
private readonly reviewContinuation: SendReviewContinuationService;
|
||||
private readonly scheduledDispatch: SendScheduledDispatchService;
|
||||
private readonly gatewaySubmit: SendGatewaySubmitService;
|
||||
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
billing: BillingService,
|
||||
riskReview: RiskReviewService,
|
||||
phoneFrequency: PhoneFrequencyService,
|
||||
phoneRouting: PhoneRoutingLookupService,
|
||||
facade: SendSubmissionService,
|
||||
callbacks: SendSubmissionCallbacks,
|
||||
) {
|
||||
this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
return this.gatewaySubmit.onModuleDestroy();
|
||||
}
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
return this.batchEntry.createBatchTask(data);
|
||||
}
|
||||
|
||||
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
|
||||
return this.batchEntry.createHttpBatchTask(data);
|
||||
}
|
||||
|
||||
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
return this.batchEntry.getBatchTask(taskId, tenantId, sourceType);
|
||||
}
|
||||
|
||||
async previewImport(data: ImportPreviewDto) {
|
||||
return this.batchEntry.previewImport(data);
|
||||
}
|
||||
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
return this.batchEntry.confirmImport(data);
|
||||
}
|
||||
|
||||
async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||
return this.batchEntry.resolveUnitPrice(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||
return this.batchEntry.resolveQueuePriority(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
return this.batchEntry.resolveApplicationAccessNumber(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveTemplateMessageClassification(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
templateId: string | undefined,
|
||||
content: string,
|
||||
) {
|
||||
return this.batchEntry.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content);
|
||||
}
|
||||
|
||||
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId);
|
||||
}
|
||||
|
||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.batchEntry.reserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.batchEntry.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
return this.inboundEntry.authenticateInboundApplication(data);
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
return this.inboundEntry.submitInboundMessage(data);
|
||||
}
|
||||
|
||||
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
|
||||
return this.inboundEntry.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers);
|
||||
}
|
||||
|
||||
async submitCompleteInboundMessage(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
) {
|
||||
return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId);
|
||||
}
|
||||
|
||||
async collectInboundLongMessageFragment(
|
||||
data: GatewayInboundSubmitDto,
|
||||
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
return this.inboundEntry.collectInboundLongMessageFragment(data, application, phoneNumbers);
|
||||
}
|
||||
|
||||
async expireInboundLongMessages(now = new Date()) {
|
||||
return this.inboundEntry.expireInboundLongMessages(now);
|
||||
}
|
||||
|
||||
async submitInboundSingleMessage(
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
) {
|
||||
return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection);
|
||||
}
|
||||
|
||||
async evaluateRiskWithPhoneFrequency(input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}) {
|
||||
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input);
|
||||
}
|
||||
|
||||
findInboundApplication(account: string) {
|
||||
return this.inboundEntry.findInboundApplication(account);
|
||||
}
|
||||
|
||||
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
return this.inboundEntry.resolveInboundTemplateCandidate(applicationId, content);
|
||||
}
|
||||
|
||||
resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
return this.inboundEntry.resolveInboundSignatureCandidate(applicationId, content);
|
||||
}
|
||||
|
||||
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
|
||||
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
|
||||
}
|
||||
|
||||
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
||||
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
|
||||
}
|
||||
|
||||
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
|
||||
return this.reviewContinuation.handleReviewDecision(reviewTaskId, decision, reason);
|
||||
}
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
return this.scheduledDispatch.dispatchDueScheduledTasks(now);
|
||||
}
|
||||
|
||||
async runScheduledDispatchScan() {
|
||||
return this.scheduledDispatch.runScheduledDispatchScan();
|
||||
}
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
return this.gatewaySubmit.enqueueBatchTask(taskId);
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
return this.gatewaySubmit.startWorker();
|
||||
}
|
||||
|
||||
async processSendJob(job: SendJob) {
|
||||
return this.gatewaySubmit.processSendJob(job);
|
||||
}
|
||||
|
||||
async submitMessageToGateway(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
routed: RoutedChannel,
|
||||
attempt: number,
|
||||
retryOfSubmitRecordId?: string,
|
||||
) {
|
||||
return this.gatewaySubmit.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId);
|
||||
}
|
||||
|
||||
async selectChannelForMessage(
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||
): Promise<RoutedChannel> {
|
||||
return this.gatewaySubmit.selectChannelForMessage(message, options);
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier);
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return this.gatewaySubmit.identifyCarrier(phoneNumber);
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.gatewaySubmit.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
async ensureSignatureReportedForChannel(
|
||||
message: {
|
||||
id: string;
|
||||
templateId?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
channelId: string,
|
||||
) {
|
||||
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId);
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
return this.gatewaySubmit.resolveMessageSignatureId(message);
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps);
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string) {
|
||||
return this.gatewaySubmit.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
return this.gatewaySubmit.getSendQueue();
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
return this.gatewaySubmit.getGatewayQueue();
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
return this.gatewaySubmit.getRedis();
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
return this.gatewaySubmit.publishGatewaySubmitCommand(command, idempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, 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 { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
||||
|
||||
|
||||
/**
|
||||
* R10 timeout implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendTimeoutService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private receiptTimeoutScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
tenantId: { not: null },
|
||||
OR: [
|
||||
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: cutoff } },
|
||||
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
batchTaskId: true,
|
||||
applicationId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
amountCents: true,
|
||||
billingUnits: true,
|
||||
status: true,
|
||||
cmppSubmitSequenceId: true,
|
||||
cmppSubmitGroupMessageId: true,
|
||||
cmppRegisteredDelivery: true,
|
||||
timeoutAt: true,
|
||||
},
|
||||
take: 10000,
|
||||
});
|
||||
const timedOutTaskIds = new Set<string>();
|
||||
let timeout = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.tenantId) continue;
|
||||
const timedOutAt = candidate.timeoutAt ?? new Date();
|
||||
if (candidate.status !== 'timeout') {
|
||||
const transitioned = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
|
||||
data: {
|
||||
status: 'timeout',
|
||||
receiptStatus: 'undelivered',
|
||||
receiptRawStatus: 'EXPIRED',
|
||||
errorCode: 'RECEIPT_TIMEOUT',
|
||||
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
|
||||
timeoutAt: timedOutAt,
|
||||
},
|
||||
});
|
||||
if (transitioned.count !== 1) continue;
|
||||
timeout += 1;
|
||||
}
|
||||
// Refund uses the platform-message idempotency key. Re-running it for a
|
||||
// timeout whose downstream outbox was not fully queued also recovers a
|
||||
// crash between the state transition and the original refund call.
|
||||
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
|
||||
const queued = await queueFinalReceiptDeliveries(
|
||||
this.prisma,
|
||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
||||
{
|
||||
message: candidate,
|
||||
payload: {
|
||||
messageId: candidate.messageId,
|
||||
gatewayMessageId: `PLATFORM_TIMEOUT:${candidate.messageId}`,
|
||||
phoneNumber: candidate.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'EXPIRED',
|
||||
errorCode: 'RECEIPT_TIMEOUT',
|
||||
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
|
||||
deliveredAt: timedOutAt.toISOString(),
|
||||
},
|
||||
propagateHttpQueueError: true,
|
||||
},
|
||||
);
|
||||
if (queued.queued) {
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: candidate.id, status: 'timeout', timeoutReceiptQueuedAt: null },
|
||||
data: { timeoutReceiptQueuedAt: new Date() },
|
||||
});
|
||||
}
|
||||
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
|
||||
}
|
||||
for (const batchTaskId of timedOutTaskIds) {
|
||||
await this.facade.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
return { timeout };
|
||||
}
|
||||
|
||||
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60_000);
|
||||
const expired = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
OR: [
|
||||
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
|
||||
{ lastRetriedAt: { lte: cutoff } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const delivery of expired) {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
`下游投递排队超过 ${olderThanHours} 小时,系统自动终止重试`,
|
||||
'queue_timeout',
|
||||
);
|
||||
}
|
||||
return { failed: expired.length };
|
||||
}
|
||||
|
||||
async runReceiptTimeoutScan() {
|
||||
if (this.receiptTimeoutScanRunning) return;
|
||||
this.receiptTimeoutScanRunning = true;
|
||||
try {
|
||||
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
|
||||
this.facade.markUnknownTimeout({}),
|
||||
this.facade.markExpiredDownstreamDeliveries(),
|
||||
this.facade.recoverStaleGatewaySubmitRequeues(),
|
||||
this.facade.recoverStaleDownstreamManualRequeues(),
|
||||
]);
|
||||
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
|
||||
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
|
||||
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
|
||||
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
|
||||
} catch (error) {
|
||||
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.receiptTimeoutScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { ReviewDecisionDto, ReviewGovernanceService } from './review-governance.service';
|
||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
@@ -71,8 +72,8 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures')
|
||||
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, page: Number(page), pageSize: Number(pageSize) };
|
||||
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
|
||||
return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query);
|
||||
}
|
||||
|
||||
@@ -92,8 +93,8 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('drainage-infos')
|
||||
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword });
|
||||
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
|
||||
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword, submittedAtFrom, submittedAtTo });
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures/:id/drainage-infos')
|
||||
@@ -125,8 +126,8 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, page: Number(page), pageSize: Number(pageSize) };
|
||||
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
|
||||
return page || pageSize ? this.smsConfig.listTemplatesPage(query) : this.smsConfig.listTemplates(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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 { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsApplicationConfigService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService) {}
|
||||
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
if (query.includeConnections) {
|
||||
await this.lifecycle.markTimedOutDownstreamConnections();
|
||||
}
|
||||
const applications = await this.prisma.smsApplication.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
omit: {
|
||||
secretHash: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
if (!query.includeConnections) {
|
||||
return applications;
|
||||
}
|
||||
const applicationIds = applications.map((application) => application.id);
|
||||
const [connections, messageStats] = await Promise.all([
|
||||
this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'connected' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['applicationId', 'status'],
|
||||
where: { applicationId: { in: applicationIds }, queuedAt: { gte: startOfToday() } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const disablingDetails = new Map((await Promise.all(applications
|
||||
.filter((application) => application.status === 'disabling')
|
||||
.map(async (application) => [application.id, await this.lifecycle.getApplicationDeactivationPreview(application.id)] as const))));
|
||||
return applications.map((application) => {
|
||||
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
|
||||
const appStats = messageStats.filter((item) => item.applicationId === application.id);
|
||||
const todayTotal = appStats.reduce((sum, item) => sum + item._count._all, 0);
|
||||
const delivered = appStats.find((item) => item.status === 'delivered')?._count._all ?? 0;
|
||||
return {
|
||||
...application,
|
||||
cmppConnections: appConnections,
|
||||
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
|
||||
sentToday: todayTotal,
|
||||
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
|
||||
deactivation: disablingDetails.get(application.id) ?? null,
|
||||
};
|
||||
}).sort((left, right) => right.sentToday - left.sentToday
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
|| left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
async listApplicationsPage(query: ApplicationListQuery) {
|
||||
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.SmsApplicationWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listApplications({ ...query, page, pageSize }),
|
||||
this.prisma.smsApplication.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listApplicationOptions(tenantId?: string) {
|
||||
return this.prisma.smsApplication.findMany({
|
||||
where: { tenantId, status: { not: 'deleted' } },
|
||||
select: { id: true, tenantId: true, name: true, status: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async getApplication(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
return application;
|
||||
}
|
||||
|
||||
async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
|
||||
if (applicationId) await this.getApplication(applicationId);
|
||||
const [commonFields, routes] = await Promise.all([
|
||||
this.prisma.commonReportField.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
reportType,
|
||||
drainageField: { status: 'active' },
|
||||
},
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
applicationId ? this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId, status: 'active' },
|
||||
include: {
|
||||
group: {
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : Promise.resolve([]),
|
||||
]);
|
||||
type MergedReportField = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
reportTypes: string[];
|
||||
commonReportTypes: string[];
|
||||
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>;
|
||||
};
|
||||
const merged = new Map<string, MergedReportField>();
|
||||
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
|
||||
for (const route of routes) {
|
||||
if (!route.group) continue;
|
||||
for (const item of route.group.items) {
|
||||
if (!routeChannels.has(item.channel.id)) {
|
||||
routeChannels.set(item.channel.id, {
|
||||
id: item.channel.id,
|
||||
code: item.channel.code,
|
||||
name: item.channel.name,
|
||||
groupId: route.group.id,
|
||||
groupName: route.group.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const configured of commonFields) {
|
||||
merged.set(configured.drainageField.id, {
|
||||
id: configured.drainageField.id,
|
||||
code: configured.drainageField.code,
|
||||
name: configured.drainageField.name,
|
||||
fieldType: configured.drainageField.fieldType,
|
||||
required: configured.required,
|
||||
description: configured.drainageField.description,
|
||||
reportTypes: [configured.reportType],
|
||||
commonReportTypes: [configured.reportType],
|
||||
channels: Array.from(routeChannels.values()).map((channel) => ({
|
||||
...channel,
|
||||
required: configured.required,
|
||||
reportType: configured.reportType,
|
||||
source: 'common' as const,
|
||||
})),
|
||||
});
|
||||
}
|
||||
for (const route of routes) {
|
||||
if (!route.group) continue;
|
||||
for (const item of route.group.items) {
|
||||
for (const configured of item.channel.reportFields) {
|
||||
if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue;
|
||||
if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue;
|
||||
const key = configured.drainageField.id;
|
||||
const current: MergedReportField = merged.get(key) ?? {
|
||||
id: configured.drainageField.id,
|
||||
code: configured.drainageField.code,
|
||||
name: configured.drainageField.name,
|
||||
fieldType: configured.drainageField.fieldType,
|
||||
required: false,
|
||||
description: configured.drainageField.description,
|
||||
reportTypes: [],
|
||||
commonReportTypes: [],
|
||||
channels: [],
|
||||
};
|
||||
current.required = current.required || configured.required;
|
||||
if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType);
|
||||
const existingChannel = current.channels.find((channel) => channel.id === item.channel.id);
|
||||
if (existingChannel) {
|
||||
existingChannel.required = existingChannel.required || configured.required;
|
||||
existingChannel.reportType = configured.reportType;
|
||||
existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source;
|
||||
} else {
|
||||
current.channels.push({
|
||||
id: item.channel.id,
|
||||
code: item.channel.code,
|
||||
name: item.channel.name,
|
||||
groupId: route.group.id,
|
||||
groupName: route.group.name,
|
||||
required: configured.required,
|
||||
reportType: configured.reportType,
|
||||
source: 'channel',
|
||||
});
|
||||
}
|
||||
merged.set(key, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
|
||||
const fields = await this.getApplicationReportFields(applicationId, reportType);
|
||||
return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field);
|
||||
}
|
||||
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价');
|
||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
||||
const cmppEnterpriseCode = cmppAccount;
|
||||
const accessNumber = normalizeCmppAccessNumberConfig(data);
|
||||
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId);
|
||||
return this.prisma.smsApplication.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppEnterpriseCode,
|
||||
cmppApplicationExtension: accessNumber.applicationExtension,
|
||||
cmppAccessNumberFillEnabled: accessNumber.fillEnabled,
|
||||
cmppAccessNumberFillPrefix: accessNumber.fillPrefix,
|
||||
cmppClientSrcId: accessNumber.clientSrcId,
|
||||
secretHash: secret,
|
||||
interfaceEnabled: data.interfaceEnabled ?? true,
|
||||
interfaceType,
|
||||
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
queuePriority,
|
||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
|
||||
ipAllowlist: {
|
||||
create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })),
|
||||
},
|
||||
},
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { httpConfig: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
if (data.customerUnitPrice !== undefined) {
|
||||
assertMoneyUnits(data.customerUnitPrice, '客户单价');
|
||||
}
|
||||
const queuePriority = data.queuePriority === undefined
|
||||
? undefined
|
||||
: normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const cmppAccount = data.cmppAccount === undefined
|
||||
? undefined
|
||||
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
|
||||
const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount;
|
||||
const interfaceType = data.interfaceType === undefined
|
||||
? undefined
|
||||
: normalizeApplicationInterfaceType(data.interfaceType);
|
||||
const secretHash = data.passwordCipher === undefined
|
||||
? undefined
|
||||
: normalizeApplicationPassword(data.passwordCipher);
|
||||
const accessNumberChanged = data.cmppApplicationExtension !== undefined
|
||||
|| data.cmppAccessNumberFillEnabled !== undefined
|
||||
|| data.cmppAccessNumberFillPrefix !== undefined;
|
||||
const accessNumber = accessNumberChanged
|
||||
? normalizeCmppAccessNumberConfig(data, application)
|
||||
: undefined;
|
||||
if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) {
|
||||
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (data.ipAllowlist) {
|
||||
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
|
||||
}
|
||||
const updated = await tx.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppEnterpriseCode,
|
||||
cmppApplicationExtension: accessNumber?.applicationExtension,
|
||||
cmppAccessNumberFillEnabled: accessNumber?.fillEnabled,
|
||||
cmppAccessNumberFillPrefix: accessNumber?.fillPrefix,
|
||||
cmppClientSrcId: accessNumber?.clientSrcId,
|
||||
secretHash,
|
||||
interfaceEnabled: data.interfaceEnabled,
|
||||
interfaceType,
|
||||
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice,
|
||||
queuePriority,
|
||||
templateMismatchMode: data.templateMismatchMode,
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled,
|
||||
status: data.status,
|
||||
ipAllowlist: data.ipAllowlist ? {
|
||||
create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })),
|
||||
} : undefined,
|
||||
},
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
});
|
||||
if (data.interfaceEnabled !== undefined && application.httpConfig) {
|
||||
const deliveryMode = automaticDeliveryMode(data.interfaceEnabled, application.httpConfig.enabled);
|
||||
await tx.smsApplicationHttpConfig.update({
|
||||
where: { applicationId },
|
||||
data: {
|
||||
receiptDeliveryMode: deliveryMode,
|
||||
uplinkDeliveryMode: deliveryMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const routes = data.routes ?? [];
|
||||
if (routes.length === 0) {
|
||||
throw new BadRequestException('At least one carrier channel group is required');
|
||||
}
|
||||
|
||||
const carriers = new Set<string>();
|
||||
routes.forEach((route) => {
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) {
|
||||
throw new BadRequestException('carrier must be mobile, unicom or telecom');
|
||||
}
|
||||
if (carriers.has(route.carrier)) {
|
||||
throw new BadRequestException('Duplicate carrier route is not allowed');
|
||||
}
|
||||
carriers.add(route.carrier);
|
||||
});
|
||||
|
||||
const groups = await this.prisma.smsChannelGroup.findMany({
|
||||
where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } },
|
||||
select: { id: true, carrier: true },
|
||||
});
|
||||
const groupMap = new Map(groups.map((group) => [group.id, group]));
|
||||
routes.forEach((route) => {
|
||||
const group = groupMap.get(route.groupId);
|
||||
if (!group) {
|
||||
throw new BadRequestException(`channel group ${route.groupId} does not exist`);
|
||||
}
|
||||
if (group.carrier !== route.carrier) {
|
||||
throw new BadRequestException('channel group carrier must match route carrier');
|
||||
}
|
||||
});
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.channelRouteRule.deleteMany({
|
||||
where: {
|
||||
applicationId,
|
||||
channelId: null,
|
||||
province: null,
|
||||
},
|
||||
});
|
||||
await tx.channelRouteRule.createMany({
|
||||
data: routes.map((route, index) => ({
|
||||
tenantId: application.tenantId,
|
||||
applicationId,
|
||||
groupId: route.groupId,
|
||||
carrier: route.carrier,
|
||||
priority: route.priority ?? (index + 1) * 10,
|
||||
status: route.status ?? 'active',
|
||||
})),
|
||||
});
|
||||
return tx.channelRouteRule.findMany({
|
||||
where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const secret = generateApplicationPassword();
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: { secretHash: secret },
|
||||
});
|
||||
await this.lifecycle.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, {
|
||||
reason: data.reason,
|
||||
});
|
||||
return { ...updated, secret };
|
||||
}
|
||||
|
||||
async getApplicationCmppParams(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
if (tenantId && !application.interfaceEnabled) {
|
||||
throw new ForbiddenException('该企业应用未开通 CMPP 接口');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
applicationName: application.name,
|
||||
tenantId: application.tenantId,
|
||||
tenantName: application.tenant.name,
|
||||
appCode: application.id,
|
||||
gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1',
|
||||
gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890),
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
srcId: application.cmppClientSrcId ?? '',
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
accessNumberFillEnabled: application.cmppAccessNumberFillEnabled,
|
||||
accessNumberFillPrefix: application.cmppAccessNumberFillPrefix,
|
||||
interfaceEnabled: application.interfaceEnabled,
|
||||
interfaceType: application.interfaceType,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
heartbeatSeconds: 30,
|
||||
windowSize: application.cmppWindowSize,
|
||||
protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType,
|
||||
};
|
||||
}
|
||||
|
||||
async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string) {
|
||||
if (!/^\d{6}$/.test(cmppAccount)) {
|
||||
throw new BadRequestException('cmppAccount must be a 6-digit number');
|
||||
}
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } });
|
||||
if (exists && exists.id !== currentApplicationId) {
|
||||
throw new BadRequestException('cmppAccount already exists');
|
||||
}
|
||||
return cmppAccount;
|
||||
}
|
||||
|
||||
async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) {
|
||||
if (!clientSrcId) return;
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } });
|
||||
if (exists && exists.id !== currentApplicationId) {
|
||||
throw new BadRequestException('client CMPP Src_Id already exists');
|
||||
}
|
||||
}
|
||||
|
||||
async generateCmppAccount() {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const cmppAccount = String(randomInt(100000, 1000000));
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } });
|
||||
if (!exists) {
|
||||
return cmppAccount;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('Unable to generate unique CMPP account');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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';
|
||||
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsApplicationLifecycleService {
|
||||
private readonly logger = new Logger(SmsApplicationLifecycleService.name);
|
||||
private applicationDisableTimer?: ReturnType<typeof setInterval>;
|
||||
private applicationDisableScanRunning = false;
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
onModuleInit() {
|
||||
this.applicationDisableTimer = setInterval(
|
||||
() => void this.runApplicationDisableScan(),
|
||||
getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS),
|
||||
);
|
||||
this.applicationDisableTimer.unref?.();
|
||||
void this.runApplicationDisableScan();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer);
|
||||
}
|
||||
|
||||
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const status = data.status ?? 'disabled';
|
||||
if (status === 'active') {
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null },
|
||||
});
|
||||
await this.writeApplicationStatusLog(application, data, 'active', {});
|
||||
return updated;
|
||||
}
|
||||
if (!['disabled', 'disabling', 'deleted'].includes(status)) {
|
||||
throw new BadRequestException(`不支持的企业应用状态:${status}`);
|
||||
}
|
||||
|
||||
const preview = await this.getApplicationDeactivationPreview(applicationId);
|
||||
if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) {
|
||||
const disablingAt = new Date();
|
||||
const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS);
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
status: 'disabling',
|
||||
disablingAt,
|
||||
autoDisableAt,
|
||||
disableReason: data.reason?.trim() || '等待未完成回执清算',
|
||||
},
|
||||
});
|
||||
await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt });
|
||||
return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } };
|
||||
}
|
||||
|
||||
const finalStatus = status === 'deleted' ? 'deleted' : 'disabled';
|
||||
const abandonReason = status === 'deleted'
|
||||
? '企业应用已删除,放弃剩余下游投递'
|
||||
: data.force
|
||||
? '运营强制停用企业应用,放弃剩余下游投递'
|
||||
: '企业应用无待清算数据,完成停用';
|
||||
const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason);
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
status: finalStatus,
|
||||
disablingAt: null,
|
||||
autoDisableAt: null,
|
||||
disableReason: data.reason?.trim() || abandonReason,
|
||||
},
|
||||
});
|
||||
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason);
|
||||
await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect });
|
||||
return { ...updated, deactivation: null, abandoned, disconnect };
|
||||
}
|
||||
|
||||
async getApplicationDeactivationPreview(applicationId: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
disablingAt: true,
|
||||
autoDisableAt: true,
|
||||
disableReason: true,
|
||||
},
|
||||
});
|
||||
if (!application) throw new NotFoundException('Application not found');
|
||||
const [
|
||||
awaitingSupplierReceipt,
|
||||
waitingToSend,
|
||||
awaitingClientAck,
|
||||
retryableFailures,
|
||||
pendingUplinks,
|
||||
activeConnections,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.count({
|
||||
where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamConnection.count({
|
||||
where: { applicationId, status: 'connected' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
status: application.status,
|
||||
reason: application.disableReason,
|
||||
disablingAt: application.disablingAt,
|
||||
autoDisableAt: application.autoDisableAt,
|
||||
awaitingSupplierReceipt,
|
||||
waitingToSend,
|
||||
awaitingClientAck,
|
||||
retryableFailures,
|
||||
pendingUplinks,
|
||||
activeConnections,
|
||||
totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks,
|
||||
};
|
||||
}
|
||||
|
||||
async listApplicationConnections(applicationId: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
await this.markTimedOutDownstreamConnections();
|
||||
const connections = await this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return {
|
||||
application,
|
||||
connections,
|
||||
summary: {
|
||||
desiredConnections: application.cmppMaxConnections,
|
||||
currentConnections: connections.filter((connection) => connection.status === 'connected').length,
|
||||
status: normalizeApplicationCmppStatus(connections, application.status),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: data.account },
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account does not reference an application');
|
||||
}
|
||||
const observedAt = parseGatewayDate(data.observedAt) ?? new Date();
|
||||
const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt;
|
||||
const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } });
|
||||
if (data.status === 'disconnected') {
|
||||
if (existing) {
|
||||
await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } });
|
||||
}
|
||||
await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, {
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: 'disconnected',
|
||||
errorMessage: data.errorMessage,
|
||||
});
|
||||
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||
}
|
||||
if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) {
|
||||
throw new ForbiddenException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new ForbiddenException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
// A Gateway process can disappear before it reports disconnect. Prune its
|
||||
// expired rows here so a restarted process can reclaim connection slots
|
||||
// without waiting for an operator to open the connection-list page.
|
||||
await this.markTimedOutDownstreamConnections(observedAt);
|
||||
const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: application.id, status: 'connected' },
|
||||
select: { connectionId: true },
|
||||
orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }],
|
||||
});
|
||||
const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId);
|
||||
if ((!existing && activeConnections.length >= application.cmppMaxConnections)
|
||||
|| (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) {
|
||||
throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`);
|
||||
}
|
||||
const payload = {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: 'connected',
|
||||
connectedAt: existing?.connectedAt ?? connectedAt,
|
||||
lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt,
|
||||
lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt,
|
||||
lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt,
|
||||
disconnectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const connection = existing
|
||||
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
|
||||
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
|
||||
if (data.status === 'connected') {
|
||||
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: connection.status,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
async markTimedOutDownstreamConnections(now = new Date()) {
|
||||
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
return this.prisma.cmppDownstreamConnection.deleteMany({
|
||||
where: {
|
||||
status: 'connected',
|
||||
OR: [
|
||||
{ lastHeartbeatAt: { lt: cutoff } },
|
||||
{ lastHeartbeatAt: null, connectedAt: { lt: cutoff } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async abandonApplicationDeliveries(applicationId: string, reason: string) {
|
||||
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
|
||||
select: { id: true },
|
||||
});
|
||||
const deliveryIds = deliveries.map((delivery) => delivery.id);
|
||||
if (deliveryIds.length === 0) return 0;
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({
|
||||
where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } },
|
||||
data: {
|
||||
status: 'abandoned',
|
||||
ackDeadlineAt: null,
|
||||
failureType: 'application_disabled',
|
||||
errorMessage: reason,
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
|
||||
data: {
|
||||
status: 'abandoned',
|
||||
retryEnabled: false,
|
||||
nextRetryAt: null,
|
||||
ackDeadlineAt: null,
|
||||
lastError: reason,
|
||||
},
|
||||
});
|
||||
return updated.count;
|
||||
}
|
||||
|
||||
async disconnectDownstreamAccount(account: string, reason: string) {
|
||||
const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090';
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ account, reason }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway returned ${response.status}: ${responseText}`);
|
||||
}
|
||||
return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`);
|
||||
return { account, disconnected: 0, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
writeApplicationStatusLog(
|
||||
application: { id: string; tenantId: string; status: string },
|
||||
data: StatusChangeDto,
|
||||
statusAfter: string,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return this.writeOperationLog(
|
||||
application.tenantId,
|
||||
data.operatorId,
|
||||
`sms_application.${statusAfter}`,
|
||||
'sms_application',
|
||||
application.id,
|
||||
{
|
||||
statusBefore: application.status,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
force: Boolean(data.force),
|
||||
...JSON.parse(JSON.stringify(detail)) as Record<string, unknown>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async runApplicationDisableScan() {
|
||||
if (this.applicationDisableScanRunning) return;
|
||||
this.applicationDisableScanRunning = true;
|
||||
try {
|
||||
const applications = await this.prisma.smsApplication.findMany({
|
||||
where: { status: 'disabling' },
|
||||
select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true },
|
||||
take: 500,
|
||||
});
|
||||
const now = new Date();
|
||||
for (const application of applications) {
|
||||
const preview = await this.getApplicationDeactivationPreview(application.id);
|
||||
if (preview.totalOutstanding === 0) {
|
||||
await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview);
|
||||
} else if (application.autoDisableAt && application.autoDisableAt <= now) {
|
||||
await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
this.applicationDisableScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeDisablingApplication(
|
||||
application: { id: string; tenantId: string; cmppAccount: string; status: string },
|
||||
abandonOutstanding: boolean,
|
||||
reason: string,
|
||||
preview: Awaited<ReturnType<SmsApplicationLifecycleService['getApplicationDeactivationPreview']>>,
|
||||
) {
|
||||
const claimed = await this.prisma.smsApplication.updateMany({
|
||||
where: { id: application.id, status: 'disabling' },
|
||||
data: {
|
||||
status: 'disabled',
|
||||
disablingAt: null,
|
||||
autoDisableAt: null,
|
||||
disableReason: reason,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
const abandoned = abandonOutstanding
|
||||
? await this.abandonApplicationDeliveries(application.id, reason)
|
||||
: 0;
|
||||
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason);
|
||||
await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', {
|
||||
preview,
|
||||
abandoned,
|
||||
disconnect,
|
||||
automatic: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
writeOperationLog(
|
||||
tenantId: string,
|
||||
userId: string | undefined,
|
||||
action: string,
|
||||
resource: string,
|
||||
resourceId: string,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
detail: detail as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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 { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
|
||||
/** 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) {}
|
||||
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' },
|
||||
});
|
||||
}
|
||||
|
||||
approveSignature(signatureId: string, data: ReviewDto) {
|
||||
return this.reviewSignature(signatureId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectSignature(signatureId: string, data: ReviewDto) {
|
||||
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
approveTemplate(templateId: string, data: ReviewDto) {
|
||||
return this.reviewTemplate(templateId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectTemplate(templateId: string, data: ReviewDto) {
|
||||
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, {
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
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, {
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
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 updated = await 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
CreateSmsSignatureDto,
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
UpdateSmsDrainageInfoDto,
|
||||
UpdateSmsSignatureDto,
|
||||
} from './sms-config.service';
|
||||
} from './sms-config.contracts';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@Controller('client')
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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 { SmsReportValidationService } from './report-validation.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsDrainageService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
|
||||
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
signatureId: true,
|
||||
applicationId: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
signature: { select: { id: true, name: true, auditStatus: true } },
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
|
||||
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
listDrainageInfos(query: DrainageInfoListQuery = {}) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
signatureId: query.signatureId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||
OR: query.keyword ? [
|
||||
{ siteName: { contains: query.keyword } },
|
||||
{ url: { contains: query.keyword } },
|
||||
{ signature: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
|
||||
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
|
||||
if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required');
|
||||
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
|
||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||
const item = await this.prisma.smsDrainageInfo.create({
|
||||
data: {
|
||||
tenantId: signature.tenantId,
|
||||
signatureId,
|
||||
applicationId: signature.applicationId,
|
||||
siteName: data.siteName.trim(),
|
||||
url: data.url.trim(),
|
||||
remark: data.remark,
|
||||
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
|
||||
auditStatus,
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : undefined,
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: item.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: item.id,
|
||||
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
|
||||
statusAfter: auditStatus,
|
||||
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
|
||||
});
|
||||
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
|
||||
return item;
|
||||
}
|
||||
|
||||
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
||||
if (!current) throw new NotFoundException('Drainage info not found');
|
||||
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
||||
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
|
||||
if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required');
|
||||
if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required');
|
||||
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
|
||||
await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
|
||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
applicationId,
|
||||
siteName: data.siteName?.trim(),
|
||||
url: data.url?.trim(),
|
||||
remark: data.remark,
|
||||
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
|
||||
auditStatus,
|
||||
rejectReason: null,
|
||||
submittedAt: new Date(),
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : null,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: itemId,
|
||||
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: auditStatus,
|
||||
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
|
||||
});
|
||||
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
||||
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
|
||||
return updated;
|
||||
}
|
||||
|
||||
approveDrainageInfo(itemId: string, data: ReviewDto) {
|
||||
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectDrainageInfo(itemId: string, data: ReviewDto) {
|
||||
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!current) throw new NotFoundException('Drainage info not found');
|
||||
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
||||
const status = data.status ?? 'deleted';
|
||||
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
|
||||
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
|
||||
if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
|
||||
await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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 { SmsApplicationConfigService } from './application-config.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsReportValidationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {}
|
||||
async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
if (!drainageInfo) return drainageInfo;
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId);
|
||||
return {
|
||||
...drainageInfo,
|
||||
reportRequirementSnapshot: {
|
||||
capturedAt: new Date().toISOString(),
|
||||
applicationId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.id,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: field.required,
|
||||
reportTypes: field.reportTypes,
|
||||
commonReportTypes: field.commonReportTypes,
|
||||
channels: field.channels,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
if (!drainageInfo) return;
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId);
|
||||
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
|
||||
const value = reportValueParts(signatureValues[field.code]);
|
||||
for (const channel of field.channels) {
|
||||
await this.prisma.signatureReportMaterial.upsert({
|
||||
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
|
||||
update: value,
|
||||
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId, 'signature');
|
||||
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||
const missingSignature = fields
|
||||
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
|
||||
.filter((field) => !hasReportValue(signatureValues[field.code]));
|
||||
if (missingSignature.length > 0) {
|
||||
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
|
||||
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async activateDrainageReporting(itemId: string) {
|
||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
|
||||
if (!applicationId) return;
|
||||
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage'))
|
||||
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
|
||||
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
|
||||
const values = isRecord(item.reportValues) ? item.reportValues : {};
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
||||
for (const field of fields) {
|
||||
const value = reportValueParts(values[field.code]);
|
||||
for (const channel of field.channels) {
|
||||
await tx.drainageReportMaterial.create({
|
||||
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
|
||||
});
|
||||
}
|
||||
}
|
||||
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
|
||||
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
|
||||
for (const channel of channels.values()) {
|
||||
const existing = existingByChannel.get(channel.id);
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
|
||||
await tx.channelSignatureReportRecord.create({
|
||||
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
|
||||
});
|
||||
}
|
||||
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
|
||||
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
|
||||
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
|
||||
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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 { SmsReportValidationService } from './report-validation.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsSignatureService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
|
||||
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
const signatures = await this.prisma.smsSignature.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.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
|
||||
updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||
drainageItems: query.drainageKeyword ? {
|
||||
some: {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: [
|
||||
{ siteName: { contains: query.drainageKeyword } },
|
||||
{ url: { contains: query.drainageKeyword } },
|
||||
{ remark: { contains: query.drainageKeyword } },
|
||||
],
|
||||
},
|
||||
} : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ purpose: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: {
|
||||
materials: true,
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
|
||||
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
|
||||
}) : [];
|
||||
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
|
||||
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
|
||||
}).then((count) => count > 0);
|
||||
return signatures.map((signature) => {
|
||||
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
const drainageLinks = signature.drainageItems.map((item) => ({
|
||||
id: item.id,
|
||||
siteName: item.siteName,
|
||||
url: item.url,
|
||||
remark: item.remark ?? '',
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
auditStatus: item.auditStatus,
|
||||
rejectReason: item.rejectReason,
|
||||
submittedAt: item.submittedAt.toISOString(),
|
||||
reviewedAt: item.reviewedAt?.toISOString(),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
}));
|
||||
return {
|
||||
...signature,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
||||
reportTargets: (() => {
|
||||
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
|
||||
})(),
|
||||
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||
const drainageItemId = drainageItem.id;
|
||||
const channels = routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
|
||||
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
|
||||
const task = taskByChannel.get(channel.id);
|
||||
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
|
||||
})];
|
||||
})),
|
||||
drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||
const drainageItemId = drainageItem.id;
|
||||
const channels = routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
|
||||
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
|
||||
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: statuses.length }];
|
||||
}))];
|
||||
})),
|
||||
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
|
||||
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: targets.length }];
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listSignaturesPage(query: SignatureListQuery) {
|
||||
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.SmsSignatureWhereInput = {
|
||||
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.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
|
||||
updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
|
||||
drainageItems: query.drainageKeyword ? {
|
||||
some: {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: [
|
||||
{ siteName: { contains: query.drainageKeyword } },
|
||||
{ url: { contains: query.drainageKeyword } },
|
||||
{ remark: { contains: query.drainageKeyword } },
|
||||
],
|
||||
},
|
||||
} : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ purpose: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listSignatures({ ...query, page, pageSize }),
|
||||
this.prisma.smsSignature.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listSignatureOptions(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: { tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
id: signatureId,
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
{ application: { name: { contains: query.keyword.trim() } } },
|
||||
] : undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
auditStatus: true,
|
||||
reportStatus: true,
|
||||
pendingReport: true,
|
||||
reportChangedAt: true,
|
||||
rejectReason: true,
|
||||
drainageInfo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
materials: {
|
||||
select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true },
|
||||
},
|
||||
drainageItems: {
|
||||
where: { auditStatus: { not: 'deleted' } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { reportMaterials: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
|
||||
take: query.pageSize,
|
||||
});
|
||||
return signatures.map((signature) => {
|
||||
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
return {
|
||||
id: signature.id,
|
||||
tenantId: signature.tenantId,
|
||||
applicationId: signature.applicationId,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
reportStatus: signature.reportStatus,
|
||||
pendingReport: signature.pendingReport,
|
||||
reportChangedAt: signature.reportChangedAt,
|
||||
rejectReason: signature.rejectReason,
|
||||
createdAt: signature.createdAt,
|
||||
updatedAt: signature.updatedAt,
|
||||
application: signature.application,
|
||||
materials: signature.materials,
|
||||
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
|
||||
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
|
||||
drainageInfo: {
|
||||
links: signature.drainageItems.map((item) => ({
|
||||
...item,
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getClientSignatureView(signatureId: string, tenantId?: string) {
|
||||
const [signature] = await this.listClientSignatures(tenantId, signatureId);
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
return signature;
|
||||
}
|
||||
|
||||
async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: 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 filteredWhere: Prisma.SmsSignatureWhereInput = {
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
{ application: { name: { contains: query.keyword.trim() } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total, statusCounts] = await Promise.all([
|
||||
this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }),
|
||||
this.prisma.smsSignature.count({ where: filteredWhere }),
|
||||
this.prisma.smsSignature.groupBy({
|
||||
by: ['auditStatus'],
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 };
|
||||
for (const item of statusCounts) {
|
||||
const count = item._count._all;
|
||||
summary.total += count;
|
||||
if (item.auditStatus in summary && item.auditStatus !== 'total') {
|
||||
summary[item.auditStatus as keyof Omit<typeof summary, 'total'>] = count;
|
||||
}
|
||||
}
|
||||
return { items, summary, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||
const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, 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,
|
||||
},
|
||||
});
|
||||
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
||||
if (options.initialAuditStatus) {
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signature.id,
|
||||
action: 'admin_create_approved',
|
||||
statusAfter: options.initialAuditStatus,
|
||||
reason: '运营端新建签名自动审核通过',
|
||||
});
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo);
|
||||
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
|
||||
const drainageInfo = data.drainageInfo
|
||||
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||
: undefined;
|
||||
const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name);
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId)
|
||||
|| (name !== undefined && name !== normalizeSmsSignature(signature.name))
|
||||
|| (data.purpose !== undefined && data.purpose !== signature.purpose)
|
||||
|| (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null));
|
||||
const auditStatus = 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' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改签名');
|
||||
}
|
||||
const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
||||
return this.prisma.signatureMaterial.create({
|
||||
data: {
|
||||
signatureId: data.signatureId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
materialType: data.materialType,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submitSignature(signatureId: string, 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 updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'submit',
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Stable request and query contracts shared by controllers and SMS configuration domains. */
|
||||
|
||||
export interface CreateSmsApplicationDto {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
scene?: string;
|
||||
callbackUrl?: string;
|
||||
cmppAccount?: string;
|
||||
cmppEnterpriseCode?: string;
|
||||
cmppApplicationExtension?: string;
|
||||
cmppAccessNumberFillEnabled?: boolean;
|
||||
cmppAccessNumberFillPrefix?: string;
|
||||
passwordCipher?: string;
|
||||
interfaceEnabled?: boolean;
|
||||
interfaceType?: string;
|
||||
cmppMaxConnections?: number;
|
||||
cmppWindowSize?: number;
|
||||
dailyLimit?: number;
|
||||
customerUnitPrice?: number;
|
||||
queuePriority?: string;
|
||||
templateMismatchMode?: string;
|
||||
downstreamReceiptRetryEnabled?: boolean;
|
||||
downstreamUplinkRetryEnabled?: boolean;
|
||||
ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
export type UpdateSmsApplicationDto = Partial<Omit<CreateSmsApplicationDto, 'tenantId'>> & {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export interface ReplaceApplicationRouteRulesDto {
|
||||
routes: Array<{
|
||||
carrier: string;
|
||||
groupId: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateSmsSignatureDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateSmsSignatureOptions {
|
||||
initialAuditStatus?: string;
|
||||
}
|
||||
|
||||
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface CreateSmsDrainageInfoDto {
|
||||
siteName: string;
|
||||
url: string;
|
||||
remark?: string;
|
||||
reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateSmsDrainageInfoDto = Partial<CreateSmsDrainageInfoDto>;
|
||||
|
||||
export interface DrainageInfoListQuery {
|
||||
tenantId?: string;
|
||||
signatureId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
submittedAtFrom?: string;
|
||||
submittedAtTo?: string;
|
||||
}
|
||||
|
||||
export interface CreateSignatureMaterialDto {
|
||||
signatureId: string;
|
||||
fileObjectId?: string;
|
||||
materialType: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateSmsTemplateDto {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
signatureId?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
variables?: Array<{ name: string; example?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
export interface CreateSmsTemplateOptions {
|
||||
initialAuditStatus?: string;
|
||||
}
|
||||
|
||||
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId' | 'signatureId'>> & {
|
||||
signatureId?: string | null;
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface ReviewDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface StatusChangeDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateListQuery {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
nameKeyword?: string;
|
||||
contentKeyword?: string;
|
||||
submittedAtFrom?: string;
|
||||
submittedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ApplicationListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
status?: string;
|
||||
includeConnections?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SignatureListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
signatureKeyword?: string;
|
||||
drainageKeyword?: string;
|
||||
submittedAtFrom?: string;
|
||||
submittedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamConnectionEventDto {
|
||||
account: string;
|
||||
connectionId: string;
|
||||
status: 'connected' | 'heartbeat' | 'submit' | 'deliver' | 'disconnected';
|
||||
remoteIp?: string;
|
||||
protocol?: string;
|
||||
connectedAt?: string;
|
||||
observedAt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { randomInt, 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 const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
|
||||
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;
|
||||
export const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const;
|
||||
|
||||
export interface TemplateVariableInput {
|
||||
name: string;
|
||||
example?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function normalizeApplicationPassword(value: string | undefined) {
|
||||
const password = value?.trim() || generateApplicationPassword();
|
||||
if (password.length !== 16) {
|
||||
throw new BadRequestException('passwordCipher must be 16 characters');
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
export function generateApplicationPassword() {
|
||||
return randomUUID().replace(/-/g, '').slice(0, 16);
|
||||
}
|
||||
|
||||
export function estimateBillingUnits(content: string) {
|
||||
const length = [...content].length;
|
||||
if (length <= 70) {
|
||||
return 1;
|
||||
}
|
||||
return Math.ceil(length / 67);
|
||||
}
|
||||
|
||||
export function inferTemplateVariables(content: string): TemplateVariableInput[] {
|
||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
}
|
||||
|
||||
export function validateAndNormalizeTemplateVariables(
|
||||
content: string,
|
||||
supplied?: Array<{ name: string; example?: string; required?: boolean }>,
|
||||
): TemplateVariableInput[] {
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const start = content.indexOf('${', cursor);
|
||||
if (start < 0) break;
|
||||
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 (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))) {
|
||||
throw new BadRequestException('变量配置中包含非法变量名');
|
||||
}
|
||||
if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量');
|
||||
if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) {
|
||||
throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致');
|
||||
}
|
||||
return supplied.map((item) => ({ ...item, name: item.name.trim() }));
|
||||
}
|
||||
|
||||
export function normalizeSmsSignature(name: string) {
|
||||
const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
}
|
||||
|
||||
export function validateCompleteSmsSignature(name: string) {
|
||||
const value = name;
|
||||
if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) {
|
||||
throw new BadRequestException('短信签名不能包含空格、换行或不可见字符');
|
||||
}
|
||||
const match = value.match(/^【([^【】]+)】$/);
|
||||
if (!match) {
|
||||
throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePriority {
|
||||
const queuePriority = value ?? 'normal';
|
||||
if (!APPLICATION_QUEUE_PRIORITIES.includes(queuePriority as ApplicationQueuePriority)) {
|
||||
throw new BadRequestException('queuePriority must be normal or priority');
|
||||
}
|
||||
return queuePriority as ApplicationQueuePriority;
|
||||
}
|
||||
|
||||
export function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType {
|
||||
const interfaceType = value ?? 'cmpp20';
|
||||
if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) {
|
||||
throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet');
|
||||
}
|
||||
return interfaceType as ApplicationInterfaceType;
|
||||
}
|
||||
|
||||
export function normalizeCmppAccessNumberConfig(
|
||||
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
|
||||
current?: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
},
|
||||
) {
|
||||
const applicationExtension = (
|
||||
data.cmppApplicationExtension === undefined
|
||||
? current?.cmppApplicationExtension
|
||||
: data.cmppApplicationExtension
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled
|
||||
?? current?.cmppAccessNumberFillEnabled
|
||||
?? false;
|
||||
const configuredPrefix = (
|
||||
data.cmppAccessNumberFillPrefix === undefined
|
||||
? current?.cmppAccessNumberFillPrefix
|
||||
: data.cmppAccessNumberFillPrefix
|
||||
)?.trim() || null;
|
||||
|
||||
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
|
||||
throw new BadRequestException('cmppApplicationExtension must contain digits only');
|
||||
}
|
||||
if (applicationExtension && applicationExtension.length > 21) {
|
||||
throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits');
|
||||
}
|
||||
if (fillEnabled && !applicationExtension) {
|
||||
throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled');
|
||||
}
|
||||
if (fillEnabled && !configuredPrefix) {
|
||||
throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled');
|
||||
}
|
||||
if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) {
|
||||
throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only');
|
||||
}
|
||||
|
||||
const fillPrefix = fillEnabled ? configuredPrefix : null;
|
||||
const clientSrcId = applicationExtension
|
||||
? `${fillPrefix ?? ''}${applicationExtension}`
|
||||
: null;
|
||||
if (clientSrcId && clientSrcId.length > 21) {
|
||||
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
|
||||
}
|
||||
return { applicationExtension, fillEnabled, fillPrefix, clientSrcId };
|
||||
}
|
||||
|
||||
export function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) {
|
||||
if (!['active', 'disabling'].includes(applicationStatus)) {
|
||||
return 'inactive';
|
||||
}
|
||||
if (connections.some((connection) => connection.status === 'connected')) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
export function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name] ?? fallback);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function parseGatewayDate(value?: string) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined };
|
||||
}
|
||||
|
||||
export function hasReportValue(value: unknown) {
|
||||
if (isRecord(value)) {
|
||||
return Boolean(value.fileObjectId || value.fieldValue || value.value);
|
||||
}
|
||||
return value !== undefined && value !== null && String(value).trim().length > 0;
|
||||
}
|
||||
@@ -1103,6 +1103,24 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the audit submission range to signatures, templates and drainage records', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
const query = { submittedAtFrom: '2026-08-01', submittedAtTo: '2026-08-03' };
|
||||
const expectedRange = {
|
||||
gte: new Date('2026-08-01T00:00:00+08:00'),
|
||||
lte: new Date('2026-08-03T23:59:59.999+08:00'),
|
||||
};
|
||||
|
||||
await service.listSignatures(query);
|
||||
await service.listTemplates(query);
|
||||
await service.listDrainageInfos(query);
|
||||
|
||||
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) }));
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) }));
|
||||
expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
'【带 空格】',
|
||||
' 【外部空格】',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsTemplateService {
|
||||
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 = {
|
||||
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' });
|
||||
}
|
||||
|
||||
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({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
billingUnits: estimateBillingUnits(data.content),
|
||||
variables: {
|
||||
create: variables.map((variable: TemplateVariableInput) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
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
|
||||
? 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) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
}
|
||||
return tx.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: 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,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async submitTemplate(templateId: string, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (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;
|
||||
}
|
||||
|
||||
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} 开头`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user