Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4f36fc50d | ||
|
|
482f7ac1ae | ||
|
|
79f5d3f215 | ||
|
|
2216d00d51 | ||
|
|
0cd09441da | ||
|
|
6ccc102830 | ||
|
|
1ef4380422 | ||
|
|
d30d9ea4d0 | ||
|
|
b78faa1aa2 | ||
|
|
96e475d60d | ||
|
|
433b2ee56f | ||
|
|
67fee21616 | ||
|
|
fb02cbcf39 | ||
|
|
4c70978da4 | ||
|
|
16135e5a3e | ||
|
|
4994841709 | ||
|
|
1d8d6701a6 | ||
|
|
f350bf5ef3 | ||
|
|
dc358798e9 | ||
|
|
0cd353450a | ||
|
|
e64b5e23fe | ||
|
|
2ecb24cf8d | ||
|
|
827d8921a8 | ||
|
|
0eb27e4ac0 | ||
|
|
55aa054005 | ||
|
|
232d1c22a3 | ||
|
|
e0c8f82bcf | ||
|
|
35de17a2d4 | ||
|
|
608662a054 | ||
|
|
78b839f468 | ||
|
|
482d332f49 | ||
|
|
7804f64ced | ||
|
|
6add563ee8 | ||
|
|
4724b9db6a | ||
|
|
44352aeb2f |
@@ -25,6 +25,9 @@ OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000
|
|||||||
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
||||||
SMS_RECEIPT_TIMEOUT_HOURS=72
|
SMS_RECEIPT_TIMEOUT_HOURS=72
|
||||||
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000
|
SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS=300000
|
||||||
|
# System monitoring reads only fixed queries from a loopback Prometheus instance.
|
||||||
|
PROMETHEUS_URL=http://127.0.0.1:9090
|
||||||
|
PROMETHEUS_QUERY_TIMEOUT_MS=5000
|
||||||
# Local HTTP development only. Production must use HTTPS and true.
|
# Local HTTP development only. Production must use HTTPS and true.
|
||||||
SESSION_COOKIE_SECURE=false
|
SESSION_COOKIE_SECURE=false
|
||||||
MINIO_ENDPOINT=localhost:9000
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "ProtocolInteractionLog"
|
||||||
|
ADD COLUMN "phoneNumber" TEXT;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
ALTER TABLE "SmsChannel"
|
||||||
|
ADD COLUMN "carriers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
UPDATE "SmsChannel"
|
||||||
|
SET "carriers" = CASE
|
||||||
|
WHEN "carrier" = 'mobile' THEN ARRAY['mobile']::TEXT[]
|
||||||
|
WHEN "carrier" = 'unicom' THEN ARRAY['unicom']::TEXT[]
|
||||||
|
WHEN "carrier" = 'telecom' THEN ARRAY['telecom']::TEXT[]
|
||||||
|
WHEN "carrier" = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
-- 旧页面和旧发送链对空/未知carrier一直按移动处理,迁移保持原业务语义且保证至少一项。
|
||||||
|
ELSE ARRAY['mobile']::TEXT[]
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE "SmsChannel"
|
||||||
|
ADD CONSTRAINT "SmsChannel_carriers_supported_check"
|
||||||
|
CHECK (
|
||||||
|
cardinality("carriers") BETWEEN 1 AND 3
|
||||||
|
AND "carriers" <@ ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "ChannelSignatureReportTask"
|
||||||
|
ADD COLUMN "carrier" TEXT,
|
||||||
|
ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "approvalScope" TEXT NOT NULL DEFAULT 'legacy_channel';
|
||||||
|
|
||||||
|
-- The current approved timestamp is reconstructed from the latest transition
|
||||||
|
-- into approved. updatedAt is deliberately not used because unrelated edits
|
||||||
|
-- can change it and would incorrectly restart the grace period.
|
||||||
|
UPDATE "ChannelSignatureReportTask" task
|
||||||
|
SET "approvedAt" = approved_record."approvedAt"
|
||||||
|
FROM (
|
||||||
|
SELECT "taskId", MAX("createdAt") AS "approvedAt"
|
||||||
|
FROM "ChannelSignatureReportRecord"
|
||||||
|
WHERE "statusAfter" = 'approved'
|
||||||
|
GROUP BY "taskId"
|
||||||
|
) approved_record
|
||||||
|
WHERE task.id = approved_record."taskId"
|
||||||
|
AND task.status = 'approved';
|
||||||
|
|
||||||
|
CREATE INDEX "ChannelSignatureReportTask_signatureId_channelId_carrier_idx"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "channelId", "carrier");
|
||||||
|
|
||||||
|
-- 旧索引把运营商排除在唯一维度外,会阻止同一签名/通道建立多运营商事实。
|
||||||
|
-- 拆成三类条件索引,在升级维度的同时继续保护历史任务和引流任务不重复。
|
||||||
|
DROP INDEX IF EXISTS "ChannelSignatureReportTask_target_key";
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_signature_channel_carrier_key"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "channelId", "carrier")
|
||||||
|
WHERE "reportType" = 'signature' AND "carrier" IS NOT NULL AND "drainageItemId" IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_legacy_signature_channel_key"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "channelId")
|
||||||
|
WHERE "reportType" = 'signature' AND "carrier" IS NULL AND "drainageItemId" IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_target_key"
|
||||||
|
ON "ChannelSignatureReportTask"("signatureId", "drainageItemId", "channelId")
|
||||||
|
WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ruleType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT,
|
||||||
|
"targetKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"mobileWindowDays" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"mobileThreshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"unicomWindowDays" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"unicomThreshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"telecomWindowDays" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"telecomThreshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"messageTemplate" TEXT,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementRule_ruleType_targetKey_key" ON "SignatureRetirementRule"("ruleType", "targetKey");
|
||||||
|
CREATE INDEX "SignatureRetirementRule_ruleType_enabled_idx" ON "SignatureRetirementRule"("ruleType", "enabled");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementWebhook" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL,
|
||||||
|
"urlEncrypted" TEXT NOT NULL,
|
||||||
|
"urlMasked" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementWebhook_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "SignatureRetirementWebhook_status_createdAt_idx" ON "SignatureRetirementWebhook"("status", "createdAt");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementCycle" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"dimensionType" TEXT NOT NULL,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"channelKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"carrier" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"startedOn" DATE NOT NULL,
|
||||||
|
"lastDetectedOn" DATE NOT NULL,
|
||||||
|
"resolvedOn" DATE,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementCycle_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "SignatureRetirementCycle_dimension_status_idx" ON "SignatureRetirementCycle"("dimensionType", "signatureId", "channelKey", "carrier", "status");
|
||||||
|
CREATE INDEX "SignatureRetirementCycle_status_lastDetectedOn_idx" ON "SignatureRetirementCycle"("status", "lastDetectedOn");
|
||||||
|
-- 同一监控维度只能存在一个开放周期,数据库约束用于兜住并发检测实例。
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementCycle_open_dimension_key"
|
||||||
|
ON "SignatureRetirementCycle"("dimensionType", "signatureId", "channelKey", "carrier")
|
||||||
|
WHERE "status" = 'open';
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementDetection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"detectionDate" DATE NOT NULL,
|
||||||
|
"dimensionType" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"channelKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"carrier" TEXT NOT NULL,
|
||||||
|
"windowDays" INTEGER NOT NULL,
|
||||||
|
"threshold" INTEGER NOT NULL,
|
||||||
|
"submittedAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"acceptedBusinessCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"deliveredBusinessCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"approvedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ruleId" TEXT,
|
||||||
|
"ruleVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"cycleId" TEXT,
|
||||||
|
"suppressed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"notificationTitle" TEXT,
|
||||||
|
"notificationContent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SignatureRetirementDetection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementDetection_dimension_key" ON "SignatureRetirementDetection"("detectionDate", "dimensionType", "signatureId", "channelKey", "carrier");
|
||||||
|
CREATE INDEX "SignatureRetirementDetection_date_type_status_idx" ON "SignatureRetirementDetection"("detectionDate", "dimensionType", "status");
|
||||||
|
CREATE INDEX "SignatureRetirementDetection_signature_carrier_date_idx" ON "SignatureRetirementDetection"("signatureId", "carrier", "detectionDate");
|
||||||
|
CREATE INDEX "SignatureRetirementDetection_channel_carrier_date_idx" ON "SignatureRetirementDetection"("channelId", "carrier", "detectionDate");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"detectionId" TEXT NOT NULL,
|
||||||
|
"cycleId" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"suppressed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"readAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SignatureRetirementMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementMessage_detectionId_key" ON "SignatureRetirementMessage"("detectionId");
|
||||||
|
CREATE INDEX "SignatureRetirementMessage_created_read_suppressed_idx" ON "SignatureRetirementMessage"("createdAt", "isRead", "suppressed");
|
||||||
|
CREATE INDEX "SignatureRetirementMessage_tenant_createdAt_idx" ON "SignatureRetirementMessage"("tenantId", "createdAt");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementSuppression" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"dimensionType" TEXT NOT NULL,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"channelKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"carrier" TEXT NOT NULL,
|
||||||
|
"mode" TEXT NOT NULL,
|
||||||
|
"muteUntil" DATE,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"reason" TEXT,
|
||||||
|
"operatorId" TEXT,
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"cancelledById" TEXT,
|
||||||
|
"cancelReason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementSuppression_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementSuppression_dimension_key" ON "SignatureRetirementSuppression"("dimensionType", "signatureId", "channelKey", "carrier");
|
||||||
|
CREATE INDEX "SignatureRetirementSuppression_active_muteUntil_idx" ON "SignatureRetirementSuppression"("active", "muteUntil");
|
||||||
|
|
||||||
|
CREATE TABLE "SignatureRetirementWebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"detectionDate" DATE NOT NULL,
|
||||||
|
"webhookId" TEXT NOT NULL,
|
||||||
|
"groupKey" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextRetryAt" TIMESTAMP(3),
|
||||||
|
"lastHttpStatus" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SignatureRetirementWebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SignatureRetirementWebhookDelivery_key" ON "SignatureRetirementWebhookDelivery"("webhookId", "detectionDate", "groupKey");
|
||||||
|
CREATE INDEX "SignatureRetirementWebhookDelivery_status_retry_idx" ON "SignatureRetirementWebhookDelivery"("status", "nextRetryAt");
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
-- 最终业务口径不再保留历史人工确认:旧通道级状态按通道能力一次性形成运营商级事实。
|
||||||
|
-- 已有运营商任务代表更新的事实,必须保留且不得被旧任务覆盖。
|
||||||
|
WITH legacy_targets AS (
|
||||||
|
SELECT
|
||||||
|
legacy.id AS "legacyId",
|
||||||
|
legacy."tenantId",
|
||||||
|
legacy."signatureId",
|
||||||
|
legacy."channelId",
|
||||||
|
legacy.status,
|
||||||
|
legacy.reason,
|
||||||
|
legacy."createdById",
|
||||||
|
supported.carrier
|
||||||
|
FROM "ChannelSignatureReportTask" legacy
|
||||||
|
JOIN "SmsChannel" channel ON channel.id = legacy."channelId"
|
||||||
|
CROSS JOIN LATERAL unnest(
|
||||||
|
CASE
|
||||||
|
WHEN cardinality(channel.carriers) > 0 THEN channel.carriers
|
||||||
|
WHEN channel.carrier = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
WHEN channel.carrier IN ('mobile', 'unicom', 'telecom') THEN ARRAY[channel.carrier]::TEXT[]
|
||||||
|
ELSE ARRAY['mobile']::TEXT[]
|
||||||
|
END
|
||||||
|
) AS supported(carrier)
|
||||||
|
WHERE legacy."reportType" = 'signature'
|
||||||
|
AND legacy."drainageItemId" IS NULL
|
||||||
|
AND legacy.carrier IS NULL
|
||||||
|
AND legacy."approvalScope" = 'legacy_channel'
|
||||||
|
), inserted_tasks AS (
|
||||||
|
INSERT INTO "ChannelSignatureReportTask" (
|
||||||
|
id,
|
||||||
|
"tenantId",
|
||||||
|
"signatureId",
|
||||||
|
"channelId",
|
||||||
|
carrier,
|
||||||
|
"approvedAt",
|
||||||
|
"approvalScope",
|
||||||
|
"reportType",
|
||||||
|
"drainageItemId",
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
"createdById",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'legacy-auto-' || md5(target."legacyId" || ':' || target.carrier),
|
||||||
|
target."tenantId",
|
||||||
|
target."signatureId",
|
||||||
|
target."channelId",
|
||||||
|
target.carrier,
|
||||||
|
CASE WHEN target.status = 'approved' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||||
|
'carrier_specific',
|
||||||
|
'signature',
|
||||||
|
NULL,
|
||||||
|
target.status,
|
||||||
|
target.reason,
|
||||||
|
target."createdById",
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM legacy_targets target
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id, "channelId", status
|
||||||
|
)
|
||||||
|
INSERT INTO "ChannelSignatureReportRecord" (
|
||||||
|
id,
|
||||||
|
"taskId",
|
||||||
|
"channelId",
|
||||||
|
action,
|
||||||
|
"statusBefore",
|
||||||
|
"statusAfter",
|
||||||
|
reason,
|
||||||
|
"operatorId",
|
||||||
|
"sourceEntry",
|
||||||
|
"createdAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'legacy-auto-record-' || md5(task.id),
|
||||||
|
task.id,
|
||||||
|
task."channelId",
|
||||||
|
'legacy_carrier_auto_split',
|
||||||
|
NULL,
|
||||||
|
task.status,
|
||||||
|
'历史通道级任务按通道运营商能力自动转换',
|
||||||
|
NULL,
|
||||||
|
'migration',
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM inserted_tasks task
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
WITH legacy_targets AS (
|
||||||
|
SELECT
|
||||||
|
legacy.id AS "legacyId",
|
||||||
|
legacy."signatureId",
|
||||||
|
legacy."channelId",
|
||||||
|
supported.carrier
|
||||||
|
FROM "ChannelSignatureReportTask" legacy
|
||||||
|
JOIN "SmsChannel" channel ON channel.id = legacy."channelId"
|
||||||
|
CROSS JOIN LATERAL unnest(
|
||||||
|
CASE
|
||||||
|
WHEN cardinality(channel.carriers) > 0 THEN channel.carriers
|
||||||
|
WHEN channel.carrier = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||||
|
WHEN channel.carrier IN ('mobile', 'unicom', 'telecom') THEN ARRAY[channel.carrier]::TEXT[]
|
||||||
|
ELSE ARRAY['mobile']::TEXT[]
|
||||||
|
END
|
||||||
|
) AS supported(carrier)
|
||||||
|
WHERE legacy."reportType" = 'signature'
|
||||||
|
AND legacy."drainageItemId" IS NULL
|
||||||
|
AND legacy.carrier IS NULL
|
||||||
|
AND legacy."approvalScope" = 'legacy_channel'
|
||||||
|
), completed_legacy AS (
|
||||||
|
SELECT target."legacyId"
|
||||||
|
FROM legacy_targets target
|
||||||
|
LEFT JOIN "ChannelSignatureReportTask" exact
|
||||||
|
ON exact."signatureId" = target."signatureId"
|
||||||
|
AND exact."channelId" = target."channelId"
|
||||||
|
AND exact."reportType" = 'signature'
|
||||||
|
AND exact."drainageItemId" IS NULL
|
||||||
|
AND exact.carrier = target.carrier
|
||||||
|
GROUP BY target."legacyId"
|
||||||
|
HAVING COUNT(DISTINCT target.carrier) = COUNT(DISTINCT exact.carrier)
|
||||||
|
), updated_legacy AS (
|
||||||
|
UPDATE "ChannelSignatureReportTask" legacy
|
||||||
|
SET "approvalScope" = 'legacy_split',
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP
|
||||||
|
FROM completed_legacy completed
|
||||||
|
WHERE legacy.id = completed."legacyId"
|
||||||
|
AND legacy."approvalScope" = 'legacy_channel'
|
||||||
|
RETURNING legacy.id, legacy."channelId", legacy.status
|
||||||
|
)
|
||||||
|
INSERT INTO "ChannelSignatureReportRecord" (
|
||||||
|
id,
|
||||||
|
"taskId",
|
||||||
|
"channelId",
|
||||||
|
action,
|
||||||
|
"statusBefore",
|
||||||
|
"statusAfter",
|
||||||
|
reason,
|
||||||
|
"operatorId",
|
||||||
|
"sourceEntry",
|
||||||
|
"createdAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'legacy-split-record-' || md5(legacy.id),
|
||||||
|
legacy.id,
|
||||||
|
legacy."channelId",
|
||||||
|
'legacy_scope_auto_split',
|
||||||
|
legacy.status,
|
||||||
|
legacy.status,
|
||||||
|
'全部适用运营商已自动形成独立报备任务',
|
||||||
|
NULL,
|
||||||
|
'migration',
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM updated_legacy legacy
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TABLE "DownstreamRequeueTask" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"taskNo" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT,
|
||||||
|
"applicationId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
"filterSnapshot" JSONB NOT NULL,
|
||||||
|
"snapshotAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"ratePerSecond" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"consecutiveFailureLimit" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"skippedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"waitingCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"pausedAt" TIMESTAMP(3),
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "DownstreamRequeueTask_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "DownstreamRequeueTaskItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"taskId" TEXT NOT NULL,
|
||||||
|
"deliveryId" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
"previousStatus" TEXT NOT NULL,
|
||||||
|
"skipReason" TEXT,
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "DownstreamRequeueTaskItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "DownstreamRequeueTask_taskNo_key" ON "DownstreamRequeueTask"("taskNo");
|
||||||
|
CREATE INDEX "DownstreamRequeueTask_status_createdAt_idx" ON "DownstreamRequeueTask"("status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTask_applicationId_status_createdAt_idx" ON "DownstreamRequeueTask"("applicationId", "status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTask_tenantId_createdAt_idx" ON "DownstreamRequeueTask"("tenantId", "createdAt");
|
||||||
|
CREATE UNIQUE INDEX "DownstreamRequeueTaskItem_taskId_deliveryId_key" ON "DownstreamRequeueTaskItem"("taskId", "deliveryId");
|
||||||
|
CREATE INDEX "DownstreamRequeueTaskItem_taskId_status_createdAt_idx" ON "DownstreamRequeueTaskItem"("taskId", "status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTaskItem_applicationId_status_createdAt_idx" ON "DownstreamRequeueTaskItem"("applicationId", "status", "createdAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueTaskItem_deliveryId_status_idx" ON "DownstreamRequeueTaskItem"("deliveryId", "status");
|
||||||
|
|
||||||
|
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTask" ADD CONSTRAINT "DownstreamRequeueTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTaskItem" ADD CONSTRAINT "DownstreamRequeueTaskItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "DownstreamRequeueTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DownstreamRequeueTaskItem" ADD CONSTRAINT "DownstreamRequeueTaskItem_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "CmppDownstreamDelivery"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
ALTER TABLE "DownstreamRequeueTask"
|
||||||
|
ADD COLUMN "applicationFailures" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
ADD COLUMN "scanLeaseOwner" TEXT,
|
||||||
|
ADD COLUMN "scanLeaseUntil" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE TABLE "DownstreamRequeueRateWindow" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"applicationId" TEXT NOT NULL,
|
||||||
|
"windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumed" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "DownstreamRequeueRateWindow_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "DownstreamRequeueRateWindow_applicationId_windowStartedAt_key"
|
||||||
|
ON "DownstreamRequeueRateWindow"("applicationId", "windowStartedAt");
|
||||||
|
CREATE INDEX "DownstreamRequeueRateWindow_windowStartedAt_idx"
|
||||||
|
ON "DownstreamRequeueRateWindow"("windowStartedAt");
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
CREATE TABLE "SecurityDetectionRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"sourceType" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"threshold" INTEGER NOT NULL,
|
||||||
|
"windowSeconds" INTEGER NOT NULL,
|
||||||
|
"cooldownSeconds" INTEGER NOT NULL,
|
||||||
|
"severity" TEXT NOT NULL,
|
||||||
|
"defaultBlockSeconds" INTEGER NOT NULL,
|
||||||
|
"maximumBlockSeconds" INTEGER NOT NULL,
|
||||||
|
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"effectiveVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"applyStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"lastApplyError" TEXT,
|
||||||
|
"pendingConfig" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityDetectionRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityDetectionEvent" (
|
||||||
|
"id" TEXT NOT NULL, "eventKey" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||||
|
"sourceIp" TEXT NOT NULL, "sourcePort" INTEGER, "accountHash" TEXT,
|
||||||
|
"path" TEXT, "protocol" TEXT, "resultCode" TEXT, "evidence" JSONB,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SecurityDetectionEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityAlert" (
|
||||||
|
"id" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "ruleId" TEXT NOT NULL,
|
||||||
|
"sourceIp" TEXT NOT NULL, "severity" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"eventCount" INTEGER NOT NULL DEFAULT 0, "windowStartedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"firstOccurredAt" TIMESTAMP(3) NOT NULL, "lastOccurredAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"acknowledgedAt" TIMESTAMP(3), "acknowledgedById" TEXT, "ignoredAt" TIMESTAMP(3),
|
||||||
|
"ignoredById" TEXT, "ignoreReason" TEXT, "blockId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityAlert_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityBlock" (
|
||||||
|
"id" TEXT NOT NULL, "operationKey" TEXT NOT NULL, "alertId" TEXT, "sourceIp" TEXT NOT NULL,
|
||||||
|
"executor" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'requested', "durationSeconds" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL, "requestedById" TEXT NOT NULL, "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"appliedAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "releasedAt" TIMESTAMP(3), "releasedById" TEXT,
|
||||||
|
"executorReference" TEXT, "lastError" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SecurityBlock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE TABLE "SecurityProtectedNetwork" (
|
||||||
|
"id" TEXT NOT NULL, "network" TEXT NOT NULL, "name" TEXT NOT NULL, "reason" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true, "createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SecurityProtectedNetwork_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "SecurityDetectionRule_code_key" ON "SecurityDetectionRule"("code");
|
||||||
|
CREATE INDEX "SecurityDetectionRule_enabled_sourceType_idx" ON "SecurityDetectionRule"("enabled", "sourceType");
|
||||||
|
CREATE UNIQUE INDEX "SecurityDetectionEvent_eventKey_key" ON "SecurityDetectionEvent"("eventKey");
|
||||||
|
CREATE INDEX "SecurityDetectionEvent_ruleId_occurredAt_idx" ON "SecurityDetectionEvent"("ruleId", "occurredAt");
|
||||||
|
CREATE INDEX "SecurityDetectionEvent_sourceIp_occurredAt_idx" ON "SecurityDetectionEvent"("sourceIp", "occurredAt");
|
||||||
|
CREATE UNIQUE INDEX "SecurityAlert_fingerprint_key" ON "SecurityAlert"("fingerprint");
|
||||||
|
CREATE INDEX "SecurityAlert_status_severity_lastOccurredAt_idx" ON "SecurityAlert"("status", "severity", "lastOccurredAt");
|
||||||
|
CREATE INDEX "SecurityAlert_sourceIp_status_lastOccurredAt_idx" ON "SecurityAlert"("sourceIp", "status", "lastOccurredAt");
|
||||||
|
CREATE INDEX "SecurityAlert_ruleId_status_lastOccurredAt_idx" ON "SecurityAlert"("ruleId", "status", "lastOccurredAt");
|
||||||
|
CREATE UNIQUE INDEX "SecurityBlock_operationKey_key" ON "SecurityBlock"("operationKey");
|
||||||
|
CREATE INDEX "SecurityBlock_status_expiresAt_idx" ON "SecurityBlock"("status", "expiresAt");
|
||||||
|
CREATE INDEX "SecurityBlock_sourceIp_status_requestedAt_idx" ON "SecurityBlock"("sourceIp", "status", "requestedAt");
|
||||||
|
CREATE INDEX "SecurityBlock_alertId_idx" ON "SecurityBlock"("alertId");
|
||||||
|
CREATE UNIQUE INDEX "SecurityProtectedNetwork_network_key" ON "SecurityProtectedNetwork"("network");
|
||||||
|
CREATE INDEX "SecurityProtectedNetwork_enabled_createdAt_idx" ON "SecurityProtectedNetwork"("enabled", "createdAt");
|
||||||
|
ALTER TABLE "SecurityDetectionEvent" ADD CONSTRAINT "SecurityDetectionEvent_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SecurityAlert" ADD CONSTRAINT "SecurityAlert_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "SecurityDetectionRule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO "SecurityDetectionRule" ("id", "code", "name", "sourceType", "threshold", "windowSeconds", "cooldownSeconds", "severity", "defaultBlockSeconds", "maximumBlockSeconds", "configVersion", "effectiveVersion", "applyStatus", "updatedAt") VALUES
|
||||||
|
('sec_admin_login', 'admin_login_failure', '运营端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_client_login', 'client_login_failure', '客户端登录失败', 'application', 8, 600, 900, 'medium', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_ssh_auth', 'ssh_auth_failure', 'SSH认证失败', 'fail2ban', 6, 600, 1800, 'high', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_cmpp_auth', 'cmpp_auth_failure', 'CMPP认证失败', 'gateway', 5, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_cmpp_abuse', 'cmpp_protocol_abuse', 'CMPP协议滥用', 'gateway', 20, 60, 900, 'critical', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_key', 'http_invalid_api_key', 'HTTP错误密钥', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_sign', 'http_signature_failure', 'HTTP签名错误', 'application', 10, 300, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_replay', 'http_replay_attempt', 'HTTP重放尝试', 'application', 3, 600, 1800, 'critical', 86400, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP),
|
||||||
|
('sec_http_scan', 'http_malicious_scan', 'HTTP恶意扫描', 'fail2ban', 20, 60, 900, 'high', 3600, 604800, 1, 1, 'effective', CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "InfrastructureAlertSetting" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'global',
|
||||||
|
"configVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"effectiveVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"thresholds" JSONB NOT NULL,
|
||||||
|
"effectiveThresholds" JSONB NOT NULL,
|
||||||
|
"applyStatus" TEXT NOT NULL DEFAULT 'effective',
|
||||||
|
"lastError" TEXT,
|
||||||
|
"updatedById" TEXT,
|
||||||
|
"appliedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "InfrastructureAlertSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "InfrastructureAlertSetting" (
|
||||||
|
"id", "thresholds", "effectiveThresholds", "appliedAt"
|
||||||
|
) VALUES (
|
||||||
|
'global',
|
||||||
|
'{"hostCpu":{"warning":80,"critical":90},"hostMemory":{"warning":85,"critical":95},"hostDisk":{"warning":80,"critical":90},"apiError":{"warning":1,"critical":5},"apiLatency":{"warning":1,"critical":3},"apiEventLoop":{"warning":0.2,"critical":1},"gatewayQueue":{"warning":30,"critical":120},"postgresConnections":{"warning":70,"critical":85},"redisMemory":{"warning":70,"critical":85},"minioCapacity":{"warning":80,"critical":90}}'::jsonb,
|
||||||
|
'{"hostCpu":{"warning":80,"critical":90},"hostMemory":{"warning":85,"critical":95},"hostDisk":{"warning":80,"critical":90},"apiError":{"warning":1,"critical":5},"apiLatency":{"warning":1,"critical":3},"apiEventLoop":{"warning":0.2,"critical":1},"gatewayQueue":{"warning":30,"critical":120},"postgresConnections":{"warning":70,"critical":85},"redisMemory":{"warning":70,"critical":85},"minioCapacity":{"warning":80,"critical":90}}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "InfrastructureAlertRead" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fingerprint" TEXT NOT NULL,
|
||||||
|
"activeAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InfrastructureAlertRead_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "InfrastructureAlertRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "InfrastructureAlertRead_fingerprint_userId_key"
|
||||||
|
ON "InfrastructureAlertRead"("fingerprint", "userId");
|
||||||
|
|
||||||
|
CREATE INDEX "InfrastructureAlertRead_userId_readAt_idx"
|
||||||
|
ON "InfrastructureAlertRead"("userId", "readAt");
|
||||||
+370
-5
@@ -43,6 +43,7 @@ model Tenant {
|
|||||||
smsUplinkMessages SmsUplinkMessage[]
|
smsUplinkMessages SmsUplinkMessage[]
|
||||||
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
|
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||||
cmppDownstreamDeliveries CmppDownstreamDelivery[]
|
cmppDownstreamDeliveries CmppDownstreamDelivery[]
|
||||||
|
downstreamRequeueTasks DownstreamRequeueTask[]
|
||||||
cmppDownstreamConnections CmppDownstreamConnection[]
|
cmppDownstreamConnections CmppDownstreamConnection[]
|
||||||
cmppConnectionStates CmppConnectionState[]
|
cmppConnectionStates CmppConnectionState[]
|
||||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||||
@@ -98,9 +99,11 @@ model User {
|
|||||||
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||||
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||||
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
||||||
|
createdDownstreamRequeueTasks DownstreamRequeueTask[] @relation("DownstreamRequeueTaskCreator")
|
||||||
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
||||||
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
||||||
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
||||||
|
infrastructureAlertReads InfrastructureAlertRead[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
@@ -203,6 +206,7 @@ model ProtocolInteractionLog {
|
|||||||
traceId String?
|
traceId String?
|
||||||
requestId String?
|
requestId String?
|
||||||
phoneMasked String?
|
phoneMasked String?
|
||||||
|
phoneNumber String?
|
||||||
resultCode String?
|
resultCode String?
|
||||||
durationMs Int?
|
durationMs Int?
|
||||||
payloadBytes Int?
|
payloadBytes Int?
|
||||||
@@ -450,6 +454,7 @@ model SmsApplication {
|
|||||||
uplinkMessages SmsUplinkMessage[]
|
uplinkMessages SmsUplinkMessage[]
|
||||||
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||||
downstreamDeliveries CmppDownstreamDelivery[]
|
downstreamDeliveries CmppDownstreamDelivery[]
|
||||||
|
downstreamRequeueTasks DownstreamRequeueTask[]
|
||||||
downstreamConnections CmppDownstreamConnection[]
|
downstreamConnections CmppDownstreamConnection[]
|
||||||
connectionStates CmppConnectionState[]
|
connectionStates CmppConnectionState[]
|
||||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||||
@@ -805,6 +810,7 @@ model SmsChannel {
|
|||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
carrier String?
|
carrier String?
|
||||||
|
carriers String[] @default([])
|
||||||
sendRegion String @default("全国")
|
sendRegion String @default("全国")
|
||||||
protocol String @default("CMPP")
|
protocol String @default("CMPP")
|
||||||
gatewayHost String
|
gatewayHost String
|
||||||
@@ -1054,17 +1060,20 @@ model DrainageReportMaterial {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ChannelSignatureReportTask {
|
model ChannelSignatureReportTask {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
signatureId String
|
signatureId String
|
||||||
channelId String
|
channelId String
|
||||||
reportType String @default("signature")
|
carrier String?
|
||||||
|
approvedAt DateTime?
|
||||||
|
approvalScope String @default("legacy_channel")
|
||||||
|
reportType String @default("signature")
|
||||||
drainageItemId String?
|
drainageItemId String?
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
reason String?
|
reason String?
|
||||||
createdById String?
|
createdById String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
signature SmsSignature @relation(fields: [signatureId], references: [id])
|
||||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||||
@@ -1077,10 +1086,152 @@ model ChannelSignatureReportTask {
|
|||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@index([signatureId, channelId])
|
@@index([signatureId, channelId])
|
||||||
|
@@index([signatureId, channelId, carrier])
|
||||||
@@index([signatureId, drainageItemId, channelId])
|
@@index([signatureId, drainageItemId, channelId])
|
||||||
@@index([reportType, status])
|
@@index([reportType, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ruleType String
|
||||||
|
targetId String?
|
||||||
|
targetKey String @default("")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
mobileWindowDays Int @default(30)
|
||||||
|
mobileThreshold Int @default(1)
|
||||||
|
unicomWindowDays Int @default(30)
|
||||||
|
unicomThreshold Int @default(1)
|
||||||
|
telecomWindowDays Int @default(30)
|
||||||
|
telecomThreshold Int @default(1)
|
||||||
|
messageTemplate String?
|
||||||
|
version Int @default(1)
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ruleType, targetKey])
|
||||||
|
@@index([ruleType, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementWebhook {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
platform String
|
||||||
|
urlEncrypted String
|
||||||
|
urlMasked String
|
||||||
|
status String @default("active")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementCycle {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
dimensionType String
|
||||||
|
signatureId String
|
||||||
|
channelId String?
|
||||||
|
channelKey String @default("")
|
||||||
|
carrier String
|
||||||
|
status String @default("open")
|
||||||
|
startedOn DateTime @db.Date
|
||||||
|
lastDetectedOn DateTime @db.Date
|
||||||
|
resolvedOn DateTime? @db.Date
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([dimensionType, signatureId, channelKey, carrier, status])
|
||||||
|
@@index([status, lastDetectedOn])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementDetection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
detectionDate DateTime @db.Date
|
||||||
|
dimensionType String
|
||||||
|
tenantId String
|
||||||
|
applicationId String?
|
||||||
|
signatureId String
|
||||||
|
channelId String?
|
||||||
|
channelKey String @default("")
|
||||||
|
carrier String
|
||||||
|
windowDays Int
|
||||||
|
threshold Int
|
||||||
|
submittedAttempts Int @default(0)
|
||||||
|
acceptedBusinessCount Int @default(0)
|
||||||
|
deliveredBusinessCount Int @default(0)
|
||||||
|
approvedAt DateTime
|
||||||
|
ruleId String?
|
||||||
|
ruleVersion Int @default(1)
|
||||||
|
status String
|
||||||
|
cycleId String?
|
||||||
|
suppressed Boolean @default(false)
|
||||||
|
notificationTitle String?
|
||||||
|
notificationContent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([detectionDate, dimensionType, signatureId, channelKey, carrier])
|
||||||
|
@@index([detectionDate, dimensionType, status])
|
||||||
|
@@index([signatureId, carrier, detectionDate])
|
||||||
|
@@index([channelId, carrier, detectionDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
detectionId String @unique
|
||||||
|
cycleId String
|
||||||
|
tenantId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
suppressed Boolean @default(false)
|
||||||
|
readAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt, isRead, suppressed])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementSuppression {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
dimensionType String
|
||||||
|
signatureId String
|
||||||
|
channelId String?
|
||||||
|
channelKey String @default("")
|
||||||
|
carrier String
|
||||||
|
mode String
|
||||||
|
muteUntil DateTime? @db.Date
|
||||||
|
active Boolean @default(true)
|
||||||
|
reason String?
|
||||||
|
operatorId String?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
cancelledById String?
|
||||||
|
cancelReason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([dimensionType, signatureId, channelKey, carrier])
|
||||||
|
@@index([active, muteUntil])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SignatureRetirementWebhookDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
detectionDate DateTime @db.Date
|
||||||
|
webhookId String
|
||||||
|
groupKey String
|
||||||
|
payload Json
|
||||||
|
status String @default("pending")
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
nextRetryAt DateTime?
|
||||||
|
lastHttpStatus Int?
|
||||||
|
lastError String?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([webhookId, detectionDate, groupKey])
|
||||||
|
@@index([status, nextRetryAt])
|
||||||
|
}
|
||||||
|
|
||||||
model ChannelSignatureReportRecord {
|
model ChannelSignatureReportRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
@@ -1960,6 +2111,7 @@ model CmppDownstreamDelivery {
|
|||||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||||
attempts CmppDownstreamDeliveryAttempt[]
|
attempts CmppDownstreamDeliveryAttempt[]
|
||||||
|
requeueItems DownstreamRequeueTaskItem[]
|
||||||
|
|
||||||
@@index([tenantId, status, createdAt])
|
@@index([tenantId, status, createdAt])
|
||||||
@@index([applicationId, status, createdAt])
|
@@index([applicationId, status, createdAt])
|
||||||
@@ -1969,6 +2121,79 @@ model CmppDownstreamDelivery {
|
|||||||
@@index([status, ackDeadlineAt])
|
@@index([status, ackDeadlineAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DownstreamRequeueTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
taskNo String @unique
|
||||||
|
tenantId String?
|
||||||
|
applicationId String?
|
||||||
|
status String @default("queued")
|
||||||
|
filterSnapshot Json
|
||||||
|
snapshotAt DateTime
|
||||||
|
reason String
|
||||||
|
ratePerSecond Int @default(10)
|
||||||
|
consecutiveFailureLimit Int @default(10)
|
||||||
|
totalCount Int @default(0)
|
||||||
|
successCount Int @default(0)
|
||||||
|
failedCount Int @default(0)
|
||||||
|
skippedCount Int @default(0)
|
||||||
|
waitingCount Int @default(0)
|
||||||
|
consecutiveFailures Int @default(0)
|
||||||
|
applicationFailures Json @default("{}")
|
||||||
|
lastError String?
|
||||||
|
scanLeaseOwner String?
|
||||||
|
scanLeaseUntil DateTime?
|
||||||
|
createdById String?
|
||||||
|
startedAt DateTime?
|
||||||
|
pausedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||||
|
createdBy User? @relation("DownstreamRequeueTaskCreator", fields: [createdById], references: [id])
|
||||||
|
items DownstreamRequeueTaskItem[]
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([applicationId, status, createdAt])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownstreamRequeueRateWindow {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
applicationId String
|
||||||
|
windowStartedAt DateTime
|
||||||
|
consumed Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([applicationId, windowStartedAt])
|
||||||
|
@@index([windowStartedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownstreamRequeueTaskItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
taskId String
|
||||||
|
deliveryId String
|
||||||
|
applicationId String
|
||||||
|
status String @default("queued")
|
||||||
|
previousStatus String
|
||||||
|
skipReason String?
|
||||||
|
errorMessage String?
|
||||||
|
claimedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
task DownstreamRequeueTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
delivery CmppDownstreamDelivery @relation(fields: [deliveryId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([taskId, deliveryId])
|
||||||
|
@@index([taskId, status, createdAt])
|
||||||
|
@@index([applicationId, status, createdAt])
|
||||||
|
@@index([deliveryId, status])
|
||||||
|
}
|
||||||
|
|
||||||
model CmppDownstreamDeliveryAttempt {
|
model CmppDownstreamDeliveryAttempt {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
deliveryId String
|
deliveryId String
|
||||||
@@ -2097,3 +2322,143 @@ model GatewayDownstreamRecoveryStatus {
|
|||||||
@@index([state, updatedAt])
|
@@index([state, updatedAt])
|
||||||
@@index([nextRetryAt])
|
@@index([nextRetryAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SecurityDetectionRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
sourceType String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
threshold Int
|
||||||
|
windowSeconds Int
|
||||||
|
cooldownSeconds Int
|
||||||
|
severity String
|
||||||
|
defaultBlockSeconds Int
|
||||||
|
maximumBlockSeconds Int
|
||||||
|
configVersion Int @default(1)
|
||||||
|
effectiveVersion Int @default(0)
|
||||||
|
applyStatus String @default("pending")
|
||||||
|
lastApplyError String?
|
||||||
|
pendingConfig Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
events SecurityDetectionEvent[]
|
||||||
|
alerts SecurityAlert[]
|
||||||
|
|
||||||
|
@@index([enabled, sourceType])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityDetectionEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventKey String @unique
|
||||||
|
ruleId String
|
||||||
|
sourceIp String
|
||||||
|
sourcePort Int?
|
||||||
|
accountHash String?
|
||||||
|
path String?
|
||||||
|
protocol String?
|
||||||
|
resultCode String?
|
||||||
|
evidence Json?
|
||||||
|
occurredAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([ruleId, occurredAt])
|
||||||
|
@@index([sourceIp, occurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityAlert {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fingerprint String @unique
|
||||||
|
ruleId String
|
||||||
|
sourceIp String
|
||||||
|
severity String
|
||||||
|
status String @default("open")
|
||||||
|
eventCount Int @default(0)
|
||||||
|
windowStartedAt DateTime
|
||||||
|
firstOccurredAt DateTime
|
||||||
|
lastOccurredAt DateTime
|
||||||
|
acknowledgedAt DateTime?
|
||||||
|
acknowledgedById String?
|
||||||
|
ignoredAt DateTime?
|
||||||
|
ignoredById String?
|
||||||
|
ignoreReason String?
|
||||||
|
blockId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rule SecurityDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([status, severity, lastOccurredAt])
|
||||||
|
@@index([sourceIp, status, lastOccurredAt])
|
||||||
|
@@index([ruleId, status, lastOccurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityBlock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
operationKey String @unique
|
||||||
|
alertId String?
|
||||||
|
sourceIp String
|
||||||
|
executor String
|
||||||
|
status String @default("requested")
|
||||||
|
durationSeconds Int
|
||||||
|
reason String
|
||||||
|
requestedById String
|
||||||
|
requestedAt DateTime @default(now())
|
||||||
|
appliedAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
releasedAt DateTime?
|
||||||
|
releasedById String?
|
||||||
|
executorReference String?
|
||||||
|
lastError String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([sourceIp, status, requestedAt])
|
||||||
|
@@index([alertId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityProtectedNetwork {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
network String @unique
|
||||||
|
name String
|
||||||
|
reason String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([enabled, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InfrastructureAlertSetting {
|
||||||
|
id String @id @default("global")
|
||||||
|
configVersion Int @default(1)
|
||||||
|
effectiveVersion Int @default(1)
|
||||||
|
thresholds Json
|
||||||
|
effectiveThresholds Json
|
||||||
|
applyStatus String @default("effective")
|
||||||
|
lastError String?
|
||||||
|
updatedById String?
|
||||||
|
appliedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model InfrastructureAlertRead {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fingerprint String
|
||||||
|
activeAt DateTime
|
||||||
|
userId String
|
||||||
|
readAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fingerprint, userId])
|
||||||
|
@@index([userId, readAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
|
|||||||
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { InfrastructureMonitoringModule } from './infrastructure-monitoring/infrastructure-monitoring.module';
|
||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
import { OpenApiModule } from './open-api/open-api.module';
|
import { OpenApiModule } from './open-api/open-api.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -23,6 +24,9 @@ import { SendChainModule } from './send-chain/send-chain.module';
|
|||||||
import { SmsConfigModule } from './sms-config/sms-config.module';
|
import { SmsConfigModule } from './sms-config/sms-config.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
|
||||||
|
import { SecurityDetectionModule } from './security-detection/security-detection.module';
|
||||||
|
import { MetricsModule } from './metrics/metrics.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -48,7 +52,11 @@ import { UsersModule } from './users/users.module';
|
|||||||
ReportMaterialsModule,
|
ReportMaterialsModule,
|
||||||
SendChainModule,
|
SendChainModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
|
InfrastructureMonitoringModule,
|
||||||
OpenApiModule,
|
OpenApiModule,
|
||||||
|
SignatureRetirementModule,
|
||||||
|
SecurityDetectionModule,
|
||||||
|
MetricsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { SessionRequest } from './session-validation.middleware';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { requestContext } from '../common/request-context';
|
||||||
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||||
|
|
||||||
type CookieResponse = {
|
type CookieResponse = {
|
||||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||||
@@ -16,7 +18,7 @@ type CookieResponse = {
|
|||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
|
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||||
|
|
||||||
@Get('admin/auth/captcha')
|
@Get('admin/auth/captcha')
|
||||||
adminCaptcha() {
|
adminCaptcha() {
|
||||||
@@ -25,7 +27,14 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('admin/auth/login')
|
@Post('admin/auth/login')
|
||||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
|
try {
|
||||||
|
result = await this.auth.login(body, 'admin');
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.finishLogin(result, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('client/auth/captcha')
|
@Get('client/auth/captcha')
|
||||||
@@ -35,7 +44,14 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('client/auth/login')
|
@Post('client/auth/login')
|
||||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
|
try {
|
||||||
|
result = await this.auth.login(body, 'client');
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.finishLogin(result, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(['admin/auth/session', 'client/auth/session'])
|
@Get(['admin/auth/session', 'client/auth/session'])
|
||||||
@@ -121,6 +137,17 @@ export class AuthController {
|
|||||||
return publicResult;
|
return publicResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
|
||||||
|
return this.security.recordEvent({
|
||||||
|
ruleCode,
|
||||||
|
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
|
||||||
|
account,
|
||||||
|
protocol: 'http',
|
||||||
|
path: ruleCode === 'admin_login_failure' ? '/admin/auth/login' : '/client/auth/login',
|
||||||
|
evidence: { userAgent: request.header('user-agent')?.slice(0, 256) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
||||||
return this.prisma.operationLog.create({
|
return this.prisma.operationLog.create({
|
||||||
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
|
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
|
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [UsersModule, SecurityDetectionModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ function createPrismaMock() {
|
|||||||
tenant: {
|
tenant: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
tenantAccount: {
|
tenantAccount: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||||
@@ -181,9 +184,10 @@ describe('BillingService', () => {
|
|||||||
it('returns the historical balance after each manual recharge', async () => {
|
it('returns the historical balance after each manual recharge', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.rechargeOrder.findMany.mockResolvedValue([
|
prisma.rechargeOrder.findMany.mockResolvedValue([
|
||||||
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 },
|
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000, operatorId: 'admin-1' },
|
||||||
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
||||||
]);
|
]);
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]);
|
||||||
prisma.accountTransaction.findMany.mockResolvedValue([
|
prisma.accountTransaction.findMany.mockResolvedValue([
|
||||||
{ relatedId: 'order-1', balanceAfter: 3000 },
|
{ relatedId: 'order-1', balanceAfter: 3000 },
|
||||||
{ relatedId: 'order-2', balanceAfter: 2700 },
|
{ relatedId: 'order-2', balanceAfter: 2700 },
|
||||||
@@ -191,8 +195,8 @@ describe('BillingService', () => {
|
|||||||
const service = new BillingService(prisma as never);
|
const service = new BillingService(prisma as never);
|
||||||
|
|
||||||
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
||||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
|
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }),
|
||||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
|
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }),
|
||||||
]);
|
]);
|
||||||
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
@@ -201,6 +205,10 @@ describe('BillingService', () => {
|
|||||||
},
|
},
|
||||||
select: { relatedId: true, balanceAfter: true },
|
select: { relatedId: true, balanceAfter: true },
|
||||||
});
|
});
|
||||||
|
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: ['admin-1'] } },
|
||||||
|
select: { id: true, displayName: true, username: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows negative manual recharge amounts for balance correction', async () => {
|
it('allows negative manual recharge amounts for balance correction', async () => {
|
||||||
|
|||||||
@@ -163,18 +163,27 @@ export class BillingService {
|
|||||||
return orders;
|
return orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactions = await this.prisma.accountTransaction.findMany({
|
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||||
where: {
|
const [transactions, operators] = await Promise.all([
|
||||||
relatedType: 'recharge_order',
|
this.prisma.accountTransaction.findMany({
|
||||||
relatedId: { in: orderIds },
|
where: {
|
||||||
},
|
relatedType: 'recharge_order',
|
||||||
select: { relatedId: true, balanceAfter: true },
|
relatedId: { in: orderIds },
|
||||||
});
|
},
|
||||||
|
select: { relatedId: true, balanceAfter: true },
|
||||||
|
}),
|
||||||
|
operatorIds.length ? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: operatorIds } },
|
||||||
|
select: { id: true, displayName: true, username: true },
|
||||||
|
}) : [],
|
||||||
|
]);
|
||||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
||||||
|
const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||||
|
|
||||||
return orders.map((order) => ({
|
return orders.map((order) => ({
|
||||||
...order,
|
...order,
|
||||||
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
||||||
|
operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +204,25 @@ export class BillingService {
|
|||||||
this.prisma.rechargeOrder.count({ where }),
|
this.prisma.rechargeOrder.count({ where }),
|
||||||
]);
|
]);
|
||||||
const orderIds = orders.map((order) => order.id);
|
const orderIds = orders.map((order) => order.id);
|
||||||
const transactions = orderIds.length ? await this.prisma.accountTransaction.findMany({
|
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||||
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
const [transactions, operators] = await Promise.all([
|
||||||
select: { relatedId: true, balanceAfter: true },
|
orderIds.length ? this.prisma.accountTransaction.findMany({
|
||||||
}) : [];
|
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
||||||
|
select: { relatedId: true, balanceAfter: true },
|
||||||
|
}) : [],
|
||||||
|
operatorIds.length ? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: operatorIds } },
|
||||||
|
select: { id: true, displayName: true, username: true },
|
||||||
|
}) : [],
|
||||||
|
]);
|
||||||
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
|
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
|
||||||
|
const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||||
return {
|
return {
|
||||||
items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null })),
|
items: orders.map((order) => ({
|
||||||
|
...order,
|
||||||
|
balanceAfterCents: balances.get(order.id) ?? null,
|
||||||
|
operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null,
|
||||||
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import 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 { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||||
import { ChannelConnectionService } from './channel-connection.service';
|
import { ChannelConnectionService } from './channel-connection.service';
|
||||||
|
|
||||||
/** R5 channel domain service composed behind ChannelsService. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
@@ -25,19 +25,36 @@ export class ChannelConfigurationService {
|
|||||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||||
const where: Prisma.SmsChannelWhereInput = {
|
const where: Prisma.SmsChannelWhereInput = {
|
||||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||||
carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined,
|
carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined,
|
||||||
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||||
};
|
};
|
||||||
const [items, total] = await Promise.all([
|
const candidates = await this.prisma.smsChannel.findMany({ where, select: { id: true, name: true } });
|
||||||
this.prisma.smsChannel.findMany({
|
const total = candidates.length;
|
||||||
where,
|
if (total === 0) return { items: [], total, page, pageSize };
|
||||||
include: { connectionStates: true },
|
const day = currentShanghaiDayRange();
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
const counts = await this.prisma.$queryRaw<Array<{ channelId: string; total: number }>>(Prisma.sql`
|
||||||
skip: (page - 1) * pageSize,
|
SELECT submit."channelId" AS "channelId", COUNT(*)::integer AS total
|
||||||
take: pageSize,
|
FROM "SmsSubmitRecord" submit
|
||||||
}),
|
WHERE submit."channelId" IN (${Prisma.join(candidates.map((channel) => channel.id))})
|
||||||
this.prisma.smsChannel.count({ where }),
|
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||||
]);
|
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||||
|
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||||
|
GROUP BY submit."channelId"
|
||||||
|
`);
|
||||||
|
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
|
||||||
|
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
|
||||||
|
const pageIds = candidates
|
||||||
|
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|
||||||
|
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||||
|
|| left.id.localeCompare(right.id))
|
||||||
|
.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
.map((channel) => channel.id);
|
||||||
|
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } });
|
||||||
|
const itemById = new Map(pageItems.map((item) => [item.id, item]));
|
||||||
|
const items = pageIds.flatMap((id) => {
|
||||||
|
const item = itemById.get(id);
|
||||||
|
return item ? [item] : [];
|
||||||
|
});
|
||||||
return { items, total, page, pageSize };
|
return { items, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +81,13 @@ export class ChannelConfigurationService {
|
|||||||
data.heartbeatMissThreshold,
|
data.heartbeatMissThreshold,
|
||||||
);
|
);
|
||||||
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
|
const carriers = normalizeChannelCarriers(data.carriers, data.carrier);
|
||||||
const channel = await this.prisma.smsChannel.create({
|
const channel = await this.prisma.smsChannel.create({
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
carrier: data.carrier,
|
carrier: legacyCarrierFromCapabilities(carriers),
|
||||||
|
carriers,
|
||||||
sendRegion: data.sendRegion ?? '全国',
|
sendRegion: data.sendRegion ?? '全国',
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: data.gatewayHost,
|
gatewayHost: data.gatewayHost,
|
||||||
@@ -120,6 +139,22 @@ export class ChannelConfigurationService {
|
|||||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
|
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
|
||||||
|
const carriers = data.carriers !== undefined || data.carrier !== undefined
|
||||||
|
? normalizeChannelCarriers(data.carriers, data.carrier)
|
||||||
|
: existingCarriers;
|
||||||
|
if (data.carriers !== undefined || data.carrier !== undefined) {
|
||||||
|
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
|
||||||
|
if (removed.length) {
|
||||||
|
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
|
||||||
|
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
|
||||||
|
include: { group: true },
|
||||||
|
});
|
||||||
|
if (blockingGroups.length) {
|
||||||
|
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||||
@@ -133,7 +168,8 @@ export class ChannelConfigurationService {
|
|||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
carrier: data.carrier,
|
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
|
||||||
|
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
|
||||||
sendRegion: data.sendRegion,
|
sendRegion: data.sendRegion,
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: data.gatewayHost,
|
gatewayHost: data.gatewayHost,
|
||||||
@@ -159,6 +195,7 @@ export class ChannelConfigurationService {
|
|||||||
code: channel.code,
|
code: channel.code,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
carrier: channel.carrier,
|
carrier: channel.carrier,
|
||||||
|
carriers: channel.carriers,
|
||||||
sendRegion: channel.sendRegion,
|
sendRegion: channel.sendRegion,
|
||||||
gatewayHost: channel.gatewayHost,
|
gatewayHost: channel.gatewayHost,
|
||||||
gatewayPort: channel.gatewayPort,
|
gatewayPort: channel.gatewayPort,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class ChannelCopyService {
|
|||||||
code: nextCode,
|
code: nextCode,
|
||||||
name: nextName,
|
name: nextName,
|
||||||
carrier: source.carrier,
|
carrier: source.carrier,
|
||||||
|
carriers: source.carriers,
|
||||||
protocol: source.protocol,
|
protocol: source.protocol,
|
||||||
gatewayHost: source.gatewayHost,
|
gatewayHost: source.gatewayHost,
|
||||||
gatewayPort: source.gatewayPort,
|
gatewayPort: source.gatewayPort,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class ChannelGroupRoutingService {
|
|||||||
|
|
||||||
listGroups() {
|
listGroups() {
|
||||||
return this.prisma.smsChannelGroup.findMany({
|
return this.prisma.smsChannelGroup.findMany({
|
||||||
|
where: { status: { not: 'deleted' } },
|
||||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -51,7 +52,7 @@ export class ChannelGroupRoutingService {
|
|||||||
if (!channel) {
|
if (!channel) {
|
||||||
throw new NotFoundException('Channel not found');
|
throw new NotFoundException('Channel not found');
|
||||||
}
|
}
|
||||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
|
||||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||||
}
|
}
|
||||||
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
||||||
@@ -158,23 +159,78 @@ export class ChannelGroupRoutingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGroup(groupId: string) {
|
async getGroupDeletionImpact(groupId: string) {
|
||||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
const group = await this.prisma.smsChannelGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
select: { id: true, name: true, items: { select: { id: true } } },
|
||||||
|
});
|
||||||
if (!group) {
|
if (!group) {
|
||||||
throw new NotFoundException('Channel group not found');
|
throw new NotFoundException('Channel group not found');
|
||||||
}
|
}
|
||||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
const routes = await this.prisma.channelRouteRule.findMany({
|
||||||
where: {
|
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
|
||||||
groupId,
|
select: { applicationId: true },
|
||||||
status: 'active',
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
if (boundRoute) {
|
const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))];
|
||||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
const [applications, pendingSupplierSubmitCount] = await Promise.all([
|
||||||
|
this.prisma.smsApplication.findMany({
|
||||||
|
where: { id: { in: applicationIds } },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
}),
|
||||||
|
this.prisma.smsSubmitRecord.count({
|
||||||
|
where: { channelGroupId: groupId, submitStatus: 'queued' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const applicationStatusById = new Map(applications.map((application) => [application.id, application.status]));
|
||||||
|
const deletedApplicationCount = applicationIds.filter((applicationId) => {
|
||||||
|
const status = applicationStatusById.get(applicationId);
|
||||||
|
return status === undefined || status === 'deleted';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
normalApplicationCount: applicationIds.length - deletedApplicationCount,
|
||||||
|
deletedApplicationCount,
|
||||||
|
channelCount: group.items.length,
|
||||||
|
pendingSupplierSubmitCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGroup(groupId: string) {
|
||||||
|
const group = await this.prisma.smsChannelGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundException('Channel group not found');
|
||||||
}
|
}
|
||||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
if (group.status === 'deleted') {
|
||||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
return group;
|
||||||
|
}
|
||||||
|
const impact = await this.getGroupDeletionImpact(groupId);
|
||||||
|
|
||||||
|
// Logical deletion keeps group items and route bindings available for historical
|
||||||
|
// receipts and uplink access-number matching; new submits already require an active group.
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const deleted = await tx.smsChannelGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data: { status: 'deleted' },
|
||||||
|
});
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'sms_channel_group.delete',
|
||||||
|
resource: 'sms_channel_group',
|
||||||
|
resourceId: groupId,
|
||||||
|
detail: {
|
||||||
|
before: channelGroupAuditSnapshot(group),
|
||||||
|
impact,
|
||||||
|
deletionMode: 'soft_delete',
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return deleted;
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
}
|
}
|
||||||
|
|
||||||
listRouteRules() {
|
listRouteRules() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import 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 { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||||
|
|
||||||
|
|
||||||
/** R5 channel domain service composed behind ChannelsService. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
@@ -325,11 +325,24 @@ export class ChannelReportingService {
|
|||||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||||
}
|
}
|
||||||
|
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||||
|
if (!channel) throw new NotFoundException('Channel not found');
|
||||||
|
if (!data.carrier) throw new BadRequestException('签名报备任务必须指定运营商');
|
||||||
|
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||||
|
if (!normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
||||||
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.channelSignatureReportTask.findFirst({
|
||||||
|
where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null },
|
||||||
|
});
|
||||||
|
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
|
||||||
const task = await this.prisma.channelSignatureReportTask.create({
|
const task = await this.prisma.channelSignatureReportTask.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
signatureId: data.signatureId,
|
signatureId: data.signatureId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
|
carrier,
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
reportType,
|
reportType,
|
||||||
drainageItemId: undefined,
|
drainageItemId: undefined,
|
||||||
createdById: data.createdById,
|
createdById: data.createdById,
|
||||||
@@ -364,11 +377,25 @@ export class ChannelReportingService {
|
|||||||
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
||||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
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 } });
|
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
|
||||||
|
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
|
||||||
|
throw new BadRequestException('报备运营商不在通道支持范围内');
|
||||||
|
}
|
||||||
|
const existing = await tx.channelSignatureReportTask.findFirst({ where: {
|
||||||
|
signatureId: item.signatureId,
|
||||||
|
channelId: item.channelId,
|
||||||
|
reportType,
|
||||||
|
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
|
||||||
|
carrier: reportType === 'signature' ? carrier : null,
|
||||||
|
} });
|
||||||
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
||||||
|
if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商');
|
||||||
|
const approvedAt = item.status === 'approved'
|
||||||
|
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date()
|
||||||
|
: null;
|
||||||
const task = existing
|
const task = existing
|
||||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
|
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } })
|
||||||
: 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.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, carrier, approvalScope: 'carrier_specific', approvedAt, 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 } });
|
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 });
|
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
||||||
}
|
}
|
||||||
@@ -389,13 +416,21 @@ export class ChannelReportingService {
|
|||||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
||||||
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
||||||
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
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 carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||||
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
||||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
const statuses = targets.map((channel) => {
|
||||||
|
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
|
||||||
|
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
|
||||||
|
return task?.status ?? 'pending';
|
||||||
|
});
|
||||||
return [carrier, summarizeReportStatuses(statuses)];
|
return [carrier, summarizeReportStatuses(statuses)];
|
||||||
}));
|
}));
|
||||||
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
|
||||||
|
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
||||||
|
return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status
|
||||||
|
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status
|
||||||
|
?? 'pending');
|
||||||
|
});
|
||||||
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
||||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||||
return { signatureId, reportStatus, carrierReportSummary };
|
return { signatureId, reportStatus, carrierReportSummary };
|
||||||
@@ -514,7 +549,13 @@ export class ChannelReportingService {
|
|||||||
) {
|
) {
|
||||||
await this.prisma.channelSignatureReportTask.update({
|
await this.prisma.channelSignatureReportTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: { status: statusAfter, reason },
|
data: {
|
||||||
|
status: statusAfter,
|
||||||
|
reason,
|
||||||
|
...((await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, select: { reportType: true, status: true, approvedAt: true } }))?.reportType === 'signature'
|
||||||
|
? { approvedAt: statusAfter === 'approved' ? statusBefore === 'approved' ? undefined : new Date() : null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface CreateChannelDto {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
carrier?: string;
|
carrier?: string;
|
||||||
|
carriers?: string[];
|
||||||
sendRegion?: string;
|
sendRegion?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
gatewayHost: string;
|
gatewayHost: string;
|
||||||
@@ -104,13 +105,14 @@ export interface CreateReportTaskDto {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
signatureId: string;
|
signatureId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
carrier?: string;
|
||||||
reportType?: 'signature' | 'drainage';
|
reportType?: 'signature' | 'drainage';
|
||||||
drainageItemId?: string;
|
drainageItemId?: string;
|
||||||
createdById?: string;
|
createdById?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeReportTaskStatusesDto {
|
export interface ChangeReportTaskStatusesDto {
|
||||||
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
items: Array<{ signatureId: string; channelId: string; carrier?: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ export class ChannelsController {
|
|||||||
return this.channels.updateGroup(groupId, body);
|
return this.channels.updateGroup(groupId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('channel-groups/:id/deletion-impact')
|
||||||
|
getGroupDeletionImpact(@Param('id') groupId: string) {
|
||||||
|
return this.channels.getGroupDeletionImpact(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('channel-groups/:id')
|
@Delete('channel-groups/:id')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
deleteGroup(@Param('id') groupId: string) {
|
deleteGroup(@Param('id') groupId: string) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
isChannelCarrierCompatible,
|
||||||
|
legacyCarrierFromCapabilities,
|
||||||
|
normalizeChannelCarriers,
|
||||||
|
} from './channels.helpers';
|
||||||
|
|
||||||
|
describe('channel carrier capabilities', () => {
|
||||||
|
it('preserves the legacy default when an old caller omits carrier fields', () => {
|
||||||
|
expect(normalizeChannelCarriers()).toEqual(['mobile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a historical three-network channel without inventing data for partial capabilities', () => {
|
||||||
|
expect(normalizeChannelCarriers(undefined, 'all')).toEqual(['mobile', 'unicom', 'telecom']);
|
||||||
|
expect(normalizeChannelCarriers(['telecom', 'mobile'], 'all')).toEqual(['mobile', 'telecom']);
|
||||||
|
expect(legacyCarrierFromCapabilities(['mobile', 'telecom'])).toBe('multi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks a group carrier against the new multi-select capability list', () => {
|
||||||
|
expect(isChannelCarrierCompatible('multi', 'mobile', ['mobile', 'telecom'])).toBe(true);
|
||||||
|
expect(isChannelCarrierCompatible('multi', 'unicom', ['mobile', 'telecom'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||||
|
|
||||||
|
export function summarizeReportStatuses(statuses: string[]) {
|
||||||
|
return summarizeCommonReportStatuses(statuses);
|
||||||
|
}
|
||||||
|
|
||||||
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
||||||
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||||
|
|
||||||
@@ -593,9 +598,29 @@ export function normalizeChannelCarrier(carrier?: string | null) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
|
export const SUPPORTED_CHANNEL_CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
const normalized = normalizeChannelCarrier(channelCarrier);
|
|
||||||
return normalized === 'all' || normalized === groupCarrier;
|
export function normalizeChannelCarriers(carriers?: string[] | null, legacyCarrier?: string | null): string[] {
|
||||||
|
const source = carriers?.length
|
||||||
|
? carriers
|
||||||
|
: normalizeChannelCarrier(legacyCarrier ?? 'mobile') === 'all'
|
||||||
|
? [...SUPPORTED_CHANNEL_CARRIERS]
|
||||||
|
: [normalizeChannelCarrier(legacyCarrier ?? 'mobile')];
|
||||||
|
const normalized = [...new Set(source.map((carrier) => normalizeBusinessCarrier(carrier)))];
|
||||||
|
if (normalized.length === 0) throw new BadRequestException('至少选择一个运营商');
|
||||||
|
return SUPPORTED_CHANNEL_CARRIERS.filter((carrier) => normalized.includes(carrier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyCarrierFromCapabilities(carriers: string[]) {
|
||||||
|
if (carriers.length === 1) return carriers[0];
|
||||||
|
if (carriers.length === SUPPORTED_CHANNEL_CARRIERS.length) return 'all';
|
||||||
|
// Old readers must fail closed for a two-carrier channel instead of treating
|
||||||
|
// it as three-network capable and accidentally routing unsupported traffic.
|
||||||
|
return 'multi';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
|
||||||
|
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRegion(region?: string | null) {
|
export function normalizeRegion(region?: string | null) {
|
||||||
@@ -609,7 +634,7 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
|
|||||||
export function validateGroupItems(
|
export function validateGroupItems(
|
||||||
groupCarrier: string,
|
groupCarrier: string,
|
||||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||||
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
|
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
|
||||||
) {
|
) {
|
||||||
const channelIds = new Set<string>();
|
const channelIds = new Set<string>();
|
||||||
const provinces = new Set<string>();
|
const provinces = new Set<string>();
|
||||||
@@ -627,7 +652,7 @@ export function validateGroupItems(
|
|||||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||||
}
|
}
|
||||||
channelIds.add(item.channelId);
|
channelIds.add(item.channelId);
|
||||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
|
||||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||||
}
|
}
|
||||||
if (item.province) {
|
if (item.province) {
|
||||||
@@ -654,17 +679,6 @@ export function normalizeReportType(value?: string) {
|
|||||||
throw new BadRequestException('reportType must be signature, drainage or both');
|
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) {
|
export function normalizeLinkEvent(action: string) {
|
||||||
if (action.includes('connect_requested')) {
|
if (action.includes('connect_requested')) {
|
||||||
return '连接请求';
|
return '连接请求';
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
|
|||||||
})));
|
})));
|
||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', reportType: 'signature', status: 'pending' };
|
||||||
const channel = {
|
const channel = {
|
||||||
id: 'channel-1',
|
id: 'channel-1',
|
||||||
code: 'CMPP-A',
|
code: 'CMPP-A',
|
||||||
name: '主通道',
|
name: '主通道',
|
||||||
carrier: 'mobile',
|
carrier: 'mobile',
|
||||||
|
carriers: ['mobile'],
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
gatewayHost: '127.0.0.1',
|
gatewayHost: '127.0.0.1',
|
||||||
gatewayPort: 17890,
|
gatewayPort: 17890,
|
||||||
@@ -81,13 +82,14 @@ function createPrismaMock() {
|
|||||||
channelHealthMetric: { findMany: jest.fn() },
|
channelHealthMetric: { findMany: jest.fn() },
|
||||||
smsChannelGroup: {
|
smsChannelGroup: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||||
},
|
},
|
||||||
smsChannelGroupItem: {
|
smsChannelGroupItem: {
|
||||||
deleteMany: jest.fn(),
|
deleteMany: jest.fn(),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findFirst: jest.fn().mockResolvedValue(null),
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
||||||
},
|
},
|
||||||
@@ -115,6 +117,7 @@ function createPrismaMock() {
|
|||||||
channelSignatureReportTask: {
|
channelSignatureReportTask: {
|
||||||
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
|
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
|
||||||
create: jest.fn().mockResolvedValue(reportTask),
|
create: jest.fn().mockResolvedValue(reportTask),
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
findUnique: jest.fn().mockResolvedValue(reportTask),
|
findUnique: jest.fn().mockResolvedValue(reportTask),
|
||||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
||||||
},
|
},
|
||||||
@@ -137,6 +140,7 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
tenant: {
|
tenant: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
|
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
|
||||||
@@ -153,6 +157,7 @@ function createPrismaMock() {
|
|||||||
},
|
},
|
||||||
smsSubmitRecord: {
|
smsSubmitRecord: {
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
cmppConnectionState: {
|
cmppConnectionState: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
@@ -169,6 +174,51 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ChannelsService', () => {
|
describe('ChannelsService', () => {
|
||||||
|
it('sorts all filtered channels by today submit count before pagination', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const candidates = [
|
||||||
|
{ id: 'channel-low', name: '乙通道' },
|
||||||
|
{ id: 'channel-high', name: '甲通道' },
|
||||||
|
{ id: 'channel-zero', name: '丙通道' },
|
||||||
|
];
|
||||||
|
const fullChannels = candidates.map((channel) => ({ ...channel, connectionStates: [] }));
|
||||||
|
prisma.smsChannel.findMany
|
||||||
|
.mockResolvedValueOnce(candidates)
|
||||||
|
.mockResolvedValueOnce([fullChannels[0], fullChannels[1]]);
|
||||||
|
prisma.$queryRaw.mockResolvedValue([
|
||||||
|
{ channelId: 'channel-low', total: 3 },
|
||||||
|
{ channelId: 'channel-high', total: 12 },
|
||||||
|
]);
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
const result = await service.listChannelsPage({ page: 1, pageSize: 2 });
|
||||||
|
|
||||||
|
expect(result.items.map((channel) => channel.id)).toEqual(['channel-high', 'channel-low']);
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
expect(prisma.smsChannel.findMany).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: { in: ['channel-high', 'channel-low'] } },
|
||||||
|
include: { connectionStates: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses channel name and id as a stable tie breaker for zero-submit channels', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const candidates = [
|
||||||
|
{ id: 'channel-b', name: 'A通道' },
|
||||||
|
{ id: 'channel-a', name: 'A通道' },
|
||||||
|
{ id: 'channel-c', name: 'B通道' },
|
||||||
|
];
|
||||||
|
prisma.smsChannel.findMany
|
||||||
|
.mockResolvedValueOnce(candidates)
|
||||||
|
.mockResolvedValueOnce(candidates);
|
||||||
|
prisma.$queryRaw.mockResolvedValue([]);
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
const result = await service.listChannelsPage({ page: 1, pageSize: 10 });
|
||||||
|
|
||||||
|
expect(result.items.map((channel) => channel.id)).toEqual(['channel-a', 'channel-b', 'channel-c']);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates channel report requirements only from the report field library', async () => {
|
it('creates channel report requirements only from the report field library', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
@@ -309,13 +359,13 @@ describe('ChannelsService', () => {
|
|||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||||
},
|
},
|
||||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
|
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }) },
|
||||||
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
|
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
|
||||||
channelSignatureReportTask: {
|
channelSignatureReportTask: {
|
||||||
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
|
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
|
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]),
|
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' } }]),
|
||||||
},
|
},
|
||||||
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
||||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
|
||||||
@@ -323,13 +373,57 @@ describe('ChannelsService', () => {
|
|||||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
|
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
|
||||||
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
||||||
]);
|
]);
|
||||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
|
||||||
|
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'task-1' },
|
||||||
|
data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }),
|
||||||
|
});
|
||||||
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const tx = {
|
||||||
|
smsSignature: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||||
|
},
|
||||||
|
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }) },
|
||||||
|
smsDrainageInfo: { findUnique: jest.fn() },
|
||||||
|
channelSignatureReportTask: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn(),
|
||||||
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })),
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' } },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
||||||
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
await service.changeReportTaskStatuses({
|
||||||
|
items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }],
|
||||||
|
sourceEntry: 'enterprise_signature',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tx.channelSignatureReportTask.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
signatureId: 'sig-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
carrier: 'mobile',
|
||||||
|
approvalScope: 'carrier_specific',
|
||||||
|
status: 'approved',
|
||||||
|
approvedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
@@ -353,7 +447,7 @@ describe('ChannelsService', () => {
|
|||||||
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
|
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
|
||||||
reason: '引流信息已报备',
|
reason: '引流信息已报备',
|
||||||
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
|
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
|
||||||
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } });
|
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', carrier: null } });
|
||||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
|
||||||
expect(tx.smsSignature.update).not.toHaveBeenCalled();
|
expect(tx.smsSignature.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -866,16 +960,60 @@ describe('ChannelsService', () => {
|
|||||||
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes channel groups only when no active route rule is bound', async () => {
|
it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] });
|
||||||
|
prisma.channelRouteRule.findMany.mockResolvedValueOnce([
|
||||||
|
{ applicationId: 'app-active' },
|
||||||
|
{ applicationId: 'app-active' },
|
||||||
|
{ applicationId: 'app-deleted' },
|
||||||
|
{ applicationId: 'app-missing' },
|
||||||
|
]);
|
||||||
|
prisma.smsApplication.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: 'app-active', status: 'active' },
|
||||||
|
{ id: 'app-deleted', status: 'deleted' },
|
||||||
|
]);
|
||||||
|
prisma.smsSubmitRecord.count.mockResolvedValueOnce(2);
|
||||||
|
|
||||||
await service.deleteGroup('group-1');
|
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
|
||||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
|
groupId: 'group-1',
|
||||||
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
groupName: '移动主通道组',
|
||||||
|
normalApplicationCount: 1,
|
||||||
|
deletedApplicationCount: 2,
|
||||||
|
channelCount: 2,
|
||||||
|
pendingSupplierSubmitCount: 2,
|
||||||
|
});
|
||||||
|
expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({
|
||||||
|
where: { channelGroupId: 'group-1', submitStatus: 'queued' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
|
it('logically deletes channel groups without removing application bindings or group items', async () => {
|
||||||
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
|
const prisma = createPrismaMock();
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' });
|
||||||
|
const operationLogCreate = jest.fn();
|
||||||
|
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]);
|
||||||
|
prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]);
|
||||||
|
prisma.$transaction.mockImplementationOnce((callback) => callback({
|
||||||
|
smsChannelGroup: { update: groupUpdate },
|
||||||
|
operationLog: { create: operationLogCreate },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' });
|
||||||
|
expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } });
|
||||||
|
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled();
|
||||||
|
expect(operationLogCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'sms_channel_group.delete',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
deletionMode: 'soft_delete',
|
||||||
|
impact: expect.objectContaining({ normalApplicationCount: 1 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upserts signature report material per channel field', async () => {
|
it('upserts signature report material per channel field', async () => {
|
||||||
@@ -901,7 +1039,7 @@ describe('ChannelsService', () => {
|
|||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', createdById: 'user-1' });
|
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', createdById: 'user-1' });
|
||||||
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
|
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
|
||||||
await service.importReportReceipt('report-task-1', {
|
await service.importReportReceipt('report-task-1', {
|
||||||
fileName: 'receipt.csv',
|
fileName: 'receipt.csv',
|
||||||
@@ -916,11 +1054,11 @@ describe('ChannelsService', () => {
|
|||||||
});
|
});
|
||||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'report-task-1' },
|
where: { id: 'report-task-1' },
|
||||||
data: { status: 'exporting', reason: undefined },
|
data: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }),
|
||||||
});
|
});
|
||||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'report-task-1' },
|
where: { id: 'report-task-1' },
|
||||||
data: { status: 'partial', reason: 'one rejected' },
|
data: expect.objectContaining({ status: 'partial', reason: 'one rejected', approvedAt: null }),
|
||||||
});
|
});
|
||||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'sig-1' },
|
where: { id: 'sig-1' },
|
||||||
@@ -951,7 +1089,7 @@ describe('ChannelsService', () => {
|
|||||||
});
|
});
|
||||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'report-task-1' },
|
where: { id: 'report-task-1' },
|
||||||
data: { status: 'partial', reason: 'carrier receipt' },
|
data: expect.objectContaining({ status: 'partial', reason: 'carrier receipt', approvedAt: null }),
|
||||||
});
|
});
|
||||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'sig-1' },
|
where: { id: 'sig-1' },
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.groups.deleteGroup(groupId);
|
return this.groups.deleteGroup(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGroupDeletionImpact(groupId: string) {
|
||||||
|
return this.groups.getGroupDeletionImpact(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
listRouteRules() {
|
listRouteRules() {
|
||||||
return this.groups.listRouteRules();
|
return this.groups.listRouteRules();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { summarizeReportStatuses } from './report-status';
|
||||||
|
|
||||||
|
describe('summarizeReportStatuses', () => {
|
||||||
|
it.each([
|
||||||
|
[[], { status: 'not_applicable', approved: 0, total: 0 }],
|
||||||
|
[['approved', 'approved'], { status: 'approved', approved: 2, total: 2 }],
|
||||||
|
[['failed', 'rejected'], { status: 'failed', approved: 0, total: 2 }],
|
||||||
|
[['approved', 'failed'], { status: 'partial_success', approved: 1, total: 2 }],
|
||||||
|
[['failed', 'pending'], { status: 'reporting', approved: 0, total: 2 }],
|
||||||
|
[['waiting_material', 'pending'], { status: 'waiting_material', approved: 0, total: 2 }],
|
||||||
|
])('summarizes %j without allowing one failure to override other targets', (statuses, expected) => {
|
||||||
|
expect(summarizeReportStatuses(statuses)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type ReportStatusSummary = {
|
||||||
|
status: string;
|
||||||
|
approved: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAILED_REPORT_STATUSES = new Set(['failed', 'rejected']);
|
||||||
|
|
||||||
|
export function summarizeReportStatuses(statuses: string[]): ReportStatusSummary {
|
||||||
|
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
||||||
|
|
||||||
|
const approved = statuses.filter((status) => status === 'approved').length;
|
||||||
|
const failed = statuses.filter((status) => FAILED_REPORT_STATUSES.has(status)).length;
|
||||||
|
|
||||||
|
if (approved === statuses.length) return { status: 'approved', approved, total: statuses.length };
|
||||||
|
|
||||||
|
// Overall failure means every current target failed. A single failed channel must not
|
||||||
|
// erase successful channels or targets that can still finish reporting.
|
||||||
|
if (failed === statuses.length) return { status: 'failed', approved, total: statuses.length };
|
||||||
|
if (approved > 0) return { status: 'partial_success', approved, total: statuses.length };
|
||||||
|
if (failed > 0) return { status: 'reporting', approved, total: statuses.length };
|
||||||
|
if (statuses.some((status) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(status))) {
|
||||||
|
return { status: 'reporting', approved, total: statuses.length };
|
||||||
|
}
|
||||||
|
if (statuses.some((status) => status === 'waiting_material')) {
|
||||||
|
return { status: 'waiting_material', approved, total: statuses.length };
|
||||||
|
}
|
||||||
|
return { status: 'pending', approved, total: statuses.length };
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ export class RequestContextMiddleware implements NestMiddleware {
|
|||||||
use(request: RequestLike, _response: unknown, next: () => void) {
|
use(request: RequestLike, _response: unknown, next: () => void) {
|
||||||
const forwarded = request.headers['x-forwarded-for'];
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
||||||
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
|
const remoteAddress = request.socket?.remoteAddress?.trim().replace(/^::ffff:/, '');
|
||||||
|
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||||
|
// 仅可信反向代理可以声明客户端地址,防止攻击者伪造 X-Forwarded-For 绕过保护名单或嫁祸他人。
|
||||||
|
const ipAddress = (remoteAddress && trustedProxies.has(remoteAddress) ? firstForwarded : remoteAddress)?.trim().replace(/^::ffff:/, '');
|
||||||
requestContext.run({ ipAddress }, next);
|
requestContext.run({ ipAddress }, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ describe('DeletionGovernanceService', () => {
|
|||||||
function setup() {
|
function setup() {
|
||||||
const tx = {
|
const tx = {
|
||||||
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
||||||
smsChannel: { updateMany: jest.fn() },
|
smsChannel: { findUnique: jest.fn(), updateMany: jest.fn() },
|
||||||
smsSignature: { updateMany: jest.fn() },
|
smsSignature: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||||
smsTemplate: { updateMany: jest.fn() },
|
smsTemplate: { findFirst: jest.fn(), updateMany: jest.fn() },
|
||||||
|
smsDrainageInfo: { updateMany: jest.fn() },
|
||||||
|
channelSignatureReportTask: { findMany: jest.fn(), update: jest.fn() },
|
||||||
|
channelSignatureReportRecord: { create: jest.fn() },
|
||||||
|
channelRouteRule: { findMany: jest.fn() },
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
operationLog: { findFirst: jest.fn() },
|
operationLog: { findFirst: jest.fn() },
|
||||||
@@ -35,12 +39,47 @@ describe('DeletionGovernanceService', () => {
|
|||||||
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']);
|
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows channel deletion with unfinished report tasks only after the cascade selection is confirmed', async () => {
|
||||||
|
const { service, prisma, tx } = setup();
|
||||||
|
const channel = {
|
||||||
|
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
|
||||||
|
groupItems: [], routeRules: [], connectionStates: [],
|
||||||
|
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||||
|
};
|
||||||
|
prisma.smsChannel.findUnique.mockResolvedValue(channel);
|
||||||
|
|
||||||
|
const preflight = await service.preflight('channel', 'channel-1');
|
||||||
|
|
||||||
|
expect(preflight.allowedActions).toEqual(['delete']);
|
||||||
|
expect(preflight.requiredSelections).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ action: 'abandon_associated_report_tasks', count: 1 }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.smsChannel.findUnique.mockResolvedValue(channel);
|
||||||
|
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||||
|
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||||
|
tx.smsChannel.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.smsSignature.findUnique.mockResolvedValue({ id: 'signature-1', applicationId: null, auditStatus: 'approved' });
|
||||||
|
tx.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: 'channel-1', status: 'abandoned', channel: { id: 'channel-1', status: 'deleted' } }]);
|
||||||
|
tx.smsSignature.update.mockResolvedValue({ id: 'signature-1' });
|
||||||
|
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||||
|
|
||||||
|
await expect(service.delete('channel', 'channel-1', {
|
||||||
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-channel-1', abandonAssociatedReportTasks: true,
|
||||||
|
})).resolves.toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||||
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }),
|
||||||
|
}));
|
||||||
|
expect(tx.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ data: { reportStatus: 'not_applicable' } }));
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an allowed template preflight scoped to the client tenant', async () => {
|
it('returns an allowed template preflight scoped to the client tenant', async () => {
|
||||||
const { service, prisma } = setup();
|
const { service, prisma } = setup();
|
||||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
|
||||||
sendTasks: [], batchTasks: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.preflight('template', 'template-1', 'tenant-1');
|
const result = await service.preflight('template', 'template-1', 'tenant-1');
|
||||||
@@ -48,30 +87,79 @@ describe('DeletionGovernanceService', () => {
|
|||||||
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
|
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
|
||||||
expect(result.allowedActions).toEqual(['delete']);
|
expect(result.allowedActions).toEqual(['delete']);
|
||||||
expect(result.identity.tenant).toBe('示例企业');
|
expect(result.identity.tenant).toBe('示例企业');
|
||||||
|
expect(result.requiredSelections).toEqual([]);
|
||||||
|
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
include: expect.not.objectContaining({ sendTasks: expect.anything(), batchTasks: expect.anything() }),
|
||||||
|
}));
|
||||||
|
expect(result.impacts).toContain('已创建任务继续使用保存的内容快照');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks signature deletion and exposes the referencing template and drainage items', async () => {
|
it('turns signature dependencies into mandatory cascade selections', async () => {
|
||||||
const { service, prisma } = setup();
|
const { service, prisma } = setup();
|
||||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
templates: [{ id: 'template-1', name: '验证码模板' }],
|
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||||
|
|
||||||
expect(result.allowedActions).toEqual([]);
|
expect(result.allowedActions).toEqual(['delete']);
|
||||||
expect(result.dependencies).toEqual(expect.arrayContaining([
|
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }),
|
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }),
|
||||||
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }),
|
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }),
|
||||||
]));
|
]));
|
||||||
|
expect(result.requiredSelections.map((item) => item.action)).toEqual([
|
||||||
|
'delete_associated_templates', 'delete_associated_drainage',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires version, idempotency key and a meaningful reason', async () => {
|
it('does not expose report task ids or statuses to the client but still requires confirmation', async () => {
|
||||||
|
const { service, prisma } = setup();
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [], drainageItems: [], reportTasks: [{ id: 'internal-task-1', status: 'reporting' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: 'report_tasks', count: 1, items: [], detailsVisible: false }),
|
||||||
|
]));
|
||||||
|
expect(JSON.stringify(result)).not.toContain('internal-task-1');
|
||||||
|
expect(result.requiredSelections).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ action: 'abandon_associated_report_tasks' }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not classify approved or abandoned report history as unfinished', async () => {
|
||||||
|
const { service, prisma } = setup();
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [], drainageItems: [], reportTasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
include: expect.objectContaining({
|
||||||
|
reportTasks: expect.objectContaining({
|
||||||
|
where: { status: { notIn: expect.arrayContaining(['approved', 'abandoned']) } },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(result.dependencies).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: 'report_tasks', count: 0 }),
|
||||||
|
]));
|
||||||
|
expect(result.allowedActions).toEqual(['delete']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires version and idempotency key but allows an omitted reason', async () => {
|
||||||
const { service } = setup();
|
const { service } = setup();
|
||||||
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('soft deletes once and writes an auditable operation number', async () => {
|
it('soft deletes once and writes an auditable operation number', async () => {
|
||||||
@@ -80,19 +168,71 @@ describe('DeletionGovernanceService', () => {
|
|||||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||||||
sendTasks: [], batchTasks: [],
|
|
||||||
});
|
});
|
||||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.smsTemplate.findFirst.mockResolvedValue({
|
||||||
|
id: 'template-1', tenantId: 'tenant-1',
|
||||||
|
});
|
||||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||||
|
|
||||||
const result = await service.delete('template', 'template-1', {
|
const result = await service.delete('template', 'template-1', {
|
||||||
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1',
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', operatorId: 'user-1',
|
||||||
|
}, 'tenant-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||||
|
expect(tx.smsTemplate.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'template-1', tenantId: 'tenant-1' },
|
||||||
|
select: { id: true, tenantId: true },
|
||||||
|
});
|
||||||
|
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades all selected signature dependencies in one transaction with task history', async () => {
|
||||||
|
const { service, prisma, tx } = setup();
|
||||||
|
const preflightItem = {
|
||||||
|
id: 'signature-1', tenantId: 'tenant-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||||
|
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }],
|
||||||
|
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
|
||||||
|
};
|
||||||
|
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||||
|
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
tx.smsSignature.findFirst.mockResolvedValue(preflightItem);
|
||||||
|
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.smsDrainageInfo.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
|
||||||
|
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
|
||||||
|
tx.smsSignature.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
|
||||||
|
|
||||||
|
const result = await service.delete('signature', 'signature-1', {
|
||||||
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-signature-1', operatorId: 'user-1',
|
||||||
|
deleteAssociatedTemplates: true, deleteAssociatedDrainage: true, abandonAssociatedReportTasks: true,
|
||||||
}, 'tenant-1');
|
}, 'tenant-1');
|
||||||
|
|
||||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||||
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
|
||||||
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
|
expect(tx.smsDrainageInfo.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted', pendingReport: false } }));
|
||||||
|
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'abandoned' }) }));
|
||||||
|
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ statusBefore: 'reporting', statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deletion until every discovered cascade selection is confirmed', async () => {
|
||||||
|
const { service, prisma } = setup();
|
||||||
|
prisma.operationLog.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||||
|
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||||
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||||
|
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }], drainageItems: [], reportTasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.delete('signature', 'signature-1', {
|
||||||
|
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'missing-selection',
|
||||||
|
}, 'tenant-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a stale optimistic-lock version', async () => {
|
it('rejects a stale optimistic-lock version', async () => {
|
||||||
@@ -101,7 +241,6 @@ describe('DeletionGovernanceService', () => {
|
|||||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||||||
sendTasks: [], batchTasks: [],
|
|
||||||
});
|
});
|
||||||
await expect(service.delete('template', 'template-1', {
|
await expect(service.delete('template', 'template-1', {
|
||||||
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
|
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
|
||||||
|
|||||||
@@ -1,17 +1,54 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { summarizeReportStatuses } from '../common/report-status';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||||
|
|
||||||
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
||||||
|
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
|
||||||
|
|
||||||
export type DeleteTargetDto = {
|
export type DeleteTargetDto = {
|
||||||
expectedUpdatedAt?: string;
|
expectedUpdatedAt?: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
|
deleteAssociatedTemplates?: boolean;
|
||||||
|
deleteAssociatedDrainage?: boolean;
|
||||||
|
abandonAssociatedReportTasks?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Dependency = { kind: string; label: string; count: number; items: string[] };
|
type Dependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
|
||||||
|
type RequiredSelection = {
|
||||||
|
action: DeletionResolutionAction;
|
||||||
|
dependencyKind: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 报备任务、人工审核任务和批量发送任务使用不同的状态词汇。这里分别维护终态,
|
||||||
|
// 是为了避免把已完成历史误判成活动依赖,也避免删除正在发送的数据配置。
|
||||||
|
const TERMINAL_REPORT_TASK_STATUSES = ['approved', 'completed', 'failed', 'cancelled', 'rejected', 'abandoned', 'partial', 'partial_success'];
|
||||||
|
const TERMINAL_SEND_TASK_STATUSES = ['approved', 'rejected'];
|
||||||
|
const TERMINAL_BATCH_TASK_STATUSES = ['finished', 'canceled', 'rejected', 'failed', 'completed', 'cancelled'];
|
||||||
|
|
||||||
|
const RESOLUTION_COPY: Record<DeletionResolutionAction, Omit<RequiredSelection, 'dependencyKind' | 'count'>> = {
|
||||||
|
delete_associated_templates: {
|
||||||
|
action: 'delete_associated_templates',
|
||||||
|
label: '同时删除关联的模板',
|
||||||
|
description: '发现关联的短信模板。勾选后将一并逻辑删除这些模板,历史发送和审核记录继续保留。',
|
||||||
|
},
|
||||||
|
delete_associated_drainage: {
|
||||||
|
action: 'delete_associated_drainage',
|
||||||
|
label: '同时删除引流信息',
|
||||||
|
description: '发现关联的引流信息。勾选后将一并逻辑删除这些引流信息,历史发送、审核和报备记录继续保留。',
|
||||||
|
},
|
||||||
|
abandon_associated_report_tasks: {
|
||||||
|
action: 'abandon_associated_report_tasks',
|
||||||
|
label: '同时结束关联的报备任务',
|
||||||
|
description: '发现关联的未结束报备任务。勾选后将全部置为“放弃报备”,历史任务和报备记录继续保留。',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type DeletionPreflight = {
|
export type DeletionPreflight = {
|
||||||
type: DeletionTargetType;
|
type: DeletionTargetType;
|
||||||
@@ -19,6 +56,7 @@ export type DeletionPreflight = {
|
|||||||
expectedUpdatedAt: string;
|
expectedUpdatedAt: string;
|
||||||
identity: Record<string, string>;
|
identity: Record<string, string>;
|
||||||
dependencies: Dependency[];
|
dependencies: Dependency[];
|
||||||
|
requiredSelections: RequiredSelection[];
|
||||||
impacts: string[];
|
impacts: string[];
|
||||||
blockedReasons: string[];
|
blockedReasons: string[];
|
||||||
allowedActions: Array<'delete'>;
|
allowedActions: Array<'delete'>;
|
||||||
@@ -40,9 +78,8 @@ export class DeletionGovernanceService {
|
|||||||
this.assertType(type);
|
this.assertType(type);
|
||||||
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
|
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
|
||||||
const idempotencyKey = body.idempotencyKey?.trim();
|
const idempotencyKey = body.idempotencyKey?.trim();
|
||||||
const reason = body.reason?.trim();
|
const reason = body.reason?.trim() || undefined;
|
||||||
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
|
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
|
||||||
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
|
|
||||||
|
|
||||||
const replay = await this.prisma.operationLog.findFirst({
|
const replay = await this.prisma.operationLog.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -58,6 +95,7 @@ export class DeletionGovernanceService {
|
|||||||
if (!preflight.allowedActions.includes('delete')) {
|
if (!preflight.allowedActions.includes('delete')) {
|
||||||
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
|
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
|
||||||
}
|
}
|
||||||
|
this.assertSelections(preflight.requiredSelections, body);
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.operationLog.findFirst({
|
const existing = await tx.operationLog.findFirst({
|
||||||
@@ -68,6 +106,12 @@ export class DeletionGovernanceService {
|
|||||||
});
|
});
|
||||||
if (existing) return { operationId: existing.id, status: 'deleted', replayed: true };
|
if (existing) return { operationId: existing.id, status: 'deleted', replayed: true };
|
||||||
|
|
||||||
|
const cascade = type === 'channel'
|
||||||
|
? await this.prepareChannelDeletion(tx, id, body, reason)
|
||||||
|
: type === 'signature'
|
||||||
|
? await this.prepareSignatureDeletion(tx, id, tenantId, body, reason)
|
||||||
|
: await this.prepareTemplateDeletion(tx, id, tenantId);
|
||||||
|
|
||||||
const updated = type === 'channel'
|
const updated = type === 'channel'
|
||||||
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
|
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
|
||||||
: type === 'signature'
|
: type === 'signature'
|
||||||
@@ -75,10 +119,22 @@ export class DeletionGovernanceService {
|
|||||||
: await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
|
: await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
|
||||||
if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检');
|
if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检');
|
||||||
|
|
||||||
|
if (type === 'channel') {
|
||||||
|
for (const signatureId of cascade.affectedSignatureIds) await this.recomputeSignatureReportSummary(tx, signatureId);
|
||||||
|
}
|
||||||
|
|
||||||
const log = await tx.operationLog.create({
|
const log = await tx.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
tenantId: cascade.tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
||||||
detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts },
|
detail: {
|
||||||
|
idempotencyKey,
|
||||||
|
reason: reason ?? null,
|
||||||
|
expectedUpdatedAt,
|
||||||
|
dependencies: preflight.dependencies,
|
||||||
|
impacts: preflight.impacts,
|
||||||
|
selections: preflight.requiredSelections.map((selection) => selection.action),
|
||||||
|
cascade: cascade.detail,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { operationId: log.id, status: 'deleted', replayed: false };
|
return { operationId: log.id, status: 'deleted', replayed: false };
|
||||||
@@ -93,7 +149,7 @@ export class DeletionGovernanceService {
|
|||||||
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
|
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
|
||||||
routeRules: { where: { status: 'active' } },
|
routeRules: { where: { status: 'active' } },
|
||||||
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
|
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
|
||||||
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } } },
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException('通道不存在');
|
if (!item) throw new NotFoundException('通道不存在');
|
||||||
@@ -101,10 +157,11 @@ export class DeletionGovernanceService {
|
|||||||
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)),
|
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)),
|
||||||
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
|
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
|
||||||
dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)),
|
dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)),
|
||||||
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => row.id)),
|
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)),
|
||||||
];
|
];
|
||||||
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
|
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
|
||||||
['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']);
|
['删除后不再参与新消息路由', '所选未结束报备任务将置为“放弃报备”', '历史发送、回执和审计记录继续保留'],
|
||||||
|
{ report_tasks: 'abandon_associated_report_tasks' });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
||||||
@@ -112,20 +169,34 @@ export class DeletionGovernanceService {
|
|||||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
include: {
|
include: {
|
||||||
tenant: { select: { name: true } }, application: { select: { name: true } },
|
tenant: { select: { name: true } }, application: { select: { name: true } },
|
||||||
templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } },
|
templates: {
|
||||||
|
where: { auditStatus: { not: 'deleted' } },
|
||||||
|
select: {
|
||||||
|
id: true, name: true,
|
||||||
|
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
|
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
|
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
|
||||||
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException('签名不存在或无权访问');
|
if (!item) throw new NotFoundException('签名不存在或无权访问');
|
||||||
|
const activeTemplateTasks = item.templates.flatMap((template) => [
|
||||||
|
...template.sendTasks.map((task) => `${template.name}:发送任务 ${task.id}(${task.status})`),
|
||||||
|
...template.batchTasks.map((task) => `${template.name}:批量任务 ${task.id}(${task.status})`),
|
||||||
|
]);
|
||||||
const dependencies: Dependency[] = [
|
const dependencies: Dependency[] = [
|
||||||
dep('templates', '仍在使用该签名的模板', item.templates.map((row) => `${row.name}(${row.id})`)),
|
dep('templates', '关联短信模板', item.templates.map((row) => `${row.name}(${row.id})`)),
|
||||||
|
dep('template_active_tasks', '关联模板仍有未结束发送任务', activeTemplateTasks),
|
||||||
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}(${row.id})`)),
|
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}(${row.id})`)),
|
||||||
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)),
|
dep('report_tasks', '未结束报备任务', tenantId ? [] : item.reportTasks.map((row) => `${row.id}(${row.status})`), item.reportTasks.length, !tenantId),
|
||||||
];
|
];
|
||||||
return buildPreflight('signature', item.id, item.updatedAt, {
|
return buildPreflight('signature', item.id, item.updatedAt, {
|
||||||
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
|
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
|
||||||
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']);
|
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '勾选的关联配置将同步逻辑删除或结束', '历史消息、审核与报备记录继续保留'], {
|
||||||
|
templates: 'delete_associated_templates', drainage: 'delete_associated_drainage', report_tasks: 'abandon_associated_report_tasks',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async templatePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
private async templatePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
||||||
@@ -133,19 +204,153 @@ export class DeletionGovernanceService {
|
|||||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
include: {
|
include: {
|
||||||
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
|
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
|
||||||
sendTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
|
||||||
batchTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
||||||
const dependencies: Dependency[] = [
|
|
||||||
dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}(${row.status})`)),
|
|
||||||
dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}(${row.status})`)),
|
|
||||||
];
|
|
||||||
return buildPreflight('template', item.id, item.updatedAt, {
|
return buildPreflight('template', item.id, item.updatedAt, {
|
||||||
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name,
|
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name,
|
||||||
signature: item.signature?.name ?? '未绑定',
|
signature: item.signature?.name ?? '未绑定',
|
||||||
}, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']);
|
}, item.auditStatus, [], ['删除后不能用于新发送任务', '已创建任务继续使用保存的内容快照', '历史消息、计费和审核记录继续保留']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareChannelDeletion(tx: Prisma.TransactionClient, id: string, body: DeleteTargetDto, reason?: string) {
|
||||||
|
const item = await tx.smsChannel.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
groupItems: { where: { group: { status: { not: 'deleted' } } }, select: { id: true } },
|
||||||
|
routeRules: { where: { status: 'active' }, select: { id: true } },
|
||||||
|
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } }, select: { id: true } },
|
||||||
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('通道不存在');
|
||||||
|
const blockers = [
|
||||||
|
item.groupItems.length ? `引用该通道的通道组共 ${item.groupItems.length} 项,请先解除或完成` : '',
|
||||||
|
item.routeRules.length ? `直接路由规则共 ${item.routeRules.length} 项,请先解除或完成` : '',
|
||||||
|
item.connectionStates.length ? `活动网关连接共 ${item.connectionStates.length} 项,请先解除或完成` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (blockers.length) throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: blockers });
|
||||||
|
this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label);
|
||||||
|
const abandonReason = reason ?? '删除通道时同步放弃关联报备任务';
|
||||||
|
await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, abandonReason);
|
||||||
|
return {
|
||||||
|
tenantId: undefined,
|
||||||
|
affectedSignatureIds: [...new Set(item.reportTasks.filter((task) => task.reportType === 'signature').map((task) => task.signatureId))],
|
||||||
|
detail: { abandonedReportTaskIds: item.reportTasks.map((task) => task.id) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSignatureDeletion(tx: Prisma.TransactionClient, id: string, tenantId: string | undefined, body: DeleteTargetDto, reason?: string) {
|
||||||
|
const item = await tx.smsSignature.findFirst({
|
||||||
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
|
include: {
|
||||||
|
templates: {
|
||||||
|
where: { auditStatus: { not: 'deleted' } },
|
||||||
|
select: {
|
||||||
|
id: true, name: true,
|
||||||
|
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true } },
|
||||||
|
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true } },
|
||||||
|
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('签名不存在或无权访问');
|
||||||
|
const activeTemplateTaskCount = item.templates.reduce((sum, template) => sum + template.sendTasks.length + template.batchTasks.length, 0);
|
||||||
|
if (activeTemplateTaskCount) {
|
||||||
|
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: [`关联模板仍有未结束发送任务共 ${activeTemplateTaskCount} 项,请先解除或完成`] });
|
||||||
|
}
|
||||||
|
this.assertRuntimeSelection(item.templates.length, body.deleteAssociatedTemplates, RESOLUTION_COPY.delete_associated_templates.label);
|
||||||
|
this.assertRuntimeSelection(item.drainageItems.length, body.deleteAssociatedDrainage, RESOLUTION_COPY.delete_associated_drainage.label);
|
||||||
|
this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label);
|
||||||
|
|
||||||
|
const templateIds = item.templates.map((template) => template.id);
|
||||||
|
const drainageIds = item.drainageItems.map((drainage) => drainage.id);
|
||||||
|
if (templateIds.length) {
|
||||||
|
await tx.smsTemplate.updateMany({ where: { id: { in: templateIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
|
||||||
|
for (const template of item.templates) {
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'template', resourceId: template.id,
|
||||||
|
detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除关联模板' } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (drainageIds.length) {
|
||||||
|
await tx.smsDrainageInfo.updateMany({ where: { id: { in: drainageIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } });
|
||||||
|
for (const drainageId of drainageIds) {
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'drainage', resourceId: drainageId,
|
||||||
|
detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除引流信息' } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, reason ?? '删除签名时同步放弃关联报备任务');
|
||||||
|
return {
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
affectedSignatureIds: [] as string[],
|
||||||
|
detail: { deletedTemplateIds: templateIds, deletedDrainageIds: drainageIds, abandonedReportTaskIds: item.reportTasks.map((task) => task.id) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareTemplateDeletion(tx: Prisma.TransactionClient, id: string, tenantId?: string) {
|
||||||
|
const item = await tx.smsTemplate.findFirst({
|
||||||
|
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||||
|
select: { id: true, tenantId: true },
|
||||||
|
});
|
||||||
|
if (!item) throw new NotFoundException('模板不存在或无权访问');
|
||||||
|
return { tenantId: item.tenantId, affectedSignatureIds: [] as string[], detail: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async abandonReportTasks(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
tasks: Array<{ id: string; channelId: string; status: string }>,
|
||||||
|
operatorId: string | undefined,
|
||||||
|
reason: string,
|
||||||
|
) {
|
||||||
|
for (const task of tasks) {
|
||||||
|
// 每条任务分别留存状态前后值,便于解释一次级联删除为何结束了哪些报备任务。
|
||||||
|
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason } });
|
||||||
|
await tx.channelSignatureReportRecord.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id, channelId: task.channelId, action: 'delete_cascade_abandon', statusBefore: task.status,
|
||||||
|
statusAfter: 'abandoned', reason, operatorId, sourceEntry: 'deletion_governance',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||||
|
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||||
|
if (!signature || signature.auditStatus === 'deleted') return;
|
||||||
|
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
||||||
|
where: { applicationId: signature.applicationId, status: 'active' },
|
||||||
|
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||||
|
}) : [];
|
||||||
|
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
||||||
|
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||||
|
const fallbackChannels = tasks.map((task) => task.channel).filter((channel) => channel.status !== 'deleted');
|
||||||
|
const uniqueChannels = [...new Map((configuredChannels.length ? configuredChannels : fallbackChannels).map((channel) => [channel.id, channel])).values()];
|
||||||
|
const statuses = uniqueChannels.flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => (
|
||||||
|
tasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
|
||||||
|
?? tasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
|
||||||
|
?? 'pending'
|
||||||
|
)));
|
||||||
|
const reportStatus = summarizeReportStatuses(statuses).status;
|
||||||
|
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSelections(requiredSelections: RequiredSelection[], body: DeleteTargetDto) {
|
||||||
|
const missing = requiredSelections.filter((selection) => !selectionSelected(selection.action, body));
|
||||||
|
if (missing.length) throw new BadRequestException(`请先确认:${missing.map((selection) => selection.label).join('、')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRuntimeSelection(count: number, selected: boolean | undefined, label: string) {
|
||||||
|
if (count > 0 && selected !== true) throw new ConflictException(`关联数据已变化,请重新预检并勾选“${label}”`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertType(type: string): asserts type is DeletionTargetType {
|
private assertType(type: string): asserts type is DeletionTargetType {
|
||||||
@@ -153,16 +358,38 @@ export class DeletionGovernanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dep(kind: string, label: string, items: string[]): Dependency {
|
function dep(kind: string, label: string, items: string[], count = items.length, detailsVisible = true): Dependency {
|
||||||
return { kind, label, count: items.length, items: items.slice(0, 8) };
|
return { kind, label, count, items: detailsVisible ? items.slice(0, 8) : [], detailsVisible };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreflight(type: DeletionTargetType, id: string, updatedAt: Date, identity: Record<string, string>, status: string, dependencies: Dependency[], impacts: string[]): DeletionPreflight {
|
function buildPreflight(
|
||||||
const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}共 ${item.count} 项,请先解除或完成`);
|
type: DeletionTargetType,
|
||||||
|
id: string,
|
||||||
|
updatedAt: Date,
|
||||||
|
identity: Record<string, string>,
|
||||||
|
status: string,
|
||||||
|
dependencies: Dependency[],
|
||||||
|
impacts: string[],
|
||||||
|
resolutions: Partial<Record<string, DeletionResolutionAction>> = {},
|
||||||
|
): DeletionPreflight {
|
||||||
|
const requiredSelections = dependencies.flatMap((dependency) => {
|
||||||
|
const action = resolutions[dependency.kind];
|
||||||
|
if (!action || dependency.count === 0) return [];
|
||||||
|
return [{ ...RESOLUTION_COPY[action], dependencyKind: dependency.kind, count: dependency.count }];
|
||||||
|
});
|
||||||
|
const blockedReasons = dependencies
|
||||||
|
.filter((dependency) => dependency.count > 0 && !resolutions[dependency.kind])
|
||||||
|
.map((dependency) => `${dependency.label}共 ${dependency.count} 项,请先解除或完成`);
|
||||||
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
|
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
|
||||||
return {
|
return {
|
||||||
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons,
|
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, requiredSelections, impacts, blockedReasons,
|
||||||
allowedActions: blockedReasons.length ? [] : ['delete'],
|
allowedActions: blockedReasons.length ? [] : ['delete'],
|
||||||
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
|
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectionSelected(action: DeletionResolutionAction, body: DeleteTargetDto) {
|
||||||
|
if (action === 'delete_associated_templates') return body.deleteAssociatedTemplates === true;
|
||||||
|
if (action === 'delete_associated_drainage') return body.deleteAssociatedDrainage === true;
|
||||||
|
return body.abandonAssociatedReportTasks === true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
export class DictionariesController {
|
export class DictionariesController {
|
||||||
constructor(private readonly dictionaries: DictionariesService) {}
|
constructor(private readonly dictionaries: DictionariesService) {}
|
||||||
|
|
||||||
|
@Get('administrative-regions')
|
||||||
|
listAdministrativeRegions() {
|
||||||
|
return this.dictionaries.listAdministrativeRegions();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('phone-segments')
|
@Get('phone-segments')
|
||||||
listPhoneSegments(
|
listPhoneSegments(
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
|
|||||||
@@ -58,6 +58,28 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DictionariesService', () => {
|
describe('DictionariesService', () => {
|
||||||
|
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
|
{ province: '山东', city: '青岛' },
|
||||||
|
{ province: '山东', city: '济南' },
|
||||||
|
{ province: '山东', city: '济南' },
|
||||||
|
{ province: '江苏', city: '苏州' },
|
||||||
|
{ province: ' ', city: '无效' },
|
||||||
|
]);
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.listAdministrativeRegions()).resolves.toEqual([
|
||||||
|
{ province: '江苏', cities: ['苏州'] },
|
||||||
|
{ province: '山东', cities: ['济南', '青岛'] },
|
||||||
|
]);
|
||||||
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { province: { not: null } },
|
||||||
|
select: { province: true, city: true },
|
||||||
|
distinct: ['province', 'city'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes a phone segment from the real dictionary table', async () => {
|
it('deletes a phone segment from the real dictionary table', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new DictionariesService(prisma as never);
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|||||||
@@ -111,6 +111,27 @@ export class DictionariesService {
|
|||||||
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
|
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async listAdministrativeRegions() {
|
||||||
|
const rows = await this.prisma.phoneSegment.findMany({
|
||||||
|
where: { province: { not: null } },
|
||||||
|
select: { province: true, city: true },
|
||||||
|
distinct: ['province', 'city'],
|
||||||
|
});
|
||||||
|
const citiesByProvince = new Map<string, Set<string>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const province = row.province?.trim();
|
||||||
|
if (!province) continue;
|
||||||
|
const cities = citiesByProvince.get(province) ?? new Set<string>();
|
||||||
|
const city = row.city?.trim();
|
||||||
|
if (city) cities.add(city);
|
||||||
|
citiesByProvince.set(province, cities);
|
||||||
|
}
|
||||||
|
return Array.from(citiesByProvince, ([province, cities]) => ({
|
||||||
|
province,
|
||||||
|
cities: Array.from(cities).sort((left, right) => left.localeCompare(right, 'zh-CN')),
|
||||||
|
})).sort((left, right) => left.province.localeCompare(right.province, 'zh-CN'));
|
||||||
|
}
|
||||||
|
|
||||||
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
||||||
const page = Math.max(1, Number(query.page ?? 1));
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits';
|
||||||
|
|
||||||
|
const express = require('express') as () => {
|
||||||
|
use(...args: unknown[]): void;
|
||||||
|
post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void;
|
||||||
|
listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null };
|
||||||
|
};
|
||||||
|
const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown };
|
||||||
|
const http = require('node:http') as typeof import('node:http');
|
||||||
|
|
||||||
|
describe('configureHttpBodyParsers', () => {
|
||||||
|
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
|
||||||
|
const use = jest.fn();
|
||||||
|
const useBodyParser = jest.fn();
|
||||||
|
|
||||||
|
configureHttpBodyParsers({ use, useBodyParser } as never);
|
||||||
|
|
||||||
|
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
|
||||||
|
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
|
||||||
|
expect(use).toHaveBeenCalledTimes(1);
|
||||||
|
expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function));
|
||||||
|
expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' });
|
||||||
|
expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a 3 MiB import JSON body but rejects the same ordinary JSON body', async () => {
|
||||||
|
const serverApp = express();
|
||||||
|
configureHttpBodyParsers({
|
||||||
|
use: serverApp.use.bind(serverApp),
|
||||||
|
useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) {
|
||||||
|
serverApp.use(type === 'json'
|
||||||
|
? expressModule.json({ limit: options.limit })
|
||||||
|
: expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true }));
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 }));
|
||||||
|
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
|
||||||
|
|
||||||
|
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
|
||||||
|
const listening = serverApp.listen(0, '127.0.0.1', () => resolve(listening));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') throw new Error('test server did not expose a TCP port');
|
||||||
|
const body = JSON.stringify({ content: 'x'.repeat(3 * 1024 * 1024) });
|
||||||
|
const importResponse = await postJSON(address.port, '/api/client/send/imports/preview', body);
|
||||||
|
expect(importResponse.status).toBe(200);
|
||||||
|
expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) });
|
||||||
|
await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 });
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function postJSON(port: number, path: string, body: string) {
|
||||||
|
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||||
|
const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
||||||
|
});
|
||||||
|
request.once('error', reject);
|
||||||
|
request.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
|
const express = require('express') as {
|
||||||
|
json(options: {
|
||||||
|
limit: string;
|
||||||
|
verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void;
|
||||||
|
}): (...args: unknown[]) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_JSON_BODY_LIMIT = '2mb';
|
||||||
|
export const IMPORT_JSON_BODY_LIMIT = '25mb';
|
||||||
|
|
||||||
|
export function configureHttpBodyParsers(app: NestExpressApplication) {
|
||||||
|
// Import preview/confirmation temporarily carries the source CSV/TSV in
|
||||||
|
// JSON. Give only these endpoints the larger boundary; keeping ordinary
|
||||||
|
// JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint.
|
||||||
|
app.use('/api/client/send/imports', express.json({
|
||||||
|
limit: IMPORT_JSON_BODY_LIMIT,
|
||||||
|
verify(request, _response, buffer) {
|
||||||
|
request.rawBody = buffer;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
|
||||||
|
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DEFAULT_ALERT_THRESHOLDS, InfrastructureAlertSettingsService } from './infrastructure-alert-settings.service';
|
||||||
|
|
||||||
|
describe('InfrastructureAlertSettingsService', () => {
|
||||||
|
const service = new InfrastructureAlertSettingsService({} as never, { get: () => undefined } as never);
|
||||||
|
|
||||||
|
it('accepts the fixed threshold whitelist and renders managed rules', () => {
|
||||||
|
const validated = (service as unknown as { validate(value: unknown): unknown }).validate(DEFAULT_ALERT_THRESHOLDS);
|
||||||
|
const rules = (service as unknown as { renderRules(value: unknown): string }).renderRules(validated);
|
||||||
|
expect(rules).toContain('HostCpuUsageWarning');
|
||||||
|
expect(rules).toContain('CmppGatewayQueueDelayedCritical');
|
||||||
|
expect(rules).toContain('threshold: "120秒"');
|
||||||
|
expect(rules).toContain('redis_memory_max_bytes > 0');
|
||||||
|
expect(rules).toContain('sum(increase(cmpp_api_http_requests_total');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown keys and warning thresholds that are not below critical', () => {
|
||||||
|
expect(() => (service as unknown as { validate(value: unknown): unknown }).validate({ ...DEFAULT_ALERT_THRESHOLDS, promql: { warning: 1, critical: 2 } })).toThrow(BadRequestException);
|
||||||
|
expect(() => (service as unknown as { validate(value: unknown): unknown }).validate({ ...DEFAULT_ALERT_THRESHOLDS, hostCpu: { warning: 90, critical: 90 } })).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from './infrastructure-monitoring.contracts';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export const ALERT_THRESHOLD_DEFINITIONS = [
|
||||||
|
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'hostDisk', label: '根磁盘使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100', names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] },
|
||||||
|
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] },
|
||||||
|
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] },
|
||||||
|
{ key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] },
|
||||||
|
{ key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
|
||||||
|
ALERT_THRESHOLD_DEFINITIONS.map((item) => [item.key, { warning: item.warning, critical: item.critical }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InfrastructureAlertSettingsService {
|
||||||
|
private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
|
||||||
|
private readonly rulesPath: string;
|
||||||
|
private readonly promtoolPath: string;
|
||||||
|
private readonly reloadUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService, config: ConfigService) {
|
||||||
|
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml');
|
||||||
|
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool');
|
||||||
|
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(): Promise<InfrastructureAlertSettings> {
|
||||||
|
const row = await this.prisma.infrastructureAlertSetting.findUnique({ where: { id: 'global' } });
|
||||||
|
const thresholds = this.asThresholds(row?.thresholds) ?? DEFAULT_ALERT_THRESHOLDS;
|
||||||
|
const effective = this.asThresholds(row?.effectiveThresholds) ?? thresholds;
|
||||||
|
return {
|
||||||
|
configVersion: row?.configVersion ?? 1,
|
||||||
|
effectiveVersion: row?.effectiveVersion ?? 1,
|
||||||
|
applyStatus: (row?.applyStatus as InfrastructureAlertSettings['applyStatus']) ?? 'effective',
|
||||||
|
lastError: row?.lastError ?? null,
|
||||||
|
appliedAt: row?.appliedAt?.toISOString() ?? null,
|
||||||
|
thresholds,
|
||||||
|
effectiveThresholds: effective,
|
||||||
|
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(body: { configVersion?: number; thresholds?: unknown }, operatorId?: string) {
|
||||||
|
const expectedVersion = Number(body.configVersion);
|
||||||
|
if (!Number.isInteger(expectedVersion) || expectedVersion < 1) throw new BadRequestException('配置版本无效');
|
||||||
|
const thresholds = this.validate(body.thresholds);
|
||||||
|
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
|
||||||
|
where: { id: 'global', configVersion: expectedVersion },
|
||||||
|
data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId },
|
||||||
|
});
|
||||||
|
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
|
||||||
|
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
|
||||||
|
const nextVersion = expectedVersion + 1;
|
||||||
|
try {
|
||||||
|
await this.applyRules(thresholds);
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }),
|
||||||
|
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
|
||||||
|
await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } });
|
||||||
|
this.logger.error(`Prometheus managed rules apply failed: ${message}`);
|
||||||
|
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
|
||||||
|
}
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate(value: unknown): InfrastructureAlertThresholds {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) throw new BadRequestException('存在不允许配置的告警指标');
|
||||||
|
const result: InfrastructureAlertThresholds = {};
|
||||||
|
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
|
||||||
|
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
|
||||||
|
const warning = Number(pair?.warning);
|
||||||
|
const critical = Number(pair?.critical);
|
||||||
|
if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) {
|
||||||
|
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
|
||||||
|
}
|
||||||
|
result[definition.key] = { warning, critical };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private asThresholds(value: unknown) {
|
||||||
|
try { return this.validate(value); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRules(thresholds: InfrastructureAlertThresholds) {
|
||||||
|
const lines = ['groups:', ' - name: cmpp-managed-thresholds', ' rules:'];
|
||||||
|
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
|
||||||
|
const pair = thresholds[definition.key];
|
||||||
|
const values = [pair.warning, pair.critical];
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
const isWarning = index === 0;
|
||||||
|
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
|
||||||
|
const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
|
||||||
|
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`;
|
||||||
|
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyRules(thresholds: InfrastructureAlertThresholds) {
|
||||||
|
const directory = dirname(this.rulesPath);
|
||||||
|
const temporary = `${this.rulesPath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
const previous = await readFile(this.rulesPath).catch(() => null);
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
|
||||||
|
await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 });
|
||||||
|
await rename(temporary, this.rulesPath);
|
||||||
|
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
|
||||||
|
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { force: true });
|
||||||
|
// 规则替换和 reload 不是一个事务,失败时必须恢复旧文件并再次 reload,避免数据库状态与实际告警漂移。
|
||||||
|
if (previous) {
|
||||||
|
await writeFile(temporary, previous, { mode: 0o640 });
|
||||||
|
await rename(temporary, this.rulesPath);
|
||||||
|
await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export type InfrastructureMonitoringRange = '1h' | '24h' | '7d';
|
||||||
|
|
||||||
|
export type InfrastructureMetricPoint = {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureServiceStatus = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
status: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureServiceMetricGroup = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
metrics: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
unit: 'percent' | 'seconds' | 'count' | 'per_second' | 'bytes';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureAlert = {
|
||||||
|
fingerprint: string;
|
||||||
|
name: string;
|
||||||
|
severity: 'info' | 'warning' | 'critical';
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
currentValue?: string;
|
||||||
|
threshold?: string;
|
||||||
|
service?: string;
|
||||||
|
instance?: string;
|
||||||
|
acknowledged: boolean;
|
||||||
|
acknowledgedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureMonitoringOverview = {
|
||||||
|
available: boolean;
|
||||||
|
range: InfrastructureMonitoringRange;
|
||||||
|
collectedAt: string;
|
||||||
|
lastSampleAt: string | null;
|
||||||
|
error?: string;
|
||||||
|
summary: {
|
||||||
|
overallStatus: 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||||
|
serviceTotal: number;
|
||||||
|
serviceHealthy: number;
|
||||||
|
warningAlerts: number;
|
||||||
|
criticalAlerts: number;
|
||||||
|
activeAlerts: number;
|
||||||
|
};
|
||||||
|
metrics: {
|
||||||
|
cpuUsagePercent: number | null;
|
||||||
|
memoryUsagePercent: number | null;
|
||||||
|
memoryTotalBytes: number | null;
|
||||||
|
memoryAvailableBytes: number | null;
|
||||||
|
diskUsagePercent: number | null;
|
||||||
|
diskTotalBytes: number | null;
|
||||||
|
diskAvailableBytes: number | null;
|
||||||
|
networkReceiveBytesPerSecond: number | null;
|
||||||
|
networkTransmitBytesPerSecond: number | null;
|
||||||
|
load1: number | null;
|
||||||
|
uptimeSeconds: number | null;
|
||||||
|
};
|
||||||
|
trends: {
|
||||||
|
cpuUsagePercent: InfrastructureMetricPoint[];
|
||||||
|
memoryUsagePercent: InfrastructureMetricPoint[];
|
||||||
|
diskUsagePercent: InfrastructureMetricPoint[];
|
||||||
|
networkReceiveBytesPerSecond: InfrastructureMetricPoint[];
|
||||||
|
networkTransmitBytesPerSecond: InfrastructureMetricPoint[];
|
||||||
|
};
|
||||||
|
services: InfrastructureServiceStatus[];
|
||||||
|
serviceMetrics: InfrastructureServiceMetricGroup[];
|
||||||
|
alerts: InfrastructureAlert[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfrastructureAlertThresholds = Record<string, { warning: number; critical: number }>;
|
||||||
|
|
||||||
|
export type InfrastructureAlertSettings = {
|
||||||
|
configVersion: number;
|
||||||
|
effectiveVersion: number;
|
||||||
|
applyStatus: 'effective' | 'applying' | 'failed';
|
||||||
|
lastError: string | null;
|
||||||
|
appliedAt: string | null;
|
||||||
|
thresholds: InfrastructureAlertThresholds;
|
||||||
|
effectiveThresholds: InfrastructureAlertThresholds;
|
||||||
|
definitions: Array<{ key: string; label: string; unit: string; min: number; max: number; step: number }>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
|
import { InfrastructureAlertSettingsService } from './infrastructure-alert-settings.service';
|
||||||
|
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||||
|
|
||||||
|
@ApiTags('infrastructure-monitoring')
|
||||||
|
@Controller('admin/infrastructure-monitoring')
|
||||||
|
export class InfrastructureMonitoringController {
|
||||||
|
constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {}
|
||||||
|
|
||||||
|
@Get('overview')
|
||||||
|
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
|
||||||
|
return this.monitoring.overview(range, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('notification-summary')
|
||||||
|
notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); }
|
||||||
|
|
||||||
|
@Post('alerts/:fingerprint/read')
|
||||||
|
markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) {
|
||||||
|
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('alert-thresholds')
|
||||||
|
alertThresholds() { return this.settings.get(); }
|
||||||
|
|
||||||
|
@Put('alert-thresholds')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.settings.update(body, operatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InfrastructureMonitoringController } from './infrastructure-monitoring.controller';
|
||||||
|
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||||
|
import { InfrastructureAlertSettingsService } from './infrastructure-alert-settings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [InfrastructureMonitoringController],
|
||||||
|
providers: [InfrastructureMonitoringService, InfrastructureAlertSettingsService],
|
||||||
|
})
|
||||||
|
export class InfrastructureMonitoringModule {}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||||
|
|
||||||
|
function success(data: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ status: 'success', data }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InfrastructureMonitoringService', () => {
|
||||||
|
const prisma = {
|
||||||
|
infrastructureAlertRead: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), findUniqueOrThrow: jest.fn() },
|
||||||
|
operationLog: { create: jest.fn() },
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
prisma.infrastructureAlertRead.findMany.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ranges outside the fixed whitelist before querying Prometheus', async () => {
|
||||||
|
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
await expect(service.overview('30d')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => {
|
||||||
|
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }), prisma as never)).toThrow('must not contain credentials');
|
||||||
|
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }), prisma as never)).toThrow('must use HTTPS');
|
||||||
|
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }), prisma as never)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads real Prometheus vectors, ranges, services and active alerts', async () => {
|
||||||
|
const requestedUrls: URL[] = [];
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
requestedUrls.push(url);
|
||||||
|
if (url.pathname.endsWith('/alerts')) {
|
||||||
|
return success({ alerts: [{
|
||||||
|
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
|
||||||
|
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
|
||||||
|
state: 'firing',
|
||||||
|
activeAt: '2026-08-14T03:00:00.000Z',
|
||||||
|
value: '88.2',
|
||||||
|
}] });
|
||||||
|
}
|
||||||
|
const query = url.searchParams.get('query') ?? '';
|
||||||
|
if (url.pathname.endsWith('/query_range')) {
|
||||||
|
return success({ result: [{ metric: {}, values: [[1_765_000_000, '12.5'], [1_765_000_060, '14.5']] }] });
|
||||||
|
}
|
||||||
|
if (query.includes('node_systemd_unit_state')) {
|
||||||
|
return success({ result: [
|
||||||
|
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (query.includes('cmpp:service_.*')) {
|
||||||
|
return success({ result: [
|
||||||
|
{ metric: { __name__: 'cmpp:service_api:requests_per_second' }, value: [1_765_000_060, '12.5'] },
|
||||||
|
{ metric: { __name__: 'cmpp:service_api:error_percent' }, value: [1_765_000_060, '0.2'] },
|
||||||
|
{ metric: { __name__: 'cmpp:service_gateway:queue_pending' }, value: [1_765_000_060, '3'] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (query.includes('timestamp(node_uname_info)')) {
|
||||||
|
return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] });
|
||||||
|
}
|
||||||
|
return success({ result: [{ metric: {}, value: [1_765_000_060, '25'] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
const result = await service.overview('1h');
|
||||||
|
|
||||||
|
expect(result.available).toBe(true);
|
||||||
|
expect(result.metrics.cpuUsagePercent).toBe(25);
|
||||||
|
expect(result.trends.cpuUsagePercent).toHaveLength(2);
|
||||||
|
expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 });
|
||||||
|
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({ unit: 'redis-server.service', status: 'healthy' });
|
||||||
|
expect(result.serviceMetrics.find((item) => item.key === 'api')).toMatchObject({ available: true });
|
||||||
|
expect(result.serviceMetrics.find((item) => item.key === 'gateway')?.metrics.find((item) => item.key === 'queuePending')?.value).toBe(3);
|
||||||
|
expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' });
|
||||||
|
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5);
|
||||||
|
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range')).every((url) => url.searchParams.get('step') === '60')).toBe(true);
|
||||||
|
expect(requestedUrls.find((url) => url.searchParams.get('query')?.includes('node_systemd_unit_state'))?.searchParams.get('query'))
|
||||||
|
.toContain('cmpp-api\\\\.service');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
const result = await service.overview('24h');
|
||||||
|
|
||||||
|
expect(result.available).toBe(false);
|
||||||
|
expect(result.summary.overallStatus).toBe('unknown');
|
||||||
|
expect(result.metrics.cpuUsagePercent).toBeNull();
|
||||||
|
expect(result.trends.cpuUsagePercent).toEqual([]);
|
||||||
|
expect(result.serviceMetrics.every((item) => item.available === false)).toBe(true);
|
||||||
|
expect(result.error).not.toContain('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
|
||||||
|
const labels = { alertname: 'QaWarning', severity: 'warning', service: 'qa-preview' };
|
||||||
|
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24);
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示预警' }, state: 'firing', activeAt: '2026-08-16T01:00:00.000Z' }] }));
|
||||||
|
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-16T01:00:00.000Z'), readAt: new Date('2026-08-16T01:01:00.000Z') }]);
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 0, criticalCount: 0 });
|
||||||
|
|
||||||
|
prisma.infrastructureAlertRead.findMany.mockResolvedValue([{ fingerprint, activeAt: new Date('2026-08-15T01:00:00.000Z'), readAt: new Date('2026-08-15T01:01:00.000Z') }]);
|
||||||
|
await expect(service.notificationSummary('admin-1')).resolves.toEqual({ count: 1, criticalCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts an idempotent per-user read record only for a currently active occurrence', async () => {
|
||||||
|
const labels = { alertname: 'QaCritical', severity: 'critical', service: 'qa-preview' };
|
||||||
|
const fingerprint = createHash('sha256').update(JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)))).digest('hex').slice(0, 24);
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue(success({ alerts: [{ labels, annotations: { summary: '演示严重告警' }, state: 'firing', activeAt: '2026-08-16T02:00:00.000Z' }] }));
|
||||||
|
prisma.$transaction.mockResolvedValue([{ activeAt: new Date('2026-08-16T02:00:00.000Z'), readAt: new Date('2026-08-16T02:01:00.000Z') }, {}]);
|
||||||
|
const service = new InfrastructureMonitoringService(new ConfigService(), prisma as never);
|
||||||
|
|
||||||
|
await expect(service.markAlertRead(fingerprint, '2026-08-16T02:00:00.000Z', 'admin-1')).resolves.toMatchObject({ fingerprint, acknowledged: true });
|
||||||
|
expect(prisma.infrastructureAlertRead.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ fingerprint, userId: 'admin-1' }) }));
|
||||||
|
await expect(service.markAlertRead(fingerprint, '2026-08-15T02:00:00.000Z', 'admin-1')).rejects.toThrow('已结束或已重新触发');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type {
|
||||||
|
InfrastructureAlert,
|
||||||
|
InfrastructureMetricPoint,
|
||||||
|
InfrastructureMonitoringOverview,
|
||||||
|
InfrastructureMonitoringRange,
|
||||||
|
InfrastructureServiceStatus,
|
||||||
|
InfrastructureServiceMetricGroup,
|
||||||
|
} from './infrastructure-monitoring.contracts';
|
||||||
|
|
||||||
|
type PrometheusSample = [number, string];
|
||||||
|
type PrometheusSeries = {
|
||||||
|
metric: Record<string, string>;
|
||||||
|
value?: PrometheusSample;
|
||||||
|
values?: PrometheusSample[];
|
||||||
|
};
|
||||||
|
type PrometheusQueryResponse = {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: { result?: PrometheusSeries[] };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
type PrometheusAlertResponse = {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: {
|
||||||
|
alerts?: Array<{
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
|
state?: string;
|
||||||
|
activeAt?: string;
|
||||||
|
value?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANGE_CONFIG: Record<InfrastructureMonitoringRange, { seconds: number; step: number }> = {
|
||||||
|
'1h': { seconds: 60 * 60, step: 60 },
|
||||||
|
'24h': { seconds: 24 * 60 * 60, step: 300 },
|
||||||
|
'7d': { seconds: 7 * 24 * 60 * 60, step: 1800 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUERIES = {
|
||||||
|
cpuUsagePercent: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
|
||||||
|
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
|
||||||
|
memoryTotalBytes: 'node_memory_MemTotal_bytes',
|
||||||
|
memoryAvailableBytes: 'node_memory_MemAvailable_bytes',
|
||||||
|
diskUsagePercent: '(1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"})) * 100',
|
||||||
|
diskTotalBytes: 'node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
||||||
|
diskAvailableBytes: 'node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
||||||
|
networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))',
|
||||||
|
networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))',
|
||||||
|
load1: 'node_load1',
|
||||||
|
uptimeSeconds: 'time() - node_boot_time_seconds',
|
||||||
|
lastSampleAt: 'max(timestamp(node_uname_info))',
|
||||||
|
// PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。
|
||||||
|
services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SERVICE_DEFINITIONS = [
|
||||||
|
{ key: 'api', name: 'API服务', units: ['cmpp-api.service'] },
|
||||||
|
{ key: 'gateway', name: 'Gateway服务', units: ['cmpp-gateway.service'] },
|
||||||
|
{ key: 'postgresql', name: 'PostgreSQL', units: ['postgresql.service'] },
|
||||||
|
{ key: 'redis', name: 'Redis', units: ['redis.service', 'redis-server.service'] },
|
||||||
|
{ key: 'minio', name: 'MinIO', units: ['cmpp-minio.service'] },
|
||||||
|
{ key: 'nginx', name: 'Nginx', units: ['nginx.service'] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SERVICE_METRIC_DEFINITIONS = [
|
||||||
|
{ key: 'api', name: 'API服务', metrics: [
|
||||||
|
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
|
||||||
|
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
|
||||||
|
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
|
||||||
|
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
|
||||||
|
] },
|
||||||
|
{ key: 'gateway', name: 'Gateway服务', metrics: [
|
||||||
|
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
|
||||||
|
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
|
||||||
|
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
|
||||||
|
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
|
||||||
|
] },
|
||||||
|
{ key: 'postgresql', name: 'PostgreSQL', metrics: [
|
||||||
|
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
|
||||||
|
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
|
||||||
|
] },
|
||||||
|
{ key: 'redis', name: 'Redis', metrics: [
|
||||||
|
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
|
||||||
|
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
|
||||||
|
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
|
||||||
|
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
|
||||||
|
] },
|
||||||
|
{ key: 'minio', name: 'MinIO', metrics: [
|
||||||
|
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
|
||||||
|
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
|
||||||
|
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
|
||||||
|
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
|
||||||
|
] },
|
||||||
|
{ key: 'nginx', name: 'Nginx', metrics: [
|
||||||
|
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
|
||||||
|
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
|
||||||
|
] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}';
|
||||||
|
|
||||||
|
function finiteNumber(value: string | number | undefined): number | null {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrometheusUrl(rawValue: unknown) {
|
||||||
|
const url = new URL(String(rawValue ?? 'http://127.0.0.1:9090'));
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('PROMETHEUS_URL must use HTTP or HTTPS');
|
||||||
|
if (url.username || url.password) throw new Error('PROMETHEUS_URL must not contain credentials');
|
||||||
|
const privateIpv4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(url.hostname);
|
||||||
|
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
||||||
|
// Plain HTTP is only safe on loopback or an explicit RFC1918 address; named remote endpoints must use HTTPS.
|
||||||
|
if (url.protocol === 'http:' && !loopback && !privateIpv4) throw new Error('Remote PROMETHEUS_URL must use HTTPS');
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function vectorValue(response: PrometheusQueryResponse): number | null {
|
||||||
|
return finiteNumber(response.data?.result?.[0]?.value?.[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrixValues(response: PrometheusQueryResponse): InfrastructureMetricPoint[] {
|
||||||
|
return (response.data?.result?.[0]?.values ?? []).flatMap(([timestamp, value]) => {
|
||||||
|
const parsed = finiteNumber(value);
|
||||||
|
return parsed === null ? [] : [{ timestamp: new Date(timestamp * 1000).toISOString(), value: parsed }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyMetrics(): InfrastructureMonitoringOverview['metrics'] {
|
||||||
|
return {
|
||||||
|
cpuUsagePercent: null,
|
||||||
|
memoryUsagePercent: null,
|
||||||
|
memoryTotalBytes: null,
|
||||||
|
memoryAvailableBytes: null,
|
||||||
|
diskUsagePercent: null,
|
||||||
|
diskTotalBytes: null,
|
||||||
|
diskAvailableBytes: null,
|
||||||
|
networkReceiveBytesPerSecond: null,
|
||||||
|
networkTransmitBytesPerSecond: null,
|
||||||
|
load1: null,
|
||||||
|
uptimeSeconds: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTrends(): InfrastructureMonitoringOverview['trends'] {
|
||||||
|
return {
|
||||||
|
cpuUsagePercent: [],
|
||||||
|
memoryUsagePercent: [],
|
||||||
|
diskUsagePercent: [],
|
||||||
|
networkReceiveBytesPerSecond: [],
|
||||||
|
networkTransmitBytesPerSecond: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InfrastructureMonitoringService {
|
||||||
|
private readonly logger = new Logger(InfrastructureMonitoringService.name);
|
||||||
|
private readonly prometheusUrl: string;
|
||||||
|
private readonly queryTimeoutMs: number;
|
||||||
|
|
||||||
|
constructor(config: ConfigService, private readonly prisma: PrismaService) {
|
||||||
|
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
|
||||||
|
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async overview(rawRange?: string, userId?: string): Promise<InfrastructureMonitoringOverview> {
|
||||||
|
const range = this.parseRange(rawRange);
|
||||||
|
const collectedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const [instant, trends, serviceResponse, serviceMetricResponse, alertResponse] = await Promise.all([
|
||||||
|
this.loadInstantMetrics(),
|
||||||
|
this.loadTrends(range),
|
||||||
|
this.query(QUERIES.services),
|
||||||
|
this.query(SERVICE_METRICS_QUERY),
|
||||||
|
this.getJson<PrometheusAlertResponse>('/api/v1/alerts'),
|
||||||
|
]);
|
||||||
|
const services = this.parseServices(serviceResponse);
|
||||||
|
const serviceMetrics = this.parseServiceMetrics(serviceMetricResponse);
|
||||||
|
const alerts = await this.attachReadState(this.parseAlerts(alertResponse), userId);
|
||||||
|
const warningAlerts = alerts.filter((item) => item.severity === 'warning').length;
|
||||||
|
const criticalAlerts = alerts.filter((item) => item.severity === 'critical').length;
|
||||||
|
const overallStatus = criticalAlerts > 0 ? 'critical' : warningAlerts > 0 ? 'warning' : 'healthy';
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
range,
|
||||||
|
collectedAt,
|
||||||
|
lastSampleAt: instant.lastSampleAt === null ? null : new Date(instant.lastSampleAt * 1000).toISOString(),
|
||||||
|
summary: {
|
||||||
|
overallStatus,
|
||||||
|
serviceTotal: services.length,
|
||||||
|
serviceHealthy: services.filter((item) => item.status === 'healthy').length,
|
||||||
|
warningAlerts,
|
||||||
|
criticalAlerts,
|
||||||
|
activeAlerts: alerts.length,
|
||||||
|
},
|
||||||
|
metrics: instant.metrics,
|
||||||
|
trends,
|
||||||
|
services,
|
||||||
|
serviceMetrics,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断。
|
||||||
|
this.logger.warn(`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
return this.unavailable(range, collectedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async notificationSummary(userId?: string) {
|
||||||
|
try {
|
||||||
|
const alerts = await this.attachReadState(this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')), userId);
|
||||||
|
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
|
||||||
|
return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAlertRead(fingerprint: string, rawActiveAt: unknown, userId: string) {
|
||||||
|
if (!/^[a-f0-9]{24}$/.test(fingerprint)) throw new BadRequestException('告警指纹无效');
|
||||||
|
const activeAt = new Date(String(rawActiveAt ?? ''));
|
||||||
|
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
|
||||||
|
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts'));
|
||||||
|
const current = activeAlerts.find((item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime());
|
||||||
|
if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试');
|
||||||
|
const readAt = new Date();
|
||||||
|
const log = () => this.prisma.operationLog.create({
|
||||||
|
data: { userId, action: 'monitoring.alert_marked_read', resource: 'infrastructure_alert', resourceId: fingerprint, detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity } },
|
||||||
|
});
|
||||||
|
let read;
|
||||||
|
try {
|
||||||
|
[read] = await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertRead.create({ data: { fingerprint, activeAt, userId, readAt } }),
|
||||||
|
log(),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
|
||||||
|
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } });
|
||||||
|
// 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。
|
||||||
|
if (existing.activeAt.getTime() === activeAt.getTime()) read = existing;
|
||||||
|
else [read] = await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertRead.update({ where: { fingerprint_userId: { fingerprint, userId } }, data: { activeAt, readAt } }),
|
||||||
|
log(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRange(value?: string): InfrastructureMonitoringRange {
|
||||||
|
const range = value || '24h';
|
||||||
|
if (!(range in RANGE_CONFIG)) throw new BadRequestException('监控时间范围只支持1h、24h或7d');
|
||||||
|
return range as InfrastructureMonitoringRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInstantMetrics() {
|
||||||
|
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
|
||||||
|
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
|
||||||
|
const metrics = emptyMetrics();
|
||||||
|
keys.forEach((key, index) => { metrics[key] = vectorValue(responses[index]); });
|
||||||
|
return { metrics, lastSampleAt: vectorValue(responses[responses.length - 1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTrends(range: InfrastructureMonitoringRange) {
|
||||||
|
const config = RANGE_CONFIG[range];
|
||||||
|
const end = Math.floor(Date.now() / 1000);
|
||||||
|
const start = end - config.seconds;
|
||||||
|
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
|
||||||
|
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
|
||||||
|
return Object.fromEntries(keys.map((key, index) => [key, matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
||||||
|
const values = new Map<string, number>();
|
||||||
|
for (const item of response.data?.result ?? []) {
|
||||||
|
if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
|
||||||
|
}
|
||||||
|
return SERVICE_DEFINITIONS.map((definition) => {
|
||||||
|
const present = definition.units.filter((unit) => values.has(unit));
|
||||||
|
const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy';
|
||||||
|
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAlerts(response: PrometheusAlertResponse): InfrastructureAlert[] {
|
||||||
|
return (response.data?.alerts ?? [])
|
||||||
|
.filter((item) => item.state === 'firing' || item.state === 'pending')
|
||||||
|
.map<InfrastructureAlert>((item) => {
|
||||||
|
const labels = item.labels ?? {};
|
||||||
|
const annotations = item.annotations ?? {};
|
||||||
|
const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
|
||||||
|
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
|
||||||
|
return {
|
||||||
|
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
|
||||||
|
name: labels.alertname || '未命名告警',
|
||||||
|
severity,
|
||||||
|
status: item.state || 'unknown',
|
||||||
|
startedAt: item.activeAt || new Date().toISOString(),
|
||||||
|
summary: annotations.summary || annotations.description || labels.alertname || '监控告警',
|
||||||
|
description: annotations.description,
|
||||||
|
currentValue: annotations.currentValue || item.value,
|
||||||
|
threshold: annotations.threshold,
|
||||||
|
service: labels.service,
|
||||||
|
instance: labels.instance,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
|
||||||
|
return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attachReadState(alerts: InfrastructureAlert[], userId?: string) {
|
||||||
|
if (!userId || alerts.length === 0) return alerts;
|
||||||
|
const reads = await this.prisma.infrastructureAlertRead.findMany({
|
||||||
|
where: { userId, fingerprint: { in: alerts.map((item) => item.fingerprint) } },
|
||||||
|
select: { fingerprint: true, activeAt: true, readAt: true },
|
||||||
|
});
|
||||||
|
const byFingerprint = new Map(reads.map((item) => [item.fingerprint, item]));
|
||||||
|
return alerts.map((alert) => {
|
||||||
|
const read = byFingerprint.get(alert.fingerprint);
|
||||||
|
const acknowledged = Boolean(read && read.activeAt.getTime() === Date.parse(alert.startedAt));
|
||||||
|
return { ...alert, acknowledged, acknowledgedAt: acknowledged ? read?.readAt.toISOString() : undefined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseServiceMetrics(response: PrometheusQueryResponse): InfrastructureServiceMetricGroup[] {
|
||||||
|
const values = new Map<string, number>();
|
||||||
|
for (const item of response.data?.result ?? []) {
|
||||||
|
const metricName = item.metric.__name__;
|
||||||
|
const value = vectorValue({ status: 'success', data: { result: [item] } });
|
||||||
|
if (metricName && value !== null) values.set(metricName, value);
|
||||||
|
}
|
||||||
|
return SERVICE_METRIC_DEFINITIONS.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
name: group.name,
|
||||||
|
available: group.metrics.some((metric) => values.has(metric[2])),
|
||||||
|
metrics: group.metrics.map(([key, label, metricName, unit]) => ({ key, label, value: values.get(metricName) ?? null, unit })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
|
||||||
|
const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const }));
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
range,
|
||||||
|
collectedAt,
|
||||||
|
lastSampleAt: null,
|
||||||
|
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
|
||||||
|
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
|
||||||
|
metrics: emptyMetrics(),
|
||||||
|
trends: emptyTrends(),
|
||||||
|
services,
|
||||||
|
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })),
|
||||||
|
alerts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private query(query: string) {
|
||||||
|
return this.getJson<PrometheusQueryResponse>('/api/v1/query', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
private queryRange(query: string, start: number, end: number, step: number) {
|
||||||
|
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||||
|
const url = new URL(`${this.prometheusUrl}${path}`);
|
||||||
|
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||||
|
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) });
|
||||||
|
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
|
||||||
|
const result = await response.json() as T;
|
||||||
|
if (result.status !== 'success') throw new Error('Prometheus query failed');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-2
@@ -1,8 +1,12 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { MetricsService } from './metrics/metrics.service';
|
||||||
import { OpenApiModule } from './open-api/open-api.module';
|
import { OpenApiModule } from './open-api/open-api.module';
|
||||||
|
import { configureHttpBodyParsers } from './http-body-limits';
|
||||||
|
|
||||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -16,8 +20,9 @@ Object.defineProperty(BigInt.prototype, 'toJSON', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, bodyParser: false });
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
configureHttpBodyParsers(app);
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('CMPP Platform API')
|
.setTitle('CMPP Platform API')
|
||||||
@@ -36,7 +41,26 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
||||||
|
|
||||||
const port = Number(process.env.API_PORT ?? 3000);
|
const port = Number(process.env.API_PORT ?? 3000);
|
||||||
await app.listen(port);
|
// 生产环境只允许 Nginx 访问管理 API;显式绑定回环,避免默认的全网卡监听绕过入口鉴权与限流。
|
||||||
|
const host = process.env.API_HOST?.trim() || '127.0.0.1';
|
||||||
|
await app.listen(port, host);
|
||||||
|
|
||||||
|
const metrics = app.get(MetricsService);
|
||||||
|
const metricsHost = process.env.API_METRICS_HOST?.trim() || '127.0.0.1';
|
||||||
|
const metricsPort = Number(process.env.API_METRICS_PORT ?? 9464);
|
||||||
|
const metricsServer = createServer((request, response) => {
|
||||||
|
if (request.method !== 'GET' || request.url !== '/metrics') {
|
||||||
|
response.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
response.end(metrics.render());
|
||||||
|
});
|
||||||
|
// Metrics use a dedicated loopback listener so Nginx cannot accidentally expose them through /api/.
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
metricsServer.once('error', reject);
|
||||||
|
metricsServer.listen(metricsPort, metricsHost, resolve);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import type { Observable } from 'rxjs';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
type RequestLike = { method?: string; baseUrl?: string; route?: { path?: string } };
|
||||||
|
type ResponseLike = { statusCode?: number };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly metrics: MetricsService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
if (context.getType() !== 'http') return next.handle();
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const request = http.getRequest<RequestLike>();
|
||||||
|
const response = http.getResponse<ResponseLike>();
|
||||||
|
const startedAt = this.metrics.beginRequest();
|
||||||
|
return next.handle().pipe(finalize(() => {
|
||||||
|
const route = `${request.baseUrl ?? ''}${request.route?.path ?? '/unmatched'}`;
|
||||||
|
this.metrics.finishRequest(startedAt, request.method ?? 'UNKNOWN', route, response.statusCode ?? 500);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { MetricsInterceptor } from './metrics.interceptor';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [MetricsService, { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }],
|
||||||
|
exports: [MetricsService],
|
||||||
|
})
|
||||||
|
export class MetricsModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
describe('MetricsService', () => {
|
||||||
|
it('exports bounded API process and HTTP metrics without raw identifiers', () => {
|
||||||
|
const service = new MetricsService();
|
||||||
|
const startedAt = service.beginRequest();
|
||||||
|
service.finishRequest(startedAt, 'GET', '/api/admin/tenants/:id', 200);
|
||||||
|
const output = service.render();
|
||||||
|
|
||||||
|
expect(output).toContain('cmpp_api_process_resident_memory_bytes');
|
||||||
|
expect(output).toContain('cmpp_api_http_requests_total{method="GET",route="/api/admin/tenants/:id",status="200"} 1');
|
||||||
|
expect(output).toContain('cmpp_api_http_request_duration_seconds_bucket');
|
||||||
|
expect(output).not.toContain('phone_number');
|
||||||
|
service.onModuleDestroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
||||||
|
|
||||||
|
const HTTP_DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
|
||||||
|
|
||||||
|
type HttpMetric = {
|
||||||
|
count: number;
|
||||||
|
durationSum: number;
|
||||||
|
buckets: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeLabel(value: string) {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricLine(name: string, value: number, labels?: Record<string, string>) {
|
||||||
|
const suffix = labels
|
||||||
|
? `{${Object.entries(labels).map(([key, item]) => `${key}="${escapeLabel(item)}"`).join(',')}}`
|
||||||
|
: '';
|
||||||
|
return `${name}${suffix} ${Number.isFinite(value) ? value : 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsService implements OnModuleDestroy {
|
||||||
|
private readonly startedAt = process.hrtime.bigint();
|
||||||
|
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||||
|
private readonly http = new Map<string, HttpMetric>();
|
||||||
|
private inFlight = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.eventLoopDelay.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginRequest() {
|
||||||
|
this.inFlight += 1;
|
||||||
|
return process.hrtime.bigint();
|
||||||
|
}
|
||||||
|
|
||||||
|
finishRequest(startedAt: bigint, method: string, route: string, statusCode: number) {
|
||||||
|
this.inFlight = Math.max(0, this.inFlight - 1);
|
||||||
|
// Only route templates enter labels. Raw URLs, IDs, phone numbers and query strings would create unbounded time series.
|
||||||
|
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
||||||
|
const labels = [method.toUpperCase(), normalizedRoute, String(statusCode)];
|
||||||
|
const key = labels.join('\u0000');
|
||||||
|
const metric = this.http.get(key) ?? { count: 0, durationSum: 0, buckets: HTTP_DURATION_BUCKETS.map(() => 0) };
|
||||||
|
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
|
||||||
|
metric.count += 1;
|
||||||
|
metric.durationSum += durationSeconds;
|
||||||
|
HTTP_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||||
|
if (durationSeconds <= bucket) metric.buckets[index] += 1;
|
||||||
|
});
|
||||||
|
this.http.set(key, metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
const uptime = Number(process.hrtime.bigint() - this.startedAt) / 1_000_000_000;
|
||||||
|
const lines = [
|
||||||
|
'# HELP cmpp_api_process_uptime_seconds API process uptime.',
|
||||||
|
'# TYPE cmpp_api_process_uptime_seconds gauge',
|
||||||
|
metricLine('cmpp_api_process_uptime_seconds', uptime),
|
||||||
|
'# HELP cmpp_api_process_resident_memory_bytes API resident memory.',
|
||||||
|
'# TYPE cmpp_api_process_resident_memory_bytes gauge',
|
||||||
|
metricLine('cmpp_api_process_resident_memory_bytes', memory.rss),
|
||||||
|
'# HELP cmpp_api_nodejs_heap_used_bytes Node.js heap currently used.',
|
||||||
|
'# TYPE cmpp_api_nodejs_heap_used_bytes gauge',
|
||||||
|
metricLine('cmpp_api_nodejs_heap_used_bytes', memory.heapUsed),
|
||||||
|
'# HELP cmpp_api_nodejs_heap_total_bytes Node.js allocated heap.',
|
||||||
|
'# TYPE cmpp_api_nodejs_heap_total_bytes gauge',
|
||||||
|
metricLine('cmpp_api_nodejs_heap_total_bytes', memory.heapTotal),
|
||||||
|
'# HELP cmpp_api_nodejs_event_loop_lag_p99_seconds Event loop delay p99 since the previous scrape.',
|
||||||
|
'# TYPE cmpp_api_nodejs_event_loop_lag_p99_seconds gauge',
|
||||||
|
metricLine('cmpp_api_nodejs_event_loop_lag_p99_seconds', this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0),
|
||||||
|
'# HELP cmpp_api_http_requests_in_flight Current API requests in flight.',
|
||||||
|
'# TYPE cmpp_api_http_requests_in_flight gauge',
|
||||||
|
metricLine('cmpp_api_http_requests_in_flight', this.inFlight),
|
||||||
|
'# HELP cmpp_api_http_requests_total API requests grouped by bounded route templates.',
|
||||||
|
'# TYPE cmpp_api_http_requests_total counter',
|
||||||
|
'# HELP cmpp_api_http_request_duration_seconds API request duration.',
|
||||||
|
'# TYPE cmpp_api_http_request_duration_seconds histogram',
|
||||||
|
];
|
||||||
|
for (const [key, metric] of this.http) {
|
||||||
|
const [method, route, status] = key.split('\u0000');
|
||||||
|
const labels = { method, route, status };
|
||||||
|
lines.push(metricLine('cmpp_api_http_requests_total', metric.count, labels));
|
||||||
|
HTTP_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||||
|
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
|
||||||
|
});
|
||||||
|
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
|
||||||
|
lines.push(metricLine('cmpp_api_http_request_duration_seconds_sum', metric.durationSum, labels));
|
||||||
|
lines.push(metricLine('cmpp_api_http_request_duration_seconds_count', metric.count, labels));
|
||||||
|
}
|
||||||
|
this.eventLoopDelay.reset();
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
this.eventLoopDelay.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import IORedis from 'ioredis';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { decryptSecret } from './open-api.crypto';
|
import { decryptSecret } from './open-api.crypto';
|
||||||
import type { OpenApiRequestLike } from './open-api.types';
|
import type { OpenApiRequestLike } from './open-api.types';
|
||||||
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||||
private redis?: IORedis;
|
private redis?: IORedis;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
||||||
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
const nonce = header(request, 'x-nonce');
|
const nonce = header(request, 'x-nonce');
|
||||||
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
||||||
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, undefined, 'AUTH_HEADERS_MISSING');
|
||||||
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
|
||||||
}
|
}
|
||||||
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, accessKey, 'NONCE_INVALID');
|
||||||
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
|
||||||
}
|
}
|
||||||
const credential = await this.prisma.httpApiCredential.findUnique({
|
const credential = await this.prisma.httpApiCredential.findUnique({
|
||||||
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
||||||
});
|
});
|
||||||
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
|
||||||
|
await this.recordFailure('http_invalid_api_key', request, accessKey, 'CREDENTIAL_INVALID');
|
||||||
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
|
||||||
}
|
}
|
||||||
const config = credential.application.httpConfig;
|
const config = credential.application.httpConfig;
|
||||||
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const timestamp = Number(timestampText);
|
const timestamp = Number(timestampText);
|
||||||
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
|
||||||
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
|
||||||
}
|
}
|
||||||
const sourceIp = requestIp(request);
|
const sourceIp = requestIp(request);
|
||||||
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||||
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
||||||
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
|
||||||
|
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
|
||||||
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
|
||||||
}
|
}
|
||||||
const redis = this.getRedis();
|
const redis = this.getRedis();
|
||||||
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
||||||
if (nonceAccepted !== 'OK') {
|
if (nonceAccepted !== 'OK') {
|
||||||
|
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
|
||||||
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
||||||
}
|
}
|
||||||
const second = Math.floor(Date.now() / 1000);
|
const second = Math.floor(Date.now() / 1000);
|
||||||
@@ -81,6 +88,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
|||||||
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
|
||||||
return this.redis;
|
return this.redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
|
||||||
|
const sourceIp = requestIp(request);
|
||||||
|
if (!sourceIp) return;
|
||||||
|
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
|
||||||
|
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function header(request: OpenApiRequestLike, name: string) {
|
function header(request: OpenApiRequestLike, name: string) {
|
||||||
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
|
|||||||
|
|
||||||
function requestIp(request: OpenApiRequestLike) {
|
function requestIp(request: OpenApiRequestLike) {
|
||||||
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
|
||||||
return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, '');
|
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
|
||||||
|
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
|
||||||
|
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipMatches(ip: string, rule: string) {
|
function ipMatches(ip: string, rule: string) {
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
|
|||||||
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
||||||
import { OpenApiController } from './open-api.controller';
|
import { OpenApiController } from './open-api.controller';
|
||||||
import { OpenApiService } from './open-api.service';
|
import { OpenApiService } from './open-api.service';
|
||||||
|
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
|
||||||
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
||||||
providers: [OpenApiService, OpenApiAuthGuard],
|
providers: [OpenApiService, OpenApiAuthGuard],
|
||||||
exports: [OpenApiService],
|
exports: [OpenApiService],
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ export class AdminOperationsController {
|
|||||||
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
|
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gateway-submit-dead-letters/:id/resolve')
|
||||||
|
resolveGatewaySubmitDeadLetter(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('receipt-anomalies')
|
@Get('receipt-anomalies')
|
||||||
receiptAnomalies(
|
receiptAnomalies(
|
||||||
@Query('tenantId') tenantId?: string,
|
@Query('tenantId') tenantId?: string,
|
||||||
@@ -354,6 +362,57 @@ export class AdminOperationsController {
|
|||||||
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
|
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
|
||||||
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
|
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('downstream-requeue-tasks/preview')
|
||||||
|
previewDownstreamRequeueTask(
|
||||||
|
@Body() body: { filter?: Record<string, string | undefined> },
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.previewDownstreamRequeueTask(body.filter ?? {}, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('downstream-requeue-tasks')
|
||||||
|
createDownstreamRequeueTask(
|
||||||
|
@Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.createDownstreamRequeueTask({
|
||||||
|
previewToken: body.previewToken ?? '',
|
||||||
|
reason: body.reason ?? '',
|
||||||
|
ratePerSecond: body.ratePerSecond,
|
||||||
|
consecutiveFailureLimit: body.consecutiveFailureLimit,
|
||||||
|
}, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('downstream-requeue-tasks')
|
||||||
|
listDownstreamRequeueTasks(@Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
|
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('downstream-requeue-tasks/:id')
|
||||||
|
getDownstreamRequeueTask(@Param('id') id: string) {
|
||||||
|
return this.sendChain.getDownstreamRequeueTask(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('downstream-requeue-tasks/:id/items')
|
||||||
|
listDownstreamRequeueTaskItems(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.listDownstreamRequeueTaskItems(id, { status, keyword, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('downstream-requeue-tasks/:id/:action')
|
||||||
|
changeDownstreamRequeueTaskStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('action') action: 'pause' | 'resume' | 'terminate',
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.sendChain.changeDownstreamRequeueTaskStatus(id, action, operatorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('admin-system-logs')
|
@ApiTags('admin-system-logs')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('ProtocolLogsService', () => {
|
|||||||
prisma.protocolInteractionLog.groupBy.mockResolvedValue([]);
|
prisma.protocolInteractionLog.groupBy.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buffers a masked and secret-free business event', async () => {
|
it('buffers a full-phone and secret-free business event', async () => {
|
||||||
const service = new ProtocolLogsService(prisma as never);
|
const service = new ProtocolLogsService(prisma as never);
|
||||||
service.record({
|
service.record({
|
||||||
protocol: 'cmpp',
|
protocol: 'cmpp',
|
||||||
@@ -36,7 +36,7 @@ describe('ProtocolLogsService', () => {
|
|||||||
|
|
||||||
expect(prisma.protocolInteractionLog.createMany).toHaveBeenCalledWith({
|
expect(prisma.protocolInteractionLog.createMany).toHaveBeenCalledWith({
|
||||||
data: [expect.objectContaining({
|
data: [expect.objectContaining({
|
||||||
phoneMasked: '188****3795',
|
phoneNumber: '18821203795',
|
||||||
gatewayMessageId: '123',
|
gatewayMessageId: '123',
|
||||||
detail: { sequenceId: 7 },
|
detail: { sequenceId: 7 },
|
||||||
})],
|
})],
|
||||||
@@ -65,4 +65,15 @@ describe('ProtocolLogsService', () => {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queries the full phone number field', async () => {
|
||||||
|
const service = new ProtocolLogsService(prisma as never);
|
||||||
|
await service.list({ keyword: '18821203795' });
|
||||||
|
|
||||||
|
expect(prisma.protocolInteractionLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
OR: expect.arrayContaining([{ phoneNumber: { contains: '18821203795' } }]),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
gatewayMessageId: input.gatewayMessageId == null ? null : String(input.gatewayMessageId).slice(0, 128),
|
gatewayMessageId: input.gatewayMessageId == null ? null : String(input.gatewayMessageId).slice(0, 128),
|
||||||
traceId: clean(input.traceId, 128),
|
traceId: clean(input.traceId, 128),
|
||||||
requestId: clean(input.requestId, 128),
|
requestId: clean(input.requestId, 128),
|
||||||
phoneMasked: maskPhone(input.phone),
|
phoneNumber: clean(input.phone, 32),
|
||||||
resultCode: input.resultCode == null ? null : String(input.resultCode).slice(0, 64),
|
resultCode: input.resultCode == null ? null : String(input.resultCode).slice(0, 64),
|
||||||
durationMs: safeInteger(input.durationMs),
|
durationMs: safeInteger(input.durationMs),
|
||||||
payloadBytes: safeInteger(input.payloadBytes),
|
payloadBytes: safeInteger(input.payloadBytes),
|
||||||
@@ -102,7 +102,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
{ requestId: { contains: query.keyword } },
|
{ requestId: { contains: query.keyword } },
|
||||||
{ traceId: { contains: query.keyword } },
|
{ traceId: { contains: query.keyword } },
|
||||||
{ account: { contains: query.keyword } },
|
{ account: { contains: query.keyword } },
|
||||||
{ phoneMasked: { contains: query.keyword } },
|
{ phoneNumber: { contains: query.keyword } },
|
||||||
{ resultCode: { contains: query.keyword } },
|
{ resultCode: { contains: query.keyword } },
|
||||||
] : undefined,
|
] : undefined,
|
||||||
};
|
};
|
||||||
@@ -151,12 +151,6 @@ function clean(value: unknown, max = 191) {
|
|||||||
return text ? text.slice(0, max) : null;
|
return text ? text.slice(0, max) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskPhone(value: unknown) {
|
|
||||||
const text = String(value ?? '').replace(/\D/g, '');
|
|
||||||
if (!text) return null;
|
|
||||||
return text.length >= 7 ? `${text.slice(0, 3)}****${text.slice(-4)}` : `***${text.slice(-2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeInteger(value: unknown) {
|
function safeInteger(value: unknown) {
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 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 { 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';
|
import type { ReportBatchGenerationService } from './batch-generation.service';
|
||||||
|
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||||
|
|
||||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||||
export class ReportChannelExportService {
|
export class ReportChannelExportService {
|
||||||
@@ -32,13 +33,19 @@ export class ReportChannelExportService {
|
|||||||
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
|
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 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 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 reportCarriers = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null];
|
||||||
const task = existingTask
|
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = [];
|
||||||
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
|
for (const carrier of reportCarriers) {
|
||||||
: 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 } });
|
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, 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, ...(reportType === 'signature' ? { approvedAt: null } : {}) } })
|
||||||
|
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, carrier, approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
|
||||||
|
tasks.push({ task, existingTask });
|
||||||
|
}
|
||||||
|
const task = tasks[0].task;
|
||||||
if (missingReason) {
|
if (missingReason) {
|
||||||
incompleteBatchItemIds.push(item.batchItem.id);
|
incompleteBatchItemIds.push(item.batchItem.id);
|
||||||
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
|
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'waiting_material', task.reason ?? undefined);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
|
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
|
||||||
@@ -58,7 +65,7 @@ export class ReportChannelExportService {
|
|||||||
}
|
}
|
||||||
row.height = targetHeight;
|
row.height = targetHeight;
|
||||||
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
|
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
|
||||||
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
|
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
|
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ describe('ReportsService', () => {
|
|||||||
$executeRaw: jest.fn(),
|
$executeRaw: jest.fn(),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() },
|
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||||
dailyProfitReport: { findMany: jest.fn(), count: jest.fn() },
|
dailyProfitReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||||
dailyQualityReport: { findMany: jest.fn(), count: jest.fn() },
|
dailyQualityReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||||
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||||
};
|
};
|
||||||
let service: ReportsService;
|
let service: ReportsService;
|
||||||
@@ -23,10 +23,13 @@ describe('ReportsService', () => {
|
|||||||
tx.$executeRaw.mockResolvedValue(0);
|
tx.$executeRaw.mockResolvedValue(0);
|
||||||
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
||||||
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
|
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
|
||||||
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
|
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||||
|
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
|
||||||
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
||||||
|
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
|
||||||
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
|
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
|
||||||
prisma.dailyQualityReport.count.mockResolvedValue(1);
|
prisma.dailyQualityReport.count.mockResolvedValue(1);
|
||||||
|
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||||
service = new ReportsService(prisma as never);
|
service = new ReportsService(prisma as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +57,19 @@ describe('ReportsService', () => {
|
|||||||
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
|
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
|
||||||
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
|
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
||||||
|
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
||||||
|
);
|
||||||
|
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||||
|
|
||||||
|
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
|
||||||
|
expect(profitQueries).toContain('message."submitId" = submit."submitId"');
|
||||||
|
expect(profitQueries).not.toContain('"billingStatus"');
|
||||||
|
expect(profitQueries).not.toContain('billing.refund');
|
||||||
|
});
|
||||||
|
|
||||||
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
||||||
await expect(service.listReconciliation({
|
await expect(service.listReconciliation({
|
||||||
dateFrom: '2026-07-01',
|
dateFrom: '2026-07-01',
|
||||||
@@ -62,7 +78,7 @@ describe('ReportsService', () => {
|
|||||||
applicationId: 'app-1',
|
applicationId: 'app-1',
|
||||||
page: 2,
|
page: 2,
|
||||||
pageSize: 500,
|
pageSize: 500,
|
||||||
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 });
|
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
skip: 100,
|
skip: 100,
|
||||||
@@ -71,7 +87,12 @@ describe('ReportsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps application and channel profit filters separate', async () => {
|
it('keeps application and channel profit filters separate', async () => {
|
||||||
await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
|
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
||||||
|
}));
|
||||||
|
expect(result.summary).not.toHaveProperty('refundCents');
|
||||||
|
expect(result.items[0]).not.toHaveProperty('refundCents');
|
||||||
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
dimensionType: 'channel',
|
dimensionType: 'channel',
|
||||||
@@ -82,9 +103,37 @@ describe('ReportsService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
|
||||||
|
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
|
||||||
|
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
|
||||||
|
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
|
||||||
|
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
|
||||||
|
total: 0,
|
||||||
|
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports income without refund columns', async () => {
|
||||||
|
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
|
||||||
|
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
|
||||||
|
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
|
||||||
|
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
|
||||||
|
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const exported = await service.exportProfit({ dimensionType: 'application' });
|
||||||
|
expect(exported.content).toContain('收入金额(元)');
|
||||||
|
expect(exported.content).not.toContain('净消费金额(元)');
|
||||||
|
expect(exported.content).not.toContain('返还金额(元)');
|
||||||
|
});
|
||||||
|
|
||||||
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
|
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
|
||||||
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
|
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
|
||||||
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
|
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
|
||||||
|
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
|
||||||
});
|
});
|
||||||
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
||||||
|
|||||||
@@ -45,31 +45,52 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async listReconciliation(query: ReportListQuery) {
|
async listReconciliation(query: ReportListQuery) {
|
||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const where = reconciliationWhere(query);
|
const where = reconciliationWhere(query);
|
||||||
const [items, total] = await Promise.all([
|
const [items, total, aggregate] = await Promise.all([
|
||||||
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
|
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
|
||||||
this.prisma.dailyReconciliationReport.count({ where }),
|
this.prisma.dailyReconciliationReport.count({ where }),
|
||||||
|
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, pageSize };
|
return { items, total, page, pageSize, summary: volumeSummary(aggregate._sum) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listProfit(query: ReportListQuery) {
|
async listProfit(query: ReportListQuery) {
|
||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const { dimensionType, where } = profitWhere(query);
|
const { dimensionType, where } = profitWhere(query);
|
||||||
const [items, total] = await Promise.all([
|
const [storedItems, total, aggregate] = await Promise.all([
|
||||||
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
||||||
this.prisma.dailyProfitReport.count({ where }),
|
this.prisma.dailyProfitReport.count({ where }),
|
||||||
|
this.prisma.dailyProfitReport.aggregate({
|
||||||
|
where,
|
||||||
|
_sum: { ...reportVolumeSumSelection, revenueCents: true, costCents: true, profitCents: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, pageSize, dimensionType };
|
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
||||||
|
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
|
||||||
|
const summary = {
|
||||||
|
...volumeSummary(aggregate._sum),
|
||||||
|
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
||||||
|
costCents: Number(aggregate._sum.costCents ?? 0),
|
||||||
|
profitCents: Number(aggregate._sum.profitCents ?? 0),
|
||||||
|
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
|
||||||
|
profitRateBps: ratioBps(Number(aggregate._sum.profitCents ?? 0), Number(aggregate._sum.revenueCents ?? 0)),
|
||||||
|
};
|
||||||
|
return { items, total, page, pageSize, dimensionType, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listQuality(query: ReportListQuery) {
|
async listQuality(query: ReportListQuery) {
|
||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const { dimensionType, where } = qualityWhere(query);
|
const { dimensionType, where } = qualityWhere(query);
|
||||||
const [items, total] = await Promise.all([
|
const [items, total, aggregate] = await Promise.all([
|
||||||
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
||||||
this.prisma.dailyQualityReport.count({ where }),
|
this.prisma.dailyQualityReport.count({ where }),
|
||||||
|
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, pageSize, dimensionType };
|
const summary = {
|
||||||
|
...volumeSummary(aggregate._sum),
|
||||||
|
// 成功率按全量筛选结果的成功量/发送量重新计算,避免分页和分组大小导致失真。
|
||||||
|
successRateBps: ratioBps(Number(aggregate._sum.successUnits ?? 0), Number(aggregate._sum.sentUnits ?? 0)),
|
||||||
|
};
|
||||||
|
return { items, total, page, pageSize, dimensionType, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportReconciliation(query: ReportListQuery) {
|
async exportReconciliation(query: ReportListQuery) {
|
||||||
@@ -80,7 +101,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async exportProfit(query: ReportListQuery) {
|
async exportProfit(query: ReportListQuery) {
|
||||||
const { dimensionType, where } = profitWhere(query);
|
const { dimensionType, where } = profitWhere(query);
|
||||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportQuality(query: ReportListQuery) {
|
async exportQuality(query: ReportListQuery) {
|
||||||
@@ -149,13 +170,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await tx.$executeRaw(Prisma.sql`
|
await tx.$executeRaw(Prisma.sql`
|
||||||
WITH billing AS (
|
WITH costs AS (
|
||||||
SELECT "messageId",
|
|
||||||
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
|
|
||||||
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
|
|
||||||
FROM "SmsBillingRecord"
|
|
||||||
GROUP BY "messageId"
|
|
||||||
), costs AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
submit."messageRecordId",
|
submit."messageRecordId",
|
||||||
SUM(submit."costUnitPrice" * CASE
|
SUM(submit."costUnitPrice" * CASE
|
||||||
@@ -210,18 +225,24 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||||
COALESCE(SUM(billing.revenue), 0)::bigint,
|
-- 收入按每条最终成功短信的计费条数和发送时客户价快照计算,不能依赖随后可能变为 refunded 的账单状态。
|
||||||
COALESCE(SUM(billing.refund), 0)::bigint,
|
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
|
||||||
|
0::bigint,
|
||||||
COALESCE(SUM(costs.cost), 0)::bigint,
|
COALESCE(SUM(costs.cost), 0)::bigint,
|
||||||
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
(COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||||
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
||||||
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
|
CASE WHEN COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
|
||||||
|
ELSE ROUND((COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 /
|
||||||
|
SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
|
||||||
CURRENT_TIMESTAMP,
|
CURRENT_TIMESTAMP,
|
||||||
CURRENT_TIMESTAMP
|
CURRENT_TIMESTAMP
|
||||||
FROM "SmsMessageRecord" message
|
FROM "SmsMessageRecord" message
|
||||||
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
|
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
|
||||||
JOIN "SmsApplication" application ON application.id = message."applicationId"
|
JOIN "SmsApplication" application ON application.id = message."applicationId"
|
||||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
|
||||||
LEFT JOIN costs ON costs."messageRecordId" = message.id
|
LEFT JOIN costs ON costs."messageRecordId" = message.id
|
||||||
WHERE message."queuedAt" >= ${day.startAt}
|
WHERE message."queuedAt" >= ${day.startAt}
|
||||||
AND message."queuedAt" < ${day.endAt}
|
AND message."queuedAt" < ${day.endAt}
|
||||||
@@ -229,13 +250,6 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await tx.$executeRaw(Prisma.sql`
|
await tx.$executeRaw(Prisma.sql`
|
||||||
WITH billing AS (
|
|
||||||
SELECT "messageId",
|
|
||||||
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
|
|
||||||
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
|
|
||||||
FROM "SmsBillingRecord"
|
|
||||||
GROUP BY "messageId"
|
|
||||||
)
|
|
||||||
INSERT INTO "DailyProfitReport" (
|
INSERT INTO "DailyProfitReport" (
|
||||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||||
"tenantId", "tenantName", "applicationId", "channelId",
|
"tenantId", "tenantName", "applicationId", "channelId",
|
||||||
@@ -277,31 +291,41 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
AND receipt."channelId" = submit."channelId"
|
AND receipt."channelId" = submit."channelId"
|
||||||
AND receipt."receiptStatus" = 'undelivered'
|
AND receipt."receiptStatus" = 'undelivered'
|
||||||
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
|
-- 补发可能产生多次提交,收入只归属最终成功提交,避免同一短信在多个通道重复计收。
|
||||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
|
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||||
|
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
|
||||||
|
0::bigint,
|
||||||
COALESCE(SUM(submit."costUnitPrice" * CASE
|
COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0)::bigint,
|
END), 0)::bigint,
|
||||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||||
|
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0))::bigint,
|
END), 0))::bigint,
|
||||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
|
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
|
||||||
|
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||||
|
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0)) * 10000.0 /
|
END), 0)) * 10000.0 /
|
||||||
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
|
SUM(CASE WHEN message."submitId" = submit."submitId"
|
||||||
|
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
|
||||||
CURRENT_TIMESTAMP,
|
CURRENT_TIMESTAMP,
|
||||||
CURRENT_TIMESTAMP
|
CURRENT_TIMESTAMP
|
||||||
FROM "SmsSubmitRecord" submit
|
FROM "SmsSubmitRecord" submit
|
||||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*)::integer AS audit_count,
|
COUNT(*)::integer AS audit_count,
|
||||||
@@ -516,6 +540,28 @@ function pagination(query: ReportListQuery) {
|
|||||||
return { page, pageSize, skip: (page - 1) * pageSize };
|
return { page, pageSize, skip: (page - 1) * pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reportVolumeSumSelection = {
|
||||||
|
submittedUnits: true,
|
||||||
|
sentUnits: true,
|
||||||
|
unknownUnits: true,
|
||||||
|
successUnits: true,
|
||||||
|
failedUnits: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) {
|
||||||
|
return {
|
||||||
|
submittedUnits: Number(sum.submittedUnits ?? 0),
|
||||||
|
sentUnits: Number(sum.sentUnits ?? 0),
|
||||||
|
unknownUnits: Number(sum.unknownUnits ?? 0),
|
||||||
|
successUnits: Number(sum.successUnits ?? 0),
|
||||||
|
failedUnits: Number(sum.failedUnits ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioBps(numerator: number, denominator: number) {
|
||||||
|
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator);
|
||||||
|
}
|
||||||
|
|
||||||
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
|
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
|
||||||
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
|
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createConnection } from 'node:net';
|
||||||
|
|
||||||
|
type AgentResponse = { ok: boolean; reference?: string; blocked?: boolean; active?: boolean; error?: string };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SecurityAgentClient {
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
block(input: { operationKey: string; sourceIp: string; executor: string; durationSeconds: number }) {
|
||||||
|
return this.call({ action: 'block', ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
unblock(input: { operationKey: string; sourceIp: string; executor: string }) {
|
||||||
|
return this.call({ action: 'unblock', ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
status(sourceIp?: string, executor?: string) {
|
||||||
|
return this.call({ action: 'status', sourceIp, executor });
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRules(version: number, rules: Array<Record<string, unknown>>) {
|
||||||
|
return this.call({ action: 'apply_rules', version, rules });
|
||||||
|
}
|
||||||
|
|
||||||
|
private call(payload: Record<string, unknown>): Promise<AgentResponse> {
|
||||||
|
const socketPath = this.config.get<string>('SECURITY_AGENT_SOCKET') ?? '/run/cmpp-security-agent/agent.sock';
|
||||||
|
const timeoutMs = Number(this.config.get<string>('SECURITY_AGENT_TIMEOUT_MS') ?? 3000);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = createConnection(socketPath);
|
||||||
|
let settled = false;
|
||||||
|
let response = '';
|
||||||
|
const finish = (error?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
if (error) reject(error);
|
||||||
|
};
|
||||||
|
socket.setTimeout(timeoutMs, () => finish(new Error('安全执行代理响应超时')));
|
||||||
|
socket.on('error', (error) => finish(new Error(`安全执行代理不可用: ${error.message}`)));
|
||||||
|
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
response += chunk.toString('utf8');
|
||||||
|
const lineEnd = response.indexOf('\n');
|
||||||
|
if (lineEnd < 0) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(response.slice(0, lineEnd)) as AgentResponse;
|
||||||
|
settled = true;
|
||||||
|
socket.end();
|
||||||
|
resolve(parsed);
|
||||||
|
} catch {
|
||||||
|
finish(new Error('安全执行代理返回了非法响应'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const SECURITY_RULE_CODES = [
|
||||||
|
'admin_login_failure', 'client_login_failure', 'ssh_auth_failure', 'cmpp_auth_failure',
|
||||||
|
'cmpp_protocol_abuse', 'http_invalid_api_key', 'http_signature_failure',
|
||||||
|
'http_replay_attempt', 'http_malicious_scan',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SecurityRuleCode = typeof SECURITY_RULE_CODES[number];
|
||||||
|
export const SECURITY_RULE_CODE_SET = new Set<string>(SECURITY_RULE_CODES);
|
||||||
|
export const SECURITY_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
||||||
|
export const SECURITY_BLOCK_DURATIONS = new Set([600, 3600, 86400, 604800]);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
|
import { SecurityDetectionService } from './security-detection.service';
|
||||||
|
|
||||||
|
@ApiTags('security-detection')
|
||||||
|
@Controller('admin/security-detection')
|
||||||
|
export class SecurityDetectionController {
|
||||||
|
constructor(private readonly security: SecurityDetectionService) {}
|
||||||
|
@Get('overview') overview(@Query('range') range?: string) { return this.security.overview(range); }
|
||||||
|
@Get('notification-summary') notificationSummary() { return this.security.notificationSummary(); }
|
||||||
|
@Get('alerts') alerts(@Query() query: Record<string, string>) { return this.security.listAlerts(query); }
|
||||||
|
@Get('rules') rules() { return this.security.listRules(); }
|
||||||
|
@Put('rules/:id') @RequireRecentAuthentication() updateRule(@Param('id') id: string, @Body() body: Record<string, unknown>, @CurrentSessionUserId() userId: string) { return this.security.updateRule(id, body, userId); }
|
||||||
|
@Post('alerts/:id/block') @RequireRecentAuthentication() block(@Param('id') id: string, @Body() body: { durationSeconds?: number; reason?: string }, @CurrentSessionUserId() userId: string) { return this.security.block(id, body, userId); }
|
||||||
|
@Post('alerts/:id/ignore') @RequireRecentAuthentication() ignore(@Param('id') id: string, @Body('reason') reason: string, @CurrentSessionUserId() userId: string) { return this.security.ignore(id, reason ?? '', userId); }
|
||||||
|
@Get('blocks') blocks() { return this.security.listBlocks(); }
|
||||||
|
@Post('blocks/:id/unblock') @RequireRecentAuthentication() unblock(@Param('id') id: string, @Body('reason') reason: string, @CurrentSessionUserId() userId: string) { return this.security.unblock(id, reason ?? '', userId); }
|
||||||
|
@Get('protected-networks') protectedNetworks() { return this.security.listProtectedNetworks(); }
|
||||||
|
@Post('protected-networks') @RequireRecentAuthentication() addProtectedNetwork(@Body() body: { network?: string; name?: string; reason?: string }, @CurrentSessionUserId() userId: string) { return this.security.addProtectedNetwork(body, userId); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SecurityAgentClient } from './security-agent.client';
|
||||||
|
import { SecurityDetectionController } from './security-detection.controller';
|
||||||
|
import { SecurityEventController } from './security-event.controller';
|
||||||
|
import { SecurityDetectionService } from './security-detection.service';
|
||||||
|
|
||||||
|
@Module({ controllers: [SecurityDetectionController, SecurityEventController], providers: [SecurityAgentClient, SecurityDetectionService], exports: [SecurityDetectionService] })
|
||||||
|
export class SecurityDetectionModule {}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { SecurityDetectionService } from './security-detection.service';
|
||||||
|
|
||||||
|
function createPrisma() {
|
||||||
|
const tx = {
|
||||||
|
$executeRaw: jest.fn(),
|
||||||
|
securityDetectionEvent: { create: jest.fn(), count: jest.fn() },
|
||||||
|
securityAlert: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||||
|
securityBlock: { create: jest.fn(), update: jest.fn() },
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
securityDetectionRule: { findUnique: jest.fn(), findMany: jest.fn() },
|
||||||
|
securityDetectionEvent: { count: jest.fn() },
|
||||||
|
securityAlert: { findUnique: jest.fn(), update: jest.fn(), count: jest.fn() },
|
||||||
|
securityBlock: { create: jest.fn(), update: jest.fn() },
|
||||||
|
securityProtectedNetwork: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
operationLog: { create: jest.fn() },
|
||||||
|
$transaction: jest.fn(async (value: unknown) => typeof value === 'function' ? value(tx) : Promise.all(value as Promise<unknown>[])),
|
||||||
|
};
|
||||||
|
return { prisma, tx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SecurityDetectionService', () => {
|
||||||
|
it('returns an independent active and critical alert summary for the global bell', async () => {
|
||||||
|
const { prisma } = createPrisma();
|
||||||
|
prisma.securityAlert.count.mockResolvedValueOnce(4).mockResolvedValueOnce(2);
|
||||||
|
const service = new SecurityDetectionService(prisma as never, {} as never);
|
||||||
|
|
||||||
|
await expect(service.notificationSummary()).resolves.toEqual({ count: 4, criticalCount: 2 });
|
||||||
|
expect(prisma.securityAlert.count).toHaveBeenNthCalledWith(1, { where: { status: { in: ['open', 'acknowledged', 'block_failed'] } } });
|
||||||
|
expect(prisma.securityAlert.count).toHaveBeenNthCalledWith(2, { where: { status: { in: ['open', 'acknowledged', 'block_failed'] }, severity: 'critical' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a below-threshold event without creating a false alert', async () => {
|
||||||
|
const { prisma, tx } = createPrisma();
|
||||||
|
prisma.securityDetectionRule.findUnique.mockResolvedValue({ id: 'rule-1', enabled: true, threshold: 3, windowSeconds: 60, cooldownSeconds: 60, severity: 'high' });
|
||||||
|
tx.securityDetectionEvent.create.mockResolvedValue({ id: 'event-1' });
|
||||||
|
tx.securityDetectionEvent.count.mockResolvedValue(2);
|
||||||
|
const service = new SecurityDetectionService(prisma as never, {} as never);
|
||||||
|
|
||||||
|
await expect(service.recordEvent({ eventKey: 'event-key-1', ruleCode: 'http_signature_failure', sourceIp: '203.0.113.5' })).resolves.toEqual({ accepted: true, duplicate: false, alertId: null });
|
||||||
|
expect(tx.securityAlert.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses built-in protected addresses before calling the privileged agent', async () => {
|
||||||
|
const { prisma } = createPrisma();
|
||||||
|
prisma.securityAlert.findUnique.mockResolvedValue({
|
||||||
|
id: 'alert-1', sourceIp: '127.0.0.1', status: 'open',
|
||||||
|
rule: { code: 'ssh_auth_failure', defaultBlockSeconds: 600, maximumBlockSeconds: 604800 },
|
||||||
|
});
|
||||||
|
const agent = { block: jest.fn(), status: jest.fn() };
|
||||||
|
const service = new SecurityDetectionService(prisma as never, agent as never);
|
||||||
|
|
||||||
|
await expect(service.block('alert-1', { durationSeconds: 600, reason: '隔离测试封禁' }, 'operator-1')).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(agent.block).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an admin-login alert to nginx and marks blocked only after readback', async () => {
|
||||||
|
const { prisma, tx } = createPrisma();
|
||||||
|
prisma.securityAlert.findUnique.mockResolvedValue({
|
||||||
|
id: 'alert-1', sourceIp: '203.0.113.8', status: 'open',
|
||||||
|
rule: { code: 'admin_login_failure', defaultBlockSeconds: 600, maximumBlockSeconds: 604800 },
|
||||||
|
});
|
||||||
|
tx.securityAlert.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
tx.securityBlock.create.mockResolvedValue({ id: 'block-1' });
|
||||||
|
tx.securityBlock.update.mockResolvedValue({ id: 'block-1', status: 'blocked' });
|
||||||
|
tx.securityAlert.update.mockResolvedValue({ id: 'alert-1', status: 'blocked' });
|
||||||
|
const agent = { block: jest.fn().mockResolvedValue({ ok: true, reference: 'op-1' }), status: jest.fn().mockResolvedValue({ ok: true, blocked: true }) };
|
||||||
|
const service = new SecurityDetectionService(prisma as never, agent as never);
|
||||||
|
|
||||||
|
await expect(service.block('alert-1', { durationSeconds: 600, reason: '确认恶意登录扫描' }, 'operator-1')).resolves.toEqual(expect.objectContaining({ status: 'blocked' }));
|
||||||
|
expect(agent.block).toHaveBeenCalledWith(expect.objectContaining({ executor: 'nginx_real_ip', sourceIp: '203.0.113.8' }));
|
||||||
|
expect(agent.status).toHaveBeenCalledWith('203.0.113.8', 'nginx_real_ip');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SecurityAgentClient } from './security-agent.client';
|
||||||
|
import { SECURITY_BLOCK_DURATIONS, SECURITY_RULE_CODE_SET, SECURITY_SEVERITIES, type SecurityRuleCode } from './security-detection.constants';
|
||||||
|
|
||||||
|
export type SecurityEventInput = {
|
||||||
|
eventKey?: string; ruleCode: SecurityRuleCode; sourceIp: string; sourcePort?: number;
|
||||||
|
account?: string; path?: string; protocol?: string; resultCode?: string;
|
||||||
|
evidence?: Record<string, unknown>; occurredAt?: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SecurityDetectionService {
|
||||||
|
constructor(private readonly prisma: PrismaService, private readonly agent: SecurityAgentClient) {}
|
||||||
|
|
||||||
|
async recordEvent(input: SecurityEventInput) {
|
||||||
|
if (!SECURITY_RULE_CODE_SET.has(input.ruleCode)) throw new BadRequestException('不支持的安全检测类型');
|
||||||
|
const sourceIp = normalizeIp(input.sourceIp);
|
||||||
|
const occurredAt = input.occurredAt ? new Date(input.occurredAt) : new Date();
|
||||||
|
if (!Number.isFinite(occurredAt.getTime())) throw new BadRequestException('安全事件时间无效');
|
||||||
|
const eventKey = input.eventKey ?? createHash('sha256').update(JSON.stringify([
|
||||||
|
input.ruleCode, sourceIp, input.sourcePort, input.account, input.path, input.resultCode,
|
||||||
|
occurredAt.toISOString(), input.evidence,
|
||||||
|
])).digest('hex');
|
||||||
|
const rule = await this.prisma.securityDetectionRule.findUnique({ where: { code: input.ruleCode } });
|
||||||
|
if (!rule) throw new NotFoundException('安全检测规则不存在');
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// 同一来源和规则串行聚合,避免并发计数跨过阈值时创建多个告警。
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`${rule.id}:${sourceIp}`}))`;
|
||||||
|
try {
|
||||||
|
await tx.securityDetectionEvent.create({ data: {
|
||||||
|
eventKey, ruleId: rule.id, sourceIp, sourcePort: input.sourcePort,
|
||||||
|
accountHash: input.account ? createHash('sha256').update(input.account).digest('hex') : undefined,
|
||||||
|
path: input.path?.slice(0, 512), protocol: input.protocol?.slice(0, 32), resultCode: input.resultCode?.slice(0, 128),
|
||||||
|
evidence: sanitizeEvidence(input.evidence), occurredAt,
|
||||||
|
} });
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) return { accepted: true, duplicate: true, alertId: null };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!rule.enabled) return { accepted: true, duplicate: false, alertId: null };
|
||||||
|
|
||||||
|
const windowStartedAt = new Date(occurredAt.getTime() - rule.windowSeconds * 1000);
|
||||||
|
const storedEventCount = await tx.securityDetectionEvent.count({
|
||||||
|
where: { ruleId: rule.id, sourceIp, occurredAt: { gte: windowStartedAt, lte: occurredAt } },
|
||||||
|
});
|
||||||
|
// Fail2ban上报代表其自身窗口已经达到maxretry;应用事件则逐条在数据库窗口内计数。
|
||||||
|
const eventCount = rule.sourceType === 'fail2ban' ? Math.max(storedEventCount, rule.threshold) : storedEventCount;
|
||||||
|
if (eventCount < rule.threshold) return { accepted: true, duplicate: false, alertId: null };
|
||||||
|
|
||||||
|
const cooldownStart = new Date(occurredAt.getTime() - rule.cooldownSeconds * 1000);
|
||||||
|
const active = await tx.securityAlert.findFirst({
|
||||||
|
where: { ruleId: rule.id, sourceIp, status: { in: ['open', 'acknowledged', 'block_failed', 'blocked'] }, lastOccurredAt: { gte: cooldownStart } },
|
||||||
|
orderBy: { lastOccurredAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (active) {
|
||||||
|
const updated = await tx.securityAlert.update({ where: { id: active.id }, data: { eventCount, lastOccurredAt: occurredAt } });
|
||||||
|
return { accepted: true, duplicate: false, alertId: updated.id };
|
||||||
|
}
|
||||||
|
const fingerprint = createHash('sha256').update(`${rule.id}:${sourceIp}:${occurredAt.toISOString()}`).digest('hex');
|
||||||
|
const alert = await tx.securityAlert.create({ data: {
|
||||||
|
fingerprint, ruleId: rule.id, sourceIp, severity: rule.severity, eventCount,
|
||||||
|
windowStartedAt, firstOccurredAt: occurredAt, lastOccurredAt: occurredAt,
|
||||||
|
} });
|
||||||
|
return { accepted: true, duplicate: false, alertId: alert.id };
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||||
|
}
|
||||||
|
|
||||||
|
async overview(range = '24h') {
|
||||||
|
if (!['1h', '24h', '7d'].includes(range)) throw new BadRequestException('仅支持1h、24h或7d安全检测范围');
|
||||||
|
const hours = range === '1h' ? 1 : range === '7d' ? 168 : 24;
|
||||||
|
const since = new Date(Date.now() - hours * 3600_000);
|
||||||
|
const activeStatuses = ['open', 'acknowledged', 'block_failed'];
|
||||||
|
const [alerts, totalEvents, activeBlocks, rules, activeAlerts, criticalAlerts, distribution, agentStatus] = await Promise.all([
|
||||||
|
this.prisma.securityAlert.findMany({ where: { lastOccurredAt: { gte: since } }, include: { rule: true }, orderBy: { lastOccurredAt: 'desc' }, take: 12 }),
|
||||||
|
this.prisma.securityDetectionEvent.count({ where: { occurredAt: { gte: since } } }),
|
||||||
|
this.prisma.securityBlock.count({ where: { status: 'blocked', expiresAt: { gt: new Date() } } }),
|
||||||
|
this.prisma.securityDetectionRule.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses } } }),
|
||||||
|
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses }, severity: 'critical' } }),
|
||||||
|
this.prisma.securityAlert.groupBy({ by: ['ruleId'], where: { lastOccurredAt: { gte: since } }, _sum: { eventCount: true } }),
|
||||||
|
this.agent.status().catch((error: Error) => ({ ok: false, active: false, error: error.message })),
|
||||||
|
]);
|
||||||
|
const ruleNames = new Map(rules.map((rule) => [rule.id, rule.name]));
|
||||||
|
return {
|
||||||
|
range, collectedAt: new Date().toISOString(), totalEvents, activeAlerts, criticalAlerts, activeBlocks,
|
||||||
|
health: { agent: agentStatus.ok && agentStatus.active ? 'healthy' : 'unavailable', agentError: agentStatus.error, rulesEffective: rules.filter((rule) => rule.applyStatus === 'effective').length, rulesTotal: rules.length },
|
||||||
|
sourceDistribution: distribution.map((item) => ({ name: ruleNames.get(item.ruleId) ?? item.ruleId, value: item._sum.eventCount ?? 0 })),
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async notificationSummary() {
|
||||||
|
const activeStatuses = ['open', 'acknowledged', 'block_failed'];
|
||||||
|
const [count, criticalCount] = await Promise.all([
|
||||||
|
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses } } }),
|
||||||
|
this.prisma.securityAlert.count({ where: { status: { in: activeStatuses }, severity: 'critical' } }),
|
||||||
|
]);
|
||||||
|
return { count, criticalCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
listAlerts(query: { status?: string; ruleCode?: string; sourceIp?: string; page?: string; pageSize?: string }) {
|
||||||
|
const page = positiveInt(query.page, 1, 100000);
|
||||||
|
const pageSize = positiveInt(query.pageSize, 20, 100);
|
||||||
|
const where: Prisma.SecurityAlertWhereInput = {
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.ruleCode ? { rule: { code: query.ruleCode } } : {}),
|
||||||
|
...(query.sourceIp ? { sourceIp: normalizeIp(query.sourceIp) } : {}),
|
||||||
|
};
|
||||||
|
return Promise.all([
|
||||||
|
this.prisma.securityAlert.findMany({ where, include: { rule: true }, orderBy: { lastOccurredAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
|
||||||
|
this.prisma.securityAlert.count({ where }),
|
||||||
|
]).then(([items, total]) => ({ items, total, page, pageSize }));
|
||||||
|
}
|
||||||
|
|
||||||
|
listRules() { return this.prisma.securityDetectionRule.findMany({ orderBy: [{ sourceType: 'asc' }, { name: 'asc' }] }); }
|
||||||
|
|
||||||
|
async updateRule(id: string, input: Record<string, unknown>, operatorId: string) {
|
||||||
|
assertAllowedKeys(input, ['configVersion', 'enabled', 'threshold', 'windowSeconds', 'cooldownSeconds', 'severity', 'defaultBlockSeconds', 'maximumBlockSeconds']);
|
||||||
|
if (typeof input.enabled !== 'boolean') throw new BadRequestException('启用状态必须为布尔值');
|
||||||
|
const current = await this.prisma.securityDetectionRule.findUnique({ where: { id } });
|
||||||
|
if (!current) throw new NotFoundException('规则不存在');
|
||||||
|
if (Number(input.configVersion) !== current.configVersion) throw new ConflictException('规则已被其他管理员修改,请刷新后重试');
|
||||||
|
const threshold = boundedInt(input.threshold, 1, 100000, '触发次数');
|
||||||
|
const windowSeconds = boundedInt(input.windowSeconds, 10, 86400, '检测窗口');
|
||||||
|
const cooldownSeconds = boundedInt(input.cooldownSeconds, 0, 604800, '告警冷却');
|
||||||
|
const defaultBlockSeconds = boundedInt(input.defaultBlockSeconds, 600, 604800, '默认封禁时长');
|
||||||
|
const maximumBlockSeconds = boundedInt(input.maximumBlockSeconds, defaultBlockSeconds, 604800, '最大封禁时长');
|
||||||
|
const severity = String(input.severity ?? '');
|
||||||
|
if (!SECURITY_SEVERITIES.has(severity)) throw new BadRequestException('告警级别无效');
|
||||||
|
const version = current.configVersion + 1;
|
||||||
|
const nextConfig = { enabled: Boolean(input.enabled), threshold, windowSeconds, cooldownSeconds, severity, defaultBlockSeconds, maximumBlockSeconds };
|
||||||
|
await this.prisma.securityDetectionRule.update({ where: { id }, data: {
|
||||||
|
configVersion: version, applyStatus: 'applying', lastApplyError: null, pendingConfig: nextConfig,
|
||||||
|
} });
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.rule_updated', resource: 'security_detection_rule', resourceId: id, detail: { version, beforeVersion: current.configVersion } } });
|
||||||
|
try {
|
||||||
|
const response = await this.agent.applyRules(version, (await this.listRules()).map((rule) => rule.id === id
|
||||||
|
? { code: rule.code, enabled: nextConfig.enabled, threshold: nextConfig.threshold, windowSeconds: nextConfig.windowSeconds, cooldownSeconds: nextConfig.cooldownSeconds }
|
||||||
|
: { code: rule.code, enabled: rule.enabled, threshold: rule.threshold, windowSeconds: rule.windowSeconds, cooldownSeconds: rule.cooldownSeconds }));
|
||||||
|
if (!response.ok) throw new Error(response.error ?? '安全代理拒绝应用规则');
|
||||||
|
return this.prisma.securityDetectionRule.update({ where: { id }, data: { ...nextConfig, effectiveVersion: version, applyStatus: 'effective', pendingConfig: Prisma.JsonNull } });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '规则应用失败';
|
||||||
|
await this.prisma.securityDetectionRule.update({ where: { id }, data: { applyStatus: 'failed', lastApplyError: message } });
|
||||||
|
throw new ConflictException({ code: 'SECURITY_RULE_APPLY_FAILED', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async block(alertId: string, input: { durationSeconds?: number; reason?: string }, operatorId: string) {
|
||||||
|
assertAllowedKeys(input as Record<string, unknown>, ['durationSeconds', 'reason']);
|
||||||
|
const alert = await this.prisma.securityAlert.findUnique({ where: { id: alertId }, include: { rule: true } });
|
||||||
|
if (!alert) throw new NotFoundException('告警不存在');
|
||||||
|
if (!['open', 'acknowledged', 'block_failed'].includes(alert.status)) throw new ConflictException('该告警当前不可封禁');
|
||||||
|
const durationSeconds = Number(input.durationSeconds ?? alert.rule.defaultBlockSeconds);
|
||||||
|
if (!SECURITY_BLOCK_DURATIONS.has(durationSeconds) || durationSeconds > alert.rule.maximumBlockSeconds) throw new BadRequestException('封禁时长不在允许范围内');
|
||||||
|
const reason = String(input.reason ?? '').trim();
|
||||||
|
if (reason.length < 5 || reason.length > 500) throw new BadRequestException('封禁原因需为5至500个字符');
|
||||||
|
if (isSystemProtected(alert.sourceIp) || await this.isProtected(alert.sourceIp)) throw new ConflictException({ code: 'PROTECTED_NETWORK', message: '该地址属于系统或人工保护名单,禁止封禁' });
|
||||||
|
// 执行器由可信的规则入口固定映射,绝不接受浏览器指定,避免把Cloudflare访客IP错误交给nftables。
|
||||||
|
const executor = ['admin_login_failure', 'client_login_failure'].includes(alert.rule.code) ? 'nginx_real_ip' : 'nftables';
|
||||||
|
const operationKey = randomUUID();
|
||||||
|
const block = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.securityAlert.updateMany({ where: { id: alert.id, status: { in: ['open', 'acknowledged', 'block_failed'] } }, data: { status: 'block_requested' } });
|
||||||
|
if (!claimed.count) throw new ConflictException('告警已由其他管理员处理,请刷新后重试');
|
||||||
|
return tx.securityBlock.create({ data: { operationKey, alertId, sourceIp: alert.sourceIp, executor, durationSeconds, reason, requestedById: operatorId } });
|
||||||
|
});
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.block_requested', resource: 'security_block', resourceId: block.id, detail: { alertId, sourceIp: alert.sourceIp, executor, durationSeconds, reason } } });
|
||||||
|
try {
|
||||||
|
const applied = await this.agent.block({ operationKey, sourceIp: alert.sourceIp, executor, durationSeconds });
|
||||||
|
if (!applied.ok) throw new Error(applied.error ?? '安全代理拒绝封禁');
|
||||||
|
const readback = await this.agent.status(alert.sourceIp, executor);
|
||||||
|
if (!readback.ok || !readback.blocked) throw new Error(readback.error ?? '执行后未读到真实封禁状态');
|
||||||
|
const expiresAt = new Date(Date.now() + durationSeconds * 1000);
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.securityBlock.update({ where: { id: block.id }, data: { status: 'blocked', appliedAt: new Date(), expiresAt, executorReference: applied.reference } });
|
||||||
|
await tx.securityAlert.update({ where: { id: alert.id }, data: { status: 'blocked', blockId: block.id } });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '封禁执行失败';
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'failed', lastError: message } }),
|
||||||
|
this.prisma.securityAlert.update({ where: { id: alert.id }, data: { status: 'block_failed' } }),
|
||||||
|
]);
|
||||||
|
throw new ConflictException({ code: 'SECURITY_BLOCK_FAILED', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ignore(alertId: string, reason: string, operatorId: string) {
|
||||||
|
if (reason.trim().length < 5) throw new BadRequestException('忽略原因至少5个字符');
|
||||||
|
const updated = await this.prisma.securityAlert.updateMany({ where: { id: alertId, status: { in: ['open', 'acknowledged', 'block_failed'] } }, data: { status: 'ignored', ignoredAt: new Date(), ignoredById: operatorId, ignoreReason: reason.trim() } });
|
||||||
|
if (!updated.count) throw new ConflictException('告警状态已变化,请刷新后重试');
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.alert_ignored', resource: 'security_alert', resourceId: alertId, detail: { reason: reason.trim() } } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
listBlocks() { return this.prisma.securityBlock.findMany({ orderBy: { requestedAt: 'desc' }, take: 200 }); }
|
||||||
|
|
||||||
|
async unblock(blockId: string, reason: string, operatorId: string) {
|
||||||
|
if (reason.trim().length < 5) throw new BadRequestException('解封原因至少5个字符');
|
||||||
|
const block = await this.prisma.securityBlock.findUnique({ where: { id: blockId } });
|
||||||
|
if (!block) throw new NotFoundException('封禁记录不存在');
|
||||||
|
if (block.status !== 'blocked') throw new ConflictException('该记录当前不可解封');
|
||||||
|
const claimed = await this.prisma.securityBlock.updateMany({ where: { id: blockId, status: 'blocked' }, data: { status: 'unblock_requested' } });
|
||||||
|
if (!claimed.count) throw new ConflictException('封禁状态已变化,请刷新后重试');
|
||||||
|
try {
|
||||||
|
const result = await this.agent.unblock({ operationKey: randomUUID(), sourceIp: block.sourceIp, executor: block.executor });
|
||||||
|
if (!result.ok) throw new Error(result.error ?? '安全代理拒绝解封');
|
||||||
|
const readback = await this.agent.status(block.sourceIp, block.executor);
|
||||||
|
if (!readback.ok || readback.blocked) throw new Error(readback.error ?? '执行后仍读到封禁规则');
|
||||||
|
const updated = await this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'released', releasedAt: new Date(), releasedById: operatorId } });
|
||||||
|
if (block.alertId) await this.prisma.securityAlert.updateMany({ where: { id: block.alertId, blockId: block.id }, data: { status: 'unblocked' } });
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.block_released', resource: 'security_block', resourceId: block.id, detail: { sourceIp: block.sourceIp, executor: block.executor, reason: reason.trim() } } });
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '解封失败';
|
||||||
|
await this.prisma.securityBlock.update({ where: { id: block.id }, data: { status: 'blocked', lastError: message } });
|
||||||
|
throw new ConflictException({ code: 'SECURITY_UNBLOCK_FAILED', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listProtectedNetworks() { return this.prisma.securityProtectedNetwork.findMany({ orderBy: { createdAt: 'desc' } }); }
|
||||||
|
|
||||||
|
async addProtectedNetwork(input: { network?: string; name?: string; reason?: string }, operatorId: string) {
|
||||||
|
const network = normalizeNetwork(String(input.network ?? ''));
|
||||||
|
if (!input.name?.trim() || !input.reason?.trim()) throw new BadRequestException('名称和保护原因不能为空');
|
||||||
|
const result = await this.prisma.securityProtectedNetwork.create({ data: { network, name: input.name.trim(), reason: input.reason.trim(), createdById: operatorId } });
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'security.protected_network_created', resource: 'security_protected_network', resourceId: result.id, detail: { network } } });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isProtected(ip: string) {
|
||||||
|
const entries = await this.prisma.securityProtectedNetwork.findMany({ where: { enabled: true }, select: { network: true } });
|
||||||
|
return entries.some((entry) => networkContains(entry.network, ip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIp(value: string) { const normalized = value?.trim().replace(/^::ffff:/, ''); if (!isIP(normalized)) throw new BadRequestException('来源IP无效'); return normalized; }
|
||||||
|
function normalizeNetwork(value: string) { const [address, prefix] = value.trim().split('/'); const family = isIP(address); if (!family) throw new BadRequestException('保护网段无效'); if (prefix === undefined) return address; const bits = Number(prefix); const max = family === 4 ? 32 : 128; if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException('保护网段前缀无效'); return `${address}/${bits}`; }
|
||||||
|
function networkContains(network: string, ip: string) { const [address, prefixText] = network.split('/'); if (isIP(address) !== isIP(ip)) return false; if (prefixText === undefined) return address === ip; const bits = Number(prefixText); return (addressToBigInt(address) >> BigInt((isIP(address) === 4 ? 32 : 128) - bits)) === (addressToBigInt(ip) >> BigInt((isIP(ip) === 4 ? 32 : 128) - bits)); }
|
||||||
|
function addressToBigInt(value: string) { if (isIP(value) === 4) return value.split('.').reduce((total, part) => (total << 8n) + BigInt(part), 0n); const [left, right = ''] = value.toLowerCase().split('::'); const leftParts = left ? left.split(':') : []; const rightParts = right ? right.split(':') : []; const parts = [...leftParts, ...Array(Math.max(0, 8 - leftParts.length - rightParts.length)).fill('0'), ...rightParts]; return parts.reduce((total, part) => (total << 16n) + BigInt(`0x${part || '0'}`), 0n); }
|
||||||
|
function positiveInt(value: string | undefined, fallback: number, max: number) { const parsed = Number(value ?? fallback); return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback; }
|
||||||
|
function boundedInt(value: unknown, min: number, max: number, label: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < min || parsed > max) throw new BadRequestException(`${label}必须在${min}至${max}之间`); return parsed; }
|
||||||
|
function sanitizeEvidence(value?: Record<string, unknown>) { if (!value) return undefined; const sanitized = JSON.parse(JSON.stringify(value, (key, item) => /password|secret|token|signature|access.?key/i.test(key) ? '[REDACTED]' : item)); return sanitized as Prisma.InputJsonValue; }
|
||||||
|
function isUniqueViolation(error: unknown) { return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; }
|
||||||
|
function assertAllowedKeys(input: Record<string, unknown>, allowed: string[]) { const unknown = Object.keys(input).filter((key) => !allowed.includes(key)); if (unknown.length) throw new BadRequestException(`不支持的字段: ${unknown.join(', ')}`); }
|
||||||
|
function isSystemProtected(ip: string) {
|
||||||
|
const builtIns = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16', '224.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', ...(process.env.SECURITY_BUILTIN_PROTECTED_NETWORKS ?? '').split(',').map((item) => item.trim()).filter(Boolean)];
|
||||||
|
return builtIns.some((network) => networkContains(network, ip));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { SecurityEventController } from './security-event.controller';
|
||||||
|
|
||||||
|
describe('SecurityEventController', () => {
|
||||||
|
const security = { recordEvent: jest.fn().mockResolvedValue({ accepted: true }) };
|
||||||
|
const config = { get: jest.fn().mockReturnValue('internal-token-0123456789') };
|
||||||
|
const controller = new SecurityEventController(security as never, config as never);
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('rejects a public event injection without the internal token', () => {
|
||||||
|
expect(() => controller.record({ ruleCode: 'ssh_auth_failure', sourceIp: '203.0.113.9' }, undefined)).toThrow(UnauthorizedException);
|
||||||
|
expect(security.recordEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a fixed event from an authenticated local producer', async () => {
|
||||||
|
await expect(controller.record({ ruleCode: 'ssh_auth_failure', sourceIp: '203.0.113.9' }, 'internal-token-0123456789')).resolves.toEqual({ accepted: true });
|
||||||
|
expect(security.recordEvent).toHaveBeenCalledWith(expect.objectContaining({ ruleCode: 'ssh_auth_failure' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { SecurityDetectionService, type SecurityEventInput } from './security-detection.service';
|
||||||
|
|
||||||
|
@ApiTags('gateway-security-events')
|
||||||
|
@Controller('gateway/events/security-detection')
|
||||||
|
export class SecurityEventController {
|
||||||
|
constructor(private readonly security: SecurityDetectionService, private readonly config: ConfigService) {}
|
||||||
|
@Post() record(@Body() body: SecurityEventInput, @Headers('x-security-event-token') supplied?: string) {
|
||||||
|
const expected = this.config.get<string>('SECURITY_EVENT_TOKEN');
|
||||||
|
if (!expected || !supplied || !safeEqual(expected, supplied)) throw new UnauthorizedException('安全事件来源认证失败');
|
||||||
|
return this.security.recordEvent(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(left: string, right: string) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b); }
|
||||||
@@ -11,7 +11,6 @@ describe('drainage content detection', () => {
|
|||||||
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
|
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
|
||||||
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
|
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
|
||||||
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
|
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
|
||||||
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
|
|
||||||
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
|
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
|
||||||
['短横线手机号', '电话 138-0013-8000', 'mobile'],
|
['短横线手机号', '电话 138-0013-8000', 'mobile'],
|
||||||
['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'],
|
['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'],
|
||||||
@@ -26,6 +25,23 @@ describe('drainage content detection', () => {
|
|||||||
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
|
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([' ', '\t', '\n', '\u3000'])('stops a URL match at whitespace %p', (separator) => {
|
||||||
|
const url = 'https://example.com/path';
|
||||||
|
const suffix = '后续字符不属于链接';
|
||||||
|
const content = `详情 ${url}${separator}${suffix}`;
|
||||||
|
const result = detectDrainageContentWithRules(content, rules);
|
||||||
|
const urlMatches = (result.drainageDetection as { matches: Array<{ category: string; text: string; normalizedText: string }> })
|
||||||
|
.matches.filter((item) => item.category === 'url');
|
||||||
|
|
||||||
|
expect(urlMatches).toHaveLength(1);
|
||||||
|
expect(urlMatches[0]).toMatchObject({ text: url, normalizedText: url });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not join a domain split by spaces into one URL', () => {
|
||||||
|
const result = detectDrainageContentWithRules('请访问 ex ample . com 领取', rules);
|
||||||
|
expect(result.hasDrainageContent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps original offsets for record-page highlighting', () => {
|
it('keeps original offsets for record-page highlighting', () => {
|
||||||
const content = '📨详情请看 example。com/path,谢谢';
|
const content = '📨详情请看 example。com/path,谢谢';
|
||||||
const result = detectDrainageContentWithRules(content, rules);
|
const result = detectDrainageContentWithRules(content, rules);
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ function normalizeContent(content: string, category: DrainageDetectionCategory):
|
|||||||
.replace(/[()]/g, (char) => char === '(' ? '(' : ')')
|
.replace(/[()]/g, (char) => char === '(' ? '(' : ')')
|
||||||
.replace(/[+]/g, '+');
|
.replace(/[+]/g, '+');
|
||||||
if (category === 'url') {
|
if (category === 'url') {
|
||||||
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
|
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
|
||||||
|
normalized = normalized.replace(/。/g, '.');
|
||||||
|
} else if (category === 'email') {
|
||||||
|
// Email exclusion keeps its broader normalization so spaced emails cannot leak into phone/URL matches.
|
||||||
normalized = normalized.replace(/\s+/gu, '').replace(/。/g, '.');
|
normalized = normalized.replace(/\s+/gu, '').replace(/。/g, '.');
|
||||||
} else if (category === 'mobile' || category === 'landline') {
|
} else if (category === 'mobile' || category === 'landline') {
|
||||||
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
|
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
|
||||||
@@ -131,7 +134,7 @@ export function detectDrainageContentWithRules(
|
|||||||
): DrainageDetectionResult {
|
): DrainageDetectionResult {
|
||||||
const matches: DrainageDetectionMatch[] = [];
|
const matches: DrainageDetectionMatch[] = [];
|
||||||
const normalizedByCategory = new Map<string, NormalizedContent>();
|
const normalizedByCategory = new Map<string, NormalizedContent>();
|
||||||
const emailNormalized = normalizeContent(content, 'url');
|
const emailNormalized = normalizeContent(content, 'email');
|
||||||
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
|
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))) {
|
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
|
||||||
validateDrainageDetectionPattern(rule.pattern, rule.flags);
|
validateDrainageDetectionPattern(rule.pattern, rule.flags);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('GatewayEventsController protocol logging', () => {
|
|||||||
const protocolLogs = {
|
const protocolLogs = {
|
||||||
record: jest.fn(),
|
record: jest.fn(),
|
||||||
};
|
};
|
||||||
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never);
|
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never, { recordEvent: jest.fn() } as never);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SendChainService } from './send-chain.service';
|
|||||||
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
||||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||||
|
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||||
|
|
||||||
@ApiTags('gateway-events')
|
@ApiTags('gateway-events')
|
||||||
@Controller('gateway/events')
|
@Controller('gateway/events')
|
||||||
@@ -26,6 +27,7 @@ export class GatewayEventsController {
|
|||||||
private readonly sendChain: SendChainService,
|
private readonly sendChain: SendChainService,
|
||||||
private readonly smsConfig: SmsConfigService,
|
private readonly smsConfig: SmsConfigService,
|
||||||
private readonly protocolLogs: ProtocolLogsService,
|
private readonly protocolLogs: ProtocolLogsService,
|
||||||
|
private readonly security: SecurityDetectionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('submit-result')
|
@Post('submit-result')
|
||||||
@@ -81,8 +83,13 @@ export class GatewayEventsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('inbound/authenticate')
|
@Post('inbound/authenticate')
|
||||||
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
async authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||||
return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
|
try {
|
||||||
|
return await this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
|
||||||
|
} catch (error) {
|
||||||
|
if (body.remoteIp) await this.security.recordEvent({ ruleCode: 'cmpp_auth_failure', sourceIp: body.remoteIp, account: body.account, protocol: body.version ?? 'cmpp', resultCode: error instanceof Error ? error.name : 'AUTH_FAILED' }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('inbound/submit')
|
@Post('inbound/submit')
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
|||||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
import type { 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 { 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 { detectDrainageContent } from './drainage-content-detection';
|
||||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||||
@@ -469,7 +469,12 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
|
|||||||
return rejected;
|
return rejected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
async validateSendResources(
|
||||||
|
tenantId: string,
|
||||||
|
applicationId?: string,
|
||||||
|
templateId?: string,
|
||||||
|
options: SendResourceValidationOptions = {},
|
||||||
|
) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
if (!tenant || tenant.status !== 'active') {
|
if (!tenant || tenant.status !== 'active') {
|
||||||
throw new BadRequestException('企业客户不存在或已停用');
|
throw new BadRequestException('企业客户不存在或已停用');
|
||||||
@@ -494,7 +499,12 @@ async validateSendResources(tenantId: string, applicationId?: string, templateId
|
|||||||
where: { id: templateId },
|
where: { id: templateId },
|
||||||
include: { signature: true },
|
include: { signature: true },
|
||||||
});
|
});
|
||||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') {
|
const templateBelongsToApplication = template
|
||||||
|
&& template.tenantId === tenantId
|
||||||
|
&& template.applicationId === applicationId;
|
||||||
|
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
|
||||||
|
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
|
||||||
|
if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) {
|
||||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||||
}
|
}
|
||||||
if (!template.signature || template.signature.auditStatus !== 'approved') {
|
if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface GatewayInboundAuthDto {
|
|||||||
authSource?: string;
|
authSource?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
|
version?: string;
|
||||||
|
requestedVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayInboundSubmitDto {
|
export interface GatewayInboundSubmitDto {
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string, channelCarriers?: string[] | null) {
|
||||||
|
if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier));
|
||||||
const normalized = normalizeCarrier(channelCarrier);
|
const normalized = normalizeCarrier(channelCarrier);
|
||||||
return normalized === 'all' || normalized === targetCarrier;
|
return normalized === 'all' || normalized === targetCarrier;
|
||||||
}
|
}
|
||||||
@@ -448,6 +449,7 @@ export type ChannelCandidate = {
|
|||||||
province?: string | null;
|
province?: string | null;
|
||||||
channel: {
|
channel: {
|
||||||
carrier?: string | null;
|
carrier?: string | null;
|
||||||
|
carriers?: string[] | null;
|
||||||
sendRegion?: string | null;
|
sendRegion?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
|
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
|
||||||
@@ -481,7 +483,7 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
|
|||||||
!options.excludedChannelIds.has(item.channelId)
|
!options.excludedChannelIds.has(item.channelId)
|
||||||
&& options.approvedChannelIds.has(item.channelId)
|
&& options.approvedChannelIds.has(item.channelId)
|
||||||
&& normalizeCarrier(item.carrier) === options.carrier
|
&& normalizeCarrier(item.carrier) === options.carrier
|
||||||
&& isCarrierCompatible(item.channel.carrier, options.carrier),
|
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
|
||||||
);
|
);
|
||||||
const provinceCandidates = options.forceNational
|
const provinceCandidates = options.forceNational
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { AdminSendChainController } from './admin-send-chain.controller';
|
|||||||
import { ClientSendChainController } from './client-send-chain.controller';
|
import { ClientSendChainController } from './client-send-chain.controller';
|
||||||
import { GatewayEventsController } from './gateway-events.controller';
|
import { GatewayEventsController } from './gateway-events.controller';
|
||||||
import { SendChainService } from './send-chain.service';
|
import { SendChainService } from './send-chain.service';
|
||||||
|
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
|
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule],
|
||||||
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
|
||||||
providers: [SendChainService],
|
providers: [SendChainService],
|
||||||
exports: [SendChainService],
|
exports: [SendChainService],
|
||||||
|
|||||||
@@ -666,6 +666,22 @@ describe('SendChainService', () => {
|
|||||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a new task that selects a deleted template', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||||
|
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
|
||||||
|
content: 'hello', auditStatus: 'deleted',
|
||||||
|
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.createBatchTask({
|
||||||
|
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
|
||||||
|
content: 'hello', phones: ['13800000001'],
|
||||||
|
})).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用');
|
||||||
|
|
||||||
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects free content without an approved leading signature', async () => {
|
it('rejects free content without an approved leading signature', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||||
@@ -837,12 +853,14 @@ describe('SendChainService', () => {
|
|||||||
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
||||||
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
||||||
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
||||||
|
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
|
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
|
||||||
try {
|
try {
|
||||||
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
|
||||||
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
|
||||||
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
|
||||||
|
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
|
||||||
service.onModuleInit();
|
service.onModuleInit();
|
||||||
await jest.advanceTimersByTimeAsync(1_000);
|
await jest.advanceTimersByTimeAsync(1_000);
|
||||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
@@ -854,6 +872,8 @@ describe('SendChainService', () => {
|
|||||||
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
||||||
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
|
||||||
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
|
||||||
|
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
||||||
|
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -927,6 +947,47 @@ describe('SendChainService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => {
|
||||||
|
const { service, prisma, billing } = createService();
|
||||||
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||||
|
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
|
||||||
|
signature: { id: 'sig-1', auditStatus: 'approved' },
|
||||||
|
});
|
||||||
|
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
||||||
|
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
|
||||||
|
}]);
|
||||||
|
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
||||||
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||||
|
|
||||||
|
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
||||||
|
dispatched: 1,
|
||||||
|
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ relatedId: 'task-1' }));
|
||||||
|
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still blocks scheduled dispatch when the persisted template signature is no longer approved', async () => {
|
||||||
|
const { service, prisma, billing } = createService();
|
||||||
|
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||||
|
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||||
|
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
|
||||||
|
signature: { id: 'sig-1', auditStatus: 'deleted' },
|
||||||
|
});
|
||||||
|
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
||||||
|
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
|
||||||
|
dispatched: 0,
|
||||||
|
results: [{ taskId: 'task-1', status: 'failed', reason: '短信签名未审核通过' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(billing.freeze).not.toHaveBeenCalled();
|
||||||
|
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
||||||
@@ -983,19 +1044,34 @@ describe('SendChainService', () => {
|
|||||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the application enterprise code after Gateway authentication', async () => {
|
it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => {
|
||||||
const { service } = createService();
|
const { service, prisma } = createService();
|
||||||
|
|
||||||
await expect(service.authenticateInboundApplication({
|
await expect(service.authenticateInboundApplication({
|
||||||
account: '100001',
|
account: '100001',
|
||||||
password: 'secret-hash',
|
password: 'secret-hash',
|
||||||
remoteIp: '127.0.0.1',
|
remoteIp: '127.0.0.1',
|
||||||
|
version: 'cmpp30',
|
||||||
|
requestedVersion: 48,
|
||||||
})).resolves.toEqual(expect.objectContaining({
|
})).resolves.toEqual(expect.objectContaining({
|
||||||
account: '100001',
|
account: '100001',
|
||||||
enterpriseCode: 'SP0001',
|
enterpriseCode: 'SP0001',
|
||||||
maxConnections: 2,
|
maxConnections: 2,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
}));
|
}));
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
action: 'cmpp_connection.connect_requested',
|
||||||
|
resource: 'cmpp_downstream_connection',
|
||||||
|
resourceId: 'app-1',
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
result: 'authenticated',
|
||||||
|
request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects Gateway authentication when application interface is disabled', async () => {
|
it('rejects Gateway authentication when application interface is disabled', async () => {
|
||||||
@@ -1015,6 +1091,38 @@ describe('SendChainService', () => {
|
|||||||
password: 'secret-hash',
|
password: 'secret-hash',
|
||||||
remoteIp: '127.0.0.1',
|
remoteIp: '127.0.0.1',
|
||||||
})).rejects.toThrow('CMPP interface is disabled for this application');
|
})).rejects.toThrow('CMPP interface is disabled for this application');
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
|
detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits an unknown Gateway authentication account with its source IP', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
prisma.smsApplication.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.authenticateInboundApplication({
|
||||||
|
account: 'ATTACKER',
|
||||||
|
authSource: 'invalid-auth-source',
|
||||||
|
timestamp: 120000000,
|
||||||
|
remoteIp: '203.0.113.9',
|
||||||
|
version: 'cmpp30',
|
||||||
|
requestedVersion: 48,
|
||||||
|
})).rejects.toThrow('CMPP account is invalid or disabled');
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantId: undefined,
|
||||||
|
resourceId: 'ATTACKER',
|
||||||
|
ipAddress: '203.0.113.9',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
result: 'failed',
|
||||||
|
request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
|
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
|
||||||
@@ -3272,6 +3380,41 @@ describe('SendChainService', () => {
|
|||||||
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks a pending gateway submit exception as resolved without requeueing it', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
prisma.gatewaySubmitDeadLetter.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'dead-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
status: 'pending',
|
||||||
|
messageId: 'MSG-1',
|
||||||
|
submitId: 'SUB-1',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ id: 'dead-1', status: 'resolved', resolvedStatus: 'manually_resolved' });
|
||||||
|
prisma.gatewaySubmitDeadLetter.updateMany.mockResolvedValueOnce({ count: 1 });
|
||||||
|
|
||||||
|
await expect(service.resolveGatewaySubmitDeadLetter('dead-1', 'user-1')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ status: 'resolved', resolvedStatus: 'manually_resolved' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'dead-1', status: 'pending' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'resolved',
|
||||||
|
resolvedAt: expect.any(Date),
|
||||||
|
resolvedStatus: 'manually_resolved',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
action: 'gateway.submit_dead_letter_resolved',
|
||||||
|
resourceId: 'dead-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
|
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
|
|
||||||
@@ -3308,6 +3451,8 @@ describe('SendChainService', () => {
|
|||||||
account: '100001',
|
account: '100001',
|
||||||
password: 'secret-hash',
|
password: 'secret-hash',
|
||||||
remoteIp: '127.0.0.1',
|
remoteIp: '127.0.0.1',
|
||||||
|
version: 'cmpp30',
|
||||||
|
requestedVersion: 48,
|
||||||
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
|
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
|
||||||
await expect(service.submitInboundMessage({
|
await expect(service.submitInboundMessage({
|
||||||
account: '100001',
|
account: '100001',
|
||||||
@@ -4102,12 +4247,14 @@ describe('SendChainService', () => {
|
|||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
||||||
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
||||||
|
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
||||||
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
|
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
|
||||||
try {
|
try {
|
||||||
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
||||||
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
|
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
|
||||||
|
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
|
||||||
service.onModuleInit();
|
service.onModuleInit();
|
||||||
await jest.advanceTimersByTimeAsync(60_000);
|
await jest.advanceTimersByTimeAsync(60_000);
|
||||||
expect(scan).toHaveBeenCalledWith({});
|
expect(scan).toHaveBeenCalledWith({});
|
||||||
@@ -4118,6 +4265,8 @@ describe('SendChainService', () => {
|
|||||||
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
|
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
|
||||||
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
|
||||||
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
|
||||||
|
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
|
||||||
|
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto,
|
|||||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, 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_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
|
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, 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_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
|
||||||
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||||
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
|
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
|
||||||
import { SendSubmissionService } from './send-submission.service';
|
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
|
||||||
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
|
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
|
||||||
|
import { SendDownstreamRequeueTaskService, type DownstreamRequeueFilter } from './send-downstream-requeue-task.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -37,8 +38,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
|
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
|
||||||
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
|
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
|
||||||
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
|
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
|
||||||
|
private downstreamRequeueTaskIntervalTimer?: ReturnType<typeof setInterval>;
|
||||||
private readonly submission: SendSubmissionService;
|
private readonly submission: SendSubmissionService;
|
||||||
private readonly completion: SendCompletionService;
|
private readonly completion: SendCompletionService;
|
||||||
|
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -68,6 +71,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
openApi,
|
openApi,
|
||||||
this as unknown as SendCompletionFacade,
|
this as unknown as SendCompletionFacade,
|
||||||
);
|
);
|
||||||
|
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -129,6 +133,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
this.upstreamReceiptInboxIntervalTimer.unref?.();
|
this.upstreamReceiptInboxIntervalTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
|
||||||
|
this.downstreamRequeueTaskIntervalTimer = setInterval(
|
||||||
|
() => void this.downstreamRequeueTasks.runScan().catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
|
||||||
|
positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
|
||||||
|
);
|
||||||
|
this.downstreamRequeueTaskIntervalTimer.unref?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
@@ -140,6 +151,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
|
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
|
||||||
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
|
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
|
||||||
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
|
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
|
||||||
|
if (this.downstreamRequeueTaskIntervalTimer) clearInterval(this.downstreamRequeueTaskIntervalTimer);
|
||||||
await this.worker?.close();
|
await this.worker?.close();
|
||||||
await this.sendQueue?.close();
|
await this.sendQueue?.close();
|
||||||
await this.gatewayQueue?.close();
|
await this.gatewayQueue?.close();
|
||||||
@@ -481,6 +493,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.completion.requeueGatewaySubmitDeadLetter(id, data);
|
return this.completion.requeueGatewaySubmitDeadLetter(id, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
|
||||||
|
return this.completion.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||||
return this.completion.recoverStaleGatewaySubmitRequeues(now);
|
return this.completion.recoverStaleGatewaySubmitRequeues(now);
|
||||||
}
|
}
|
||||||
@@ -497,6 +513,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.completion.batchRequeueDownstreamDeliveries(ids);
|
return this.completion.batchRequeueDownstreamDeliveries(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previewDownstreamRequeueTask(filter: DownstreamRequeueFilter, operatorId?: string) {
|
||||||
|
return this.downstreamRequeueTasks.preview(filter, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDownstreamRequeueTask(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) {
|
||||||
|
return this.downstreamRequeueTasks.create(data, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listDownstreamRequeueTasks(query: { status?: string; page?: number; pageSize?: number }) {
|
||||||
|
return this.downstreamRequeueTasks.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDownstreamRequeueTask(id: string) {
|
||||||
|
return this.downstreamRequeueTasks.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
listDownstreamRequeueTaskItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
|
||||||
|
return this.downstreamRequeueTasks.listItems(id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeDownstreamRequeueTaskStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
|
||||||
|
return this.downstreamRequeueTasks.changeStatus(id, action, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||||
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
||||||
}
|
}
|
||||||
@@ -722,8 +762,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
|
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
|
||||||
return this.submission.validateSendResources(tenantId, applicationId, templateId);
|
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||||
@@ -770,8 +810,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
signature?: { id?: string | null; name?: string | null } | null;
|
signature?: { id?: string | null; name?: string | null } | null;
|
||||||
},
|
},
|
||||||
channelId: string,
|
channelId: string,
|
||||||
|
carrier: string,
|
||||||
) {
|
) {
|
||||||
return this.submission.ensureSignatureReportedForChannel(message, channelId);
|
return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ export class SendCompletionService {
|
|||||||
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
|
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
|
||||||
|
return this.retry.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||||
return this.retry.recoverStaleGatewaySubmitRequeues(now);
|
return this.retry.recoverStaleGatewaySubmitRequeues(now);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { SendDownstreamRequeueTaskService } from './send-downstream-requeue-task.service';
|
||||||
|
|
||||||
|
function prismaMock(): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {
|
||||||
|
cmppDownstreamDelivery: { count: jest.fn(), groupBy: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
cmppDownstreamConnection: { count: jest.fn() },
|
||||||
|
downstreamRequeueTask: { findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
||||||
|
downstreamRequeueTaskItem: { createMany: jest.fn(), count: jest.fn(), groupBy: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
|
||||||
|
operationLog: { create: jest.fn() },
|
||||||
|
downstreamRequeueRateWindow: { deleteMany: jest.fn() },
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
|
};
|
||||||
|
result.$transaction = jest.fn(async (callback: (tx: unknown) => unknown) => callback(result));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mock: Record<string, any>;
|
||||||
|
let service: SendDownstreamRequeueTaskService;
|
||||||
|
|
||||||
|
describe('SendDownstreamRequeueTaskService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.DATABASE_URL = 'postgresql://test:test@127.0.0.1/test';
|
||||||
|
mock = prismaMock();
|
||||||
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: jest.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the selected status in matched counts and returns a signed preview token', async () => {
|
||||||
|
mock.cmppDownstreamDelivery.count.mockResolvedValueOnce(8).mockResolvedValueOnce(8);
|
||||||
|
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'pending', _count: { _all: 8 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 8 } }]);
|
||||||
|
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date('2026-08-11T00:00:00Z') });
|
||||||
|
const result = await service.preview({ status: 'pending' }, 'user-1');
|
||||||
|
expect(result).toEqual(expect.objectContaining({ matchedCount: 8, replayableCount: 8, skippedCount: 0, previewToken: expect.stringContaining('.') }));
|
||||||
|
expect(mock.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: expect.objectContaining({ status: 'pending' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered preview token and short reasons', async () => {
|
||||||
|
await expect(service.create({ previewToken: 'invalid.token', reason: '处理事故积压' }, 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(service.create({ previewToken: 'invalid.token', reason: '短' }, 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes only the server-signed preview range', async () => {
|
||||||
|
mock.cmppDownstreamDelivery.count.mockResolvedValue(2);
|
||||||
|
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'failed', _count: { _all: 2 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 2 } }]);
|
||||||
|
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date() });
|
||||||
|
const preview = await service.preview({ applicationId: 'app-1', status: 'failed' }, 'user-1');
|
||||||
|
mock.downstreamRequeueTask.findFirst.mockResolvedValue(null);
|
||||||
|
mock.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'd-1', applicationId: 'app-1', status: 'failed' }]);
|
||||||
|
mock.downstreamRequeueTask.create.mockResolvedValue({ id: 'task-1' });
|
||||||
|
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
|
||||||
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
|
||||||
|
await service.create({ previewToken: preview.previewToken, reason: '处理历史回执积压' }, 'user-1');
|
||||||
|
expect(mock.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ AND: expect.any(Array) }) }));
|
||||||
|
expect(mock.downstreamRequeueTaskItem.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ deliveryId: 'd-1', applicationId: 'app-1' })] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes client-confirmed deliveries from the signed preview range', async () => {
|
||||||
|
mock.cmppDownstreamDelivery.count.mockResolvedValue(1);
|
||||||
|
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'delivered', _count: { _all: 1 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 1 } }]);
|
||||||
|
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date() });
|
||||||
|
const preview = await service.preview({ applicationId: 'app-1', status: 'delivered' }, 'user-1');
|
||||||
|
expect(preview).toEqual(expect.objectContaining({ matchedCount: 1, replayableCount: 1, skippedCount: 0 }));
|
||||||
|
mock.downstreamRequeueTask.findFirst.mockResolvedValue(null);
|
||||||
|
mock.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'd-1', applicationId: 'app-1', status: 'delivered' }]);
|
||||||
|
mock.downstreamRequeueTask.create.mockResolvedValue({ id: 'task-1' });
|
||||||
|
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
|
||||||
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
|
||||||
|
await service.create({ previewToken: preview.previewToken, reason: '再次投递客户已确认记录' }, 'user-1');
|
||||||
|
expect(mock.downstreamRequeueTaskItem.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ deliveryId: 'd-1', previousStatus: 'delivered' })] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays a delivery that was already client-confirmed in the task snapshot', async () => {
|
||||||
|
const requeue = jest.fn().mockResolvedValue({ status: 'awaiting_ack' });
|
||||||
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
|
||||||
|
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'delivered', ackResult: 0, payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
|
||||||
|
mock.downstreamRequeueTaskItem.findFirst.mockResolvedValue(null);
|
||||||
|
mock.cmppDownstreamConnection.count.mockResolvedValue(1);
|
||||||
|
await expect(service['processItem']('item-1', 'd-1', 'delivered')).resolves.toBe('waiting');
|
||||||
|
expect(requeue).toHaveBeenCalledWith('d-1');
|
||||||
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'waiting_ack' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not replay a record that became delivered after a non-delivered task snapshot', async () => {
|
||||||
|
const requeue = jest.fn();
|
||||||
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
|
||||||
|
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'delivered', ackResult: 0, payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
|
||||||
|
await expect(service['processItem']('item-1', 'd-1', 'failed')).resolves.toBe('skipped');
|
||||||
|
expect(requeue).not.toHaveBeenCalled();
|
||||||
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'skipped', skipReason: '创建任务后已被客户确认' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates all task items with status and keyword filters', async () => {
|
||||||
|
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
|
||||||
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValue([{ id: 'item-1' }]);
|
||||||
|
mock.downstreamRequeueTaskItem.count.mockResolvedValue(21);
|
||||||
|
await expect(service.listItems('task-1', { status: 'failed', keyword: 'MSG-1', page: 2, pageSize: 20 })).resolves.toEqual({ items: [{ id: 'item-1' }], total: 21, page: 2, pageSize: 20 });
|
||||||
|
expect(mock.downstreamRequeueTaskItem.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 20, where: expect.objectContaining({ status: 'failed', OR: expect.any(Array) }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers an expired processing claim before scanning queued work', async () => {
|
||||||
|
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
|
||||||
|
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'running', startedAt: new Date(), ratePerSecond: 10, consecutiveFailureLimit: 10, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
|
||||||
|
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValue([]);
|
||||||
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
|
||||||
|
await service.runScan();
|
||||||
|
expect(mock.downstreamRequeueTaskItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ status: 'processing', claimedAt: expect.any(Object) }), data: expect.objectContaining({ status: 'queued', claimedAt: null }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an offline application to waiting_connection without calling Gateway', async () => {
|
||||||
|
const requeue = jest.fn();
|
||||||
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
|
||||||
|
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
|
||||||
|
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'queued', startedAt: null, ratePerSecond: 10, consecutiveFailureLimit: 10, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
|
||||||
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'item-1', deliveryId: 'd-1', applicationId: 'app-1' }]).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
|
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'waiting_connection', _count: { _all: 1 } }]);
|
||||||
|
mock.$queryRaw.mockResolvedValue([{ consumed: 1 }]);
|
||||||
|
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'failed', payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
|
||||||
|
mock.downstreamRequeueTaskItem.findFirst.mockResolvedValue(null);
|
||||||
|
mock.cmppDownstreamConnection.count.mockResolvedValue(0);
|
||||||
|
await service.runScan();
|
||||||
|
expect(requeue).not.toHaveBeenCalled();
|
||||||
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'waiting_connection' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts ACK failures by application and auto-pauses at the threshold', async () => {
|
||||||
|
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
|
||||||
|
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'running', startedAt: new Date(), ratePerSecond: 10, consecutiveFailureLimit: 1, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
|
||||||
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'item-1', applicationId: 'app-1', status: 'waiting_ack', delivery: { status: 'rejected', ackResult: 1, ackDeadlineAt: new Date(), lastError: 'ACK Result=1' } }]).mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||||
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'failed', _count: { _all: 1 } }]);
|
||||||
|
await service.runScan();
|
||||||
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) }));
|
||||||
|
expect(mock.downstreamRequeueTask.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'paused', lastError: expect.stringContaining('app-1') }) }));
|
||||||
|
expect(mock.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'gateway.downstream_requeue_task_auto_paused' }) }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { parseDateBoundary } from '../operations/operations.helpers';
|
||||||
|
|
||||||
|
export type DownstreamRequeueFilter = {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequeueFacade = { requeueDownstreamDelivery(id: string): Promise<unknown> };
|
||||||
|
const REPLAYABLE_STATUSES = ['pending', 'failed', 'unconfirmed', 'rejected', 'delivered'];
|
||||||
|
const ACTIVE_TASK_STATUSES = ['queued', 'running', 'paused'];
|
||||||
|
const PROCESSING_LEASE_MS = 2 * 60_000;
|
||||||
|
const SCAN_LEASE_MS = 15_000;
|
||||||
|
const PREVIEW_TOKEN_TTL_MS = 15 * 60_000;
|
||||||
|
|
||||||
|
function normalizedFilter(filter: DownstreamRequeueFilter): DownstreamRequeueFilter {
|
||||||
|
return {
|
||||||
|
tenantId: filter.tenantId || 'all',
|
||||||
|
applicationId: filter.applicationId || 'all',
|
||||||
|
deliveryType: filter.deliveryType || 'all',
|
||||||
|
status: filter.status || 'all',
|
||||||
|
keyword: filter.keyword?.trim() || undefined,
|
||||||
|
createdAtFrom: filter.createdAtFrom || undefined,
|
||||||
|
createdAtTo: filter.createdAtTo || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayableByDefault = true): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||||
|
const normalized = normalizedFilter(filter);
|
||||||
|
const from = parseDateBoundary(normalized.createdAtFrom, false);
|
||||||
|
const to = parseDateBoundary(normalized.createdAtTo, true);
|
||||||
|
return {
|
||||||
|
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
|
||||||
|
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined,
|
||||||
|
deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined,
|
||||||
|
status: normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined,
|
||||||
|
createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt },
|
||||||
|
OR: normalized.keyword ? [
|
||||||
|
{ messageId: { contains: normalized.keyword } },
|
||||||
|
{ payload: { path: ['account'], string_contains: normalized.keyword } },
|
||||||
|
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
|
||||||
|
{ lastError: { contains: normalized.keyword } },
|
||||||
|
{ tenant: { name: { contains: normalized.keyword } } },
|
||||||
|
{ application: { name: { contains: normalized.keyword } } },
|
||||||
|
] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewSecret() {
|
||||||
|
const source = process.env.DOWNSTREAM_REQUEUE_PREVIEW_SECRET || process.env.DATABASE_URL;
|
||||||
|
if (!source) throw new BadRequestException('后台重投预检签名密钥未配置');
|
||||||
|
return createHash('sha256').update(`cmpp-downstream-requeue-preview\0${source}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPreview(payload: Record<string, unknown>) {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', previewSecret()).update(encoded).digest('base64url');
|
||||||
|
return `${encoded}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPreview(token: string, operatorId?: string) {
|
||||||
|
const [encoded, supplied] = String(token || '').split('.');
|
||||||
|
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
|
||||||
|
const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
|
||||||
|
let actual: Buffer;
|
||||||
|
try { actual = Buffer.from(supplied, 'base64url'); } catch { throw new BadRequestException('预检凭证无效,请重新预检'); }
|
||||||
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new BadRequestException('预检凭证无效,请重新预检');
|
||||||
|
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { filter: DownstreamRequeueFilter; snapshotAt: string; operatorId?: string; expiresAt: number };
|
||||||
|
if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检');
|
||||||
|
if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonFailures(value: unknown): Record<string, number> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, count]) => [key, Math.max(0, Number(count) || 0)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SendDownstreamRequeueTaskService {
|
||||||
|
constructor(private readonly prisma: PrismaService, private readonly facade: RequeueFacade) {}
|
||||||
|
|
||||||
|
async preview(filter: DownstreamRequeueFilter, operatorId?: string) {
|
||||||
|
const snapshotAt = new Date();
|
||||||
|
const normalized = normalizedFilter(filter);
|
||||||
|
const base = taskWhere(normalized, snapshotAt, false);
|
||||||
|
const replayableWhere = { AND: [base, { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput;
|
||||||
|
const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([
|
||||||
|
this.prisma.cmppDownstreamDelivery.count({ where: base }),
|
||||||
|
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
|
||||||
|
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }),
|
||||||
|
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }),
|
||||||
|
this.prisma.cmppDownstreamDelivery.findFirst({ where: base, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
|
||||||
|
]);
|
||||||
|
const tokenPayload = { filter: normalized, snapshotAt: snapshotAt.toISOString(), operatorId: operatorId || '', expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS };
|
||||||
|
return {
|
||||||
|
snapshotAt,
|
||||||
|
previewToken: signPreview(tokenPayload),
|
||||||
|
matchedCount,
|
||||||
|
replayableCount,
|
||||||
|
skippedCount: matchedCount - replayableCount,
|
||||||
|
applicationCount: appGroups.length,
|
||||||
|
oldestCreatedAt: oldest?.createdAt ?? null,
|
||||||
|
statusCounts: Object.fromEntries(statusGroups.map((item) => [item.status, item._count._all])),
|
||||||
|
filter: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, createdById?: string) {
|
||||||
|
const reason = data.reason?.trim();
|
||||||
|
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
|
||||||
|
const preview = verifyPreview(data.previewToken, createdById);
|
||||||
|
const filter = normalizedFilter(preview.filter);
|
||||||
|
const snapshotAt = new Date(preview.snapshotAt);
|
||||||
|
if (filter.status === 'awaiting_ack') throw new BadRequestException('后台任务不支持正在等待ACK的记录');
|
||||||
|
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: {
|
||||||
|
status: { in: ACTIVE_TASK_STATUSES },
|
||||||
|
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}),
|
||||||
|
}, select: { taskNo: true } });
|
||||||
|
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
|
||||||
|
const where = { AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput;
|
||||||
|
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ where, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: 100001, select: { id: true, applicationId: true, status: true } });
|
||||||
|
if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录');
|
||||||
|
if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
|
||||||
|
const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10)));
|
||||||
|
const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10)));
|
||||||
|
const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`;
|
||||||
|
const task = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.downstreamRequeueTask.create({ data: {
|
||||||
|
taskNo,
|
||||||
|
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
|
||||||
|
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
|
||||||
|
filterSnapshot: filter as Prisma.InputJsonValue,
|
||||||
|
snapshotAt, reason, ratePerSecond, consecutiveFailureLimit: failureLimit,
|
||||||
|
totalCount: deliveries.length, createdById,
|
||||||
|
} });
|
||||||
|
await tx.downstreamRequeueTaskItem.createMany({ data: deliveries.map((item) => ({ taskId: created.id, deliveryId: item.id, applicationId: item.applicationId, previousStatus: item.status })) });
|
||||||
|
await tx.operationLog.create({ data: { userId: createdById, action: 'gateway.downstream_requeue_task_created', resource: 'downstream_requeue_task', resourceId: created.id, detail: { taskNo, reason, totalCount: deliveries.length, snapshotAt, filter, ratePerSecond, consecutiveFailureLimit: failureLimit } } });
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return this.get(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: { status?: string; page?: number; pageSize?: number }) {
|
||||||
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
|
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
|
||||||
|
const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.downstreamRequeueTask.findMany({ where, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
|
||||||
|
this.prisma.downstreamRequeueTask.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string) {
|
||||||
|
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } } });
|
||||||
|
if (!task) throw new NotFoundException('后台重投任务不存在');
|
||||||
|
const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId: id }, _count: { _all: true } });
|
||||||
|
return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
|
||||||
|
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
if (!task) throw new NotFoundException('后台重投任务不存在');
|
||||||
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 20)));
|
||||||
|
const keyword = query.keyword?.trim();
|
||||||
|
const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
|
||||||
|
taskId: id,
|
||||||
|
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||||
|
OR: keyword ? [
|
||||||
|
{ delivery: { messageId: { contains: keyword } } },
|
||||||
|
{ skipReason: { contains: keyword } },
|
||||||
|
{ errorMessage: { contains: keyword } },
|
||||||
|
] : undefined,
|
||||||
|
};
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.downstreamRequeueTaskItem.findMany({ where, include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }),
|
||||||
|
this.prisma.downstreamRequeueTaskItem.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
|
||||||
|
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
|
||||||
|
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
|
||||||
|
if (!task) throw new NotFoundException('后台重投任务不存在');
|
||||||
|
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
|
||||||
|
if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作');
|
||||||
|
const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated';
|
||||||
|
const updated = await this.prisma.downstreamRequeueTask.update({ where: { id }, data: { status, pausedAt: status === 'paused' ? new Date() : null, finishedAt: status === 'terminated' ? new Date() : undefined, scanLeaseOwner: null, scanLeaseUntil: null } });
|
||||||
|
if (status === 'terminated') await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } }, data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() } });
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: `gateway.downstream_requeue_task_${action}`, resource: 'downstream_requeue_task', resourceId: id, detail: { taskNo: task.taskNo, previousStatus: task.status, status } } });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runScan() {
|
||||||
|
await this.prisma.downstreamRequeueRateWindow.deleteMany({ where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } } });
|
||||||
|
const tasks = await this.prisma.downstreamRequeueTask.findMany({ where: { status: { in: ['queued', 'running'] } }, orderBy: { createdAt: 'asc' }, take: 3, select: { id: true } });
|
||||||
|
for (const task of tasks) await this.processTask(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processTask(taskId: string) {
|
||||||
|
const leaseOwner = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
const lease = await this.prisma.downstreamRequeueTask.updateMany({
|
||||||
|
where: { id: taskId, status: { in: ['queued', 'running'] }, OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }] },
|
||||||
|
data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) },
|
||||||
|
});
|
||||||
|
if (!lease.count) return;
|
||||||
|
try {
|
||||||
|
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task || !['queued', 'running'].includes(task.status)) return;
|
||||||
|
// A process may die after the database claim but before the Gateway call. The lease makes that
|
||||||
|
// ambiguous window visible and recoverable; every recovered item is revalidated before replay.
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } }, data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' } });
|
||||||
|
let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures));
|
||||||
|
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
|
||||||
|
if (existingFailureEntry) {
|
||||||
|
await this.autoPause(task, existingFailureEntry[0], existingFailureEntry[1]);
|
||||||
|
await this.refreshTask(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'running', startedAt: task.startedAt ?? new Date() } });
|
||||||
|
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'queued' }, orderBy: { createdAt: 'asc' }, take: Math.min(200, task.ratePerSecond * 3), select: { id: true, deliveryId: true, applicationId: true, previousStatus: true } });
|
||||||
|
for (const item of items) {
|
||||||
|
const latestTask = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } });
|
||||||
|
if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break;
|
||||||
|
if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue;
|
||||||
|
const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'queued' }, data: { status: 'processing', claimedAt: new Date() } });
|
||||||
|
if (!claimed.count) continue;
|
||||||
|
const outcome = await this.processItem(item.id, item.deliveryId, item.previousStatus);
|
||||||
|
if (outcome === 'success') failures[item.applicationId] = 0;
|
||||||
|
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
|
||||||
|
const maxFailures = Math.max(0, ...Object.values(failures));
|
||||||
|
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } });
|
||||||
|
if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) {
|
||||||
|
await this.autoPause(task, item.applicationId, failures[item.applicationId]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failures = await this.reconcileWaiting(taskId, failures);
|
||||||
|
const maxFailures = Math.max(0, ...Object.values(failures));
|
||||||
|
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, status: { in: ['queued', 'running'] } }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } });
|
||||||
|
const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
|
||||||
|
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
|
||||||
|
await this.refreshTask(taskId);
|
||||||
|
} finally {
|
||||||
|
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, scanLeaseOwner: leaseOwner }, data: { scanLeaseOwner: null, scanLeaseUntil: null } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processItem(itemId: string, deliveryId: string, previousStatus: string): Promise<'success' | 'failed' | 'waiting' | 'skipped'> {
|
||||||
|
try {
|
||||||
|
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||||
|
where: { id: deliveryId },
|
||||||
|
include: { application: { select: { status: true, interfaceEnabled: true } } },
|
||||||
|
});
|
||||||
|
if (!delivery) return this.finishItem(itemId, 'skipped', '投递记录已不存在');
|
||||||
|
// Only a record that was already delivered in the frozen task snapshot may be replayed as
|
||||||
|
// delivered. This preserves the operator's explicit duplicate-delivery intent while preventing
|
||||||
|
// a pending/failed record that receives a late ACK after task creation from being sent again.
|
||||||
|
if (delivery.status === 'delivered' && previousStatus !== 'delivered') {
|
||||||
|
return this.finishItem(itemId, 'skipped', '创建任务后已被客户确认');
|
||||||
|
}
|
||||||
|
if (!REPLAYABLE_STATUSES.includes(delivery.status)) {
|
||||||
|
if (delivery.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_external_ack', skipReason: null } }); return 'waiting'; }
|
||||||
|
return this.finishItem(itemId, 'skipped', '执行前状态已变化');
|
||||||
|
}
|
||||||
|
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
|
||||||
|
if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType)) return this.finishItem(itemId, 'skipped', '投递数据不完整');
|
||||||
|
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({ where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } }, select: { id: true } });
|
||||||
|
if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理');
|
||||||
|
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } });
|
||||||
|
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' } }); return 'waiting'; }
|
||||||
|
const result = await this.facade.requeueDownstreamDelivery(deliveryId) as { status?: string; lastError?: string | null };
|
||||||
|
if (result?.status === 'delivered') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'success', completedAt: new Date() } }); return 'success'; }
|
||||||
|
if (result?.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_ack', completedAt: null } }); return 'waiting'; }
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'failed', errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态', completedAt: new Date() } });
|
||||||
|
return 'failed';
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '后台重投失败';
|
||||||
|
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化'
|
||||||
|
: /payload|投递类型/.test(message) ? '投递数据不完整'
|
||||||
|
: /Submit|Msg_Id|Sequence/.test(message) ? '缺少原Submit映射,无法安全重投' : null;
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: skipReason ? 'skipped' : 'failed', skipReason, errorMessage: skipReason ? null : message, completedAt: new Date() } });
|
||||||
|
return skipReason ? 'skipped' : 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> {
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status, skipReason: reason, completedAt: new Date() } });
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeRate(applicationId: string, limit: number) {
|
||||||
|
const windowStartedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ consumed: number }>>(Prisma.sql`
|
||||||
|
INSERT INTO "DownstreamRequeueRateWindow" ("id", "applicationId", "windowStartedAt", "consumed", "updatedAt")
|
||||||
|
VALUES (${randomUUID()}, ${applicationId}, ${windowStartedAt}, 1, NOW())
|
||||||
|
ON CONFLICT ("applicationId", "windowStartedAt") DO UPDATE
|
||||||
|
SET "consumed" = "DownstreamRequeueRateWindow"."consumed" + 1, "updatedAt" = NOW()
|
||||||
|
WHERE "DownstreamRequeueRateWindow"."consumed" < ${limit}
|
||||||
|
RETURNING "consumed"
|
||||||
|
`);
|
||||||
|
return rows.length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) {
|
||||||
|
const task = currentFailures ? null : await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { applicationFailures: true } });
|
||||||
|
const failures = currentFailures ?? jsonFailures(task?.applicationFailures);
|
||||||
|
const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'waiting_connection' }, select: { id: true, applicationId: true } });
|
||||||
|
for (const item of connectionItems) {
|
||||||
|
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: item.applicationId, status: 'connected' } });
|
||||||
|
if (connected > 0) await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'waiting_connection' }, data: { status: 'queued', errorMessage: null, claimedAt: null } });
|
||||||
|
}
|
||||||
|
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } }, include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } }, take: 500 });
|
||||||
|
const now = new Date();
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) {
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now } : { status: 'success', completedAt: now } });
|
||||||
|
if (item.status === 'waiting_ack') failures[item.applicationId] = 0;
|
||||||
|
} else if (['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) || (item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)) {
|
||||||
|
if (item.status === 'waiting_external_ack') {
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null } });
|
||||||
|
} else {
|
||||||
|
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'failed', errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时', completedAt: now } });
|
||||||
|
failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async autoPause(task: { id: string; taskNo: string; consecutiveFailureLimit: number }, applicationId: string, count: number) {
|
||||||
|
const pausedAt = new Date();
|
||||||
|
const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`;
|
||||||
|
const updated = await this.prisma.downstreamRequeueTask.updateMany({ where: { id: task.id, status: { in: ['queued', 'running'] } }, data: { status: 'paused', pausedAt, lastError: message } });
|
||||||
|
if (updated.count) await this.prisma.operationLog.create({ data: { action: 'gateway.downstream_requeue_task_auto_paused', resource: 'downstream_requeue_task', resourceId: task.id, detail: { taskNo: task.taskNo, applicationId, consecutiveFailures: count, failureLimit: task.consecutiveFailureLimit, pausedAt } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshTask(taskId: string) {
|
||||||
|
const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId }, _count: { _all: true } });
|
||||||
|
const counts = new Map(groups.map((item) => [item.status, item._count._all]));
|
||||||
|
const queued = counts.get('queued') ?? 0;
|
||||||
|
const active = (counts.get('processing') ?? 0) + (counts.get('waiting_connection') ?? 0) + (counts.get('waiting_ack') ?? 0) + (counts.get('waiting_external_ack') ?? 0);
|
||||||
|
const failed = counts.get('failed') ?? 0;
|
||||||
|
const current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } });
|
||||||
|
const status = current?.status === 'paused' || current?.status === 'terminated' ? current.status : queued + active === 0 ? (failed > 0 ? 'partial_completed' : 'completed') : 'running';
|
||||||
|
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status, successCount: counts.get('success') ?? 0, failedCount: failed, skippedCount: counts.get('skipped') ?? 0, waitingCount: active, ...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,7 +162,7 @@ async submitMessageToGateway(
|
|||||||
) {
|
) {
|
||||||
const channel = routed.channel;
|
const channel = routed.channel;
|
||||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||||
await this.facade.ensureSignatureReportedForChannel(message, channel.id);
|
await this.facade.ensureSignatureReportedForChannel(message, channel.id, routed.carrier);
|
||||||
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||||
const submitId = `SUB-${randomUUID()}`;
|
const submitId = `SUB-${randomUUID()}`;
|
||||||
try {
|
try {
|
||||||
@@ -321,7 +321,13 @@ async selectChannelForMessage(
|
|||||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||||
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
|
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
|
||||||
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
|
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||||
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
|
where: {
|
||||||
|
signatureId,
|
||||||
|
reportType: 'signature',
|
||||||
|
status: 'approved',
|
||||||
|
channelId: { in: route.group.items.map((item) => item.channelId) },
|
||||||
|
OR: signatureReportApprovalScopes(carrier),
|
||||||
|
},
|
||||||
select: { channelId: true },
|
select: { channelId: true },
|
||||||
});
|
});
|
||||||
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
|
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
|
||||||
@@ -386,13 +392,20 @@ async ensureSignatureReportedForChannel(
|
|||||||
signature?: { id?: string | null; name?: string | null } | null;
|
signature?: { id?: string | null; name?: string | null } | null;
|
||||||
},
|
},
|
||||||
channelId: string,
|
channelId: string,
|
||||||
|
carrier: string,
|
||||||
) {
|
) {
|
||||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||||
if (!signatureId) {
|
if (!signatureId) {
|
||||||
throw new BadRequestException('短信签名未配置,不能提交到通道');
|
throw new BadRequestException('短信签名未配置,不能提交到通道');
|
||||||
}
|
}
|
||||||
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
|
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
|
||||||
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
|
where: {
|
||||||
|
signatureId,
|
||||||
|
channelId,
|
||||||
|
reportType: 'signature',
|
||||||
|
status: 'approved',
|
||||||
|
OR: signatureReportApprovalScopes(carrier),
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!reportTask) {
|
if (!reportTask) {
|
||||||
@@ -489,3 +502,11 @@ return streamId`,
|
|||||||
return typeof result === 'string' ? result : String(result ?? '');
|
return typeof result === 'string' ? result : String(result ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signatureReportApprovalScopes(carrier: string) {
|
||||||
|
const exact = { carrier, approvalScope: 'carrier_specific' };
|
||||||
|
// 迁移期保留双读用于平滑发布;自动转换migration完成且兼容命中清零后再切换严格口径。
|
||||||
|
return process.env.SIGNATURE_REPORT_STRICT_CARRIER === 'true'
|
||||||
|
? [exact]
|
||||||
|
: [exact, { carrier: null, approvalScope: 'legacy_channel' }];
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,31 +59,77 @@ export class SendInboundEntryService {
|
|||||||
|
|
||||||
|
|
||||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||||
const application = await this.facade.findInboundApplication(data.account);
|
let tenantId: string | undefined;
|
||||||
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
|
let applicationId: string | undefined;
|
||||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
try {
|
||||||
|
const application = await this.facade.findInboundApplication(data.account);
|
||||||
|
tenantId = application?.tenantId;
|
||||||
|
applicationId = application?.id;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' });
|
||||||
|
return {
|
||||||
|
applicationId: application.id,
|
||||||
|
tenantId: application.tenantId,
|
||||||
|
account: application.cmppAccount,
|
||||||
|
enterpriseCode: application.cmppEnterpriseCode,
|
||||||
|
passwordCipher: application.secretHash,
|
||||||
|
maxConnections: application.cmppMaxConnections,
|
||||||
|
status: 'authenticated',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordInboundConnectRequest(data, {
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
result: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'unknown error',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (!application.interfaceEnabled) {
|
}
|
||||||
throw new BadRequestException('CMPP interface is disabled for this application');
|
|
||||||
}
|
private recordInboundConnectRequest(
|
||||||
if (application.tenant.certificationStatus !== 'approved') {
|
data: GatewayInboundAuthDto,
|
||||||
throw new BadRequestException('Enterprise certification is not approved');
|
outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string },
|
||||||
}
|
) {
|
||||||
if (!matchesApplicationSecret(data, application.secretHash)) {
|
return this.prisma.operationLog.create({
|
||||||
throw new BadRequestException('CMPP account or password is invalid');
|
data: {
|
||||||
}
|
tenantId: outcome.tenantId,
|
||||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
action: 'cmpp_connection.connect_requested',
|
||||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
resource: 'cmpp_downstream_connection',
|
||||||
}
|
resourceId: outcome.applicationId ?? data.account,
|
||||||
return {
|
ipAddress: data.remoteIp?.trim() || undefined,
|
||||||
applicationId: application.id,
|
detail: {
|
||||||
tenantId: application.tenantId,
|
direction: 'client_to_platform',
|
||||||
account: application.cmppAccount,
|
result: outcome.result,
|
||||||
enterpriseCode: application.cmppEnterpriseCode,
|
applicationId: outcome.applicationId ?? null,
|
||||||
passwordCipher: application.secretHash,
|
request: {
|
||||||
maxConnections: application.cmppMaxConnections,
|
remoteIp: data.remoteIp?.trim() || null,
|
||||||
status: 'authenticated',
|
account: data.account,
|
||||||
};
|
// Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful.
|
||||||
|
password: data.password ?? null,
|
||||||
|
authSource: data.authSource ?? null,
|
||||||
|
timestamp: data.timestamp ?? null,
|
||||||
|
version: data.version ?? null,
|
||||||
|
requestedVersion: data.requestedVersion ?? null,
|
||||||
|
},
|
||||||
|
error: outcome.error ?? null,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||||
|
|||||||
@@ -166,6 +166,47 @@ export class SendRetryService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
|
||||||
|
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||||
|
if (!deadLetter) {
|
||||||
|
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||||
|
}
|
||||||
|
if (deadLetter.status === 'resolved') {
|
||||||
|
return deadLetter;
|
||||||
|
}
|
||||||
|
if (deadLetter.status !== 'pending') {
|
||||||
|
throw new BadRequestException('只有待处理的提交异常可以标记为已处理');
|
||||||
|
}
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
const resolved = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||||
|
where: { id, status: 'pending' },
|
||||||
|
data: {
|
||||||
|
status: 'resolved',
|
||||||
|
resolvedAt,
|
||||||
|
resolvedStatus: 'manually_resolved',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (resolved.count !== 1) {
|
||||||
|
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||||
|
}
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: deadLetter.tenantId ?? undefined,
|
||||||
|
userId: operatorId,
|
||||||
|
action: 'gateway.submit_dead_letter_resolved',
|
||||||
|
resource: 'gateway_submit_dead_letter',
|
||||||
|
resourceId: deadLetter.id,
|
||||||
|
detail: {
|
||||||
|
previousStatus: deadLetter.status,
|
||||||
|
resolvedStatus: 'manually_resolved',
|
||||||
|
messageId: deadLetter.messageId,
|
||||||
|
submitId: deadLetter.submitId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||||
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ async dispatchDueScheduledTasks(now = new Date()) {
|
|||||||
let reservationEstablished = false;
|
let reservationEstablished = false;
|
||||||
let dispatchPrepared = false;
|
let dispatchPrepared = false;
|
||||||
try {
|
try {
|
||||||
await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
await this.facade.validateSendResources(
|
||||||
|
task.tenantId,
|
||||||
|
task.applicationId ?? undefined,
|
||||||
|
task.templateId ?? undefined,
|
||||||
|
{ usePersistedTemplateSnapshot: true },
|
||||||
|
);
|
||||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||||
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
|
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
|
||||||
select: { id: true, amountCents: true, billingUnits: true },
|
select: { id: true, amountCents: true, billingUnits: true },
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export type SendSubmissionCallbacks = {
|
|||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SendResourceValidationOptions = {
|
||||||
|
usePersistedTemplateSnapshot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||||
*/
|
*/
|
||||||
@@ -109,8 +113,8 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
|
|||||||
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
|
||||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId);
|
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||||
@@ -273,8 +277,9 @@ async ensureSignatureReportedForChannel(
|
|||||||
signature?: { id?: string | null; name?: string | null } | null;
|
signature?: { id?: string | null; name?: string | null } | null;
|
||||||
},
|
},
|
||||||
channelId: string,
|
channelId: string,
|
||||||
|
carrier: string,
|
||||||
) {
|
) {
|
||||||
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId);
|
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type RetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel';
|
||||||
|
|
||||||
|
export interface UpsertRetirementRuleDto {
|
||||||
|
ruleType: RetirementRuleType;
|
||||||
|
targetId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
mobileWindowDays: number;
|
||||||
|
mobileThreshold: number;
|
||||||
|
unicomWindowDays: number;
|
||||||
|
unicomThreshold: number;
|
||||||
|
telecomWindowDays: number;
|
||||||
|
telecomThreshold: number;
|
||||||
|
messageTemplate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRetirementWebhookDto {
|
||||||
|
name: string;
|
||||||
|
platform: 'wecom' | 'feishu';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppressRetirementMessageDto {
|
||||||
|
mode: 'temporary' | 'permanent';
|
||||||
|
days?: number;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelRetirementSuppressionDto {
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreportedSignatureQuery {
|
||||||
|
date?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetirementMessageQuery {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
dimensionType?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
signatureKeyword?: string;
|
||||||
|
channelId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
|
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||||
|
import { SignatureRetirementService } from './signature-retirement.service';
|
||||||
|
|
||||||
|
@ApiTags('signature-retirement')
|
||||||
|
@Controller('admin/signature-retirement')
|
||||||
|
export class SignatureRetirementController {
|
||||||
|
constructor(private readonly service: SignatureRetirementService) {}
|
||||||
|
|
||||||
|
@Get('configuration')
|
||||||
|
getConfiguration() {
|
||||||
|
return this.service.getConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('rules')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
upsertRule(@Body() body: UpsertRetirementRuleDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.service.upsertRule(body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('webhooks')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
createWebhook(@Body() body: CreateRetirementWebhookDto) {
|
||||||
|
return this.service.createWebhook(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('webhooks/:id')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
deleteWebhook(@Param('id') id: string) {
|
||||||
|
return this.service.deleteWebhook(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('messages')
|
||||||
|
listMessages(
|
||||||
|
@Query('dateFrom') dateFrom?: string,
|
||||||
|
@Query('dateTo') dateTo?: string,
|
||||||
|
@Query('dimensionType') dimensionType?: string,
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('signatureKeyword') signatureKeyword?: string,
|
||||||
|
@Query('channelId') channelId?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const query: RetirementMessageQuery = { dateFrom, dateTo, dimensionType, tenantId, applicationId, signatureKeyword, channelId, page: Number(page), pageSize: Number(pageSize) };
|
||||||
|
return this.service.listMessages(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('unread-count')
|
||||||
|
unreadCount() {
|
||||||
|
return this.service.unreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/:id/read')
|
||||||
|
markRead(@Param('id') id: string) {
|
||||||
|
return this.service.markRead(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/read-all-today')
|
||||||
|
markAllTodayRead() {
|
||||||
|
return this.service.markAllTodayRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/:id/suppress')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
suppress(@Param('id') id: string, @Body() body: SuppressRetirementMessageDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.service.suppressMessage(id, body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('suppressions')
|
||||||
|
listSuppressions() {
|
||||||
|
return this.service.listSuppressions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('suppressions/:id/cancel')
|
||||||
|
@RequireRecentAuthentication()
|
||||||
|
cancelSuppression(@Param('id') id: string, @Body() body: CancelRetirementSuppressionDto, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.service.cancelSuppression(id, body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('heatmap')
|
||||||
|
heatmap(@Query('date') date?: string) {
|
||||||
|
return this.service.heatmap(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('unreported-signatures')
|
||||||
|
unreportedSignatures(
|
||||||
|
@Query('date') date?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const query: UnreportedSignatureQuery = { date, keyword, page: Number(page), pageSize: Number(pageSize) };
|
||||||
|
return this.service.unreportedSignatures(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SignatureRetirementController } from './signature-retirement.controller';
|
||||||
|
import { SignatureRetirementService } from './signature-retirement.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SignatureRetirementController],
|
||||||
|
providers: [SignatureRetirementService],
|
||||||
|
exports: [SignatureRetirementService],
|
||||||
|
})
|
||||||
|
export class SignatureRetirementModule {}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
|
||||||
|
|
||||||
|
describe('SignatureRetirementService dimensions', () => {
|
||||||
|
const service = new SignatureRetirementService({} as never);
|
||||||
|
|
||||||
|
it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => {
|
||||||
|
const rules = [
|
||||||
|
rule('enterprise_global', ''),
|
||||||
|
rule('enterprise_application', 'app-1'),
|
||||||
|
rule('channel_global', ''),
|
||||||
|
rule('channel', 'channel-2'),
|
||||||
|
];
|
||||||
|
const tasks = [
|
||||||
|
task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'),
|
||||||
|
task('channel-2', '移动二号', 'mobile', '2026-06-05T00:00:00Z'),
|
||||||
|
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks);
|
||||||
|
|
||||||
|
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
|
||||||
|
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
|
||||||
|
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
|
||||||
|
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
|
||||||
|
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not monitor legacy carrier-null reporting facts', () => {
|
||||||
|
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
|
||||||
|
[rule('enterprise_global', ''), rule('channel_global', '')],
|
||||||
|
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
|
||||||
|
);
|
||||||
|
expect(dimensions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
|
||||||
|
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
|
||||||
|
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
|
||||||
|
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T23:30:00.000Z'), 8)).toBe(30 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes frozen alert content only in the notification phase', async () => {
|
||||||
|
const detection = {
|
||||||
|
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
|
||||||
|
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
|
||||||
|
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
|
||||||
|
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
|
||||||
|
};
|
||||||
|
const notificationService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
|
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
|
||||||
|
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns enterprise application metadata for heatmap hover and search', async () => {
|
||||||
|
const prisma = {
|
||||||
|
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
|
||||||
|
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
tenant: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
|
||||||
|
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
|
||||||
|
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
|
||||||
|
channel: { name: '移动通道' },
|
||||||
|
}]) },
|
||||||
|
};
|
||||||
|
const heatmapService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
|
const result = await heatmapService.heatmap('2026-08-10');
|
||||||
|
|
||||||
|
expect(result.dimensions).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
|
||||||
|
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
|
||||||
|
]));
|
||||||
|
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists daily observing snapshots without opening alert cycles', async () => {
|
||||||
|
const prisma = {
|
||||||
|
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
|
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
|
||||||
|
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
|
||||||
|
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
||||||
|
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
|
||||||
|
};
|
||||||
|
const observingService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
|
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
|
||||||
|
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
|
||||||
|
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the real unreported-signature aggregation to an independent page', async () => {
|
||||||
|
const prisma = {
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([{
|
||||||
|
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
|
||||||
|
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
|
||||||
|
}]),
|
||||||
|
};
|
||||||
|
const unreportedService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
|
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
|
||||||
|
date: '2026-08-10',
|
||||||
|
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
|
||||||
|
total: 3,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
||||||
|
const sql = query.strings?.join('?') ?? '';
|
||||||
|
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
|
||||||
|
expect(sql).toContain('message."signatureId" IS NULL');
|
||||||
|
expect(sql).toContain('FROM "SmsSignature" signature');
|
||||||
|
expect(sql).toContain('signature."applicationId" = extracted.application_id');
|
||||||
|
expect(sql).toContain('GROUP BY\n extracted.tenant_id,\n extracted.application_id,');
|
||||||
|
expect(sql).not.toContain('FROM "ChannelSignatureReportTask" report');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a filtered historical message page with application metadata', async () => {
|
||||||
|
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
|
||||||
|
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
|
||||||
|
const prisma = {
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
|
||||||
|
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
|
||||||
|
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([detection]) },
|
||||||
|
smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'signature-1', name: '测试签名' }]) },
|
||||||
|
smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '测试通道' }]) },
|
||||||
|
tenant: { findMany: jest.fn().mockResolvedValue([{ id: 'tenant-1', name: '测试企业' }]) },
|
||||||
|
smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', name: '测试应用' }]) },
|
||||||
|
};
|
||||||
|
const messageService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
|
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
|
||||||
|
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
|
||||||
|
total: 21,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reason for temporary and permanent suppression', async () => {
|
||||||
|
const prisma = {
|
||||||
|
signatureRetirementMessage: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
||||||
|
};
|
||||||
|
const suppressionService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
|
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
|
||||||
|
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function rule(ruleType: string, targetKey: string) {
|
||||||
|
return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
|
||||||
|
return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { decryptSecret, encryptSecret } from '../open-api/open-api.crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||||
|
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||||
|
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
|
const DEFAULT_DELIVERY_INTERVAL_MS = 60_000;
|
||||||
|
const carrierLabels: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||||
|
|
||||||
|
type RuleRecord = Awaited<ReturnType<PrismaService['signatureRetirementRule']['findFirst']>>;
|
||||||
|
type DetectionDimension = {
|
||||||
|
dimensionType: 'enterprise' | 'channel';
|
||||||
|
tenantId: string;
|
||||||
|
applicationId: string | null;
|
||||||
|
signatureId: string;
|
||||||
|
signatureName: string;
|
||||||
|
tenantName: string;
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
carrier: string;
|
||||||
|
approvedAt: Date;
|
||||||
|
rule: NonNullable<RuleRecord>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivityCounts = {
|
||||||
|
submittedAttempts: number;
|
||||||
|
acceptedBusinessCount: number;
|
||||||
|
deliveredBusinessCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(SignatureRetirementService.name);
|
||||||
|
private detectionTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private notificationTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private deliveryTimer?: ReturnType<typeof setInterval>;
|
||||||
|
private startupTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
if (process.env.NODE_ENV === 'test') return;
|
||||||
|
// 04:00检测、08:00发消息分别调度;启动补偿与数据库唯一键共同保证当天不漏、不重。
|
||||||
|
this.startupTimer = setTimeout(() => void this.runStartupCompensation(), 10_000);
|
||||||
|
this.startupTimer.unref?.();
|
||||||
|
this.scheduleDetection();
|
||||||
|
this.scheduleNotification();
|
||||||
|
this.deliveryTimer = setInterval(() => void this.deliverPendingWebhooks(), positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS));
|
||||||
|
this.deliveryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||||
|
if (this.detectionTimer) clearTimeout(this.detectionTimer);
|
||||||
|
if (this.notificationTimer) clearTimeout(this.notificationTimer);
|
||||||
|
if (this.deliveryTimer) clearInterval(this.deliveryTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfiguration() {
|
||||||
|
const [rules, webhooks] = await Promise.all([
|
||||||
|
this.prisma.signatureRetirementRule.findMany({ orderBy: [{ ruleType: 'asc' }, { targetKey: 'asc' }] }),
|
||||||
|
this.prisma.signatureRetirementWebhook.findMany({ orderBy: { createdAt: 'asc' } }),
|
||||||
|
]);
|
||||||
|
return { rules, webhooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRule(data: UpsertRetirementRuleDto, operatorId?: string) {
|
||||||
|
assertRuleType(data.ruleType);
|
||||||
|
if (['enterprise_application', 'channel'].includes(data.ruleType) && !data.targetId?.trim()) {
|
||||||
|
throw new BadRequestException('特殊规则必须选择目标');
|
||||||
|
}
|
||||||
|
const values = CARRIERS.flatMap((carrier) => [
|
||||||
|
Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]),
|
||||||
|
Number(data[`${carrier}Threshold` as keyof UpsertRetirementRuleDto]),
|
||||||
|
]);
|
||||||
|
if (values.some((value) => !Number.isInteger(value) || value < 0)) throw new BadRequestException('检测天数和阈值必须为非负整数');
|
||||||
|
if (CARRIERS.some((carrier) => Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) < 1 || Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) > 365)) {
|
||||||
|
throw new BadRequestException('检测天数必须在1至365天之间');
|
||||||
|
}
|
||||||
|
const targetId = data.targetId?.trim() || null;
|
||||||
|
const targetKey = targetId ?? '';
|
||||||
|
const existing = await this.prisma.signatureRetirementRule.findUnique({ where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } } });
|
||||||
|
const rule = await this.prisma.signatureRetirementRule.upsert({
|
||||||
|
where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } },
|
||||||
|
create: { ...data, targetId, targetKey, createdById: operatorId },
|
||||||
|
update: { ...data, targetId, targetKey, version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: existing ? 'signature_retirement.rule_updated' : 'signature_retirement.rule_created', resource: 'signature_retirement_rule', resourceId: rule.id, detail: { ruleType: rule.ruleType, targetId, version: rule.version } as Prisma.InputJsonValue } });
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWebhook(data: CreateRetirementWebhookDto) {
|
||||||
|
if (!data.name?.trim()) throw new BadRequestException('Webhook名称不能为空');
|
||||||
|
if (!['wecom', 'feishu'].includes(data.platform)) throw new BadRequestException('仅支持企业微信或飞书');
|
||||||
|
await assertSafeWebhookUrl(data.url);
|
||||||
|
return this.prisma.signatureRetirementWebhook.create({
|
||||||
|
data: { name: data.name.trim(), platform: data.platform, urlEncrypted: encryptSecret(data.url.trim()), urlMasked: maskWebhookUrl(data.url.trim()) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWebhook(id: string) {
|
||||||
|
const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id } });
|
||||||
|
if (!webhook) throw new NotFoundException('Webhook不存在');
|
||||||
|
return this.prisma.signatureRetirementWebhook.update({ where: { id }, data: { status: 'deleted' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessages(query: RetirementMessageQuery) {
|
||||||
|
const page = Math.max(1, Math.floor(query.page || 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize || 10)));
|
||||||
|
const range = shanghaiDateRange(query.dateFrom || shanghaiDateKey(), query.dateTo || query.dateFrom || shanghaiDateKey());
|
||||||
|
const dimensionType = query.dimensionType && query.dimensionType !== 'all' ? query.dimensionType : null;
|
||||||
|
if (dimensionType && !['enterprise', 'channel'].includes(dimensionType)) throw new BadRequestException('不支持的预警类型');
|
||||||
|
const tenantId = query.tenantId?.trim() || null;
|
||||||
|
const applicationId = query.applicationId?.trim() || null;
|
||||||
|
const signatureKeyword = query.signatureKeyword?.trim() || null;
|
||||||
|
const signaturePattern = signatureKeyword ? `%${signatureKeyword}%` : null;
|
||||||
|
const channelId = query.channelId?.trim() || null;
|
||||||
|
const messageRows = await this.prisma.$queryRaw<Array<{ id: string; totalCount: number }>>(Prisma.sql`
|
||||||
|
SELECT message.id, COUNT(*) OVER()::integer AS "totalCount"
|
||||||
|
FROM "SignatureRetirementMessage" message
|
||||||
|
JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId"
|
||||||
|
JOIN "SmsSignature" signature ON signature.id = detection."signatureId"
|
||||||
|
LEFT JOIN "SmsApplication" application ON application.id = detection."applicationId"
|
||||||
|
WHERE message."createdAt" >= ${range?.gte}
|
||||||
|
AND message."createdAt" <= ${range?.lte}
|
||||||
|
AND (${dimensionType}::text IS NULL OR detection."dimensionType" = ${dimensionType})
|
||||||
|
AND (${tenantId}::text IS NULL OR detection."tenantId" = ${tenantId})
|
||||||
|
AND (${applicationId}::text IS NULL OR application.id = ${applicationId})
|
||||||
|
AND (${signatureKeyword}::text IS NULL OR signature.name ILIKE ${signaturePattern})
|
||||||
|
AND (${channelId}::text IS NULL OR detection."channelId" = ${channelId})
|
||||||
|
ORDER BY message."createdAt" DESC, message.id DESC
|
||||||
|
LIMIT ${pageSize}
|
||||||
|
OFFSET ${(page - 1) * pageSize}
|
||||||
|
`);
|
||||||
|
const orderedIds = messageRows.map((item) => item.id);
|
||||||
|
const unorderedItems = orderedIds.length ? await this.prisma.signatureRetirementMessage.findMany({ where: { id: { in: orderedIds } } }) : [];
|
||||||
|
const itemMap = new Map(unorderedItems.map((item) => [item.id, item]));
|
||||||
|
const items = orderedIds.flatMap((id) => itemMap.has(id) ? [itemMap.get(id)!] : []);
|
||||||
|
const total = messageRows[0]?.totalCount ?? 0;
|
||||||
|
const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { id: { in: items.map((item) => item.detectionId) } } });
|
||||||
|
const detectionMap = new Map(detections.map((item) => [item.id, item]));
|
||||||
|
const [signatures, channels, tenants, applications] = await Promise.all([
|
||||||
|
this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }),
|
||||||
|
this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }),
|
||||||
|
this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }),
|
||||||
|
this.prisma.smsApplication.findMany({ where: { id: { in: detections.flatMap((item) => item.applicationId ? [item.applicationId] : []) } }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
const signatureMap = new Map(signatures.map((item) => [item.id, item.name]));
|
||||||
|
const channelMap = new Map(channels.map((item) => [item.id, item.name]));
|
||||||
|
const tenantMap = new Map(tenants.map((item) => [item.id, item.name]));
|
||||||
|
const applicationMap = new Map(applications.map((item) => [item.id, item.name]));
|
||||||
|
return {
|
||||||
|
items: items.map((item) => {
|
||||||
|
const detection = detectionMap.get(item.detectionId);
|
||||||
|
return { ...item, detection, signatureName: detection ? signatureMap.get(detection.signatureId) : undefined, channelName: detection?.channelId ? channelMap.get(detection.channelId) : undefined, tenantName: detection ? tenantMap.get(detection.tenantId) : undefined, applicationName: detection?.applicationId ? applicationMap.get(detection.applicationId) : undefined };
|
||||||
|
}),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async unreadCount() {
|
||||||
|
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
|
||||||
|
const count = await this.prisma.signatureRetirementMessage.count({ where: { createdAt: range, isRead: false, suppressed: false } });
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(id: string) {
|
||||||
|
return this.prisma.signatureRetirementMessage.update({ where: { id }, data: { isRead: true, readAt: new Date() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllTodayRead() {
|
||||||
|
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
|
||||||
|
const result = await this.prisma.signatureRetirementMessage.updateMany({ where: { createdAt: range, isRead: false }, data: { isRead: true, readAt: new Date() } });
|
||||||
|
return { count: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async suppressMessage(id: string, data: SuppressRetirementMessageDto, operatorId?: string) {
|
||||||
|
const message = await this.prisma.signatureRetirementMessage.findUnique({ where: { id } });
|
||||||
|
if (!message) throw new NotFoundException('预警消息不存在');
|
||||||
|
const newerMessage = await this.prisma.signatureRetirementMessage.findFirst({ where: { cycleId: message.cycleId, createdAt: { gt: message.createdAt } }, select: { id: true } });
|
||||||
|
if (newerMessage) throw new BadRequestException('只能从当前预警周期的最新消息设置抑制');
|
||||||
|
const detection = await this.prisma.signatureRetirementDetection.findUnique({ where: { id: message.detectionId } });
|
||||||
|
if (!detection) throw new NotFoundException('预警检测记录不存在');
|
||||||
|
if (!['temporary', 'permanent'].includes(data.mode)) throw new BadRequestException('不支持的抑制类型');
|
||||||
|
if (!data.reason?.trim()) throw new BadRequestException('抑制原因不能为空');
|
||||||
|
const days = data.mode === 'temporary' ? Math.floor(Number(data.days)) : undefined;
|
||||||
|
if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) throw new BadRequestException('临时抑制天数必须在1至3650之间');
|
||||||
|
const muteUntil = days ? databaseDate(addDays(shanghaiDateKey(), days)) : null;
|
||||||
|
const suppression = await this.prisma.signatureRetirementSuppression.upsert({
|
||||||
|
where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelKey: detection.channelKey, carrier: detection.carrier } },
|
||||||
|
create: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelId: detection.channelId, channelKey: detection.channelKey, carrier: detection.carrier, mode: data.mode, muteUntil, reason: data.reason?.trim(), operatorId },
|
||||||
|
update: { channelId: detection.channelId, mode: data.mode, muteUntil, active: true, reason: data.reason?.trim(), operatorId, cancelledAt: null, cancelledById: null, cancelReason: null },
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.signatureRetirementMessage.update({ where: { id }, data: { suppressed: true } }),
|
||||||
|
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppressed', resource: 'signature_retirement_suppression', resourceId: suppression.id, detail: { mode: data.mode, days, reason: data.reason } as Prisma.InputJsonValue } }),
|
||||||
|
]);
|
||||||
|
return suppression;
|
||||||
|
}
|
||||||
|
|
||||||
|
listSuppressions() {
|
||||||
|
return this.prisma.signatureRetirementSuppression.findMany({
|
||||||
|
where: { active: true, OR: [{ mode: 'permanent' }, { muteUntil: { gte: databaseDate(shanghaiDateKey()) } }] },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelSuppression(id: string, data: CancelRetirementSuppressionDto, operatorId?: string) {
|
||||||
|
if (!data.reason?.trim()) throw new BadRequestException('取消抑制原因不能为空');
|
||||||
|
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { id } });
|
||||||
|
if (!suppression) throw new NotFoundException('抑制记录不存在');
|
||||||
|
const updated = await this.prisma.signatureRetirementSuppression.update({ where: { id }, data: { active: false, cancelledAt: new Date(), cancelledById: operatorId, cancelReason: data.reason.trim() } });
|
||||||
|
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppression_cancelled', resource: 'signature_retirement_suppression', resourceId: id, detail: { reason: data.reason.trim() } as Prisma.InputJsonValue } });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async heatmap(date?: string) {
|
||||||
|
const endKey = assertDateKey(date || shanghaiDateKey());
|
||||||
|
const startKey = addDays(endKey, -30);
|
||||||
|
const detections = await this.prisma.signatureRetirementDetection.findMany({
|
||||||
|
where: { detectionDate: { gt: databaseDate(startKey), lte: databaseDate(endKey) } },
|
||||||
|
orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }],
|
||||||
|
});
|
||||||
|
const [signatures, channels, tenants, approvedTasks] = await Promise.all([
|
||||||
|
this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }),
|
||||||
|
this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }),
|
||||||
|
this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }),
|
||||||
|
this.prisma.channelSignatureReportTask.findMany({
|
||||||
|
where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
|
||||||
|
include: { signature: { include: { tenant: true, application: true } }, channel: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const dimensionMap = new Map<string, { dimensionType: 'enterprise' | 'channel'; signatureId: string; channelId: string | null; carrier: string; approvedAt: Date; signatureName: string; channelName: string | null; tenantName: string; applicationName: string | null }>();
|
||||||
|
for (const task of approvedTasks) {
|
||||||
|
if (!task.carrier || !task.approvedAt) continue;
|
||||||
|
const channelDimension = { dimensionType: 'channel' as const, signatureId: task.signatureId, channelId: task.channelId, carrier: task.carrier, approvedAt: task.approvedAt, signatureName: task.signature.name, channelName: task.channel.name, tenantName: task.signature.tenant.name, applicationName: task.signature.application?.name ?? null };
|
||||||
|
dimensionMap.set(`channel:${task.signatureId}:${task.channelId}:${task.carrier}`, channelDimension);
|
||||||
|
const enterpriseKey = `enterprise:${task.signatureId}::${task.carrier}`;
|
||||||
|
const current = dimensionMap.get(enterpriseKey);
|
||||||
|
if (!current || task.approvedAt < current.approvedAt) dimensionMap.set(enterpriseKey, { ...channelDimension, dimensionType: 'enterprise', channelId: null, channelName: null });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: endKey,
|
||||||
|
dimensions: [...dimensionMap.values()],
|
||||||
|
// 检测在次日04:00运行,因此检测日对应的活动自然日固定为T-1。
|
||||||
|
items: detections.map((item) => ({ ...item, activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async unreportedSignatures(query: UnreportedSignatureQuery) {
|
||||||
|
const date = assertDateKey(query.date || shanghaiDateKey());
|
||||||
|
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 rows = await this.prisma.$queryRaw<Array<{
|
||||||
|
signatureId: string;
|
||||||
|
signatureName: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
applicationId: string | null;
|
||||||
|
applicationName: string | null;
|
||||||
|
messageCount: number;
|
||||||
|
rowCount: number;
|
||||||
|
}>>(Prisma.sql`
|
||||||
|
WITH extracted AS (
|
||||||
|
SELECT
|
||||||
|
message."tenantId" AS tenant_id,
|
||||||
|
message."applicationId" AS application_id,
|
||||||
|
SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name
|
||||||
|
FROM "SmsMessageRecord" message
|
||||||
|
WHERE message."queuedAt" >= ${shanghaiStart(date)}
|
||||||
|
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
|
||||||
|
AND message."signatureId" IS NULL
|
||||||
|
), unreported AS (
|
||||||
|
SELECT
|
||||||
|
CONCAT('unregistered:', MD5(extracted.tenant_id || ':' || extracted.application_id || ':' || extracted.signature_name)) AS signature_id,
|
||||||
|
extracted.signature_name,
|
||||||
|
tenant.id AS tenant_id,
|
||||||
|
tenant.name AS tenant_name,
|
||||||
|
application.id AS application_id,
|
||||||
|
application.name AS application_name,
|
||||||
|
COUNT(*)::integer AS message_count
|
||||||
|
FROM extracted
|
||||||
|
JOIN "Tenant" tenant ON tenant.id = extracted.tenant_id
|
||||||
|
JOIN "SmsApplication" application ON application.id = extracted.application_id
|
||||||
|
WHERE extracted.signature_name IS NOT NULL
|
||||||
|
-- 未报备签名指系统签名库中不存在,而不是已有签名缺少某个通道的运营商报备。
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "SmsSignature" signature
|
||||||
|
WHERE signature."tenantId" = extracted.tenant_id
|
||||||
|
AND signature."applicationId" = extracted.application_id
|
||||||
|
AND signature.name = extracted.signature_name
|
||||||
|
AND signature."auditStatus" <> 'deleted'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
${keyword}::text IS NULL
|
||||||
|
OR extracted.signature_name ILIKE ${keywordPattern}
|
||||||
|
OR tenant.name ILIKE ${keywordPattern}
|
||||||
|
OR application.name ILIKE ${keywordPattern}
|
||||||
|
)
|
||||||
|
GROUP BY
|
||||||
|
extracted.tenant_id,
|
||||||
|
extracted.application_id,
|
||||||
|
extracted.signature_name,
|
||||||
|
tenant.id,
|
||||||
|
tenant.name,
|
||||||
|
application.id,
|
||||||
|
application.name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
signature_id AS "signatureId",
|
||||||
|
signature_name AS "signatureName",
|
||||||
|
tenant_id AS "tenantId",
|
||||||
|
tenant_name AS "tenantName",
|
||||||
|
application_id AS "applicationId",
|
||||||
|
application_name AS "applicationName",
|
||||||
|
message_count AS "messageCount",
|
||||||
|
COUNT(*) OVER()::integer AS "rowCount"
|
||||||
|
FROM unreported
|
||||||
|
ORDER BY message_count DESC, signature_name, application_name NULLS LAST
|
||||||
|
LIMIT ${pageSize}
|
||||||
|
OFFSET ${(page - 1) * pageSize}
|
||||||
|
`);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
items: rows.map(({ rowCount: _rowCount, ...item }) => item),
|
||||||
|
total: rows[0]?.rowCount ?? 0,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async runDetection(date?: string) {
|
||||||
|
const detectionKey = assertDateKey(date || shanghaiDateKey());
|
||||||
|
await this.prisma.signatureRetirementSuppression.updateMany({
|
||||||
|
where: { active: true, mode: 'temporary', muteUntil: { lt: databaseDate(detectionKey) } },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
const [rules, approvedTasks] = await Promise.all([
|
||||||
|
this.prisma.signatureRetirementRule.findMany({ where: { enabled: true } }),
|
||||||
|
this.prisma.channelSignatureReportTask.findMany({
|
||||||
|
where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
|
||||||
|
include: { signature: { include: { tenant: true, application: true } }, channel: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const dimensions = this.buildDimensions(rules, approvedTasks);
|
||||||
|
let alerted = 0;
|
||||||
|
let healthy = 0;
|
||||||
|
let ineligible = 0;
|
||||||
|
for (const dimension of dimensions) {
|
||||||
|
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
|
||||||
|
const windowStartKey = addDays(detectionKey, -windowDays);
|
||||||
|
const windowStart = shanghaiStart(windowStartKey);
|
||||||
|
const activityStart = shanghaiStart(addDays(detectionKey, -1));
|
||||||
|
const activityEnd = shanghaiStart(detectionKey);
|
||||||
|
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
|
||||||
|
if (effectiveActivityStart >= activityEnd) {
|
||||||
|
ineligible += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
|
||||||
|
if (dimension.approvedAt > windowStart) {
|
||||||
|
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
|
||||||
|
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
|
||||||
|
ineligible += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
|
||||||
|
const isAlert = windowCounts.acceptedBusinessCount < threshold;
|
||||||
|
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, isAlert, false, windowCounts);
|
||||||
|
if (isAlert) alerted += 1;
|
||||||
|
else healthy += 1;
|
||||||
|
}
|
||||||
|
return { detectionDate: detectionKey, dimensions: dimensions.length, alerted, healthy, ineligible };
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishNotifications(date?: string) {
|
||||||
|
const notificationKey = assertDateKey(date || shanghaiDateKey());
|
||||||
|
const detections = await this.prisma.signatureRetirementDetection.findMany({
|
||||||
|
where: {
|
||||||
|
detectionDate: databaseDate(notificationKey), status: 'alert', suppressed: false,
|
||||||
|
cycleId: { not: null }, notificationTitle: { not: null }, notificationContent: { not: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const detection of detections) {
|
||||||
|
if (!detection.cycleId || !detection.notificationTitle || !detection.notificationContent) continue;
|
||||||
|
try {
|
||||||
|
await this.prisma.signatureRetirementMessage.create({
|
||||||
|
data: { detectionId: detection.id, cycleId: detection.cycleId, tenantId: detection.tenantId, title: detection.notificationTitle, content: detection.notificationContent },
|
||||||
|
});
|
||||||
|
created += 1;
|
||||||
|
} catch (error) {
|
||||||
|
// 多实例08:00并发发布时,检测ID唯一键保证只产生一条站内消息。
|
||||||
|
if (!isPrismaUniqueError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.enqueueWebhookSummaries(notificationKey);
|
||||||
|
return { notificationDate: notificationKey, created };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runStartupCompensation() {
|
||||||
|
const now = new Date();
|
||||||
|
const hour = shanghaiHour(now);
|
||||||
|
try {
|
||||||
|
if (hour >= 4) await this.runDetection(shanghaiDateKey(now));
|
||||||
|
if (hour >= 8) {
|
||||||
|
await this.publishNotifications(shanghaiDateKey(now));
|
||||||
|
await this.deliverPendingWebhooks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleDetection() {
|
||||||
|
this.detectionTimer = setTimeout(() => {
|
||||||
|
void this.runDetection(shanghaiDateKey())
|
||||||
|
.catch((error) => this.logger.error(`Signature retirement 04:00 detection failed: ${error instanceof Error ? error.message : String(error)}`))
|
||||||
|
.finally(() => this.scheduleDetection());
|
||||||
|
}, millisecondsUntilShanghaiHour(new Date(), 4));
|
||||||
|
this.detectionTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNotification() {
|
||||||
|
this.notificationTimer = setTimeout(() => {
|
||||||
|
void this.publishNotifications(shanghaiDateKey())
|
||||||
|
.then(() => this.deliverPendingWebhooks())
|
||||||
|
.catch((error) => this.logger.error(`Signature retirement 08:00 notification failed: ${error instanceof Error ? error.message : String(error)}`))
|
||||||
|
.finally(() => this.scheduleNotification());
|
||||||
|
}, millisecondsUntilShanghaiHour(new Date(), 8));
|
||||||
|
this.notificationTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDimensions(rules: Array<NonNullable<RuleRecord>>, tasks: Array<{ signatureId: string; channelId: string; carrier: string | null; approvedAt: Date | null; signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } }; channel: { name: string } }>) {
|
||||||
|
const dimensions: DetectionDimension[] = [];
|
||||||
|
const enterprise = new Map<string, DetectionDimension>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.carrier || !task.approvedAt) continue;
|
||||||
|
const channelRule = selectRule(rules, 'channel', task.channelId);
|
||||||
|
if (channelRule) dimensions.push({ dimensionType: 'channel', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: task.channelId, channelName: task.channel.name, carrier: task.carrier, approvedAt: task.approvedAt, rule: channelRule });
|
||||||
|
const enterpriseRule = selectRule(rules, 'enterprise', task.signature.applicationId);
|
||||||
|
if (!enterpriseRule) continue;
|
||||||
|
const key = `${task.signatureId}:${task.carrier}`;
|
||||||
|
const current = enterprise.get(key);
|
||||||
|
if (!current || task.approvedAt < current.approvedAt) enterprise.set(key, { dimensionType: 'enterprise', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: null, channelName: null, carrier: task.carrier, approvedAt: task.approvedAt, rule: enterpriseRule });
|
||||||
|
}
|
||||||
|
return [...enterprise.values(), ...dimensions];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activityCounts(dimension: DetectionDimension, startAt: Date, endAt: Date): Promise<ActivityCounts> {
|
||||||
|
const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty;
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<ActivityCounts>>(Prisma.sql`
|
||||||
|
WITH attempts AS (
|
||||||
|
SELECT
|
||||||
|
submit.id,
|
||||||
|
submit."messageRecordId" AS message_id,
|
||||||
|
submit."submitStatus" AS submit_status,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id)
|
||||||
|
THEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM "SmsMessageSegmentAudit" segment
|
||||||
|
WHERE segment."submitRecordId" = submit.id AND segment."receiptStatus" IS DISTINCT FROM 'delivered'
|
||||||
|
)
|
||||||
|
ELSE EXISTS (
|
||||||
|
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||||
|
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||||
|
AND receipt."channelId" = submit."channelId"
|
||||||
|
AND receipt."receiptStatus" = 'delivered'
|
||||||
|
)
|
||||||
|
END AS delivery_success
|
||||||
|
FROM "SmsSubmitRecord" submit
|
||||||
|
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||||
|
WHERE message."signatureId" = ${dimension.signatureId}
|
||||||
|
AND message.carrier = ${dimension.carrier}
|
||||||
|
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${startAt}
|
||||||
|
AND COALESCE(submit."submittedAt", submit."createdAt") < ${endAt}
|
||||||
|
${channelFilter}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(id)::integer AS "submittedAttempts",
|
||||||
|
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedBusinessCount",
|
||||||
|
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted' AND delivery_success)::integer AS "deliveredBusinessCount"
|
||||||
|
FROM attempts
|
||||||
|
`);
|
||||||
|
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean, observing = false, alertCounts = counts) {
|
||||||
|
const detectionDate = databaseDate(dateKey);
|
||||||
|
const channelKey = dimension.channelId ?? '';
|
||||||
|
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
|
||||||
|
where: { detectionDate_dimensionType_signatureId_channelKey_carrier: { detectionDate, dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
// 同一检测日的结果冻结规则版本;规则变更只在下一检测日生效。
|
||||||
|
if (existingDetection) return;
|
||||||
|
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } });
|
||||||
|
const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate));
|
||||||
|
let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
|
||||||
|
if (observing) {
|
||||||
|
cycle = null;
|
||||||
|
} else if (isAlert) {
|
||||||
|
if (!cycle) {
|
||||||
|
try {
|
||||||
|
cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPrismaUniqueError(error)) throw error;
|
||||||
|
cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cycle = await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { lastDetectedOn: detectionDate } });
|
||||||
|
}
|
||||||
|
} else if (cycle) {
|
||||||
|
await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate } });
|
||||||
|
cycle = null;
|
||||||
|
}
|
||||||
|
const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
|
||||||
|
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, alertCounts.acceptedBusinessCount) : null;
|
||||||
|
try {
|
||||||
|
await this.prisma.signatureRetirementDetection.create({
|
||||||
|
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: observing ? 'observing' : isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。
|
||||||
|
if (isPrismaUniqueError(error)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueWebhookSummaries(dateKey: string) {
|
||||||
|
const detectionDate = databaseDate(dateKey);
|
||||||
|
const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate, status: 'alert', suppressed: false } });
|
||||||
|
if (!detections.length) return;
|
||||||
|
const [webhooks, messages] = await Promise.all([
|
||||||
|
this.prisma.signatureRetirementWebhook.findMany({ where: { status: 'active' } }),
|
||||||
|
this.prisma.signatureRetirementMessage.findMany({ where: { detectionId: { in: detections.map((item) => item.id) }, suppressed: false } }),
|
||||||
|
]);
|
||||||
|
const messageMap = new Map(messages.map((item) => [item.detectionId, item.content]));
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
for (const detection of detections) {
|
||||||
|
const key = detection.dimensionType === 'enterprise' ? `enterprise:${detection.tenantId}` : 'channel:all';
|
||||||
|
const values = groups.get(key) ?? [];
|
||||||
|
const content = messageMap.get(detection.id);
|
||||||
|
if (content) values.push(content);
|
||||||
|
groups.set(key, values);
|
||||||
|
}
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
for (const [groupKey, contents] of groups) {
|
||||||
|
await this.prisma.signatureRetirementWebhookDelivery.upsert({
|
||||||
|
where: { webhookId_detectionDate_groupKey: { webhookId: webhook.id, detectionDate, groupKey } },
|
||||||
|
create: { webhookId: webhook.id, detectionDate, groupKey, payload: { content: contents.join('\n') } },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverPendingWebhooks() {
|
||||||
|
await this.prisma.signatureRetirementWebhookDelivery.updateMany({
|
||||||
|
where: { status: 'sending', updatedAt: { lt: new Date(Date.now() - 5 * 60_000) } },
|
||||||
|
data: { status: 'retrying', nextRetryAt: new Date() },
|
||||||
|
});
|
||||||
|
const deliveries = await this.prisma.signatureRetirementWebhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }] }, orderBy: { createdAt: 'asc' }, take: 20 });
|
||||||
|
for (const delivery of deliveries) {
|
||||||
|
const claimed = await this.prisma.signatureRetirementWebhookDelivery.updateMany({ where: { id: delivery.id, status: { in: ['pending', 'retrying'] }, attemptCount: delivery.attemptCount }, data: { status: 'sending', attemptCount: { increment: 1 } } });
|
||||||
|
if (!claimed.count) continue;
|
||||||
|
const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id: delivery.webhookId } });
|
||||||
|
if (!webhook || webhook.status !== 'active') {
|
||||||
|
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastError: 'Webhook已停用' } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = decryptSecret(webhook.urlEncrypted);
|
||||||
|
await assertSafeWebhookUrl(url);
|
||||||
|
const content = String((delivery.payload as { content?: unknown }).content ?? '');
|
||||||
|
const body = webhook.platform === 'feishu' ? { msg_type: 'text', content: { text: content } } : { msgtype: 'text', text: { content } };
|
||||||
|
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const responseBody = await response.json().catch(() => null) as { errcode?: number; code?: number } | null;
|
||||||
|
if ((typeof responseBody?.errcode === 'number' && responseBody.errcode !== 0) || (typeof responseBody?.code === 'number' && responseBody.code !== 0)) {
|
||||||
|
throw new Error(`Webhook业务响应失败:${responseBody.errcode ?? responseBody.code}`);
|
||||||
|
}
|
||||||
|
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'delivered', deliveredAt: new Date(), lastHttpStatus: response.status, lastError: null } });
|
||||||
|
} catch (error) {
|
||||||
|
const attempts = delivery.attemptCount + 1;
|
||||||
|
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: attempts >= 5 ? 'failed' : 'retrying', nextRetryAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60 * 60_000, 2 ** attempts * 60_000)), lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRule(rules: Array<NonNullable<RuleRecord>>, dimension: 'enterprise' | 'channel', targetId: string | null) {
|
||||||
|
const specialType = dimension === 'enterprise' ? 'enterprise_application' : 'channel';
|
||||||
|
const globalType = dimension === 'enterprise' ? 'enterprise_global' : 'channel_global';
|
||||||
|
return (targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined)
|
||||||
|
?? rules.find((rule) => rule.ruleType === globalType && rule.targetKey === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function carrierRule(rule: NonNullable<RuleRecord>, carrier: string) {
|
||||||
|
if (carrier === 'mobile') return { windowDays: rule.mobileWindowDays, threshold: rule.mobileThreshold };
|
||||||
|
if (carrier === 'unicom') return { windowDays: rule.unicomWindowDays, threshold: rule.unicomThreshold };
|
||||||
|
return { windowDays: rule.telecomWindowDays, threshold: rule.telecomThreshold };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessage(template: string | null, dimension: DetectionDimension, windowDays: number, threshold: number, actual: number) {
|
||||||
|
const fallback = dimension.dimensionType === 'enterprise'
|
||||||
|
? '请通知 {enterprise}:{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
|
||||||
|
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
|
||||||
|
return (template?.trim() || fallback)
|
||||||
|
.replaceAll('{enterprise}', dimension.tenantName)
|
||||||
|
.replaceAll('{signature}', dimension.signatureName)
|
||||||
|
.replaceAll('{channel}', dimension.channelName ?? '-')
|
||||||
|
.replaceAll('{carrier}', carrierLabels[dimension.carrier] ?? dimension.carrier)
|
||||||
|
.replaceAll('{days}', String(windowDays))
|
||||||
|
.replaceAll('{threshold}', String(threshold))
|
||||||
|
.replaceAll('{actual}', String(actual));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shanghaiDateKey(date = new Date()) {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shanghaiHour(date = new Date()) {
|
||||||
|
return Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function millisecondsUntilShanghaiHour(now: Date, targetHour: number) {
|
||||||
|
const target = new Date(`${shanghaiDateKey(now)}T${String(targetHour).padStart(2, '0')}:00:00+08:00`);
|
||||||
|
if (target.getTime() <= now.getTime()) target.setUTCDate(target.getUTCDate() + 1);
|
||||||
|
return target.getTime() - now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDateKey(value: string) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(`${value}T00:00:00+08:00`).getTime())) throw new BadRequestException('日期格式必须为YYYY-MM-DD');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(value: string, days: number) {
|
||||||
|
const date = new Date(`${assertDateKey(value)}T12:00:00+08:00`);
|
||||||
|
return shanghaiDateKey(new Date(date.getTime() + days * DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shanghaiStart(value: string) {
|
||||||
|
return new Date(`${assertDateKey(value)}T00:00:00+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseDate(value: string) {
|
||||||
|
return new Date(`${assertDateKey(value)}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRuleType(value: string): asserts value is RetirementRuleType {
|
||||||
|
if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) throw new BadRequestException('不支持的规则类型');
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveIntegerEnv(name: string, fallback: number) {
|
||||||
|
const value = Number(process.env[name]);
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: number | undefined, fallback: number) {
|
||||||
|
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrismaUniqueError(error: unknown) {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSafeWebhookUrl(value: string) {
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(value); } catch { throw new BadRequestException('Webhook地址无效'); }
|
||||||
|
if (url.protocol !== 'https:') throw new BadRequestException('Webhook必须使用HTTPS');
|
||||||
|
if (url.username || url.password) throw new BadRequestException('Webhook地址不能包含用户名或密码');
|
||||||
|
if (url.hostname === 'localhost' || url.hostname.endsWith('.local')) throw new BadRequestException('Webhook地址不能指向本地网络');
|
||||||
|
const addresses = await lookup(url.hostname, { all: true }).catch(() => []);
|
||||||
|
if (!addresses.length) throw new BadRequestException('Webhook域名无法解析');
|
||||||
|
if (addresses.some((entry) => isPrivateAddress(entry.address))) throw new BadRequestException('Webhook地址不能指向内网');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateAddress(address: string) {
|
||||||
|
const normalized = address.toLowerCase();
|
||||||
|
if (normalized === '::1' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||||
|
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (!match) return false;
|
||||||
|
const [a, b] = [Number(match[1]), Number(match[2])];
|
||||||
|
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskWebhookUrl(value: string) {
|
||||||
|
const url = new URL(value);
|
||||||
|
const suffix = url.pathname.slice(-6);
|
||||||
|
return `${url.origin}/***${suffix}`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL
|
|||||||
import { SmsReportValidationService } from './report-validation.service';
|
import { SmsReportValidationService } from './report-validation.service';
|
||||||
import { SmsAuditService } from './audit.service';
|
import { SmsAuditService } from './audit.service';
|
||||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||||
|
import { summarizeReportStatuses } from '../common/report-status';
|
||||||
|
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||||
|
|
||||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||||
export class SmsSignatureService {
|
export class SmsSignatureService {
|
||||||
@@ -83,8 +85,14 @@ export class SmsSignatureService {
|
|||||||
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
||||||
reportTargets: (() => {
|
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 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]));
|
const tasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature');
|
||||||
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 }));
|
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => (
|
||||||
|
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
|
||||||
|
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
|
||||||
|
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
|
||||||
|
return { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific' };
|
||||||
|
})
|
||||||
|
));
|
||||||
})(),
|
})(),
|
||||||
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||||
const drainageItemId = drainageItem.id;
|
const drainageItemId = drainageItem.id;
|
||||||
@@ -107,21 +115,19 @@ export class SmsSignatureService {
|
|||||||
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
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]));
|
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) => {
|
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||||
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
||||||
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
|
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
|
||||||
const approved = statuses.filter((status) => status === 'approved').length;
|
return [carrier, summarizeReportStatuses(statuses)];
|
||||||
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) => {
|
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 configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
||||||
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
|
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 signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature');
|
||||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
|
||||||
const approved = statuses.filter((status) => status === 'approved').length;
|
?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
|
||||||
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';
|
?? 'pending');
|
||||||
return [carrier, { status, approved, total: targets.length }];
|
return [carrier, summarizeReportStatuses(statuses)];
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[Definition]
|
||||||
|
failregex = ^<HOST> .* "(?:GET|POST|HEAD) /(?:\.env|\.git|wp-admin|wp-login\.php|phpmyadmin|vendor/phpunit|actuator|cgi-bin)(?:[/? ][^\"]*)?" (?:400|403|404) .*$
|
||||||
|
ignoreregex =
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[Definition]
|
||||||
|
# Detection remains report-only. The fixed action is installed by the deployment
|
||||||
|
# script and can only forward Fail2ban's matched IP/jail values to the local collector.
|
||||||
|
actionstart =
|
||||||
|
actionstop =
|
||||||
|
actioncheck =
|
||||||
|
actionban = @CMPP_SECURITY_AGENT_BIN@ report <name> <ip>
|
||||||
|
actionunban =
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CMPP restricted security execution agent
|
||||||
|
After=network.target fail2ban.service nftables.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=cmpp-security
|
||||||
|
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
|
||||||
|
ExecStart=@CMPP_SECURITY_AGENT_BIN@
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/run/cmpp-security-agent /var/lib/cmpp-security-agent /etc/nginx/snippets /etc/fail2ban/jail.d
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||||
|
CapabilityBoundingSet=CAP_NET_ADMIN CAP_DAC_OVERRIDE CAP_KILL
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN CAP_DAC_OVERRIDE
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -548,8 +548,8 @@ api/src/channels/
|
|||||||
```
|
```
|
||||||
|
|
||||||
`docs/contracts/channels-r5-methods.json` 与
|
`docs/contracts/channels-r5-methods.json` 与
|
||||||
`tools/quality/verify-channels-r5.mjs` 固定 37 个公开方法、14 个内部方法、
|
`tools/quality/verify-channels-r5.mjs` 固定 38 个公开方法、14 个内部方法、
|
||||||
17 个契约及 60 个辅助声明,并专项锁定连接参数重连条件、Gateway
|
17 个契约及 61 个辅助声明,并专项锁定连接参数重连条件、Gateway
|
||||||
连接/断开路径、定时器、Redis队列、测试短信单次尝试和控制器兼容入口。
|
连接/断开路径、定时器、Redis队列、测试短信单次尝试和控制器兼容入口。
|
||||||
|
|
||||||
### 版本 R6:拆分 Gateway 入站服务
|
### 版本 R6:拆分 Gateway 入站服务
|
||||||
@@ -1138,3 +1138,17 @@ global,不能错误归入client。`client-signature-*`、发送页、企业认
|
|||||||
```
|
```
|
||||||
|
|
||||||
每次开始新拆分版本时,在本路线图基础上另写该版本的短实施计划,不直接把路线图当作可执行变更清单。
|
每次开始新拆分版本时,在本路线图基础上另写该版本的短实施计划,不直接把路线图当作可执行变更清单。
|
||||||
|
## 安全检测领域边界补充(2026-08-14)
|
||||||
|
|
||||||
|
- `api/src/security-detection/` 是安全事件、聚合告警、规则版本和人工封禁编排的唯一业务边界;登录、OpenAPI 和 Gateway 只上报固定类型的结构化事件,不复制聚合或封禁逻辑。
|
||||||
|
- `gateway/cmd/security-agent/` 是最小特权执行边界,不依赖 NestJS Service,不接受任意命令、路径、jail、action 或 shell 参数。该二进制与 `deploy/security/`、`tools/security/install-security-agent.sh` 作为同一发布单元评审。
|
||||||
|
- 安全代理可执行文件位置只由安装器的`agent_binary=$APP_DIR/dist/cmpp-security-agent`定义;systemd和Fail2ban模板使用同一占位符渲染,不能各自维护易漂移的绝对路径。
|
||||||
|
- 前端 `src/apps/admin/security-detection/` 通过 `src/api/admin/security-detection.api.ts` 访问稳定门面,不直接访问 Fail2ban、Nginx、nftables 或安全代理。
|
||||||
|
- NestJS进程边界由`api/src/main.ts`和生产环境`API_HOST`共同固定为回环监听,外部HTTP入口统一归Nginx模块治理;后续拆分不得让业务模块自行新增外部监听或绕过反向代理边界。
|
||||||
|
|
||||||
|
## 基础设施指标领域边界补充(2026-08-14)
|
||||||
|
|
||||||
|
- `api/src/metrics/`只负责API进程和HTTP路由模板的低基数计数/直方图,不依赖Prisma、Redis或业务Service;独立回环监听由`api/src/main.ts`组装。
|
||||||
|
- `gateway/internal/metrics/`只负责线程安全聚合和Prometheus文本暴露;上游连接池、下游Session和Submit Worker仅提供总数或有界结果,不把实体ID或凭据交给监控模块。
|
||||||
|
- `api/src/infrastructure-monitoring/`是运营端监控聚合与固定阈值应用边界:只消费代码白名单 PromQL,并通过版本化 PostgreSQL 单例、promtool 校验和原子规则热加载管理数值阈值;Exporter安装、端口隔离、固定规则模板和权限仍归`tools/monitoring/`治理。
|
||||||
|
- 活动告警已读也归该边界:Prometheus保留告警事实,Prisma仅持久化逐管理员、逐触发周期的阅读状态;全局布局只消费轻量未读汇总,不复制指纹、activeAt或用户隔离逻辑。
|
||||||
|
|||||||
@@ -255,7 +255,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "listReconciliationReports",
|
"name": "listReconciliationReports",
|
||||||
"implementationSha256": "7d4dafd20064b8fb5ffec207c7656fc3807c805d8ebb112867751ef79b04c1c0"
|
"implementationSha256": "09c7c1e5797e2e7f9391db05e0ca8d5140a3496eb7aff34129af572e9cd4d58c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "exportReconciliationReports",
|
"name": "exportReconciliationReports",
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "listProfitReports",
|
"name": "listProfitReports",
|
||||||
"implementationSha256": "d037e421b83586ada4758e1f3b0a5d1603c31295093b36f58f287c09ce98aed0"
|
"implementationSha256": "d5cda61c78b0762941c6dd7661501e1aea3cd33615a51dc86d1af66e48d669ea"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "exportProfitReports",
|
"name": "exportProfitReports",
|
||||||
@@ -271,7 +271,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "listQualityReports",
|
"name": "listQualityReports",
|
||||||
"implementationSha256": "293d470b44d6ea12ff90d7497d985c3194386ff3ba1f6cedafb7a6d6925352ad"
|
"implementationSha256": "ba043613917d325272db8c6e5ca8b6d4fd202b03ac6ed522f1064ba271a36bda"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "exportQualityReports",
|
"name": "exportQualityReports",
|
||||||
@@ -439,7 +439,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "updateChannelGroup",
|
"name": "updateChannelGroup",
|
||||||
"implementationSha256": "d01b2318706087800997b8f5d2e4dff6677b67bc92c1880211161a7dcea5d04f"
|
"implementationSha256": "d5f2722429048690cbed422d62729d8eee17bea6de4cc55dc187a629de991710"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getChannelGroupDeletionImpact",
|
||||||
|
"implementationSha256": "61c7fcb2c90244f97a740a32ae44f6a6d04342d5f50dfcd72c9d51167b3503e2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deleteChannelGroup",
|
"name": "deleteChannelGroup",
|
||||||
@@ -737,6 +741,10 @@
|
|||||||
"name": "listPhoneSegments",
|
"name": "listPhoneSegments",
|
||||||
"implementationSha256": "3faf0469c44dfdfe69029fa23f77983d7c0c888e181f24e7b8387b1ac87d4c97"
|
"implementationSha256": "3faf0469c44dfdfe69029fa23f77983d7c0c888e181f24e7b8387b1ac87d4c97"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "listAdministrativeRegions",
|
||||||
|
"implementationSha256": "3393f57588cef8eaa0e8cfd8a24cedc12fe6fffe4e2b3dadf90921fd1e58fb58"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "createPhoneSegment",
|
"name": "createPhoneSegment",
|
||||||
"implementationSha256": "e93ff81cbf2a93fd2a50356e671755b28f5335cc4293b8aefd22f4dfbe00bf9f"
|
"implementationSha256": "e93ff81cbf2a93fd2a50356e671755b28f5335cc4293b8aefd22f4dfbe00bf9f"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "signatureCardVisual",
|
"name": "signatureCardVisual",
|
||||||
"canonicalSha256": "1cea544821c9c03b7c544644d5865e57810ae6f9c8d05d577ed104811ada63ec"
|
"canonicalSha256": "e4749077295323ea3ce85d7793c78b51ca76748ca7d830265af69f8189904023"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "AuditStatusTag",
|
"name": "AuditStatusTag",
|
||||||
@@ -65,15 +65,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "SignatureReportModal",
|
"name": "SignatureReportModal",
|
||||||
"canonicalSha256": "7439104e171e7d9eeb8449f497696b6d3736b881195c0b96a1a096079ba0789b"
|
"canonicalSha256": "4cfec954f564c998af70da441971bede16915f4bb5ac9c10e45da15101957de5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ChannelReportStatusModal",
|
"name": "ChannelReportStatusModal",
|
||||||
"canonicalSha256": "d32f8d7d3025ee286ed7898ed41a22404db3d684b80d23819965b3332e0e8310"
|
"canonicalSha256": "e0717a89302e477fd1e57f18e3f97b993ebe3c7c42ccbfcd2761110054f6abcb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DrainageReportModal",
|
"name": "DrainageReportModal",
|
||||||
"canonicalSha256": "716a2d480a4b652ee951de0306854f1d8f6d72759d7b2fbffbfbb600abc8a789"
|
"canonicalSha256": "d2b41c26eb3992bb1ff3b50ee5ef6c06e7f84d89832e3ec7446b35178520431f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DrainageReportStatusModal",
|
"name": "DrainageReportStatusModal",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da"
|
"canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tableJsxSha256": "203ec16e0ca67f0dafea08d659e84db02dd8346d06a52f41378625e39a43def7",
|
"tableJsxSha256": "5cae4926ab10a762b26d77d0a0633b85de68fa204b7d5732ba0942e7fc276234",
|
||||||
"apiCalls": [
|
"apiCalls": [
|
||||||
"listEnterpriseSignaturesPage",
|
"listEnterpriseSignaturesPage",
|
||||||
"listTenants",
|
"listTenants",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"admin-audit-",
|
"admin-audit-",
|
||||||
"admin-detail-metric-",
|
"admin-detail-metric-",
|
||||||
"admin-report-filter-",
|
"admin-report-filter-",
|
||||||
|
"admin-report-summary",
|
||||||
"admin-security-",
|
"admin-security-",
|
||||||
"admin-split-",
|
"admin-split-",
|
||||||
"admin-system-",
|
"admin-system-",
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
{
|
{
|
||||||
"name": "listGroups",
|
"name": "listGroups",
|
||||||
"signature": "listGroups()",
|
"signature": "listGroups()",
|
||||||
"canonicalBodySha256": "fc4ae9bd701db6f55108aa057f42a56b283901b3401903edb5464058d5a96374",
|
"canonicalBodySha256": "3a6cbcd0159f9c2a380a34378007648f636e71be25802189d2821135c65351ba",
|
||||||
"originalLines": [
|
"originalLines": [
|
||||||
853,
|
853,
|
||||||
858
|
858
|
||||||
@@ -203,10 +203,20 @@
|
|||||||
],
|
],
|
||||||
"domain": "groups"
|
"domain": "groups"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "getGroupDeletionImpact",
|
||||||
|
"signature": "getGroupDeletionImpact(groupId: string)",
|
||||||
|
"canonicalBodySha256": "e57295fe8e1dd0f22b7845b8327a8d535af4d8a44d81e3bcccfcf95fb5269aad",
|
||||||
|
"originalLines": [
|
||||||
|
162,
|
||||||
|
198
|
||||||
|
],
|
||||||
|
"domain": "groups"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "deleteGroup",
|
"name": "deleteGroup",
|
||||||
"signature": "async deleteGroup(groupId: string)",
|
"signature": "async deleteGroup(groupId: string)",
|
||||||
"canonicalBodySha256": "ca89f1d6861fa4a5808e1d3e6a21062223faeab172246d5615d8447672105800",
|
"canonicalBodySha256": "1c993f68537f1b6dbd25e904bfaa4bd65a71c05301f08d5d2f69a3dbb24803c6",
|
||||||
"originalLines": [
|
"originalLines": [
|
||||||
998,
|
998,
|
||||||
1015
|
1015
|
||||||
@@ -765,7 +775,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "summarizeReportStatuses",
|
"name": "summarizeReportStatuses",
|
||||||
"sha256": "ca9c3f0eece4b9e04f30cbc317041c52d63dcbb74ef1f87021dae5b20ca154b0"
|
"sha256": "98abf67ebafb41096119611949ac8105f08705b43abb940ed1e2dbbfa7e66d35"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "normalizeLinkEvent",
|
"name": "normalizeLinkEvent",
|
||||||
@@ -886,6 +896,7 @@
|
|||||||
"createGroup",
|
"createGroup",
|
||||||
"addGroupItem",
|
"addGroupItem",
|
||||||
"updateGroup",
|
"updateGroup",
|
||||||
|
"getGroupDeletionImpact",
|
||||||
"deleteGroup",
|
"deleteGroup",
|
||||||
"listRouteRules",
|
"listRouteRules",
|
||||||
"createRouteRule"
|
"createRouteRule"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "R10",
|
"version": "R10",
|
||||||
"generatedAt": "2026-08-03",
|
"generatedAt": "2026-08-03",
|
||||||
"source": "api/src/send-chain/send-chain.service.ts at R9 local baseline",
|
"source": "api/src/send-chain/send-chain.service.ts at R9 local baseline; facade extended by 5 downstream requeue task methods on 2026-08-12",
|
||||||
"facade": "api/src/send-chain/send-completion.service.ts",
|
"facade": "api/src/send-chain/send-completion.service.ts",
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{
|
{
|
||||||
"name": "GatewayInboundAuthDto",
|
"name": "GatewayInboundAuthDto",
|
||||||
"kind": "interface",
|
"kind": "interface",
|
||||||
"sha256": "1e55f6a2a393cd72c7aca2b4a1e18e293ae20f513412f627197515448c8da166"
|
"sha256": "bf53b9c9a55d28920d87d4d2a3154e6365d6a1ccbc94d64fc00c1e1059713ff5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GatewayInboundSubmitDto",
|
"name": "GatewayInboundSubmitDto",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "authenticateInboundApplication",
|
"name": "authenticateInboundApplication",
|
||||||
"bodySha256": "2630dd1066b5a3e86c805d13102973e1c928ca2747bd0c3741481d03169eba2f",
|
"bodySha256": "22fc19937d6e104428f6e112c2881a8096978d46a52b5b883c23eeb58fbf0c1a",
|
||||||
"file": "send-inbound-entry.service.ts"
|
"file": "send-inbound-entry.service.ts"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -204,8 +204,8 @@
|
|||||||
{
|
{
|
||||||
"name": "listSignatures",
|
"name": "listSignatures",
|
||||||
"signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)",
|
"signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)",
|
||||||
"bodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a",
|
"bodySha256": "bb60158b4e709ec24ac12b352c4d781bc35e3e87a51ab50cc63720011c4c910b",
|
||||||
"canonicalBodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a",
|
"canonicalBodySha256": "bb60158b4e709ec24ac12b352c4d781bc35e3e87a51ab50cc63720011c4c910b",
|
||||||
"originalLines": [
|
"originalLines": [
|
||||||
915,
|
915,
|
||||||
1025
|
1025
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# 下游投递后台批量重投任务设计与实现符合性审计
|
||||||
|
|
||||||
|
> 版本:V1.1<br>
|
||||||
|
> 需求确认日期:2026-08-12<br>
|
||||||
|
> 文档整理日期:2026-08-13;业务口径更新:2026-08-14<br>
|
||||||
|
> 适用页面:运营端 → 下游投递记录<br>
|
||||||
|
> 审计基线:当前工作区 `HEAD=67fee216162e638ba21004fcf87e7711facefb91`;本功能相关文件相对 HEAD 无未提交修改<br>
|
||||||
|
> 本文目的:还原 2026-08-12 已确认的设计口径,并将当前实现逐条映射到设计,不能以“已有代码”代替“符合设计”的结论。
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
下游投递记录原有两种人工操作:单条重投、勾选当前页后批量重投。它们适合少量记录,但不适合事故期间处理跨页、跨应用的大批量积压。
|
||||||
|
|
||||||
|
新增“按当前筛选条件创建后台重投任务”的目标是:
|
||||||
|
|
||||||
|
1. 后端按当前真实筛选条件确定范围,不受页面分页影响;
|
||||||
|
2. 创建时冻结任务快照,避免执行期间不断卷入新记录;
|
||||||
|
3. 后台限速执行,客户离线或 ACK 异常时不得形成重投洪峰;
|
||||||
|
4. 每条任务项可追踪、可恢复、可审计,成功确认后不得重复投递;
|
||||||
|
5. 运营人员能够预检、创建、暂停、继续、终止和查看完整结果。
|
||||||
|
|
||||||
|
本功能不替代单条重投和当前页勾选重投,三种入口应同时保留。
|
||||||
|
|
||||||
|
## 2. 已确认的第一版业务口径
|
||||||
|
|
||||||
|
以下七条是 2026-08-12 已写入正式需求文档和测试用例的确认口径:
|
||||||
|
|
||||||
|
1. 保留单条重投、当前页勾选批量重投,新增“按筛选条件重投”;投递记录分页支持每页 10/25/50 条。
|
||||||
|
2. 后台任务使用企业、应用、投递类型、状态、创建日期、关键词组成的筛选快照;页码和每页条数不属于任务范围。预检生成 `snapshotAt`,创建任务后产生的新记录不进入该任务。
|
||||||
|
3. 后台任务允许 `pending`、`failed`、`unconfirmed`、`rejected`、`delivered`。客户端已确认的 `delivered` 可按筛选快照再次投递,但必须醒目提示可能造成客户端重复处理;处于 `awaiting_ack` 的记录不得并发重投。创建前展示真实命中数、可重投数、规则跳过数、状态分布,任务原因必填。
|
||||||
|
4. 任务按企业应用分批执行,默认每个应用 10 条/秒。单条失败不阻断整批;连续失败达到 10 条,或 ACK 超时/拒绝达到安全阈值时,自动暂停。客户离线、已有链路等待 ACK 属于“等待”,不能记作“跳过”。
|
||||||
|
5. “跳过”严格表示本任务没有调用 Gateway。跳过原因包括:执行前状态变化、创建任务后才被客户确认、已被其他任务处理、本任务已成功处理、不属于任务快照、应用或投递能力已停用、投递数据不完整、缺少原 Submit 映射。只有任务项冻结的原状态已是 `delivered` 时,才允许按已确认记录重投;其他状态在执行前收到迟到成功 ACK 时必须跳过。
|
||||||
|
6. 任务支持列表、详情、暂停、继续、终止。终止只影响尚未发送的任务项。任务项以 `taskId + deliveryId` 幂等;执行前原子认领并复核当前状态;API 重启后继续执行;已获得成功 ACK 的任务项不得再次发送。
|
||||||
|
7. 创建、暂停、继续、终止、自动暂停都必须写操作日志。任务必须使用真实 PostgreSQL、Gateway 和客户 ACK,不得使用 mock、静态数据或 localStorage。
|
||||||
|
|
||||||
|
## 3. 用户交互设计
|
||||||
|
|
||||||
|
### 3.1 投递记录筛选区
|
||||||
|
|
||||||
|
筛选条件应包含:
|
||||||
|
|
||||||
|
| 条件 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 企业 | 全部企业或指定企业 |
|
||||||
|
| 企业应用 | 受企业条件联动;全部应用或指定应用 |
|
||||||
|
| 投递类型 | 全部、状态回执、上行短信 |
|
||||||
|
| 状态 | 全部或单一状态 |
|
||||||
|
| 创建日期 | 开始日期、结束日期,按北京时间自然日 |
|
||||||
|
| 关键词 | 消息 ID、客户账号、手机号、最后错误、企业名称、应用名称 |
|
||||||
|
|
||||||
|
“按筛选条件重投”使用上表条件,但明确排除页码、每页条数和当前页勾选状态。
|
||||||
|
|
||||||
|
### 3.2 创建任务流程
|
||||||
|
|
||||||
|
1. 用户设置筛选条件,点击“按筛选条件重投”。
|
||||||
|
2. 后端在同一时点生成预检快照,返回:筛选条件、`snapshotAt`、筛选命中数、可重投数、规则跳过数、状态分布、涉及应用数、最早记录时间。
|
||||||
|
3. 弹窗明确告知允许 `pending/failed/unconfirmed/rejected/delivered`、禁止 `awaiting_ack`,并提示已确认记录再次投递可能造成客户端重复处理。
|
||||||
|
4. 用户选择执行速度,填写不少于 5 个字的事故原因、工单号或处理说明。
|
||||||
|
5. 用户确认后,后端必须重新按预检的筛选快照和 `snapshotAt` 物化任务项,而不是使用前端传入的记录 ID 列表。
|
||||||
|
6. 创建成功后关闭弹窗,任务出现在任务列表,状态为“排队中”。
|
||||||
|
|
||||||
|
预检结果必须严格遵守当前筛选条件。例如当前状态选择“待投递”,命中数、可重投数和状态分布都只能基于“待投递”记录,不能擅自扩展为全部状态。
|
||||||
|
|
||||||
|
### 3.3 任务列表
|
||||||
|
|
||||||
|
任务列表至少展示:任务号、创建时间、企业/应用范围、任务原因、中文状态、总数、成功数、失败数、跳过数、等待数、创建人。
|
||||||
|
|
||||||
|
列表应支持分页和状态筛选,保证历史任务可查询,不能只展示固定数量的最近任务。
|
||||||
|
|
||||||
|
可执行操作:
|
||||||
|
|
||||||
|
| 当前状态 | 可执行操作 |
|
||||||
|
| --- | --- |
|
||||||
|
| 排队中、执行中 | 查看详情、暂停、终止 |
|
||||||
|
| 已暂停 | 查看详情、继续、终止 |
|
||||||
|
| 已完成、部分完成、已终止 | 查看详情 |
|
||||||
|
|
||||||
|
### 3.4 任务详情
|
||||||
|
|
||||||
|
任务详情分为三部分:
|
||||||
|
|
||||||
|
1. 任务信息:筛选快照、快照时间、原因、创建人、速度、安全阈值、开始/暂停/完成时间;
|
||||||
|
2. 结果汇总:排队、处理中、等待连接、等待 ACK、成功、失败、跳过、未处理;
|
||||||
|
3. 完整任务项:支持分页及按结果状态、消息 ID、错误/跳过原因查询。
|
||||||
|
|
||||||
|
任务项必须使用中文状态和明确原因,不能只返回内部英文枚举,也不能只展示最近 50 项而使其余项目不可查询。
|
||||||
|
|
||||||
|
## 4. 后端数据设计
|
||||||
|
|
||||||
|
### 4.1 任务主表
|
||||||
|
|
||||||
|
`DownstreamRequeueTask` 保存:
|
||||||
|
|
||||||
|
- 任务号、企业范围、应用范围;
|
||||||
|
- 完整筛选快照及 `snapshotAt`;
|
||||||
|
- 原因、执行速度、失败安全阈值;
|
||||||
|
- 任务状态和各结果计数;
|
||||||
|
- 创建人、开始时间、暂停时间、完成时间、最近错误;
|
||||||
|
- 创建时间、更新时间。
|
||||||
|
|
||||||
|
### 4.2 任务项表
|
||||||
|
|
||||||
|
`DownstreamRequeueTaskItem` 保存:
|
||||||
|
|
||||||
|
- `taskId`、`deliveryId`、`applicationId`;
|
||||||
|
- 创建任务时的原状态;
|
||||||
|
- 当前任务项状态;
|
||||||
|
- 跳过原因、失败信息;
|
||||||
|
- 认领时间、完成时间、创建时间、更新时间。
|
||||||
|
|
||||||
|
数据库必须有 `taskId + deliveryId` 唯一约束。任务项不能级联删除原下游投递记录,历史下游投递和 ACK 证据必须保留。
|
||||||
|
|
||||||
|
## 5. 状态机设计
|
||||||
|
|
||||||
|
### 5.1 任务状态
|
||||||
|
|
||||||
|
| 状态 | 含义 | 进入条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `queued` | 排队中 | 创建成功或人工继续 |
|
||||||
|
| `running` | 执行中 | 扫描器开始处理 |
|
||||||
|
| `paused` | 已暂停 | 人工暂停或触发安全阈值 |
|
||||||
|
| `completed` | 已完成 | 所有任务项完成且没有失败 |
|
||||||
|
| `partial_completed` | 部分完成 | 所有任务项结束但存在失败 |
|
||||||
|
| `terminated` | 已终止 | 人工终止;未认领项不再执行 |
|
||||||
|
|
||||||
|
### 5.2 任务项状态
|
||||||
|
|
||||||
|
| 状态 | 是否调用过 Gateway | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `queued` | 否 | 等待认领 |
|
||||||
|
| `processing` | 尚不确定 | 已原子认领,正在复核并准备调用 |
|
||||||
|
| `waiting_connection` | 否或尚未写出 | 客户离线,等待可用连接,不算失败、不算跳过 |
|
||||||
|
| `waiting_external_ack` | 否 | 其他链路已写出,等待其 ACK 结论 |
|
||||||
|
| `waiting_ack` | 是 | 本任务已写出,等待客户 ACK |
|
||||||
|
| `success` | 是 | 客户返回可关联原消息的成功 ACK |
|
||||||
|
| `failed` | 是或调用失败 | Gateway 调用失败、ACK 超时或 ACK 拒绝 |
|
||||||
|
| `skipped` | 否 | 复核后确认本任务不应调用 Gateway |
|
||||||
|
| `unprocessed` | 否 | 任务终止时尚未开始 |
|
||||||
|
|
||||||
|
“写出成功”不能直接记为 `success`,只有客户有效 ACK 才是成功。
|
||||||
|
|
||||||
|
## 6. 执行与并发控制
|
||||||
|
|
||||||
|
1. 扫描器只处理 `queued/running` 任务。
|
||||||
|
2. 多实例扫描时必须先原子认领任务项;同一任务项只能有一个执行者。
|
||||||
|
3. 执行前重新读取下游投递及应用状态,再判断等待、跳过或调用 Gateway。
|
||||||
|
4. 限速维度是“企业应用”,不是任务总量。一个跨 3 个应用的任务配置 10 条/秒时,每个应用各自最多 10 条/秒,并且各应用互不阻塞。
|
||||||
|
5. 限速必须使用时间窗口或分布式令牌,不能依赖“定时器大约每秒执行一次 + 每轮取 N 条”,否则扫描重叠或执行耗时变化会突破或降低速率。
|
||||||
|
6. 客户离线时任务项进入等待连接;客户恢复后继续,不应消耗连续失败阈值。
|
||||||
|
7. 本任务写出后进入 `waiting_ack`;成功 ACK 转 `success`;ACK 超时、拒绝、无效 Msg_Id 转 `failed` 并计入安全阈值。
|
||||||
|
8. 连续失败只能在真正成功 ACK 后清零,不能在“写出并开始等待 ACK”时提前清零。
|
||||||
|
9. `processing` 必须有租约/超时恢复。API 进程中断后,超过认领租约的项目恢复为 `queued` 并重新复核,才能满足重启续跑。
|
||||||
|
10. 暂停或终止与执行器并发时,执行器在认领下一项和调用 Gateway 前都必须复核任务状态。
|
||||||
|
|
||||||
|
## 7. 跳过、等待和失败判定
|
||||||
|
|
||||||
|
### 7.1 跳过
|
||||||
|
|
||||||
|
跳过意味着本任务没有调用 Gateway:
|
||||||
|
|
||||||
|
| 原因 | 判定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行前状态已变化 | 已不属于允许重投状态,且不是等待 ACK |
|
||||||
|
| 创建任务后才被客户确认 | 任务项冻结原状态不是 `delivered`,执行前收到有效成功 ACK;避免把迟到确认变成未授权重复投递 |
|
||||||
|
| 已被其他任务处理 | 其他任务已认领、等待 ACK 或成功 |
|
||||||
|
| 本任务已成功处理 | 同任务项已有成功结果,重复扫描不得再调用 |
|
||||||
|
| 不属于任务快照 | 创建时间晚于 `snapshotAt` 或不再满足冻结范围 |
|
||||||
|
| 应用或投递能力已停用 | 应用状态或接口能力不允许投递 |
|
||||||
|
| 投递数据不完整 | 缺少可重放 payload 或投递类型非法 |
|
||||||
|
| 缺少原 Submit 映射 | 无法形成可关联原短信的安全回执 |
|
||||||
|
|
||||||
|
### 7.2 等待
|
||||||
|
|
||||||
|
- 客户离线、没有可用连接:`waiting_connection`;
|
||||||
|
- 其他链路已经写出且仍在 ACK 窗口:`waiting_external_ack`;
|
||||||
|
- 本任务已经写出:`waiting_ack`。
|
||||||
|
|
||||||
|
等待项不计入跳过数或失败数。等待结束后根据真实状态继续、成功或失败。
|
||||||
|
|
||||||
|
### 7.3 失败
|
||||||
|
|
||||||
|
- 调用 Gateway 发生非等待型错误;
|
||||||
|
- Gateway 明确拒绝或无法安全投递;
|
||||||
|
- 本任务写出后 ACK 超时;
|
||||||
|
- 客户 ACK 非零;
|
||||||
|
- 客户 ACK 无法关联原消息。
|
||||||
|
|
||||||
|
失败原因必须保留原始错误,同时归一为可统计的失败类别。
|
||||||
|
|
||||||
|
## 8. 安全阈值与自动暂停
|
||||||
|
|
||||||
|
默认安全策略:
|
||||||
|
|
||||||
|
- 每应用默认 10 条/秒;
|
||||||
|
- 连续失败阈值默认 10 条;
|
||||||
|
- ACK 超时和 ACK 拒绝纳入失败阈值;
|
||||||
|
- 达到阈值时,在认领下一条之前将任务原子改为 `paused`;
|
||||||
|
- 写操作日志,记录任务号、失败类别、连续失败数、阈值和暂停时间;
|
||||||
|
- 页面展示明确的自动暂停原因;
|
||||||
|
- 人工继续后从待处理/等待项继续,不重放已成功项。
|
||||||
|
|
||||||
|
如果多个应用同时执行,连续失败计数至少应按应用隔离,避免一个客户应用故障暂停其他正常应用;第一版若选择整任务暂停,也必须在设计和页面中明确,且仍要保存触发暂停的应用。
|
||||||
|
|
||||||
|
## 9. API 设计
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/admin/operations/downstream-requeue-tasks/preview` | 按当前筛选条件生成预检快照 |
|
||||||
|
| POST | `/admin/operations/downstream-requeue-tasks` | 使用预检快照创建任务 |
|
||||||
|
| GET | `/admin/operations/downstream-requeue-tasks` | 分页查询任务列表,支持状态筛选 |
|
||||||
|
| GET | `/admin/operations/downstream-requeue-tasks/:id` | 查询任务汇总 |
|
||||||
|
| GET | `/admin/operations/downstream-requeue-tasks/:id/items` | 分页查询完整任务项及原因 |
|
||||||
|
| POST | `/admin/operations/downstream-requeue-tasks/:id/pause` | 暂停 |
|
||||||
|
| POST | `/admin/operations/downstream-requeue-tasks/:id/resume` | 继续 |
|
||||||
|
| POST | `/admin/operations/downstream-requeue-tasks/:id/terminate` | 终止 |
|
||||||
|
|
||||||
|
创建接口不得仅信任前端回传的筛选条件和时间。建议预检生成短期有效、服务端签名的 `previewToken`,绑定筛选条件、`snapshotAt` 和操作人;创建时校验令牌,防止绕过页面篡改范围。
|
||||||
|
|
||||||
|
## 10. 验收用例
|
||||||
|
|
||||||
|
| 编号 | 验收点 | 预期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| DRQ-001 | 筛选快照 | 企业、应用、类型、状态、日期、关键词均生效;分页无关;快照后新记录不进入 |
|
||||||
|
| DRQ-002 | 预检口径 | 命中、可重投、跳过和状态分布严格基于当前筛选条件 |
|
||||||
|
| DRQ-003 | 状态白名单 | 物化 `pending/failed/unconfirmed/rejected/delivered`;拒绝 `awaiting_ack`;仅冻结原状态为 `delivered` 的任务项可按已确认记录重投 |
|
||||||
|
| DRQ-004 | 每应用限速 | 多应用任务中每个应用独立达到配置速度,任意扫描重叠都不超速 |
|
||||||
|
| DRQ-005 | 客户离线 | 进入等待连接,不记失败或跳过,恢复连接后继续 |
|
||||||
|
| DRQ-006 | ACK 闭环 | 写出只进入等待;有效 ACK 成功;超时、拒绝、无效 Msg_Id 失败 |
|
||||||
|
| DRQ-007 | 自动暂停 | 立即失败与 ACK 失败均计入阈值;达到阈值自动暂停并审计 |
|
||||||
|
| DRQ-008 | 并发幂等 | 多实例同时扫描,同一投递最多调用一次 Gateway |
|
||||||
|
| DRQ-009 | 重启恢复 | 在 `processing`、等待连接、等待 ACK 三种阶段重启 API,任务均可继续且不重复成功项 |
|
||||||
|
| DRQ-010 | 人工控制 | 暂停、继续、终止与执行并发时状态正确;终止不撤回已写出项 |
|
||||||
|
| DRQ-011 | 完整可查 | 任务列表和任务项均可分页查询全部历史数据,不限最近 10/50 条 |
|
||||||
|
| DRQ-012 | 操作审计 | 创建、暂停、继续、终止、自动暂停均有操作人/触发源和完整上下文 |
|
||||||
|
|
||||||
|
## 11. 当前实现符合性审计
|
||||||
|
|
||||||
|
当前实现的主要文件:
|
||||||
|
|
||||||
|
- `api/src/send-chain/send-downstream-requeue-task.service.ts`
|
||||||
|
- `api/src/send-chain/send-downstream-state.service.ts`
|
||||||
|
- `api/src/operations/admin-operations.controller.ts`
|
||||||
|
- `api/prisma/schema.prisma`
|
||||||
|
- `src/apps/admin/AdminDownstreamDeliveriesPage.tsx`
|
||||||
|
- `api/src/send-chain/send-downstream-requeue-task.service.spec.ts`
|
||||||
|
|
||||||
|
### 11.1 已符合或基本符合
|
||||||
|
|
||||||
|
| 设计项 | 结论 | 当前证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 真实持久化 | 符合 | 已有任务表、任务项表和 migration,不使用前端本地状态代替任务 |
|
||||||
|
| 快照时间上限 | 基本符合 | 创建时按 `snapshotAt` 限制 `createdAt`,快照后记录不物化 |
|
||||||
|
| 后台状态白名单 | 符合 | `REPLAYABLE_STATUSES` 为 `pending/failed/unconfirmed/rejected/delivered` |
|
||||||
|
| 禁止后台任务处理等待 ACK 筛选 | 符合 | 创建接口明确拒绝 `awaiting_ack`;`delivered` 按已确认重复投递风险口径放行 |
|
||||||
|
| 原因校验 | 符合 | 少于 5 个字拒绝 |
|
||||||
|
| 任务项幂等 | 符合 | 数据库唯一约束 `taskId + deliveryId` |
|
||||||
|
| 执行前任务项认领 | 基本符合 | 通过 `status=queued` 的条件更新认领为 `processing` |
|
||||||
|
| 执行前复核 | 基本符合 | 重新查询投递、应用、payload 和其他任务状态 |
|
||||||
|
| 成功 ACK 不再误发送 | 基本符合 | 非 `delivered` 快照项执行前才收到成功 ACK 时跳过;其他任务 `success` 也会阻止调用;冻结原状态为 `delivered` 的项目属于运营明确授权的再次投递 |
|
||||||
|
| 人工控制 | 基本符合 | 已有暂停、继续、终止接口和页面按钮 |
|
||||||
|
| 核心操作日志 | 基本符合 | 创建、暂停、继续、终止、自动暂停均写日志 |
|
||||||
|
| 投递记录分页 | 符合 | 页面支持每页 10/25/50 条并回到第一页 |
|
||||||
|
|
||||||
|
### 11.2 明确偏差与缺口
|
||||||
|
|
||||||
|
| 优先级 | 偏差 | 当前实现 | 与设计冲突及风险 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P0 | API 重启后 `processing` 项无法恢复 | 只扫描 `queued`,没有 `processing` 认领租约或超时回收 | 进程在认领后中断会使任务项永久卡住,任务永久 `running`,不满足重启续跑 |
|
||||||
|
| P0 | ACK 失败不参与自动暂停阈值 | `reconcileWaiting`把 ACK 超时/拒绝改为 `failed`,但不增加`consecutiveFailures`;进入 `waiting_ack` 时反而立即清零 | 客户持续拒绝或 ACK 超时不会触发安全暂停,可能持续向故障客户重投 |
|
||||||
|
| P0 | 限速不是按应用,且可能被并发扫描突破 | 每个任务每轮最多取 `min(10, ratePerSecond)`;没有按`applicationId`分组,也没有分布式速率令牌;定时器可能重叠执行 | 不符合“每应用10条/秒”;配置20实际单轮最多10,多扫描器/多实例又可能超过配置 |
|
||||||
|
| P0 | 客户离线被记为任务项失败 | Gateway未写出时,底层通常返回`pending/failed`,任务层将非`awaiting_ack/delivered`结果直接记为`failed` | 不符合“客户离线属于等待”;会错误增加失败数并可能自动暂停 |
|
||||||
|
| P1 | 预检不严格遵守当前状态筛选 | 计算`matchedCount/statusCounts`时强制把状态改成`all` | 用户选择“待投递”时,命中数和状态分布仍可能包含其他状态;预检范围表达失真 |
|
||||||
|
| P1 | 页面缺少企业筛选 | 后端类型支持`tenantId`,但页面只有应用筛选,`currentTaskFilter`不传企业 | 未完整实现已确认的“企业 + 应用”筛选范围 |
|
||||||
|
| P1 | 任务列表不可完整查询 | 页面固定请求第1页、每页10条,没有任务分页和状态筛选 | 第11个以后历史任务在页面不可达,不满足任务列表要求 |
|
||||||
|
| P1 | 任务详情不可完整查询 | 详情接口固定返回最近50个任务项,没有任务项分页接口 | 大任务无法核对全部失败、跳过和等待项,难以验收和审计 |
|
||||||
|
| P1 | 扫描器缺少任务级/应用级分布式锁 | `setInterval`直接调用扫描,`runScan`可被重叠触发,多实例也会同时处理同一任务 | 虽有任务项条件认领可降低单项重复,但无法保证整体限速和连续失败统计一致 |
|
||||||
|
| P1 | 连续失败清零时点错误 | Gateway写出进入`waiting_ack`即把连续失败清零 | 写出不是业务成功;应等有效 ACK 后再清零 |
|
||||||
|
| P1 | 预检与创建没有不可篡改绑定 | 创建接口信任前端回传的`filter + snapshotAt`,没有预检令牌或服务端预检记录 | 可绕过页面修改筛选范围;虽仍受状态白名单和10万条上限保护,但不等于复用原预检结果 |
|
||||||
|
| P1 | 自动化覆盖远低于设计风险 | 专项仅4个测试:预检计数、参数拒绝、活动任务冲突、已确认跳过 | 未覆盖限速、多应用、离线等待、ACK阈值、重启恢复、并发扫描、完整分页及所有审计动作 |
|
||||||
|
| P2 | 状态和详情表达偏内部化 | 任务列表/详情直接展示英文状态;任务详情只展示汇总和最近项 | 运营人员不易区分等待连接、等待外部ACK、任务写出等待ACK等状态 |
|
||||||
|
| P2 | 终止后的未处理数未进入常规汇总 | 终止把`queued`改为`unprocessed`,但任务汇总只保存成功/失败/跳过/等待 | 任务进度分子可能小于总数,页面没有单独解释未处理数量 |
|
||||||
|
| P2 | 外部 ACK 跳过原因存在口径混用 | `waiting_external_ack`最终由其他链路成功后记为“跳过:已由其他投递链路完成” | 可以接受为“本任务未调用Gateway”,但详情必须明确这是外部链路成功,不应让用户误以为业务未处理;这与冻结原状态为 `delivered` 的主动再次投递是两种情形 |
|
||||||
|
|
||||||
|
### 11.3 综合结论
|
||||||
|
|
||||||
|
当前实现完成了数据库模型、基本预检、任务创建、任务项认领、Gateway调用、ACK结果回看和人工控制的骨架,但不能判定为严格按照 2026-08-12 方案完成。
|
||||||
|
|
||||||
|
尤其以下四项属于设计中的安全核心,当前存在实质缺口:
|
||||||
|
|
||||||
|
1. `processing` 任务项缺少重启恢复;
|
||||||
|
2. ACK 超时/拒绝未进入自动暂停阈值;
|
||||||
|
3. 每应用限速没有实现,且扫描重叠可能突破限速;
|
||||||
|
4. 客户离线没有作为等待状态处理。
|
||||||
|
|
||||||
|
在上述 P0 修复并通过真实 PostgreSQL、Gateway、客户 ACK 链路测试前,不应把该后台任务认定为完整满足事故批量恢复方案。
|
||||||
|
|
||||||
|
## 12. 建议整改顺序
|
||||||
|
|
||||||
|
1. 先补 `processing` 租约恢复、扫描分布式锁和每应用速率令牌;
|
||||||
|
2. 重构任务项等待/失败状态,让离线、外部 ACK、本任务 ACK 三类等待分开;
|
||||||
|
3. 将 ACK 超时、拒绝、无效 ACK 纳入按应用安全阈值,并把清零时点改为有效 ACK;
|
||||||
|
4. 修正预检状态口径,增加企业筛选,并用服务端令牌绑定预检与创建;
|
||||||
|
5. 增加任务列表和任务项分页、中文状态及完整原因查询;
|
||||||
|
6. 补齐 DRQ-001~DRQ-012 自动化,并使用真实 PostgreSQL、Redis、Gateway 与本地客户连接完成专项验收。
|
||||||
|
|
||||||
|
整改应作为独立需求进行,不在未经授权的情况下直接修改或发布现有批量重投逻辑。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user