Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a17df78b8 | ||
|
|
2a181777ad | ||
|
|
b1e297245c | ||
|
|
b5005f21d5 | ||
|
|
394949f9f6 | ||
|
|
76e7c8c401 | ||
|
|
d3ceeb1e16 | ||
|
|
9292352be1 | ||
|
|
761c123b65 | ||
|
|
c6f11014d6 | ||
|
|
03f72c87de | ||
|
|
2b5256d4a1 | ||
|
|
90345fba22 | ||
|
|
3c6f1beed1 | ||
|
|
fcf6d3e6b4 | ||
|
|
487b5282a6 | ||
|
|
0176aa6952 | ||
|
|
52028b9bbd | ||
|
|
57192b7586 | ||
|
|
f4560479d3 | ||
|
|
99fb346566 | ||
|
|
633e7a7055 | ||
|
|
229a0b28fd | ||
|
|
6708f1f7c5 | ||
|
|
53073461e9 | ||
|
|
0b63bcd74e | ||
|
|
26ef67fb6a | ||
|
|
14f993c1f8 | ||
|
|
67b760a599 | ||
|
|
485af688d2 | ||
|
|
e9c73333b3 | ||
|
|
0757a699ff | ||
|
|
b9a71fe0b9 | ||
|
|
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 |
@@ -12,6 +12,16 @@ HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
|
||||
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
|
||||
API_ENABLE_SEND_WORKER=true
|
||||
API_SEND_WORKER_CONCURRENCY=50
|
||||
API_WORKER_DATABASE_URL=
|
||||
API_WORKER_METRICS_HOST=127.0.0.1
|
||||
API_WORKER_METRICS_PORT=9465
|
||||
CMPP_INBOUND_FAST_PATH_ENABLED=true
|
||||
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED=true
|
||||
API_INBOUND_WORKFLOW_CONCURRENCY=32
|
||||
API_INBOUND_WORKFLOW_BATCH_ENABLED=true
|
||||
API_INBOUND_WORKFLOW_BATCH_SIZE=64
|
||||
API_INBOUND_WORKFLOW_POLL_INTERVAL_MS=100
|
||||
API_INBOUND_WORKFLOW_STALE_SECONDS=300
|
||||
ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000
|
||||
CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
|
||||
SESSION_LOCK_RECOVERY_MS=14400000
|
||||
@@ -25,6 +35,9 @@ OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000
|
||||
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED=true
|
||||
SMS_RECEIPT_TIMEOUT_HOURS=72
|
||||
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.
|
||||
SESSION_COOKIE_SECURE=false
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
@@ -39,4 +52,10 @@ GATEWAY_CMPP_VERSION=3.0
|
||||
GATEWAY_CMPP_ADDR=127.0.0.1:7890
|
||||
GATEWAY_CMPP_USER=900001
|
||||
GATEWAY_CMPP_PASSWORD=888888
|
||||
GATEWAY_SUBMIT_WORKER_CONCURRENCY=64
|
||||
GATEWAY_SUBMIT_RESULT_STREAM=gateway.submit.results
|
||||
GATEWAY_SUBMIT_RESULT_GROUP=cmpp-api-callback
|
||||
GATEWAY_SUBMIT_RESULT_CONSUMER=gateway-1
|
||||
GATEWAY_SUBMIT_RESULT_WORKER_CONCURRENCY=8
|
||||
GATEWAY_CMPP_INBOUND_MAX_CONCURRENCY=64
|
||||
CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "SmsSubmitRecord"
|
||||
ADD COLUMN "resultEventId" TEXT,
|
||||
ADD COLUMN "resultProcessedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE UNIQUE INDEX "SmsSubmitRecord_resultEventId_key"
|
||||
ON "SmsSubmitRecord"("resultEventId");
|
||||
@@ -0,0 +1,91 @@
|
||||
CREATE TABLE "CmppInboundSubmissionInbox" (
|
||||
"id" TEXT NOT NULL,
|
||||
"requestKey" TEXT NOT NULL,
|
||||
"payloadHash" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"queuePriority" TEXT NOT NULL DEFAULT 'normal',
|
||||
"payload" JSONB NOT NULL,
|
||||
"response" JSONB NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lockedAt" TIMESTAMP(3),
|
||||
"lockedBy" TEXT,
|
||||
"lastError" TEXT,
|
||||
"result" JSONB,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CmppInboundSubmissionInbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "CmppInboundSubmissionInbox_requestKey_key"
|
||||
ON "CmppInboundSubmissionInbox"("requestKey");
|
||||
|
||||
CREATE INDEX "CmppInboundSubmissionInbox_status_nextAttemptAt_createdAt_idx"
|
||||
ON "CmppInboundSubmissionInbox"("status", "nextAttemptAt", "createdAt");
|
||||
|
||||
CREATE INDEX "CmppInboundSubmissionInbox_status_lockedAt_idx"
|
||||
ON "CmppInboundSubmissionInbox"("status", "lockedAt");
|
||||
|
||||
CREATE INDEX "CmppInboundSubmissionInbox_applicationId_createdAt_idx"
|
||||
ON "CmppInboundSubmissionInbox"("applicationId", "createdAt");
|
||||
|
||||
CREATE INDEX "CmppInboundSubmissionInbox_queuePriority_status_createdAt_idx"
|
||||
ON "CmppInboundSubmissionInbox"("queuePriority", "status", "createdAt");
|
||||
|
||||
ALTER TABLE "CmppInboundSubmissionInbox"
|
||||
ADD CONSTRAINT "CmppInboundSubmissionInbox_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CmppInboundSubmissionInbox"
|
||||
ADD CONSTRAINT "CmppInboundSubmissionInbox_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "SmsApplicationDailyReservation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"reservationKey" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"usageDate" DATE NOT NULL,
|
||||
"requestedCount" INTEGER NOT NULL,
|
||||
"dailyLimit" INTEGER NOT NULL,
|
||||
"usedCount" INTEGER,
|
||||
"reserved" BOOLEAN NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SmsApplicationDailyReservation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SmsApplicationDailyReservation_reservationKey_key"
|
||||
ON "SmsApplicationDailyReservation"("reservationKey");
|
||||
CREATE INDEX "SmsApplicationDailyReservation_applicationId_usageDate_idx"
|
||||
ON "SmsApplicationDailyReservation"("applicationId", "usageDate");
|
||||
CREATE INDEX "SmsApplicationDailyReservation_tenantId_createdAt_idx"
|
||||
ON "SmsApplicationDailyReservation"("tenantId", "createdAt");
|
||||
ALTER TABLE "SmsApplicationDailyReservation" ADD CONSTRAINT "SmsApplicationDailyReservation_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsApplicationDailyReservation" ADD CONSTRAINT "SmsApplicationDailyReservation_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "PhoneFrequencyReservation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"reservationKey" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"result" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PhoneFrequencyReservation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PhoneFrequencyReservation_reservationKey_key"
|
||||
ON "PhoneFrequencyReservation"("reservationKey");
|
||||
CREATE INDEX "PhoneFrequencyReservation_applicationId_createdAt_idx"
|
||||
ON "PhoneFrequencyReservation"("applicationId", "createdAt");
|
||||
CREATE INDEX "PhoneFrequencyReservation_tenantId_createdAt_idx"
|
||||
ON "PhoneFrequencyReservation"("tenantId", "createdAt");
|
||||
ALTER TABLE "PhoneFrequencyReservation" ADD CONSTRAINT "PhoneFrequencyReservation_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "PhoneFrequencyReservation" ADD CONSTRAINT "PhoneFrequencyReservation_applicationId_fkey"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "GatewaySubmitOutbox" (
|
||||
"id" TEXT NOT NULL,
|
||||
"submitId" TEXT NOT NULL,
|
||||
"messageRecordId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"schemaVersion" TEXT NOT NULL DEFAULT 'v1',
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"leaseOwner" TEXT,
|
||||
"leaseExpiresAt" TIMESTAMP(3),
|
||||
"streamEntryId" TEXT,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "GatewaySubmitOutbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "GatewaySubmitOutbox_submitId_key"
|
||||
ON "GatewaySubmitOutbox"("submitId");
|
||||
|
||||
CREATE INDEX "GatewaySubmitOutbox_pending_claim_idx"
|
||||
ON "GatewaySubmitOutbox"("nextAttemptAt", "createdAt")
|
||||
WHERE "status" IN ('pending', 'publishing');
|
||||
|
||||
CREATE INDEX "GatewaySubmitOutbox_messageRecordId_idx"
|
||||
ON "GatewaySubmitOutbox"("messageRecordId");
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "SmsUplinkMessage" ADD COLUMN "eventId" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "SmsUplinkMessage_eventId_key" ON "SmsUplinkMessage"("eventId");
|
||||
@@ -43,6 +43,7 @@ model Tenant {
|
||||
smsUplinkMessages SmsUplinkMessage[]
|
||||
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||
cmppDownstreamDeliveries CmppDownstreamDelivery[]
|
||||
downstreamRequeueTasks DownstreamRequeueTask[]
|
||||
cmppDownstreamConnections CmppDownstreamConnection[]
|
||||
cmppConnectionStates CmppConnectionState[]
|
||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||
@@ -51,6 +52,9 @@ model Tenant {
|
||||
openApiRequests OpenApiRequest[]
|
||||
httpWebhookEvents HttpWebhookEvent[]
|
||||
cmppInboundLongMessages CmppInboundLongMessage[]
|
||||
cmppInboundSubmissionInboxes CmppInboundSubmissionInbox[]
|
||||
smsApplicationDailyReservations SmsApplicationDailyReservation[]
|
||||
phoneFrequencyReservations PhoneFrequencyReservation[]
|
||||
}
|
||||
|
||||
model EnterpriseCertification {
|
||||
@@ -98,9 +102,11 @@ model User {
|
||||
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
|
||||
createdDownstreamRequeueTasks DownstreamRequeueTask[] @relation("DownstreamRequeueTaskCreator")
|
||||
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
|
||||
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
|
||||
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
|
||||
infrastructureAlertReads InfrastructureAlertRead[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -203,6 +209,7 @@ model ProtocolInteractionLog {
|
||||
traceId String?
|
||||
requestId String?
|
||||
phoneMasked String?
|
||||
phoneNumber String?
|
||||
resultCode String?
|
||||
durationMs Int?
|
||||
payloadBytes Int?
|
||||
@@ -450,6 +457,7 @@ model SmsApplication {
|
||||
uplinkMessages SmsUplinkMessage[]
|
||||
uplinkMatchCandidates SmsUplinkMatchCandidate[]
|
||||
downstreamDeliveries CmppDownstreamDelivery[]
|
||||
downstreamRequeueTasks DownstreamRequeueTask[]
|
||||
downstreamConnections CmppDownstreamConnection[]
|
||||
connectionStates CmppConnectionState[]
|
||||
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
|
||||
@@ -462,10 +470,13 @@ model SmsApplication {
|
||||
httpWebhookEndpoints HttpWebhookEndpoint[]
|
||||
httpWebhookEvents HttpWebhookEvent[]
|
||||
dailyUsages SmsApplicationDailyUsage[]
|
||||
dailyReservations SmsApplicationDailyReservation[]
|
||||
inboundLongMessages CmppInboundLongMessage[]
|
||||
inboundSubmissionInboxes CmppInboundSubmissionInbox[]
|
||||
riskRules RiskRule[]
|
||||
phoneFrequencyStates PhoneFrequencyState[]
|
||||
phoneFrequencyHits PhoneFrequencyHit[]
|
||||
phoneFrequencyReservations PhoneFrequencyReservation[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([status, createdAt])
|
||||
@@ -528,6 +539,25 @@ model SmsApplicationDailyUsage {
|
||||
@@index([usageDate])
|
||||
}
|
||||
|
||||
model SmsApplicationDailyReservation {
|
||||
id String @id @default(cuid())
|
||||
reservationKey String @unique
|
||||
tenantId String
|
||||
applicationId String
|
||||
usageDate DateTime @db.Date
|
||||
requestedCount Int
|
||||
dailyLimit Int
|
||||
usedCount Int?
|
||||
reserved Boolean
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([applicationId, usageDate])
|
||||
@@index([tenantId, createdAt])
|
||||
}
|
||||
|
||||
model SmsApplicationHttpIpAllowlist {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
@@ -805,6 +835,7 @@ model SmsChannel {
|
||||
code String @unique
|
||||
name String
|
||||
carrier String?
|
||||
carriers String[] @default([])
|
||||
sendRegion String @default("全国")
|
||||
protocol String @default("CMPP")
|
||||
gatewayHost String
|
||||
@@ -1058,6 +1089,9 @@ model ChannelSignatureReportTask {
|
||||
tenantId String
|
||||
signatureId String
|
||||
channelId String
|
||||
carrier String?
|
||||
approvedAt DateTime?
|
||||
approvalScope String @default("legacy_channel")
|
||||
reportType String @default("signature")
|
||||
drainageItemId String?
|
||||
status String @default("pending")
|
||||
@@ -1077,10 +1111,152 @@ model ChannelSignatureReportTask {
|
||||
@@index([tenantId, status])
|
||||
@@index([status, createdAt])
|
||||
@@index([signatureId, channelId])
|
||||
@@index([signatureId, channelId, carrier])
|
||||
@@index([signatureId, drainageItemId, channelId])
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
@@ -1427,6 +1603,21 @@ model PhoneFrequencyHit {
|
||||
@@index([releasedById])
|
||||
}
|
||||
|
||||
model PhoneFrequencyReservation {
|
||||
id String @id @default(cuid())
|
||||
reservationKey String @unique
|
||||
tenantId String
|
||||
applicationId String
|
||||
result Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([applicationId, createdAt])
|
||||
@@index([tenantId, createdAt])
|
||||
}
|
||||
|
||||
model PhoneFrequencyWhitelist {
|
||||
id String @id @default(cuid())
|
||||
phoneNumber String @unique
|
||||
@@ -1609,6 +1800,8 @@ model SmsSubmitRecord {
|
||||
sequenceId Int?
|
||||
gatewayMessageId String?
|
||||
submitStatus String @default("queued")
|
||||
resultEventId String? @unique
|
||||
resultProcessedAt DateTime?
|
||||
costUnitPrice BigInt @default(0)
|
||||
costAmountCents BigInt @default(0)
|
||||
errorCode String?
|
||||
@@ -1635,6 +1828,28 @@ model SmsSubmitRecord {
|
||||
@@index([channelGroupId])
|
||||
}
|
||||
|
||||
model GatewaySubmitOutbox {
|
||||
id String @id @default(cuid())
|
||||
submitId String @unique
|
||||
messageRecordId String
|
||||
channelId String
|
||||
payload Json
|
||||
schemaVersion String @default("v1")
|
||||
status String @default("pending")
|
||||
attemptCount Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
leaseOwner String?
|
||||
leaseExpiresAt DateTime?
|
||||
streamEntryId String?
|
||||
publishedAt DateTime?
|
||||
lastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([submitId, status])
|
||||
@@index([messageRecordId])
|
||||
}
|
||||
|
||||
model DailyReconciliationReport {
|
||||
id String @id @default(cuid())
|
||||
reportDate DateTime @db.Date
|
||||
@@ -1799,6 +2014,35 @@ model CmppInboundLongMessageSegment {
|
||||
@@index([sequenceId])
|
||||
}
|
||||
|
||||
model CmppInboundSubmissionInbox {
|
||||
id String @id @default(cuid())
|
||||
requestKey String @unique
|
||||
payloadHash String
|
||||
tenantId String
|
||||
applicationId String
|
||||
queuePriority String @default("normal")
|
||||
payload Json
|
||||
response Json
|
||||
status String @default("pending")
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lockedAt DateTime?
|
||||
lockedBy String?
|
||||
lastError String?
|
||||
result Json?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
|
||||
@@index([status, nextAttemptAt, createdAt])
|
||||
@@index([status, lockedAt])
|
||||
@@index([applicationId, createdAt])
|
||||
@@index([queuePriority, status, createdAt])
|
||||
}
|
||||
|
||||
model SmsReceiptRecord {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
@@ -1873,6 +2117,7 @@ model SmsReceiptAnomaly {
|
||||
|
||||
model SmsUplinkMessage {
|
||||
id String @id @default(cuid())
|
||||
eventId String? @unique
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
channelId String
|
||||
@@ -1960,6 +2205,7 @@ model CmppDownstreamDelivery {
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
|
||||
attempts CmppDownstreamDeliveryAttempt[]
|
||||
requeueItems DownstreamRequeueTaskItem[]
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([applicationId, status, createdAt])
|
||||
@@ -1969,6 +2215,79 @@ model CmppDownstreamDelivery {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
deliveryId String
|
||||
@@ -2097,3 +2416,143 @@ model GatewayDownstreamRecoveryStatus {
|
||||
@@index([state, updatedAt])
|
||||
@@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 { FilesModule } from './files/files.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { InfrastructureMonitoringModule } from './infrastructure-monitoring/infrastructure-monitoring.module';
|
||||
import { OperationsModule } from './operations/operations.module';
|
||||
import { OpenApiModule } from './open-api/open-api.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 { TenantsModule } from './tenants/tenants.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({
|
||||
imports: [
|
||||
@@ -48,7 +52,11 @@ import { UsersModule } from './users/users.module';
|
||||
ReportMaterialsModule,
|
||||
SendChainModule,
|
||||
OperationsModule,
|
||||
InfrastructureMonitoringModule,
|
||||
OpenApiModule,
|
||||
SignatureRetirementModule,
|
||||
SecurityDetectionModule,
|
||||
MetricsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { SessionRequest } from './session-validation.middleware';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { requestContext } from '../common/request-context';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
|
||||
type CookieResponse = {
|
||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||
@@ -16,7 +18,7 @@ type CookieResponse = {
|
||||
@ApiTags('auth')
|
||||
@Controller()
|
||||
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')
|
||||
adminCaptcha() {
|
||||
@@ -25,7 +27,14 @@ export class AuthController {
|
||||
|
||||
@Post('admin/auth/login')
|
||||
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')
|
||||
@@ -35,7 +44,14 @@ export class AuthController {
|
||||
|
||||
@Post('client/auth/login')
|
||||
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'])
|
||||
@@ -121,6 +137,17 @@ export class AuthController {
|
||||
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>) {
|
||||
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 },
|
||||
|
||||
@@ -5,9 +5,10 @@ import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||
import { SessionService } from './session.service';
|
||||
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
imports: [UsersModule, SecurityDetectionModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -7,13 +7,18 @@ function createPrismaMock() {
|
||||
tenant: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
findUniqueOrThrow: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
create: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
updateMany: jest.fn().mockImplementation(({ data }) => {
|
||||
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
||||
else if (data.balanceCents?.decrement !== undefined) accountState.balanceCents -= data.balanceCents.decrement;
|
||||
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
@@ -25,10 +30,12 @@ function createPrismaMock() {
|
||||
}),
|
||||
},
|
||||
accountTransaction: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
findUniqueOrThrow: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: 'tx-charged', idempotencyKey: where.idempotencyKey })),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
rechargeOrder: {
|
||||
findMany: jest.fn(),
|
||||
@@ -48,6 +55,7 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
||||
},
|
||||
$executeRaw: jest.fn(),
|
||||
$queryRaw: jest.fn(),
|
||||
};
|
||||
return Object.assign(prisma, {
|
||||
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
||||
@@ -91,7 +99,7 @@ describe('BillingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
||||
it('allows sending only when cash balance plus credit covers the required amount', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
@@ -99,7 +107,7 @@ describe('BillingService', () => {
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
);
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: false }),
|
||||
);
|
||||
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
||||
@@ -107,7 +115,7 @@ describe('BillingService', () => {
|
||||
);
|
||||
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }),
|
||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: false }),
|
||||
);
|
||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
@@ -181,9 +189,10 @@ describe('BillingService', () => {
|
||||
it('returns the historical balance after each manual recharge', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
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 },
|
||||
]);
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]);
|
||||
prisma.accountTransaction.findMany.mockResolvedValue([
|
||||
{ relatedId: 'order-1', balanceAfter: 3000 },
|
||||
{ relatedId: 'order-2', balanceAfter: 2700 },
|
||||
@@ -191,8 +200,8 @@ describe('BillingService', () => {
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
|
||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
|
||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }),
|
||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }),
|
||||
]);
|
||||
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -201,6 +210,10 @@ describe('BillingService', () => {
|
||||
},
|
||||
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 () => {
|
||||
@@ -304,6 +317,28 @@ describe('BillingService', () => {
|
||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
||||
});
|
||||
|
||||
it('settles a frozen SMS charge with one account lock and an idempotent ledger pair', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const rows = new Map<string, Record<string, unknown>>();
|
||||
prisma.accountTransaction.findMany.mockImplementation(() => Promise.resolve([...rows.values()]));
|
||||
prisma.$queryRaw.mockImplementation(() => {
|
||||
const charged = { id: 'tx-charged', idempotencyKey: 'sms-charge:msg-paid-1', transactionType: 'charged', amountCents: -325 };
|
||||
rows.set(String(charged.idempotencyKey), charged);
|
||||
return Promise.resolve([charged]);
|
||||
});
|
||||
const service = new BillingService(prisma as never);
|
||||
const input = { tenantId: 'tenant-1', amountCents: 325, messageId: 'msg-paid-1', taskId: 'task-paid-1' };
|
||||
|
||||
const first = await service.settleFrozenCharge(input);
|
||||
const replay = await service.settleFrozenCharge(input);
|
||||
|
||||
expect(first).toEqual(expect.objectContaining({ transactionType: 'charged', amountCents: -325 }));
|
||||
expect(replay).toEqual(first);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
expect(prisma.tenantAccount.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
let transactionChain = Promise.resolve<unknown>(undefined);
|
||||
|
||||
@@ -163,18 +163,27 @@ export class BillingService {
|
||||
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)))];
|
||||
const [transactions, operators] = await Promise.all([
|
||||
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 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) => ({
|
||||
...order,
|
||||
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 }),
|
||||
]);
|
||||
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)))];
|
||||
const [transactions, operators] = await Promise.all([
|
||||
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 operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||
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,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -395,7 +416,7 @@ export class BillingService {
|
||||
availableAmount,
|
||||
balanceCents,
|
||||
creditCents,
|
||||
canSend: availableAmount > 0,
|
||||
canSend: availableAmount >= requiredAmount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,6 +436,56 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
|
||||
async settleFrozenCharge(data: { tenantId: string; amountCents: number; messageId: string; taskId: string; remark?: string }) {
|
||||
const amountCents = data.amountCents ?? 0;
|
||||
if (amountCents <= 0) return null;
|
||||
const releaseKey = `sms-charge-release:${data.messageId}`;
|
||||
const chargeKey = `sms-charge:${data.messageId}`;
|
||||
const existing = await this.prisma.accountTransaction.findMany({
|
||||
where: { idempotencyKey: { in: [releaseKey, chargeKey] } },
|
||||
});
|
||||
const charge = existing.find((row) => row.idempotencyKey === chargeKey);
|
||||
if (charge) return charge;
|
||||
const release = existing.find((row) => row.idempotencyKey === releaseKey);
|
||||
if (release) {
|
||||
// Recover the legacy two-transaction boundary: a crash may have committed
|
||||
// release before charge, so this path must perform the missing balance debit.
|
||||
return this.applyAccountDelta({
|
||||
tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey,
|
||||
amountCents: -amountCents, relatedType: 'sms_message_record', relatedId: data.messageId,
|
||||
remark: '提交成功扣费(恢复既有已释放冻结)',
|
||||
});
|
||||
}
|
||||
const rows = await this.prisma.$queryRaw<Array<{ id: string; idempotencyKey: string }>>(Prisma.sql`
|
||||
WITH account AS (
|
||||
SELECT "balanceCents" FROM "TenantAccount" WHERE "tenantId" = ${data.tenantId}
|
||||
), inserted AS (
|
||||
INSERT INTO "AccountTransaction" (
|
||||
id, "tenantId", "transactionType", "idempotencyKey", "amountCents",
|
||||
"balanceAfter", "relatedType", "relatedId", remark, "createdAt"
|
||||
)
|
||||
SELECT gen_random_uuid()::text, ${data.tenantId}, ledger."transactionType", ledger."idempotencyKey",
|
||||
ledger."amountCents", account."balanceCents" + ledger."balanceDelta",
|
||||
ledger."relatedType", ledger."relatedId", ledger.remark, (NOW() AT TIME ZONE 'UTC')
|
||||
FROM account
|
||||
CROSS JOIN (VALUES
|
||||
('released', ${releaseKey}, ${amountCents}::bigint, ${amountCents}::bigint, 'sms_batch_task', ${data.taskId}, ${data.remark ?? null}),
|
||||
('charged', ${chargeKey}, ${-amountCents}::bigint, 0::bigint, 'sms_message_record', ${data.messageId}, '提交成功扣费')
|
||||
) AS ledger("transactionType", "idempotencyKey", "amountCents", "balanceDelta", "relatedType", "relatedId", remark)
|
||||
ON CONFLICT ("idempotencyKey") DO NOTHING
|
||||
RETURNING id, "idempotencyKey"
|
||||
)
|
||||
SELECT id, "idempotencyKey" FROM inserted WHERE "idempotencyKey" = ${chargeKey}
|
||||
UNION ALL
|
||||
SELECT id, "idempotencyKey" FROM "AccountTransaction" WHERE "idempotencyKey" = ${chargeKey}
|
||||
LIMIT 1
|
||||
`);
|
||||
if (rows[0]) return rows[0];
|
||||
// A concurrent identical callback can win ON CONFLICT while remaining
|
||||
// invisible to this statement's snapshot; one read repairs that MVCC edge.
|
||||
return this.prisma.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } });
|
||||
}
|
||||
|
||||
release(data: BillingActionDto) {
|
||||
return this.applyAccountDelta({
|
||||
...data,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { 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';
|
||||
|
||||
/** 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 where: Prisma.SmsChannelWhereInput = {
|
||||
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,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsChannel.findMany({
|
||||
where,
|
||||
include: { connectionStates: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsChannel.count({ where }),
|
||||
]);
|
||||
const candidates = await this.prisma.smsChannel.findMany({ where, select: { id: true, name: true } });
|
||||
const total = candidates.length;
|
||||
if (total === 0) return { items: [], total, page, pageSize };
|
||||
const day = currentShanghaiDayRange();
|
||||
const counts = await this.prisma.$queryRaw<Array<{ channelId: string; total: number }>>(Prisma.sql`
|
||||
SELECT submit."channelId" AS "channelId", COUNT(*)::integer AS total
|
||||
FROM "SmsSubmitRecord" submit
|
||||
WHERE submit."channelId" IN (${Prisma.join(candidates.map((channel) => channel.id))})
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -64,11 +81,13 @@ export class ChannelConfigurationService {
|
||||
data.heartbeatMissThreshold,
|
||||
);
|
||||
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const carriers = normalizeChannelCarriers(data.carriers, data.carrier);
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
carrier: legacyCarrierFromCapabilities(carriers),
|
||||
carriers,
|
||||
sendRegion: data.sendRegion ?? '全国',
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
@@ -120,6 +139,22 @@ export class ChannelConfigurationService {
|
||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||
? undefined
|
||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
|
||||
const carriers = data.carriers !== undefined || data.carrier !== undefined
|
||||
? normalizeChannelCarriers(data.carriers, data.carrier)
|
||||
: existingCarriers;
|
||||
if (data.carriers !== undefined || data.carrier !== undefined) {
|
||||
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
|
||||
if (removed.length) {
|
||||
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
|
||||
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
|
||||
include: { group: true },
|
||||
});
|
||||
if (blockingGroups.length) {
|
||||
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||
@@ -133,7 +168,8 @@ export class ChannelConfigurationService {
|
||||
data: {
|
||||
code: data.code,
|
||||
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,
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
@@ -159,6 +195,7 @@ export class ChannelConfigurationService {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
carriers: channel.carriers,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
|
||||
@@ -367,6 +367,10 @@ export class ChannelConnectionService {
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
connectionWarmupSeconds: Number(getConfigValue(channel.config, 'connectionWarmupSeconds') ?? 30),
|
||||
connectionDrainTimeoutSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'connectionDrainTimeoutSeconds'), 60, 'connectionDrainTimeoutSeconds'),
|
||||
submitResponseTimeoutSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'submitResponseTimeoutSeconds'), 60, 'submitResponseTimeoutSeconds'),
|
||||
connectionFailureCooldownSeconds: getPositiveRuntimeInteger(getConfigValue(channel.config, 'connectionFailureCooldownSeconds'), 30, 'connectionFailureCooldownSeconds'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
|
||||
@@ -32,6 +32,7 @@ export class ChannelCopyService {
|
||||
code: nextCode,
|
||||
name: nextName,
|
||||
carrier: source.carrier,
|
||||
carriers: source.carriers,
|
||||
protocol: source.protocol,
|
||||
gatewayHost: source.gatewayHost,
|
||||
gatewayPort: source.gatewayPort,
|
||||
|
||||
@@ -15,6 +15,7 @@ export class ChannelGroupRoutingService {
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
where: { status: { not: 'deleted' } },
|
||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -51,7 +52,7 @@ export class ChannelGroupRoutingService {
|
||||
if (!channel) {
|
||||
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');
|
||||
}
|
||||
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
||||
@@ -158,23 +159,78 @@ export class ChannelGroupRoutingService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupId: string) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
async getGroupDeletionImpact(groupId: string) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, name: true, items: { select: { id: true } } },
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
status: 'active',
|
||||
},
|
||||
select: { id: true },
|
||||
const routes = await this.prisma.channelRouteRule.findMany({
|
||||
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
|
||||
select: { applicationId: true },
|
||||
});
|
||||
if (boundRoute) {
|
||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
||||
const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))];
|
||||
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,
|
||||
};
|
||||
}
|
||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
||||
|
||||
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');
|
||||
}
|
||||
if (group.status === 'deleted') {
|
||||
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() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { 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. */
|
||||
@@ -325,11 +325,24 @@ export class ChannelReportingService {
|
||||
if (drainageInfo.auditStatus !== 'approved') 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({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
carrier,
|
||||
approvalScope: 'carrier_specific',
|
||||
reportType,
|
||||
drainageItemId: undefined,
|
||||
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.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 === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商');
|
||||
const approvedAt = item.status === 'approved'
|
||||
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date()
|
||||
: null;
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
|
||||
? await tx.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, 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 } });
|
||||
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 channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
||||
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
|
||||
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
|
||||
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)];
|
||||
}));
|
||||
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;
|
||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||
return { signatureId, reportStatus, carrierReportSummary };
|
||||
@@ -514,7 +549,13 @@ export class ChannelReportingService {
|
||||
) {
|
||||
await this.prisma.channelSignatureReportTask.update({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface CreateChannelDto {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier?: string;
|
||||
carriers?: string[];
|
||||
sendRegion?: string;
|
||||
protocol?: string;
|
||||
gatewayHost: string;
|
||||
@@ -104,13 +105,14 @@ export interface CreateReportTaskDto {
|
||||
tenantId: string;
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
carrier?: string;
|
||||
reportType?: 'signature' | 'drainage';
|
||||
drainageItemId?: string;
|
||||
createdById?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
operatorId?: string;
|
||||
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||
|
||||
@@ -119,6 +119,11 @@ export class ChannelsController {
|
||||
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')
|
||||
@RequireRecentAuthentication()
|
||||
deleteGroup(@Param('id') groupId: string) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { normalizeChannelRuntimeConfig } from './channels.helpers';
|
||||
|
||||
describe('Gateway channel capacity validation', () => {
|
||||
it.each([1, 2, 4, 8])('accepts %i supplier connections', (desiredConnections) => {
|
||||
expect(normalizeChannelRuntimeConfig(undefined, undefined, desiredConnections, 16)).toEqual(expect.objectContaining({ desiredConnections, windowSize: 16 }));
|
||||
});
|
||||
it.each([1, 16, 32, 64])('accepts supplier window %i', (windowSize) => {
|
||||
expect(normalizeChannelRuntimeConfig(undefined, undefined, 1, windowSize)).toEqual(expect.objectContaining({ desiredConnections: 1, windowSize }));
|
||||
});
|
||||
it.each([[0, 16], [9, 16], [1, 0], [1, 65]])('rejects capacity outside 1..8 connections and 1..64 window', (connections, window) => {
|
||||
expect(() => normalizeChannelRuntimeConfig(undefined, undefined, connections, window)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -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 { Prisma } from '@prisma/client';
|
||||
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';
|
||||
|
||||
export function summarizeReportStatuses(statuses: string[]) {
|
||||
return summarizeCommonReportStatuses(statuses);
|
||||
}
|
||||
|
||||
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
||||
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||
|
||||
@@ -223,7 +228,7 @@ export function defaultChannelConnectionId(channelId: string) {
|
||||
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||
const value = Number(config.desiredConnections);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 8) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -322,8 +327,12 @@ export function normalizeChannelRuntimeConfig(
|
||||
? incomingConfig
|
||||
: {};
|
||||
const base = { ...existing, ...incoming };
|
||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
||||
base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections');
|
||||
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
|
||||
base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds');
|
||||
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds');
|
||||
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds');
|
||||
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds');
|
||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
@@ -340,6 +349,14 @@ export function normalizeChannelRuntimeConfig(
|
||||
return base;
|
||||
}
|
||||
|
||||
function boundedRuntimeInteger(value: unknown, minimum: number, maximum: number, fallback: number, field: string) {
|
||||
const normalized = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized < minimum || normalized > maximum) {
|
||||
throw new BadRequestException(`${field} must be an integer between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeLongMessageReceiptMode(value: unknown) {
|
||||
const normalized = String(value ?? 'per_segment').trim() || 'per_segment';
|
||||
if (!['per_segment', 'message_level'].includes(normalized)) {
|
||||
@@ -593,9 +610,29 @@ export function normalizeChannelCarrier(carrier?: string | null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
|
||||
const normalized = normalizeChannelCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === groupCarrier;
|
||||
export const SUPPORTED_CHANNEL_CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||||
|
||||
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) {
|
||||
@@ -609,7 +646,7 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
|
||||
export function validateGroupItems(
|
||||
groupCarrier: string,
|
||||
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 provinces = new Set<string>();
|
||||
@@ -627,7 +664,7 @@ export function validateGroupItems(
|
||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||
}
|
||||
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');
|
||||
}
|
||||
if (item.province) {
|
||||
@@ -654,17 +691,6 @@ export function normalizeReportType(value?: string) {
|
||||
throw new BadRequestException('reportType must be signature, drainage or both');
|
||||
}
|
||||
|
||||
export function summarizeReportStatuses(statuses: string[]) {
|
||||
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
let status = 'pending';
|
||||
if (approved === statuses.length) status = 'approved';
|
||||
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
|
||||
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
|
||||
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
|
||||
return { status, approved, total: statuses.length };
|
||||
}
|
||||
|
||||
export function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connect_requested')) {
|
||||
return '连接请求';
|
||||
|
||||
@@ -28,12 +28,13 @@ jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
|
||||
})));
|
||||
|
||||
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 = {
|
||||
id: 'channel-1',
|
||||
code: 'CMPP-A',
|
||||
name: '主通道',
|
||||
carrier: 'mobile',
|
||||
carriers: ['mobile'],
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
@@ -81,13 +82,14 @@ function createPrismaMock() {
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
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 })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
||||
},
|
||||
@@ -115,6 +117,7 @@ function createPrismaMock() {
|
||||
channelSignatureReportTask: {
|
||||
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
|
||||
create: jest.fn().mockResolvedValue(reportTask),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findUnique: jest.fn().mockResolvedValue(reportTask),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
|
||||
},
|
||||
@@ -137,6 +140,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
tenant: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
|
||||
@@ -153,6 +157,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsSubmitRecord: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn(),
|
||||
@@ -169,6 +174,51 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const prisma = createPrismaMock();
|
||||
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' }),
|
||||
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' }) },
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
|
||||
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', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }),
|
||||
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' }) },
|
||||
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));
|
||||
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(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' } });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
@@ -353,7 +447,7 @@ describe('ChannelsService', () => {
|
||||
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
|
||||
reason: '引流信息已报备',
|
||||
})).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.smsSignature.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -866,16 +960,60 @@ describe('ChannelsService', () => {
|
||||
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 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');
|
||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
|
||||
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
|
||||
groupId: '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' });
|
||||
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
|
||||
it('logically deletes channel groups without removing application bindings or group items', async () => {
|
||||
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 () => {
|
||||
@@ -901,7 +1039,7 @@ describe('ChannelsService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
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.importReportReceipt('report-task-1', {
|
||||
fileName: 'receipt.csv',
|
||||
@@ -916,11 +1054,11 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'report-task-1' },
|
||||
data: { status: 'exporting', reason: undefined },
|
||||
data: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }),
|
||||
});
|
||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||
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({
|
||||
where: { id: 'sig-1' },
|
||||
@@ -951,7 +1089,7 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||
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({
|
||||
where: { id: 'sig-1' },
|
||||
|
||||
@@ -114,6 +114,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.groups.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
getGroupDeletionImpact(groupId: string) {
|
||||
return this.groups.getGroupDeletionImpact(groupId);
|
||||
}
|
||||
|
||||
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) {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@ describe('DeletionGovernanceService', () => {
|
||||
function setup() {
|
||||
const tx = {
|
||||
operationLog: { findFirst: jest.fn(), create: jest.fn() },
|
||||
smsChannel: { updateMany: jest.fn() },
|
||||
smsSignature: { updateMany: jest.fn() },
|
||||
smsTemplate: { updateMany: jest.fn() },
|
||||
smsChannel: { findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
smsSignature: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), 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 = {
|
||||
operationLog: { findFirst: jest.fn() },
|
||||
@@ -35,12 +39,47 @@ describe('DeletionGovernanceService', () => {
|
||||
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 () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
|
||||
sendTasks: [], batchTasks: [],
|
||||
});
|
||||
|
||||
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(result.allowedActions).toEqual(['delete']);
|
||||
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();
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
|
||||
templates: [{ id: 'template-1', name: '验证码模板' }],
|
||||
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
|
||||
});
|
||||
|
||||
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.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-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();
|
||||
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 () => {
|
||||
@@ -80,19 +168,71 @@ describe('DeletionGovernanceService', () => {
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||||
sendTasks: [], batchTasks: [],
|
||||
});
|
||||
tx.operationLog.findFirst.mockResolvedValue(null);
|
||||
tx.smsTemplate.findFirst.mockResolvedValue({
|
||||
id: 'template-1', tenantId: 'tenant-1',
|
||||
});
|
||||
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.operationLog.create.mockResolvedValue({ id: 'operation-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');
|
||||
|
||||
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
|
||||
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 () => {
|
||||
@@ -101,7 +241,6 @@ describe('DeletionGovernanceService', () => {
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue({
|
||||
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
|
||||
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
|
||||
sendTasks: [], batchTasks: [],
|
||||
});
|
||||
await expect(service.delete('template', 'template-1', {
|
||||
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
|
||||
|
||||
@@ -1,17 +1,54 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { summarizeReportStatuses } from '../common/report-status';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||
|
||||
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
||||
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
|
||||
|
||||
export type DeleteTargetDto = {
|
||||
expectedUpdatedAt?: string;
|
||||
idempotencyKey?: string;
|
||||
reason?: 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 = {
|
||||
type: DeletionTargetType;
|
||||
@@ -19,6 +56,7 @@ export type DeletionPreflight = {
|
||||
expectedUpdatedAt: string;
|
||||
identity: Record<string, string>;
|
||||
dependencies: Dependency[];
|
||||
requiredSelections: RequiredSelection[];
|
||||
impacts: string[];
|
||||
blockedReasons: string[];
|
||||
allowedActions: Array<'delete'>;
|
||||
@@ -40,9 +78,8 @@ export class DeletionGovernanceService {
|
||||
this.assertType(type);
|
||||
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
|
||||
const idempotencyKey = body.idempotencyKey?.trim();
|
||||
const reason = body.reason?.trim();
|
||||
const reason = body.reason?.trim() || undefined;
|
||||
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
|
||||
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
|
||||
|
||||
const replay = await this.prisma.operationLog.findFirst({
|
||||
where: {
|
||||
@@ -58,6 +95,7 @@ export class DeletionGovernanceService {
|
||||
if (!preflight.allowedActions.includes('delete')) {
|
||||
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
|
||||
}
|
||||
this.assertSelections(preflight.requiredSelections, body);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.operationLog.findFirst({
|
||||
@@ -68,6 +106,12 @@ export class DeletionGovernanceService {
|
||||
});
|
||||
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'
|
||||
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
|
||||
: 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' } });
|
||||
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({
|
||||
data: {
|
||||
tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
||||
detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts },
|
||||
tenantId: cascade.tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
|
||||
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 };
|
||||
@@ -93,7 +149,7 @@ export class DeletionGovernanceService {
|
||||
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
|
||||
routeRules: { where: { status: 'active' } },
|
||||
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('通道不存在');
|
||||
@@ -101,10 +157,11 @@ export class DeletionGovernanceService {
|
||||
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)),
|
||||
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
|
||||
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,
|
||||
['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']);
|
||||
['删除后不再参与新消息路由', '所选未结束报备任务将置为“放弃报备”', '历史发送、回执和审计记录继续保留'],
|
||||
{ report_tasks: 'abandon_associated_report_tasks' });
|
||||
}
|
||||
|
||||
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
|
||||
@@ -112,20 +169,34 @@ export class DeletionGovernanceService {
|
||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||
include: {
|
||||
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 } },
|
||||
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('签名不存在或无权访问');
|
||||
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[] = [
|
||||
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('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, {
|
||||
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> {
|
||||
@@ -133,19 +204,153 @@ export class DeletionGovernanceService {
|
||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||
include: {
|
||||
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('模板不存在或无权访问');
|
||||
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, {
|
||||
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.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 {
|
||||
@@ -153,16 +358,38 @@ export class DeletionGovernanceService {
|
||||
}
|
||||
}
|
||||
|
||||
function dep(kind: string, label: string, items: string[]): Dependency {
|
||||
return { kind, label, count: items.length, items: items.slice(0, 8) };
|
||||
function dep(kind: string, label: string, items: string[], count = items.length, detailsVisible = true): Dependency {
|
||||
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 {
|
||||
const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}共 ${item.count} 项,请先解除或完成`);
|
||||
function buildPreflight(
|
||||
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('对象已经删除,请勿重复操作');
|
||||
return {
|
||||
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons,
|
||||
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, requiredSelections, impacts, blockedReasons,
|
||||
allowedActions: blockedReasons.length ? [] : ['delete'],
|
||||
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 {
|
||||
constructor(private readonly dictionaries: DictionariesService) {}
|
||||
|
||||
@Get('administrative-regions')
|
||||
listAdministrativeRegions() {
|
||||
return this.dictionaries.listAdministrativeRegions();
|
||||
}
|
||||
|
||||
@Get('phone-segments')
|
||||
listPhoneSegments(
|
||||
@Query('keyword') keyword?: string,
|
||||
|
||||
@@ -58,6 +58,28 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
@@ -111,6 +111,27 @@ export class DictionariesService {
|
||||
@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 = {}) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
||||
|
||||
@@ -22,18 +22,25 @@ export class PhoneRoutingLookupService {
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
const prefixes = phonePrefixes(phoneNumber);
|
||||
if (prefixes.length === 0) return null;
|
||||
return (await this.identifyProvinces([phoneNumber])).get(phoneNumber) ?? null;
|
||||
}
|
||||
|
||||
async identifyProvinces(phoneNumbers: string[]) {
|
||||
const uniquePhones = [...new Set(phoneNumbers)];
|
||||
const prefixesByPhone = new Map(uniquePhones.map((phone) => [phone, phonePrefixes(phone)]));
|
||||
const prefixes = [...new Set([...prefixesByPhone.values()].flat())];
|
||||
if (prefixes.length === 0) return new Map(uniquePhones.map((phone) => [phone, null]));
|
||||
const segments = await this.prisma.phoneSegment.findMany({
|
||||
where: { prefix: { in: prefixes } },
|
||||
select: { prefix: true, province: true },
|
||||
});
|
||||
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
||||
for (const prefix of prefixes) {
|
||||
const province = provinceByPrefix.get(prefix);
|
||||
if (province) return province;
|
||||
}
|
||||
return null;
|
||||
return new Map(uniquePhones.map((phone) => {
|
||||
const province = (prefixesByPhone.get(phone) ?? [])
|
||||
.map((prefix) => provinceByPrefix.get(prefix))
|
||||
.find((value): value is string => Boolean(value)) ?? null;
|
||||
return [phone, province];
|
||||
}));
|
||||
}
|
||||
|
||||
invalidateCarrierRules() {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BillingService } from './billing/billing.service';
|
||||
import { PhoneRoutingLookupService } from './dictionaries/phone-routing-lookup.service';
|
||||
import { MetricsModule } from './metrics/metrics.module';
|
||||
import { OpenApiService } from './open-api/open-api.service';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module';
|
||||
import { PhoneFrequencyService } from './risk-review/phone-frequency.service';
|
||||
import { RiskReviewService } from './risk-review/risk-review.service';
|
||||
import { GatewayCallbackController } from './send-chain/gateway-callback.controller';
|
||||
import { SendChainService } from './send-chain/send-chain.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }),
|
||||
PrismaModule,
|
||||
MetricsModule,
|
||||
ProtocolLogsModule,
|
||||
],
|
||||
controllers: [GatewayCallbackController],
|
||||
providers: [
|
||||
BillingService,
|
||||
RiskReviewService,
|
||||
PhoneFrequencyService,
|
||||
PhoneRoutingLookupService,
|
||||
SendChainService,
|
||||
OpenApiService,
|
||||
],
|
||||
})
|
||||
export class GatewayCallbackModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { createServer } from 'node:http';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { GatewayCallbackModule } from './gateway-callback.module';
|
||||
import { configureHttpBodyParsers } from './http-body-limits';
|
||||
import { MetricsService } from './metrics/metrics.service';
|
||||
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
configurable: true,
|
||||
value(this: bigint) {
|
||||
const result = Number(this);
|
||||
if (!Number.isSafeInteger(result)) throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
if (process.env.CMPP_PROCESS_ROLE !== 'callback') {
|
||||
throw new Error('gateway-callback requires CMPP_PROCESS_ROLE=callback');
|
||||
}
|
||||
const app = await NestFactory.create<NestExpressApplication>(GatewayCallbackModule, { rawBody: true, bodyParser: false });
|
||||
app.setGlobalPrefix('api');
|
||||
configureHttpBodyParsers(app);
|
||||
app.enableShutdownHooks();
|
||||
const host = process.env.API_CALLBACK_HOST?.trim() || '127.0.0.1';
|
||||
const port = Number(process.env.API_CALLBACK_PORT ?? 3001);
|
||||
await app.listen(port, host);
|
||||
|
||||
const metrics = app.get(MetricsService);
|
||||
const metricsHost = process.env.API_CALLBACK_METRICS_HOST?.trim() || '127.0.0.1';
|
||||
const metricsPort = Number(process.env.API_CALLBACK_METRICS_PORT ?? 9468);
|
||||
const metricsServer = createServer((request, response) => {
|
||||
if (request.method !== 'GET' || request.url !== '/metrics') return void response.writeHead(404).end();
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
response.end(metrics.render());
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
metricsServer.once('error', reject);
|
||||
metricsServer.listen(metricsPort, metricsHost, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -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,20 @@
|
||||
import { configureApiHttpServerTimeouts } from './http-server-timeouts';
|
||||
|
||||
describe('configureApiHttpServerTimeouts', () => {
|
||||
it('keeps the API connection alive longer than the Gateway idle pool', () => {
|
||||
const server = { keepAliveTimeout: 0, headersTimeout: 0 };
|
||||
expect(configureApiHttpServerTimeouts(server, {})).toEqual({
|
||||
keepAliveTimeoutMs: 120_000,
|
||||
headersTimeoutMs: 125_000,
|
||||
});
|
||||
expect(server).toEqual({ keepAliveTimeout: 120_000, headersTimeout: 125_000 });
|
||||
});
|
||||
|
||||
it('keeps headers timeout above a configured keep-alive timeout', () => {
|
||||
const server = { keepAliveTimeout: 0, headersTimeout: 0 };
|
||||
expect(configureApiHttpServerTimeouts(server, {
|
||||
API_HTTP_KEEP_ALIVE_TIMEOUT_MS: '90000',
|
||||
API_HTTP_HEADERS_TIMEOUT_MS: '1000',
|
||||
})).toEqual({ keepAliveTimeoutMs: 90_000, headersTimeoutMs: 91_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_HEADERS_TIMEOUT_MS = 125_000;
|
||||
|
||||
export function configureApiHttpServerTimeouts(
|
||||
server: Pick<Server, 'keepAliveTimeout' | 'headersTimeout'>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
) {
|
||||
const keepAliveTimeoutMs = positiveInteger(env.API_HTTP_KEEP_ALIVE_TIMEOUT_MS, DEFAULT_KEEP_ALIVE_TIMEOUT_MS);
|
||||
const configuredHeadersTimeoutMs = positiveInteger(env.API_HTTP_HEADERS_TIMEOUT_MS, DEFAULT_HEADERS_TIMEOUT_MS);
|
||||
const headersTimeoutMs = Math.max(configuredHeadersTimeoutMs, keepAliveTimeoutMs + 1_000);
|
||||
server.keepAliveTimeout = keepAliveTimeoutMs;
|
||||
server.headersTimeout = headersTimeoutMs;
|
||||
return { keepAliveTimeoutMs, headersTimeoutMs };
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -1,8 +1,13 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { createServer } from 'node:http';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { MetricsService } from './metrics/metrics.service';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
import { configureHttpBodyParsers } from './http-body-limits';
|
||||
import { configureApiHttpServerTimeouts } from './http-server-timeouts';
|
||||
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
configurable: true,
|
||||
@@ -16,8 +21,9 @@ Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
});
|
||||
|
||||
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');
|
||||
configureHttpBodyParsers(app);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('CMPP Platform API')
|
||||
@@ -36,7 +42,27 @@ async function bootstrap() {
|
||||
SwaggerModule.setup('api/client-docs', app, clientDocument);
|
||||
|
||||
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';
|
||||
const apiServer = await app.listen(port, host);
|
||||
configureApiHttpServerTimeouts(apiServer, process.env);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,35 @@
|
||||
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 inboundStartedAt = service.beginCmppInboundStage();
|
||||
service.finishCmppInboundStage(inboundStartedAt, 'application_lookup', 'success');
|
||||
const sendStartedAt = service.beginSendWorkerStage();
|
||||
service.finishSendWorkerStage(sendStartedAt, 'route_lookup', 'success');
|
||||
service.setSendWorkerSlots(20, 3);
|
||||
service.setSendWorkerQueueJobs('waiting', 12);
|
||||
service.recordSendWorkerResult('completed');
|
||||
service.setSendWorkerDatabasePool('max', 8);
|
||||
service.setSendWorkerDatabasePool('waiting', 2);
|
||||
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).toContain('cmpp_api_cmpp_inbound_stage_duration_seconds_count{stage="application_lookup",result="success"} 1');
|
||||
expect(output).toContain('cmpp_worker_send_stage_duration_seconds_count{stage="route_lookup",result="success"} 1');
|
||||
expect(output).toContain('cmpp_worker_send_slots{state="configured"} 20');
|
||||
expect(output).toContain('cmpp_worker_send_slots{state="in_flight"} 3');
|
||||
expect(output).toContain('cmpp_worker_send_queue_jobs{state="waiting"} 12');
|
||||
expect(output).toContain('cmpp_worker_send_jobs_total{result="completed"} 1');
|
||||
expect(output).toContain('cmpp_worker_database_pool_connections{state="max"} 8');
|
||||
expect(output).toContain('cmpp_worker_database_pool_connections{state="waiting"} 2');
|
||||
expect(output).not.toContain('phone_number');
|
||||
expect(output).not.toContain('tenant_id');
|
||||
expect(output).not.toContain('channel_id');
|
||||
service.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
const CMPP_INBOUND_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
|
||||
const SEND_WORKER_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 3, 10] as const;
|
||||
|
||||
export type CmppInboundStage =
|
||||
| 'application_lookup'
|
||||
| 'inbox_persist'
|
||||
| 'worker_claim'
|
||||
| 'reference_preload'
|
||||
| 'daily_quota'
|
||||
| 'long_message_fragment'
|
||||
| 'submission_precheck'
|
||||
| 'template_match'
|
||||
| 'task_persist'
|
||||
| 'api_request_persist'
|
||||
| 'content_detection'
|
||||
| 'message_persist'
|
||||
| 'risk_frequency'
|
||||
| 'billing'
|
||||
| 'queue_publish'
|
||||
| 'complete_submit'
|
||||
| 'total';
|
||||
|
||||
export type CmppInboundStageResult = 'success' | 'error';
|
||||
|
||||
export type SendWorkerStage =
|
||||
| 'message_load'
|
||||
| 'phone_routing'
|
||||
| 'route_lookup'
|
||||
| 'signature_candidates'
|
||||
| 'signature_final_check'
|
||||
| 'rate_limit'
|
||||
| 'submit_transaction'
|
||||
| 'gateway_bullmq_publish'
|
||||
| 'gateway_stream_publish'
|
||||
| 'task_progress'
|
||||
| 'total';
|
||||
|
||||
export type SendWorkerStageResult = 'success' | 'error' | 'skipped';
|
||||
export type SendWorkerQueueState = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'prioritized';
|
||||
|
||||
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 readonly cmppInbound = new Map<string, HttpMetric>();
|
||||
private readonly sendWorkerStages = new Map<string, HttpMetric>();
|
||||
private readonly sendWorkerQueueJobs = new Map<SendWorkerQueueState, number>();
|
||||
private readonly sendWorkerResults = new Map<string, number>();
|
||||
private sendWorkerConfiguredSlots = 0;
|
||||
private sendWorkerInFlightSlots = 0;
|
||||
private readonly sendWorkerDatabasePool = new Map<'max' | 'total' | 'idle' | 'waiting', number>();
|
||||
private inFlight = 0;
|
||||
private inboundWorkflowPending = 0;
|
||||
private inboundWorkflowProcessing = 0;
|
||||
private inboundWorkflowOldestPendingAgeSeconds = 0;
|
||||
private inboundWorkflowConfiguredSlots = 0;
|
||||
private inboundWorkflowInFlightSlots = 0;
|
||||
private readonly inboundWorkflowResults = new Map<string, number>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
beginCmppInboundStage() {
|
||||
return process.hrtime.bigint();
|
||||
}
|
||||
|
||||
finishCmppInboundStage(startedAt: bigint, stage: CmppInboundStage, result: CmppInboundStageResult) {
|
||||
const key = `${stage}\u0000${result}`;
|
||||
const metric = this.cmppInbound.get(key) ?? {
|
||||
count: 0,
|
||||
durationSum: 0,
|
||||
buckets: CMPP_INBOUND_DURATION_BUCKETS.map(() => 0),
|
||||
};
|
||||
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
|
||||
metric.count += 1;
|
||||
metric.durationSum += durationSeconds;
|
||||
CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||
if (durationSeconds <= bucket) metric.buckets[index] += 1;
|
||||
});
|
||||
this.cmppInbound.set(key, metric);
|
||||
}
|
||||
|
||||
beginSendWorkerStage() {
|
||||
return process.hrtime.bigint();
|
||||
}
|
||||
|
||||
finishSendWorkerStage(startedAt: bigint, stage: SendWorkerStage, result: SendWorkerStageResult) {
|
||||
const key = `${stage}\u0000${result}`;
|
||||
const metric = this.sendWorkerStages.get(key) ?? {
|
||||
count: 0,
|
||||
durationSum: 0,
|
||||
buckets: SEND_WORKER_DURATION_BUCKETS.map(() => 0),
|
||||
};
|
||||
const durationSeconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
|
||||
metric.count += 1;
|
||||
metric.durationSum += durationSeconds;
|
||||
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||
if (durationSeconds <= bucket) metric.buckets[index] += 1;
|
||||
});
|
||||
this.sendWorkerStages.set(key, metric);
|
||||
}
|
||||
|
||||
setSendWorkerSlots(configured: number, inFlight: number) {
|
||||
this.sendWorkerConfiguredSlots = Math.max(0, configured);
|
||||
this.sendWorkerInFlightSlots = Math.max(0, inFlight);
|
||||
}
|
||||
|
||||
setSendWorkerQueueJobs(state: SendWorkerQueueState, count: number) {
|
||||
this.sendWorkerQueueJobs.set(state, Math.max(0, count));
|
||||
}
|
||||
|
||||
recordSendWorkerResult(result: 'completed' | 'failed' | 'skipped') {
|
||||
this.sendWorkerResults.set(result, (this.sendWorkerResults.get(result) ?? 0) + 1);
|
||||
}
|
||||
|
||||
setSendWorkerDatabasePool(state: 'max' | 'total' | 'idle' | 'waiting', count: number) {
|
||||
this.sendWorkerDatabasePool.set(state, Math.max(0, count));
|
||||
}
|
||||
|
||||
setInboundWorkflowState(pending: number, processing: number, oldestPendingAgeSeconds: number) {
|
||||
this.inboundWorkflowPending = Math.max(0, pending);
|
||||
this.inboundWorkflowProcessing = Math.max(0, processing);
|
||||
this.inboundWorkflowOldestPendingAgeSeconds = Math.max(0, oldestPendingAgeSeconds);
|
||||
}
|
||||
|
||||
setInboundWorkflowSlots(configured: number, inFlight: number) {
|
||||
this.inboundWorkflowConfiguredSlots = Math.max(0, configured);
|
||||
this.inboundWorkflowInFlightSlots = Math.max(0, inFlight);
|
||||
}
|
||||
|
||||
recordInboundWorkflowResult(result: 'completed' | 'retry') {
|
||||
this.inboundWorkflowResults.set(result, (this.inboundWorkflowResults.get(result) ?? 0) + 1);
|
||||
}
|
||||
|
||||
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',
|
||||
'# HELP cmpp_api_cmpp_inbound_stage_duration_seconds CMPP inbound processing duration by bounded stage and result.',
|
||||
'# TYPE cmpp_api_cmpp_inbound_stage_duration_seconds histogram',
|
||||
'# HELP cmpp_worker_send_stage_duration_seconds Send worker processing duration by bounded stage and result.',
|
||||
'# TYPE cmpp_worker_send_stage_duration_seconds histogram',
|
||||
'# HELP cmpp_worker_send_queue_jobs BullMQ send jobs by queue state.',
|
||||
'# TYPE cmpp_worker_send_queue_jobs gauge',
|
||||
'# HELP cmpp_worker_send_slots Send worker concurrency slots by state.',
|
||||
'# TYPE cmpp_worker_send_slots gauge',
|
||||
metricLine('cmpp_worker_send_slots', this.sendWorkerConfiguredSlots, { state: 'configured' }),
|
||||
metricLine('cmpp_worker_send_slots', this.sendWorkerInFlightSlots, { state: 'in_flight' }),
|
||||
'# HELP cmpp_worker_send_jobs_total Send worker processing outcomes.',
|
||||
'# TYPE cmpp_worker_send_jobs_total counter',
|
||||
'# HELP cmpp_worker_database_pool_connections Worker PostgreSQL client pool slots by state.',
|
||||
'# TYPE cmpp_worker_database_pool_connections gauge',
|
||||
'# HELP cmpp_worker_inbound_workflow_items Current durable CMPP inbound workflow items by state.',
|
||||
'# TYPE cmpp_worker_inbound_workflow_items gauge',
|
||||
metricLine('cmpp_worker_inbound_workflow_items', this.inboundWorkflowPending, { state: 'pending' }),
|
||||
metricLine('cmpp_worker_inbound_workflow_items', this.inboundWorkflowProcessing, { state: 'processing' }),
|
||||
'# HELP cmpp_worker_inbound_workflow_slots Durable workflow worker slots by state.',
|
||||
'# TYPE cmpp_worker_inbound_workflow_slots gauge',
|
||||
metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowConfiguredSlots, { state: 'configured' }),
|
||||
metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowInFlightSlots, { state: 'in_flight' }),
|
||||
'# HELP cmpp_worker_inbound_workflow_oldest_pending_age_seconds Age of the oldest pending durable workflow.',
|
||||
'# TYPE cmpp_worker_inbound_workflow_oldest_pending_age_seconds gauge',
|
||||
metricLine('cmpp_worker_inbound_workflow_oldest_pending_age_seconds', this.inboundWorkflowOldestPendingAgeSeconds),
|
||||
'# HELP cmpp_worker_inbound_workflow_results_total Durable workflow processing outcomes.',
|
||||
'# TYPE cmpp_worker_inbound_workflow_results_total counter',
|
||||
];
|
||||
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));
|
||||
}
|
||||
for (const [key, metric] of this.cmppInbound) {
|
||||
const [stage, result] = key.split('\u0000');
|
||||
const labels = { stage, result };
|
||||
CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
|
||||
});
|
||||
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
|
||||
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_sum', metric.durationSum, labels));
|
||||
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_count', metric.count, labels));
|
||||
}
|
||||
for (const [key, metric] of this.sendWorkerStages) {
|
||||
const [stage, result] = key.split('\u0000');
|
||||
const labels = { stage, result };
|
||||
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
|
||||
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
|
||||
});
|
||||
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
|
||||
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_sum', metric.durationSum, labels));
|
||||
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_count', metric.count, labels));
|
||||
}
|
||||
for (const [state, count] of this.sendWorkerQueueJobs) {
|
||||
lines.push(metricLine('cmpp_worker_send_queue_jobs', count, { state }));
|
||||
}
|
||||
for (const [result, count] of this.sendWorkerResults) {
|
||||
lines.push(metricLine('cmpp_worker_send_jobs_total', count, { result }));
|
||||
}
|
||||
for (const [state, count] of this.sendWorkerDatabasePool) {
|
||||
lines.push(metricLine('cmpp_worker_database_pool_connections', count, { state }));
|
||||
}
|
||||
for (const [result, count] of this.inboundWorkflowResults) {
|
||||
lines.push(metricLine('cmpp_worker_inbound_workflow_results_total', count, { result }));
|
||||
}
|
||||
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 { decryptSecret } from './open-api.crypto';
|
||||
import type { OpenApiRequestLike } from './open-api.types';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
|
||||
@Injectable()
|
||||
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
private redis?: IORedis;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
|
||||
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
const nonce = header(request, 'x-nonce');
|
||||
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
|
||||
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接口鉴权请求头' });
|
||||
}
|
||||
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 格式非法' });
|
||||
}
|
||||
const credential = await this.prisma.httpApiCredential.findUnique({
|
||||
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
|
||||
});
|
||||
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: '访问凭据无效或已失效' });
|
||||
}
|
||||
const config = credential.application.httpConfig;
|
||||
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
}
|
||||
const timestamp = Number(timestampText);
|
||||
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: '请求时间戳已过期' });
|
||||
}
|
||||
const sourceIp = requestIp(request);
|
||||
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
|
||||
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: '请求签名校验失败' });
|
||||
}
|
||||
const redis = this.getRedis();
|
||||
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
|
||||
if (nonceAccepted !== 'OK') {
|
||||
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
|
||||
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
|
||||
}
|
||||
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 });
|
||||
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) {
|
||||
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
|
||||
|
||||
function requestIp(request: OpenApiRequestLike) {
|
||||
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) {
|
||||
|
||||
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
|
||||
import { OpenApiAuthGuard } from './open-api-auth.guard';
|
||||
import { OpenApiController } from './open-api.controller';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
|
||||
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
|
||||
providers: [OpenApiService, OpenApiAuthGuard],
|
||||
exports: [OpenApiService],
|
||||
|
||||
@@ -55,6 +55,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
onModuleInit() {
|
||||
const connection = bullmqConnection();
|
||||
this.queue = new Queue(WEBHOOK_QUEUE, { connection });
|
||||
// The isolated Gateway callback process only enqueues customer callbacks.
|
||||
// Delivery remains owned by the main API process so callback DB/HTTP capacity
|
||||
// cannot be consumed by slow customer webhook endpoints.
|
||||
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
|
||||
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 });
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,14 @@ export class AdminOperationsController {
|
||||
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')
|
||||
receiptAnomalies(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@@ -354,6 +362,57 @@ export class AdminOperationsController {
|
||||
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
|
||||
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')
|
||||
|
||||
@@ -7,7 +7,25 @@ describe('PrismaService', () => {
|
||||
expect(Object.getOwnPropertyDescriptor(prisma, 'operationLog')).toEqual(
|
||||
expect.objectContaining({ configurable: true }),
|
||||
);
|
||||
expect(prisma.getPoolState()).toEqual({ max: 32, total: 0, idle: 0, waiting: 0 });
|
||||
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('reserves a bounded database pool for the isolated Gateway callback process', async () => {
|
||||
const previousRole = process.env.CMPP_PROCESS_ROLE;
|
||||
const previousMax = process.env.API_CALLBACK_DB_POOL_MAX;
|
||||
process.env.CMPP_PROCESS_ROLE = 'callback';
|
||||
process.env.API_CALLBACK_DB_POOL_MAX = '12';
|
||||
try {
|
||||
const prisma = new PrismaService();
|
||||
expect(prisma.getPoolState()).toEqual({ max: 12, total: 0, idle: 0, waiting: 0 });
|
||||
await prisma.$disconnect();
|
||||
} finally {
|
||||
if (previousRole === undefined) delete process.env.CMPP_PROCESS_ROLE;
|
||||
else process.env.CMPP_PROCESS_ROLE = previousRole;
|
||||
if (previousMax === undefined) delete process.env.API_CALLBACK_DB_POOL_MAX;
|
||||
else process.env.API_CALLBACK_DB_POOL_MAX = previousMax;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool } from 'pg';
|
||||
import { requestContext } from '../common/request-context';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
private readonly databasePool: Pool;
|
||||
private readonly databasePoolMax: number;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
adapter: new PrismaPg(
|
||||
process.env.DATABASE_URL ??
|
||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||
),
|
||||
const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'api';
|
||||
const workerRole = processRole === 'worker';
|
||||
const outboxRole = processRole === 'outbox';
|
||||
const callbackRole = processRole === 'callback';
|
||||
const protocolLogRole = processRole === 'protocol-log-worker';
|
||||
const databaseUrl = protocolLogRole
|
||||
? process.env.API_PROTOCOL_LOG_DATABASE_URL || process.env.DATABASE_URL
|
||||
: outboxRole
|
||||
? process.env.API_OUTBOX_DATABASE_URL || process.env.DATABASE_URL
|
||||
: callbackRole
|
||||
? process.env.API_CALLBACK_DATABASE_URL || process.env.DATABASE_URL
|
||||
: workerRole
|
||||
? process.env.API_WORKER_DATABASE_URL || process.env.DATABASE_URL
|
||||
: process.env.DATABASE_URL;
|
||||
const configuredPoolMax = Number(protocolLogRole
|
||||
? process.env.API_PROTOCOL_LOG_DB_POOL_MAX ?? 4
|
||||
: outboxRole
|
||||
? process.env.API_OUTBOX_DB_POOL_MAX ?? 6
|
||||
: callbackRole
|
||||
? process.env.API_CALLBACK_DB_POOL_MAX ?? 16
|
||||
: workerRole
|
||||
? process.env.API_WORKER_DB_POOL_MAX ?? 8
|
||||
: process.env.API_DB_POOL_MAX ?? 32);
|
||||
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
|
||||
? configuredPoolMax
|
||||
: protocolLogRole ? 4 : outboxRole ? 6 : callbackRole ? 16 : workerRole ? 8 : 32;
|
||||
const databasePool = new Pool({
|
||||
connectionString: databaseUrl
|
||||
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||
// API capacity must be reserved independently from the heavier Worker
|
||||
// transactions; explicit bounds also protect PostgreSQL max_connections.
|
||||
max: poolMax,
|
||||
});
|
||||
super({ adapter: new PrismaPg(databasePool, { disposeExternalPool: true }) });
|
||||
this.databasePool = databasePool;
|
||||
this.databasePoolMax = poolMax;
|
||||
const operationLog = this.operationLog;
|
||||
Object.defineProperty(this, 'operationLog', {
|
||||
value: new Proxy(operationLog, {
|
||||
@@ -33,6 +67,15 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
getPoolState() {
|
||||
return {
|
||||
max: this.databasePoolMax,
|
||||
total: this.databasePool.totalCount,
|
||||
idle: this.databasePool.idleCount,
|
||||
waiting: this.databasePool.waitingCount,
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }), PrismaModule, ProtocolLogsModule],
|
||||
})
|
||||
export class ProtocolLogWorkerModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import IORedis from 'ioredis';
|
||||
import { ProtocolLogWorkerModule } from './protocol-log-worker.module';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from './protocol-logs/protocol-logs.service';
|
||||
|
||||
const STREAM = process.env.GATEWAY_PROTOCOL_LOG_STREAM ?? 'gateway.protocol.logs';
|
||||
const GROUP = process.env.GATEWAY_PROTOCOL_LOG_GROUP ?? 'cmpp-protocol-log-writer';
|
||||
const CONSUMER = process.env.GATEWAY_PROTOCOL_LOG_CONSUMER ?? `protocol-log-${process.pid}`;
|
||||
const BATCH_SIZE = boundedEnv('PROTOCOL_LOG_STREAM_BATCH_SIZE', 250, 100, 500);
|
||||
|
||||
async function bootstrap() {
|
||||
process.env.CMPP_PROCESS_ROLE = 'protocol-log-worker';
|
||||
const app = await NestFactory.createApplicationContext(ProtocolLogWorkerModule, { logger: ['log', 'warn', 'error'] });
|
||||
const logs = app.get(ProtocolLogsService);
|
||||
const redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null });
|
||||
try { await redis.xgroup('CREATE', STREAM, GROUP, '0', 'MKSTREAM'); } catch (error) {
|
||||
if (!String(error).includes('BUSYGROUP')) throw error;
|
||||
}
|
||||
let stopping = false;
|
||||
const stop = () => { stopping = true; };
|
||||
process.on('SIGTERM', stop); process.on('SIGINT', stop);
|
||||
while (!stopping) {
|
||||
const claimed = await redis.xautoclaim(STREAM, GROUP, CONSUMER, 30_000, '0-0', 'COUNT', BATCH_SIZE) as unknown as [string, Array<[string, string[]]>];
|
||||
let messages = claimed[1] ?? [];
|
||||
if (!messages.length) {
|
||||
const reply = await redis.xreadgroup('GROUP', GROUP, CONSUMER, 'COUNT', BATCH_SIZE, 'BLOCK', 2000, 'STREAMS', STREAM, '>') as unknown as Array<[string, Array<[string, string[]]>]> | null;
|
||||
if (!reply) continue;
|
||||
messages = reply[0]?.[1] ?? [];
|
||||
}
|
||||
const accepted: string[] = [];
|
||||
const parsed: ProtocolLogInput[] = [];
|
||||
for (const [id, fields] of messages) {
|
||||
const dataIndex = fields.indexOf('data');
|
||||
try {
|
||||
if (dataIndex < 0) throw new Error('data field missing');
|
||||
parsed.push(JSON.parse(fields[dataIndex + 1]) as ProtocolLogInput);
|
||||
accepted.push(id);
|
||||
} catch (error) {
|
||||
console.error(`protocol log stream event ${id} is invalid`, error);
|
||||
await redis.xadd(`${STREAM}.dead`, '*', 'sourceId', id, 'error', String(error), 'data', dataIndex >= 0 ? fields[dataIndex + 1] : '');
|
||||
await redis.xack(STREAM, GROUP, id); await redis.xdel(STREAM, id);
|
||||
}
|
||||
}
|
||||
logs.recordMany(parsed);
|
||||
if (accepted.length && await logs.flushNow()) {
|
||||
const pipeline = redis.pipeline();
|
||||
for (const id of accepted) pipeline.xack(STREAM, GROUP, id).xdel(STREAM, id);
|
||||
await pipeline.exec();
|
||||
}
|
||||
}
|
||||
await logs.flushNow(); await redis.quit(); await app.close();
|
||||
}
|
||||
|
||||
function boundedEnv(name: string, fallback: number, minimum: number, maximum: number) {
|
||||
const value = Number(process.env[name] ?? fallback);
|
||||
return Number.isInteger(value) && value >= minimum && value <= maximum ? value : fallback;
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -20,7 +20,7 @@ describe('ProtocolLogsService', () => {
|
||||
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);
|
||||
service.record({
|
||||
protocol: 'cmpp',
|
||||
@@ -36,7 +36,7 @@ describe('ProtocolLogsService', () => {
|
||||
|
||||
expect(prisma.protocolInteractionLog.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
phoneMasked: '188****3795',
|
||||
phoneNumber: '18821203795',
|
||||
gatewayMessageId: '123',
|
||||
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' } }]),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ export type ProtocolLogInput = {
|
||||
payloadBytes?: number | null;
|
||||
retryCount?: number | null;
|
||||
detail?: Record<string, unknown> | null;
|
||||
eventId?: string | null;
|
||||
gatewayInstanceId?: string | null;
|
||||
connectionId?: string | null;
|
||||
submitId?: string | null;
|
||||
};
|
||||
|
||||
export type ProtocolLogQuery = {
|
||||
@@ -45,8 +49,10 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.CMPP_PROCESS_ROLE !== 'protocol-log-worker') {
|
||||
this.flushTimer = setInterval(() => void this.flush(), positiveEnv('PROTOCOL_LOG_FLUSH_INTERVAL_MS', 500));
|
||||
this.flushTimer.unref?.();
|
||||
}
|
||||
this.retentionTimer = setInterval(() => void this.purgeExpired(), positiveEnv('PROTOCOL_LOG_RETENTION_INTERVAL_MS', 86_400_000));
|
||||
this.retentionTimer.unref?.();
|
||||
setTimeout(() => void this.purgeExpired(), 30_000).unref?.();
|
||||
@@ -77,16 +83,30 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
||||
gatewayMessageId: input.gatewayMessageId == null ? null : String(input.gatewayMessageId).slice(0, 128),
|
||||
traceId: clean(input.traceId, 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),
|
||||
durationMs: safeInteger(input.durationMs),
|
||||
payloadBytes: safeInteger(input.payloadBytes),
|
||||
retryCount: safeInteger(input.retryCount),
|
||||
detail: sanitizeDetail(input.detail),
|
||||
detail: sanitizeDetail({
|
||||
...input.detail,
|
||||
eventId: input.eventId,
|
||||
gatewayInstanceId: input.gatewayInstanceId,
|
||||
connectionId: input.connectionId,
|
||||
submitId: input.submitId,
|
||||
}),
|
||||
});
|
||||
if (this.buffer.length >= positiveEnv('PROTOCOL_LOG_BATCH_SIZE', 100)) void this.flush();
|
||||
}
|
||||
|
||||
recordMany(inputs: ProtocolLogInput[]) {
|
||||
for (const input of inputs) this.record(input);
|
||||
}
|
||||
|
||||
flushNow() {
|
||||
return this.flush();
|
||||
}
|
||||
|
||||
async list(query: ProtocolLogQuery) {
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize) || 20));
|
||||
@@ -102,7 +122,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
||||
{ requestId: { contains: query.keyword } },
|
||||
{ traceId: { contains: query.keyword } },
|
||||
{ account: { contains: query.keyword } },
|
||||
{ phoneMasked: { contains: query.keyword } },
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ resultCode: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
@@ -119,18 +139,22 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
||||
return { items, total, page, pageSize, eventTypes: eventTypes.map((item) => item.eventType) };
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
if (this.flushing || this.buffer.length === 0) return;
|
||||
private async flush(): Promise<boolean> {
|
||||
if (this.flushing) return false;
|
||||
if (this.buffer.length === 0) return true;
|
||||
this.flushing = true;
|
||||
const batch = this.buffer.splice(0, positiveEnv('PROTOCOL_LOG_BATCH_SIZE', 100));
|
||||
try {
|
||||
await this.prisma.protocolInteractionLog.createMany({ data: batch });
|
||||
} catch (error) {
|
||||
this.buffer.unshift(...batch);
|
||||
this.logger.error(`Protocol log batch write failed (${batch.length} events)`, error instanceof Error ? error.stack : String(error));
|
||||
return false;
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
if (this.buffer.length > 0) setImmediate(() => void this.flush());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async purgeExpired() {
|
||||
@@ -151,12 +175,6 @@ function clean(value: unknown, max = 191) {
|
||||
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) {
|
||||
const number = Number(value);
|
||||
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 { 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 { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
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 missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
||||
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
|
||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
|
||||
const reportCarriers = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null];
|
||||
const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = [];
|
||||
for (const carrier of reportCarriers) {
|
||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
|
||||
const task = existingTask
|
||||
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
|
||||
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
|
||||
? 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) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
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)) {
|
||||
|
||||
@@ -8,9 +8,9 @@ describe('ReportsService', () => {
|
||||
$executeRaw: jest.fn(),
|
||||
};
|
||||
const prisma = {
|
||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() },
|
||||
dailyProfitReport: { findMany: jest.fn(), count: jest.fn() },
|
||||
dailyQualityReport: { findMany: jest.fn(), count: jest.fn() },
|
||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||
dailyProfitReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||
dailyQualityReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
let service: ReportsService;
|
||||
@@ -23,10 +23,13 @@ describe('ReportsService', () => {
|
||||
tx.$executeRaw.mockResolvedValue(0);
|
||||
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-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.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.count.mockResolvedValue(1);
|
||||
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
||||
service = new ReportsService(prisma as never);
|
||||
});
|
||||
|
||||
@@ -54,6 +57,19 @@ describe('ReportsService', () => {
|
||||
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 () => {
|
||||
await expect(service.listReconciliation({
|
||||
dateFrom: '2026-07-01',
|
||||
@@ -62,7 +78,7 @@ describe('ReportsService', () => {
|
||||
applicationId: 'app-1',
|
||||
page: 2,
|
||||
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({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||
skip: 100,
|
||||
@@ -71,7 +87,12 @@ describe('ReportsService', () => {
|
||||
});
|
||||
|
||||
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({
|
||||
where: expect.objectContaining({
|
||||
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 () => {
|
||||
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',
|
||||
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
|
||||
});
|
||||
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
||||
|
||||
@@ -45,31 +45,52 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
async listReconciliation(query: ReportListQuery) {
|
||||
const { page, pageSize, skip } = pagination(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.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) {
|
||||
const { page, pageSize, skip } = pagination(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.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) {
|
||||
const { page, pageSize, skip } = pagination(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.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) {
|
||||
@@ -80,7 +101,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
async exportProfit(query: ReportListQuery) {
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
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) {
|
||||
@@ -149,13 +170,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
`);
|
||||
|
||||
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"
|
||||
), costs AS (
|
||||
WITH costs AS (
|
||||
SELECT
|
||||
submit."messageRecordId",
|
||||
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))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::bigint,
|
||||
COALESCE(SUM(billing.refund), 0)::bigint,
|
||||
-- 收入按每条最终成功短信的计费条数和发送时客户价快照计算,不能依赖随后可能变为 refunded 的账单状态。
|
||||
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(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
|
||||
(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))::bigint,
|
||||
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
|
||||
FROM "SmsMessageRecord" message
|
||||
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
|
||||
JOIN "SmsApplication" application ON application.id = message."applicationId"
|
||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
||||
LEFT JOIN costs ON costs."messageRecordId" = message.id
|
||||
WHERE message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
@@ -229,13 +250,6 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
`);
|
||||
|
||||
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" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
@@ -277,31 +291,41 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) 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
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
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 legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
CASE WHEN 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) = 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 legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 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
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN billing ON billing."messageId" = message."messageId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS audit_count,
|
||||
@@ -516,6 +540,28 @@ function pagination(query: ReportListQuery) {
|
||||
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 {
|
||||
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { PhoneFrequencyService, fixedShanghaiWindow } from './phone-frequency.service';
|
||||
|
||||
describe('PhoneFrequencyService', () => {
|
||||
it('persists independent idempotency results for a unique-phone batch in one transaction', async () => {
|
||||
const tx = {
|
||||
phoneFrequencyReservation: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
phoneFrequencyWhitelist: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
const prisma = {
|
||||
riskRule: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new PhoneFrequencyService(prisma as never, riskReview as never);
|
||||
|
||||
const results = await service.reserveBatch([
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13800000001', reservationKey: 'inbox-1:frequency' },
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13800000002', reservationKey: 'inbox-2:frequency' },
|
||||
]);
|
||||
|
||||
expect(results.get('inbox-1:frequency')?.size).toBe(0);
|
||||
expect(results.get('inbox-2:frequency')?.size).toBe(0);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(tx.phoneFrequencyReservation.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ reservationKey: 'inbox-1:frequency', result: [] }),
|
||||
expect.objectContaining({ reservationKey: 'inbox-2:frequency', result: [] }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('aligns five-minute cycles and natural days in Asia/Shanghai', () => {
|
||||
const requestedAt = new Date('2026-07-30T16:07:42.000Z');
|
||||
|
||||
|
||||
@@ -63,6 +63,15 @@ export interface PhoneFrequencyRejection {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PhoneFrequencyBatchReservation {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
phoneNumber: string;
|
||||
reservationKey: string;
|
||||
sourceType?: string;
|
||||
requestedAt?: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhoneFrequencyService {
|
||||
constructor(
|
||||
@@ -80,6 +89,7 @@ export class PhoneFrequencyService {
|
||||
phones: string[],
|
||||
sourceType?: string,
|
||||
requestedAt = new Date(),
|
||||
reservationKey?: string,
|
||||
) {
|
||||
if (!applicationId) return new Map<string, PhoneFrequencyRejection>();
|
||||
const normalizedPhones = [...new Set(phones.map((phone) => phone.trim()).filter(Boolean))].sort();
|
||||
@@ -87,14 +97,35 @@ export class PhoneFrequencyService {
|
||||
|
||||
await this.riskReview.ensureDefaultRules();
|
||||
const rules = await this.effectiveRules(applicationId);
|
||||
if (rules.length === 0) return new Map<string, PhoneFrequencyRejection>();
|
||||
const normalizedReservationKey = reservationKey?.trim();
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (normalizedReservationKey) {
|
||||
// Frequency counters and the reservation result commit together. Retrying a reclaimed
|
||||
// Inbox item therefore returns the original decision without incrementing either window.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'phone-frequency:' + normalizedReservationKey}, 0))`;
|
||||
const existingReservation = await tx.phoneFrequencyReservation.findUnique({
|
||||
where: { reservationKey: normalizedReservationKey },
|
||||
});
|
||||
if (existingReservation) {
|
||||
if (existingReservation.tenantId !== tenantId || existingReservation.applicationId !== applicationId) {
|
||||
throw new BadRequestException('号码频控幂等键已用于另一笔预留');
|
||||
}
|
||||
return frequencyRejectionsFromJson(existingReservation.result);
|
||||
}
|
||||
}
|
||||
const rejected = new Map<string, PhoneFrequencyRejection>();
|
||||
// 平台级白名单只截断号码频控链路;调用 reserve 之前已执行的格式、黑名单等校验不受影响。
|
||||
const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, normalizedPhones);
|
||||
const controlledPhones = normalizedPhones.filter((phone) => !whitelistedPhones.has(phone));
|
||||
if (controlledPhones.length === 0) return rejected;
|
||||
if (controlledPhones.length === 0 || rules.length === 0) {
|
||||
if (normalizedReservationKey) {
|
||||
await tx.phoneFrequencyReservation.create({
|
||||
data: { reservationKey: normalizedReservationKey, tenantId, applicationId, result: [] },
|
||||
});
|
||||
}
|
||||
return rejected;
|
||||
}
|
||||
for (const rule of rules) {
|
||||
const window = fixedShanghaiWindow(requestedAt, readPeriodSeconds(rule));
|
||||
// 分块限制 SQL 参数数量,但两条规则的全部分块仍在同一事务中提交或回滚。
|
||||
@@ -144,10 +175,155 @@ export class PhoneFrequencyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (normalizedReservationKey) {
|
||||
await tx.phoneFrequencyReservation.create({
|
||||
data: {
|
||||
reservationKey: normalizedReservationKey,
|
||||
tenantId,
|
||||
applicationId,
|
||||
result: [...rejected.entries()].map(([phoneNumber, rejection]) => ({ phoneNumber, ...rejection })),
|
||||
},
|
||||
});
|
||||
}
|
||||
return rejected;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve independent one-phone Inbox items in bounded database batches. The
|
||||
* reservation rows and counters commit in the same transaction, so reclaiming
|
||||
* any subset replays its original decision. Duplicate phones intentionally use
|
||||
* the established single-item path because their within-batch threshold order
|
||||
* is business-significant.
|
||||
*/
|
||||
async reserveBatch(items: PhoneFrequencyBatchReservation[]) {
|
||||
const results = new Map<string, Map<string, PhoneFrequencyRejection>>();
|
||||
if (items.length === 0) return results;
|
||||
const reservationKeys = items.map((item) => item.reservationKey.trim());
|
||||
if (reservationKeys.some((key) => !key) || new Set(reservationKeys).size !== reservationKeys.length) {
|
||||
throw new BadRequestException('号码频控批次幂等键为空或重复');
|
||||
}
|
||||
const groups = new Map<string, PhoneFrequencyBatchReservation[]>();
|
||||
for (const item of items) {
|
||||
const key = `${item.tenantId}:${item.applicationId}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push({ ...item, phoneNumber: item.phoneNumber.trim(), reservationKey: item.reservationKey.trim() });
|
||||
groups.set(key, group);
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
const uniquePhones = new Set(group.map((item) => item.phoneNumber));
|
||||
if (uniquePhones.size !== group.length) {
|
||||
for (const item of group) {
|
||||
results.set(item.reservationKey, await this.reserve(
|
||||
item.tenantId,
|
||||
item.applicationId,
|
||||
[item.phoneNumber],
|
||||
item.sourceType,
|
||||
item.requestedAt ?? new Date(),
|
||||
item.reservationKey,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await this.riskReview.ensureDefaultRules();
|
||||
const rules = await this.effectiveRules(group[0].applicationId);
|
||||
const groupResults = await this.prisma.$transaction(async (tx) => {
|
||||
const output = new Map<string, Map<string, PhoneFrequencyRejection>>();
|
||||
const existing = await tx.phoneFrequencyReservation.findMany({
|
||||
where: { reservationKey: { in: group.map((item) => item.reservationKey) } },
|
||||
});
|
||||
const existingByKey = new Map(existing.map((item) => [item.reservationKey, item]));
|
||||
const missing: PhoneFrequencyBatchReservation[] = [];
|
||||
for (const item of group) {
|
||||
const replay = existingByKey.get(item.reservationKey);
|
||||
if (!replay) {
|
||||
missing.push(item);
|
||||
continue;
|
||||
}
|
||||
if (replay.tenantId !== item.tenantId || replay.applicationId !== item.applicationId) {
|
||||
throw new BadRequestException('号码频控幂等键已用于另一笔预留');
|
||||
}
|
||||
output.set(item.reservationKey, frequencyRejectionsFromJson(replay.result));
|
||||
}
|
||||
if (missing.length === 0) return output;
|
||||
const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, missing.map((item) => item.phoneNumber));
|
||||
const controlled = missing.filter((item) => !whitelistedPhones.has(item.phoneNumber));
|
||||
const rejectedByPhone = new Map<string, PhoneFrequencyRejection>();
|
||||
for (const rule of rules) {
|
||||
const byWindow = new Map<string, { startAt: Date; endAt: Date; items: PhoneFrequencyBatchReservation[] }>();
|
||||
for (const item of controlled) {
|
||||
const window = fixedShanghaiWindow(item.requestedAt ?? new Date(), readPeriodSeconds(rule));
|
||||
const key = `${window.startAt.toISOString()}:${window.endAt.toISOString()}`;
|
||||
const bucket = byWindow.get(key) ?? { ...window, items: [] };
|
||||
bucket.items.push(item);
|
||||
byWindow.set(key, bucket);
|
||||
}
|
||||
for (const bucket of byWindow.values()) {
|
||||
const states = await this.upsertStates(tx, {
|
||||
tenantId: group[0].tenantId,
|
||||
applicationId: group[0].applicationId,
|
||||
phones: bucket.items.map((item) => item.phoneNumber),
|
||||
rule,
|
||||
window: { startAt: bucket.startAt, endAt: bucket.endAt },
|
||||
});
|
||||
const newTriggers = states.filter((state) => state.activeHitId === null && state.count > rule.thresholdValue);
|
||||
const hitByStateId = new Map<string, string>();
|
||||
if (newTriggers.length > 0) {
|
||||
await tx.phoneFrequencyHit.createMany({
|
||||
data: newTriggers.map((state) => {
|
||||
const hitId = randomUUID();
|
||||
hitByStateId.set(state.id, hitId);
|
||||
return {
|
||||
id: hitId,
|
||||
tenantId: group[0].tenantId,
|
||||
applicationId: group[0].applicationId,
|
||||
ruleId: rule.id,
|
||||
ruleCode: rule.code,
|
||||
ruleName: rule.name,
|
||||
phoneNumber: state.phoneNumber,
|
||||
thresholdValue: Math.floor(rule.thresholdValue),
|
||||
actualValue: state.count,
|
||||
windowStartedAt: state.windowStartedAt,
|
||||
windowEndsAt: state.windowEndsAt,
|
||||
generation: state.generation,
|
||||
action: 'block',
|
||||
sourceType: bucket.items[0]?.sourceType,
|
||||
};
|
||||
}),
|
||||
});
|
||||
await this.attachActiveHits(tx, hitByStateId);
|
||||
}
|
||||
for (const state of states) {
|
||||
if (state.activeHitId === null && state.count <= rule.thresholdValue) continue;
|
||||
const reason = `${rule.name}命中:本周期最多${Math.floor(rule.thresholdValue)}条,当前第${state.count}条,周期${formatWindow(state.windowStartedAt, state.windowEndsAt)}`;
|
||||
const previous = rejectedByPhone.get(state.phoneNumber);
|
||||
rejectedByPhone.set(state.phoneNumber, {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: previous ? `${previous.reason};${reason}` : reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await tx.phoneFrequencyReservation.createMany({
|
||||
data: missing.map((item) => {
|
||||
const rejection = rejectedByPhone.get(item.phoneNumber);
|
||||
const result = rejection ? [{ phoneNumber: item.phoneNumber, ...rejection }] : [];
|
||||
output.set(item.reservationKey, frequencyRejectionsFromJson(result));
|
||||
return {
|
||||
reservationKey: item.reservationKey,
|
||||
tenantId: item.tenantId,
|
||||
applicationId: item.applicationId,
|
||||
result,
|
||||
};
|
||||
}),
|
||||
});
|
||||
return output;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
for (const [key, value] of groupResults) results.set(key, value);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async listHits(query: PhoneFrequencyHitQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
@@ -566,6 +742,18 @@ export class PhoneFrequencyService {
|
||||
}
|
||||
}
|
||||
|
||||
function frequencyRejectionsFromJson(value: Prisma.JsonValue) {
|
||||
const result = new Map<string, PhoneFrequencyRejection>();
|
||||
if (!Array.isArray(value)) return result;
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||
const phoneNumber = typeof item.phoneNumber === 'string' ? item.phoneNumber : '';
|
||||
const reason = typeof item.reason === 'string' ? item.reason : '';
|
||||
if (phoneNumber && reason) result.set(phoneNumber, { code: 'PHONE_FREQUENCY_LIMIT', reason });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const whitelistUserInclude = {
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
updatedBy: { select: { id: true, username: true, displayName: true } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RiskReviewService } from './risk-review.service';
|
||||
function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
riskRule: {
|
||||
count: jest.fn().mockResolvedValue(5),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
@@ -26,6 +27,7 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
},
|
||||
smsTemplate: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
smsSendTask: {
|
||||
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
|
||||
@@ -61,6 +63,68 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('RiskReviewService', () => {
|
||||
it('shares read-only rule inputs across an approved CMPP evaluation batch', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
const results = await service.evaluateTasksBatch([
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000001', phones: ['13800000001'], sourceType: 'cmpp' },
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', content: '【测试】验证码000002', phones: ['13800000002'], sourceType: 'cmpp' },
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((result) => result.status === 'approved')).toBe(true);
|
||||
expect(prisma.riskRule.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsBatchTask.count).not.toHaveBeenCalled();
|
||||
expect(prisma.smsSendTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coalesces concurrent default-rule checks and reuses the short completeness cache', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
let releaseCount: ((count: number) => void) | undefined;
|
||||
prisma.riskRule.count.mockReturnValue(new Promise((resolve) => { releaseCount = resolve; }));
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
const first = service.ensureDefaultRules();
|
||||
const second = service.ensureDefaultRules();
|
||||
releaseCount?.(5);
|
||||
await Promise.all([first, second]);
|
||||
await service.ensureDefaultRules();
|
||||
|
||||
expect(prisma.riskRule.count).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.riskRule.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a failed default-rule check so the next request can retry', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.riskRule.count
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
.mockResolvedValueOnce(5);
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
await expect(service.ensureDefaultRules()).rejects.toThrow('database unavailable');
|
||||
await expect(service.ensureDefaultRules()).resolves.toBeUndefined();
|
||||
|
||||
expect(prisma.riskRule.count).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to per-rule recovery when the completeness count finds a missing default', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.riskRule.count.mockResolvedValue(4);
|
||||
prisma.riskRule.findFirst.mockImplementation(({ where }: { where: { code: string } }) => (
|
||||
Promise.resolve(where.code === 'PHONE_FREQUENCY_5M' ? null : { id: `rule-${where.code}` })
|
||||
));
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
service.createRule = jest.fn().mockResolvedValue({ id: 'restored-rule' }) as never;
|
||||
|
||||
await service.ensureDefaultRules();
|
||||
|
||||
expect(prisma.riskRule.findFirst).toHaveBeenCalledTimes(5);
|
||||
expect(service.createRule).toHaveBeenCalledTimes(1);
|
||||
expect(service.createRule).toHaveBeenCalledWith(expect.objectContaining({ code: 'PHONE_FREQUENCY_5M' }));
|
||||
});
|
||||
|
||||
it('keeps phone-frequency periods fixed and rejects manual-review actions', () => {
|
||||
const service = new RiskReviewService(createPrismaMock() as never);
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ const RULE_DEFINITIONS = new Map(DEFAULT_RULES.map((rule) => [rule.code, rule]))
|
||||
|
||||
@Injectable()
|
||||
export class RiskReviewService {
|
||||
private defaultRulesCheck?: { expiresAt: number; promise: Promise<void> };
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listRules(applicationId?: string) {
|
||||
@@ -431,6 +433,81 @@ export class RiskReviewService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an Inbox claim batch against one database snapshot of the read-only
|
||||
* rule inputs. Approved items do not need their own template/rule/sensitive-word
|
||||
* queries; exceptional decisions still go through evaluateTask so their audit
|
||||
* task and hit rows keep the existing semantics.
|
||||
*/
|
||||
async evaluateTasksBatch(items: EvaluateSmsTaskDto[]) {
|
||||
if (items.length === 0) return [];
|
||||
await this.ensureDefaultRules();
|
||||
if (items.some((item) => item.createdById)) {
|
||||
// The CMPP worker never supplies createdById. Keep the general API honest
|
||||
// instead of silently weakening its foreign-key validation in the fast path.
|
||||
return Promise.all(items.map((item) => this.evaluateTask(item)));
|
||||
}
|
||||
const applicationIds = [...new Set(items.map((item) => item.applicationId).filter((id): id is string => Boolean(id)))];
|
||||
const templateIds = [...new Set(items.map((item) => item.templateId).filter((id): id is string => Boolean(id)))];
|
||||
const [templates, sensitiveWords, applicationInputs] = await Promise.all([
|
||||
templateIds.length
|
||||
? this.prisma.smsTemplate.findMany({ where: { id: { in: templateIds } }, include: { variables: true } })
|
||||
: Promise.resolve([]),
|
||||
this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }),
|
||||
Promise.all(applicationIds.map(async (applicationId) => ({
|
||||
applicationId,
|
||||
rules: await this.effectiveRules(applicationId),
|
||||
recentTaskCount: await this.countRecentClientTasks(applicationId, 'cmpp'),
|
||||
}))),
|
||||
]);
|
||||
const templateById = new Map(templates.map((template) => [template.id, template]));
|
||||
const inputsByApplication = new Map(applicationInputs.map((entry) => [entry.applicationId, entry]));
|
||||
|
||||
return Promise.all(items.map(async (data) => {
|
||||
const phones = data.phones ?? [];
|
||||
const uniquePhones = [...new Set(phones)];
|
||||
const phoneTotal = phones.length;
|
||||
const template = data.templateId ? templateById.get(data.templateId) : undefined;
|
||||
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
|
||||
const contentIssues = evaluateContent(data.content, sensitiveWords);
|
||||
if (variableIssues.length > 0) {
|
||||
contentIssues.push({
|
||||
ruleCode: 'TEMPLATE_VARIABLE_INVALID',
|
||||
ruleName: '模板变量校验失败',
|
||||
thresholdValue: 0,
|
||||
actualValue: variableIssues.length,
|
||||
action: 'block',
|
||||
reason: formatTemplateVariableIssueReason(variableIssues),
|
||||
});
|
||||
}
|
||||
const applicationInput = data.applicationId ? inputsByApplication.get(data.applicationId) : undefined;
|
||||
const rules = applicationInput?.rules ?? [];
|
||||
const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
|
||||
const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK');
|
||||
const nonWorkingMarketingPhones = isMarketing(data.category ?? template?.category)
|
||||
&& isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config))
|
||||
? phoneTotal
|
||||
: 0;
|
||||
const hits = this.evaluateRules(rules, {
|
||||
phoneTotal,
|
||||
nonWorkingMarketingPhones,
|
||||
recentTaskCount: applicationInput?.recentTaskCount ?? 0,
|
||||
});
|
||||
hits.push(...contentIssues.map(contentIssueToHit));
|
||||
const decision = decideRiskAction(hits);
|
||||
if (decision.status !== 'approved') {
|
||||
return this.evaluateTask(data);
|
||||
}
|
||||
return {
|
||||
canSubmit: true,
|
||||
status: decision.status,
|
||||
riskDecision: decision.riskDecision,
|
||||
reason: hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null,
|
||||
task: null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async approveTask(taskId: string, data: ReviewSmsTaskDto) {
|
||||
const task = await this.prisma.smsSendTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
@@ -491,14 +568,39 @@ export class RiskReviewService {
|
||||
}
|
||||
|
||||
async ensureDefaultRules() {
|
||||
const now = Date.now();
|
||||
if (this.defaultRulesCheck && this.defaultRulesCheck.expiresAt > now) {
|
||||
return this.defaultRulesCheck.promise;
|
||||
}
|
||||
|
||||
// Submit高并发时,任务风控和号码频控都会确认默认规则。短TTL只缓存“规则是否齐全”,
|
||||
// 实际生效规则仍逐次查询;并发单飞避免每条短信重复执行5次存在性SQL,同时允许删除后自动恢复。
|
||||
const promise = this.ensureDefaultRulesFromDatabase();
|
||||
this.defaultRulesCheck = { expiresAt: now + 30_000, promise };
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (this.defaultRulesCheck?.promise === promise) this.defaultRulesCheck = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDefaultRulesFromDatabase() {
|
||||
const existingCount = await this.prisma.riskRule.count({
|
||||
where: {
|
||||
applicationId: null,
|
||||
code: { in: DEFAULT_RULES.map((rule) => rule.code) },
|
||||
status: { not: 'deleted' },
|
||||
},
|
||||
});
|
||||
if (existingCount === DEFAULT_RULES.length) return;
|
||||
|
||||
for (const rule of DEFAULT_RULES) {
|
||||
const exists = await this.prisma.riskRule.findFirst({
|
||||
where: { applicationId: null, code: rule.code, status: { not: 'deleted' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!exists) {
|
||||
await this.createRule(rule);
|
||||
}
|
||||
if (!exists) await this.createRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
@@ -21,6 +21,7 @@ export type DownstreamDeliveryQueueRequest = {
|
||||
receiptDedupeKey?: string;
|
||||
queueHttpWebhook?: boolean;
|
||||
queueCmppDelivery?: boolean;
|
||||
allowBusinessRejectionCmppDelivery?: boolean;
|
||||
propagateHttpQueueError?: boolean;
|
||||
};
|
||||
|
||||
@@ -80,6 +81,7 @@ export async function queueFinalReceiptDeliveries(
|
||||
message: FinalReceiptMessage;
|
||||
payload: Record<string, unknown>;
|
||||
segmentPayloads?: Record<number, Record<string, unknown>>;
|
||||
allowBusinessRejectionCmppDelivery?: boolean;
|
||||
propagateHttpQueueError?: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -125,6 +127,7 @@ export async function queueFinalReceiptDeliveries(
|
||||
: `receipt:${message.id}:segment:${target.segmentIndex}`,
|
||||
queueHttpWebhook: false,
|
||||
queueCmppDelivery: true,
|
||||
allowBusinessRejectionCmppDelivery: data.allowBusinessRejectionCmppDelivery,
|
||||
});
|
||||
}
|
||||
return { queued: true, cmppTargetCount: targets.length };
|
||||
|
||||
@@ -11,7 +11,6 @@ describe('drainage content detection', () => {
|
||||
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
|
||||
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
|
||||
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
|
||||
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
|
||||
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
|
||||
['短横线手机号', '电话 138-0013-8000', 'mobile'],
|
||||
['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'],
|
||||
@@ -26,6 +25,23 @@ describe('drainage content detection', () => {
|
||||
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', () => {
|
||||
const content = '📨详情请看 example。com/path,谢谢';
|
||||
const result = detectDrainageContentWithRules(content, rules);
|
||||
|
||||
@@ -83,7 +83,10 @@ function normalizeContent(content: string, category: DrainageDetectionCategory):
|
||||
.replace(/[()]/g, (char) => char === '(' ? '(' : ')')
|
||||
.replace(/[+]/g, '+');
|
||||
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, '.');
|
||||
} else if (category === 'mobile' || category === 'landline') {
|
||||
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
|
||||
@@ -131,7 +134,7 @@ export function detectDrainageContentWithRules(
|
||||
): DrainageDetectionResult {
|
||||
const matches: DrainageDetectionMatch[] = [];
|
||||
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));
|
||||
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
|
||||
validateDrainageDetectionPattern(rule.pattern, rule.flags);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { GatewayCallbackController } from './gateway-callback.controller';
|
||||
|
||||
describe('GatewayCallbackController', () => {
|
||||
const sendChain = {
|
||||
handleSubmitResult: jest.fn(), handleSubmitSegmentResult: jest.fn(),
|
||||
intakeReceipt: jest.fn(), handleReceipt: jest.fn(), handleUplink: jest.fn(),
|
||||
recordGatewaySubmitDeadLetter: jest.fn(),
|
||||
};
|
||||
const protocolLogs = { record: jest.fn() };
|
||||
const prisma = { $queryRaw: jest.fn(), getPoolState: jest.fn().mockReturnValue({ max: 16, total: 2, idle: 1, waiting: 0 }) };
|
||||
const controller = new GatewayCallbackController(sendChain as never, protocolLogs as never, prisma as never);
|
||||
|
||||
beforeEach(() => { jest.clearAllMocks(); process.env.PROTOCOL_LOG_CALLBACK_TRACKING_ENABLED = 'true'; });
|
||||
afterAll(() => { delete process.env.PROTOCOL_LOG_CALLBACK_TRACKING_ENABLED; });
|
||||
|
||||
it('keeps Submit result persistence on the callback process without duplicating protocol logs', async () => {
|
||||
sendChain.handleSubmitResult.mockResolvedValue({ accepted: true });
|
||||
await expect(controller.submitResult({
|
||||
messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: '1', sequenceId: 1,
|
||||
submitStatus: 'accepted',
|
||||
})).resolves.toEqual({ accepted: true });
|
||||
expect(sendChain.handleSubmitResult).toHaveBeenCalledTimes(1);
|
||||
expect(protocolLogs.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('durably intakes receipts before recording the callback protocol event', async () => {
|
||||
sendChain.intakeReceipt.mockResolvedValue({ accepted: true, inboxId: 'inbox-1' });
|
||||
await controller.receiptIntake({
|
||||
messageId: 'MSG-1', channelId: 'channel-1', gatewayMessageId: '1',
|
||||
phoneNumber: '13800000001', receiptStatus: 'delivered', rawStatus: 'DELIVRD',
|
||||
});
|
||||
expect(sendChain.intakeReceipt).toHaveBeenCalledTimes(1);
|
||||
expect(protocolLogs.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
eventType: 'deliver_receipt', messageId: 'MSG-1', status: 'success',
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects inbound/client packet logs from the isolated supplier callback surface', () => {
|
||||
expect(() => controller.protocolLog({
|
||||
protocol: 'cmpp', direction: 'client_to_platform', eventType: 'submit', status: 'success',
|
||||
})).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns an independent result for every event in a callback batch', async () => {
|
||||
sendChain.handleSubmitResult.mockResolvedValue({ accepted: true });
|
||||
sendChain.handleUplink.mockResolvedValue({ accepted: true });
|
||||
await expect(controller.batch({
|
||||
batchId: 'CB-1', gatewayInstanceId: 'gateway-1', events: [
|
||||
{ eventId: 'EV-1', type: 'submit_result', payload: { messageId: 'MSG-1', channelId: 'channel-1', submitStatus: 'accepted' } },
|
||||
{ eventId: 'EV-2', type: 'uplink', payload: { messageId: 'MSG-1', channelId: 'channel-1', content: '1' } },
|
||||
{ eventId: 'EV-3', type: 'unsupported', payload: {} },
|
||||
],
|
||||
})).resolves.toEqual({ batchId: 'CB-1', results: [
|
||||
{ eventId: 'EV-1', accepted: true },
|
||||
{ eventId: 'EV-2', accepted: true },
|
||||
{ eventId: 'EV-3', accepted: false, retryable: false, errorCode: 'INVALID_EVENT' },
|
||||
] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { BadRequestException, Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||
import type {
|
||||
GatewayReceiptEventDto,
|
||||
GatewaySubmitDeadLetterDto,
|
||||
GatewaySubmitResultDto,
|
||||
GatewaySubmitSegmentResultDto,
|
||||
GatewayUplinkEventDto,
|
||||
} from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@Controller()
|
||||
export class GatewayCallbackController {
|
||||
constructor(
|
||||
private readonly sendChain: SendChainService,
|
||||
private readonly protocolLogs: ProtocolLogsService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('health')
|
||||
async health() {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { status: 'ok', role: 'gateway-callback', databasePool: this.prisma.getPoolState() };
|
||||
}
|
||||
|
||||
@Post('gateway/events/submit-result')
|
||||
submitResult(@Body() body: GatewaySubmitResultDto) {
|
||||
return this.sendChain.handleSubmitResult(body);
|
||||
}
|
||||
|
||||
@Post('gateway/events/submit-segment-result')
|
||||
submitSegmentResult(@Body() body: GatewaySubmitSegmentResultDto) {
|
||||
return this.sendChain.handleSubmitSegmentResult(body);
|
||||
}
|
||||
|
||||
@Post('gateway/events/receipt/intake')
|
||||
receiptIntake(@Body() body: GatewayReceiptEventDto) {
|
||||
return this.track('deliver_receipt', body, () => this.sendChain.intakeReceipt(body));
|
||||
}
|
||||
|
||||
@Post('gateway/events/receipt')
|
||||
receipt(@Body() body: GatewayReceiptEventDto) {
|
||||
return this.track('deliver_receipt', body, () => this.sendChain.handleReceipt(body));
|
||||
}
|
||||
|
||||
@Post('gateway/events/uplink')
|
||||
uplink(@Body() body: GatewayUplinkEventDto) {
|
||||
return this.track('deliver_uplink', body, () => this.sendChain.handleUplink(body));
|
||||
}
|
||||
|
||||
@Post('gateway/events/protocol-log')
|
||||
protocolLog(@Body() body: ProtocolLogInput) {
|
||||
const allowedPacket = (
|
||||
body.direction === 'platform_to_channel' && ['submit', 'deliver_resp'].includes(body.eventType)
|
||||
) || (
|
||||
body.direction === 'channel_to_platform' && body.eventType === 'submit_resp'
|
||||
);
|
||||
if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) {
|
||||
throw new BadRequestException('Unsupported Gateway callback protocol log event');
|
||||
}
|
||||
this.protocolLogs.record(body);
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
@Post('gateway/events/dead-letter')
|
||||
deadLetter(@Body() body: GatewaySubmitDeadLetterDto) {
|
||||
return this.sendChain.recordGatewaySubmitDeadLetter(body);
|
||||
}
|
||||
|
||||
@Post('gateway/events/batch')
|
||||
async batch(@Body() body: {
|
||||
batchId?: string;
|
||||
gatewayInstanceId?: string;
|
||||
createdAt?: string;
|
||||
events?: Array<{ eventId?: string; type?: string; payload?: Record<string, unknown> }>;
|
||||
}) {
|
||||
if (!body.batchId || !body.gatewayInstanceId || !Array.isArray(body.events) || body.events.length < 1 || body.events.length > 100) {
|
||||
throw new BadRequestException('batchId, gatewayInstanceId and 1 to 100 events are required');
|
||||
}
|
||||
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > 1024 * 1024) {
|
||||
throw new BadRequestException('Gateway callback batch exceeds 1MB');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const results: Array<{ eventId: string; accepted: boolean; retryable?: boolean; errorCode?: string }> = [];
|
||||
for (let offset = 0; offset < body.events.length; offset += 25) {
|
||||
results.push(...await Promise.all(body.events.slice(offset, offset + 25).map(async (event) => {
|
||||
const eventId = String(event.eventId ?? '').trim();
|
||||
if (!eventId || seen.has(eventId) || !event.payload || typeof event.payload !== 'object') {
|
||||
return { eventId, accepted: false, retryable: false, errorCode: seen.has(eventId) ? 'DUPLICATE_EVENT_ID' : 'INVALID_EVENT' };
|
||||
}
|
||||
seen.add(eventId);
|
||||
try {
|
||||
await this.dispatchBatchEvent(String(event.type ?? ''), { ...event.payload, eventId });
|
||||
return { eventId, accepted: true };
|
||||
} catch (error) {
|
||||
const invalid = error instanceof BadRequestException;
|
||||
return { eventId, accepted: false, retryable: !invalid, errorCode: invalid ? 'INVALID_EVENT' : 'PROCESSING_FAILED' };
|
||||
}
|
||||
})));
|
||||
}
|
||||
return { batchId: body.batchId, results };
|
||||
}
|
||||
|
||||
private dispatchBatchEvent(type: string, payload: Record<string, unknown>) {
|
||||
switch (type) {
|
||||
case 'submit_result': return this.sendChain.handleSubmitResult(payload as unknown as GatewaySubmitResultDto);
|
||||
case 'submit_segment_result': return this.sendChain.handleSubmitSegmentResult(payload as unknown as GatewaySubmitSegmentResultDto);
|
||||
case 'receipt_intake': return this.track('deliver_receipt', payload, () => this.sendChain.intakeReceipt(payload as unknown as GatewayReceiptEventDto));
|
||||
case 'uplink': return this.track('deliver_uplink', payload, () => this.sendChain.handleUplink(payload as unknown as GatewayUplinkEventDto));
|
||||
case 'dead_letter': return this.sendChain.recordGatewaySubmitDeadLetter(payload as unknown as GatewaySubmitDeadLetterDto);
|
||||
default: throw new BadRequestException(`Unsupported batch event type ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async track<T>(eventType: string, body: object, action: () => Promise<T> | T) {
|
||||
const startedAt = Date.now();
|
||||
const value = body as Record<string, unknown>;
|
||||
const common: Omit<ProtocolLogInput, 'status'> = {
|
||||
protocol: 'cmpp', direction: 'channel_to_platform', eventType,
|
||||
tenantId: value.tenantId as string, applicationId: value.applicationId as string,
|
||||
channelId: value.channelId as string,
|
||||
messageId: (value.messageId ?? value.platformMessageId) as string,
|
||||
gatewayMessageId: (value.gatewayMessageId ?? value.msgId ?? value.upstreamMessageId) as string,
|
||||
phone: (value.phoneNumber ?? value.srcTerminalId) as string,
|
||||
resultCode: (value.rawStatus ?? value.status ?? value.stat) as string,
|
||||
};
|
||||
try {
|
||||
const result = await action();
|
||||
const resolved = result && typeof result === 'object' ? result as Record<string, unknown> : {};
|
||||
if (process.env.PROTOCOL_LOG_CALLBACK_TRACKING_ENABLED === 'true') this.protocolLogs.record({
|
||||
...common,
|
||||
tenantId: (resolved.tenantId ?? common.tenantId) as string,
|
||||
applicationId: (resolved.applicationId ?? common.applicationId) as string,
|
||||
messageId: (resolved.messageId ?? common.messageId) as string,
|
||||
status: 'success', durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (process.env.PROTOCOL_LOG_CALLBACK_TRACKING_ENABLED === 'true') this.protocolLogs.record({
|
||||
...common, status: 'failed', durationMs: Date.now() - startedAt,
|
||||
detail: { error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,17 @@ describe('GatewayEventsController protocol logging', () => {
|
||||
const protocolLogs = {
|
||||
record: jest.fn(),
|
||||
};
|
||||
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never);
|
||||
const metrics = {
|
||||
beginCmppInboundStage: jest.fn().mockReturnValue(1n),
|
||||
finishCmppInboundStage: jest.fn(),
|
||||
};
|
||||
const controller = new GatewayEventsController(
|
||||
sendChain as never,
|
||||
{} as never,
|
||||
protocolLogs as never,
|
||||
{ recordEvent: jest.fn() } as never,
|
||||
metrics as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -77,6 +87,20 @@ describe('GatewayEventsController protocol logging', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('records a failed inbound total without swallowing the service error', async () => {
|
||||
const failure = new Error('inbound failed');
|
||||
(sendChain as Record<string, jest.Mock>).submitInboundMessage = jest.fn().mockRejectedValue(failure);
|
||||
|
||||
await expect(controller.submitInbound({
|
||||
account: '607532',
|
||||
phoneNumber: '13127620092',
|
||||
content: 'failed',
|
||||
sequenceId: 142,
|
||||
})).rejects.toBe(failure);
|
||||
|
||||
expect(metrics.finishCmppInboundStage).toHaveBeenCalledWith(1n, 'total', 'error');
|
||||
});
|
||||
|
||||
it('accepts only safe outbound Gateway packet events', () => {
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
@@ -155,6 +179,7 @@ describe('GatewayEventsController protocol logging', () => {
|
||||
messageId: 'MSG-1',
|
||||
status: 'success',
|
||||
}));
|
||||
expect(metrics.finishCmppInboundStage).toHaveBeenCalledWith(1n, 'total', 'success');
|
||||
});
|
||||
|
||||
it('replaces a fallback receipt identifier with the resolved main message identifier', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Body, Controller, Post } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Optional, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
GatewayInboundAuthDto,
|
||||
@@ -18,6 +18,8 @@ import { SendChainService } from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
import { MetricsService } from '../metrics/metrics.service';
|
||||
|
||||
@ApiTags('gateway-events')
|
||||
@Controller('gateway/events')
|
||||
@@ -26,6 +28,8 @@ export class GatewayEventsController {
|
||||
private readonly sendChain: SendChainService,
|
||||
private readonly smsConfig: SmsConfigService,
|
||||
private readonly protocolLogs: ProtocolLogsService,
|
||||
private readonly security: SecurityDetectionService,
|
||||
@Optional() private readonly metrics?: MetricsService,
|
||||
) {}
|
||||
|
||||
@Post('submit-result')
|
||||
@@ -81,8 +85,13 @@ export class GatewayEventsController {
|
||||
}
|
||||
|
||||
@Post('inbound/authenticate')
|
||||
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||
return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
|
||||
async authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||
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')
|
||||
@@ -132,6 +141,9 @@ export class GatewayEventsController {
|
||||
direction: ProtocolLogInput['direction'] = 'channel_to_platform',
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
const inboundMetricStartedAt = eventType === 'submit' && direction === 'client_to_platform'
|
||||
? this.metrics?.beginCmppInboundStage()
|
||||
: undefined;
|
||||
const value = body as Record<string, unknown>;
|
||||
const common: Omit<ProtocolLogInput, 'status'> = {
|
||||
protocol: 'cmpp',
|
||||
@@ -166,6 +178,9 @@ export class GatewayEventsController {
|
||||
status: 'success',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
if (inboundMetricStartedAt != null) {
|
||||
this.metrics?.finishCmppInboundStage(inboundMetricStartedAt, 'total', 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.protocolLogs.record({
|
||||
@@ -174,6 +189,9 @@ export class GatewayEventsController {
|
||||
durationMs: Date.now() - startedAt,
|
||||
detail: { error: error instanceof Error ? error.message : 'unknown error' },
|
||||
});
|
||||
if (inboundMetricStartedAt != null) {
|
||||
this.metrics?.finishCmppInboundStage(inboundMetricStartedAt, 'total', 'error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,24 +44,13 @@ export class SendAccountingService {
|
||||
if (exists?.billingStatus === 'charged') {
|
||||
return;
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.release({
|
||||
const transaction = amountCents > 0 ? await this.billing.settleFrozenCharge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge-release:${message.messageId}`,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
taskId: message.batchTaskId,
|
||||
messageId: message.messageId,
|
||||
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
||||
});
|
||||
}
|
||||
const transaction = await this.billing.charge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: '提交成功扣费',
|
||||
});
|
||||
}) : null;
|
||||
const data = {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
@@ -73,7 +62,7 @@ export class SendAccountingService {
|
||||
unitPrice,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
transactionId: transaction.id,
|
||||
transactionId: transaction?.id,
|
||||
};
|
||||
if (exists) {
|
||||
await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
@@ -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 { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import { detectDrainageContent } from './drainage-content-detection';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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 } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
throw new BadRequestException('企业客户不存在或已停用');
|
||||
@@ -494,7 +499,12 @@ async validateSendResources(tenantId: string, applicationId?: string, templateId
|
||||
where: { id: templateId },
|
||||
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('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
@@ -515,15 +525,17 @@ async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
|
||||
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
|
||||
throw new BadRequestException('发送号码数量必须为正整数');
|
||||
}
|
||||
const usageDate = shanghaiDateKey();
|
||||
const usageDateValue = new Date(`${usageDate}T00:00:00.000Z`);
|
||||
const reserve = (client: Pick<Prisma.TransactionClient, '$queryRaw'>) => {
|
||||
const reservationId = randomUUID();
|
||||
const rows = await this.prisma.$queryRaw<Array<{ dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
|
||||
return client.$queryRaw<Array<{ tenantId: string; dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
|
||||
WITH application_limit AS (
|
||||
SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit"
|
||||
SELECT id, "tenantId", COALESCE("dailyLimit", 100000)::integer AS "dailyLimit"
|
||||
FROM "SmsApplication"
|
||||
WHERE id = ${applicationId}
|
||||
), reservation AS (
|
||||
@@ -540,10 +552,49 @@ async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
<= (SELECT "dailyLimit" FROM application_limit)
|
||||
RETURNING "usedCount"
|
||||
)
|
||||
SELECT application_limit."dailyLimit", reservation."usedCount"
|
||||
SELECT application_limit."tenantId", application_limit."dailyLimit", reservation."usedCount"
|
||||
FROM application_limit
|
||||
LEFT JOIN reservation ON TRUE
|
||||
`);
|
||||
};
|
||||
const normalizedReservationKey = reservationKey?.trim();
|
||||
const rows = normalizedReservationKey
|
||||
? await this.prisma.$transaction(async (tx) => {
|
||||
// The quota increment and its idempotency record share one short transaction. A worker
|
||||
// crash can therefore neither lose a successful reservation nor increment it twice.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'daily-quota:' + normalizedReservationKey}, 0))`;
|
||||
const existing = await tx.smsApplicationDailyReservation.findUnique({
|
||||
where: { reservationKey: normalizedReservationKey },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.applicationId !== applicationId || existing.requestedCount !== requestedCount) {
|
||||
throw new ConflictException('日发送配额幂等键已用于另一笔预留');
|
||||
}
|
||||
return [{
|
||||
tenantId: existing.tenantId,
|
||||
dailyLimit: existing.dailyLimit,
|
||||
usedCount: existing.usedCount,
|
||||
}];
|
||||
}
|
||||
const reservedRows = await reserve(tx);
|
||||
if (reservedRows.length > 0) {
|
||||
const row = reservedRows[0];
|
||||
await tx.smsApplicationDailyReservation.create({
|
||||
data: {
|
||||
reservationKey: normalizedReservationKey,
|
||||
tenantId: row.tenantId,
|
||||
applicationId,
|
||||
usageDate: usageDateValue,
|
||||
requestedCount,
|
||||
dailyLimit: Number(row.dailyLimit),
|
||||
usedCount: row.usedCount == null ? null : Number(row.usedCount),
|
||||
reserved: row.usedCount != null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return reservedRows;
|
||||
})
|
||||
: await reserve(this.prisma);
|
||||
if (rows.length === 0) {
|
||||
throw new NotFoundException('短信应用不存在');
|
||||
}
|
||||
|
||||
@@ -25,9 +25,12 @@ export interface GatewayInboundAuthDto {
|
||||
authSource?: string;
|
||||
timestamp?: number;
|
||||
remoteIp?: string;
|
||||
version?: string;
|
||||
requestedVersion?: number;
|
||||
}
|
||||
|
||||
export interface GatewayInboundSubmitDto {
|
||||
requestId?: string;
|
||||
account: string;
|
||||
phoneNumber?: string;
|
||||
phoneNumbers?: string[];
|
||||
@@ -56,6 +59,7 @@ export interface GatewayInboundSingleSubmitResult {
|
||||
}
|
||||
|
||||
export interface GatewaySubmitResultDto {
|
||||
eventId?: string;
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
@@ -79,6 +83,7 @@ export interface GatewaySubmitResultDto {
|
||||
}
|
||||
|
||||
export interface GatewaySubmitSegmentResultDto {
|
||||
eventId?: string;
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
@@ -94,6 +99,7 @@ export interface GatewaySubmitSegmentResultDto {
|
||||
}
|
||||
|
||||
export interface GatewayReceiptEventDto {
|
||||
eventId?: string;
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
@@ -109,6 +115,7 @@ export interface GatewayReceiptEventDto {
|
||||
}
|
||||
|
||||
export interface GatewayUplinkEventDto {
|
||||
eventId?: string;
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
@@ -131,10 +138,13 @@ export type UplinkMatchCandidateInput = {
|
||||
export interface GatewayPendingDeliveryQueryDto {
|
||||
account: string;
|
||||
limit?: number;
|
||||
claimId?: string;
|
||||
leaseMs?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamSentDto {
|
||||
id: string;
|
||||
claimId?: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
@@ -148,6 +158,7 @@ export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentD
|
||||
}
|
||||
|
||||
export type GatewayDownstreamFailureType =
|
||||
| 'claim_released'
|
||||
| 'send_failed'
|
||||
| 'ack_timeout'
|
||||
| 'ack_rejected'
|
||||
|
||||
@@ -49,6 +49,20 @@ describe('send-chain pure policies', () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses a stable weighted choice among equal-priority online primary channels', () => {
|
||||
const items = [
|
||||
{ channelId: 'primary-a', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'primary-b', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'backup', carrier: 'mobile', province: null, priority: 2, weight: 100, isBackup: true, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
const selected = new Set(Array.from({ length: 100 }, (_, index) => selectChannelCandidate(items, {
|
||||
carrier: 'mobile', routingKey: `message-${index}`,
|
||||
excludedChannelIds: new Set(), approvedChannelIds: new Set(items.map((item) => item.channelId)),
|
||||
})?.channelId));
|
||||
|
||||
expect(selected).toEqual(new Set(['primary-a', 'primary-b']));
|
||||
});
|
||||
|
||||
it('keeps a segmented message non-terminal until all receipts arrive', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:00:00Z') }],
|
||||
|
||||
@@ -226,7 +226,8 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
|
||||
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);
|
||||
return normalized === 'all' || normalized === targetCarrier;
|
||||
}
|
||||
@@ -446,8 +447,12 @@ export type ChannelCandidate = {
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
isBackup?: boolean | null;
|
||||
channel: {
|
||||
carrier?: string | null;
|
||||
carriers?: string[] | null;
|
||||
sendRegion?: string | null;
|
||||
status: string;
|
||||
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
|
||||
@@ -475,19 +480,41 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
|
||||
forceNational?: boolean;
|
||||
excludedChannelIds: ReadonlySet<string>;
|
||||
approvedChannelIds: ReadonlySet<string>;
|
||||
routingKey?: string;
|
||||
},
|
||||
) {
|
||||
const eligible = items.filter((item) =>
|
||||
!options.excludedChannelIds.has(item.channelId)
|
||||
&& options.approvedChannelIds.has(item.channelId)
|
||||
&& normalizeCarrier(item.carrier) === options.carrier
|
||||
&& isCarrierCompatible(item.channel.carrier, options.carrier),
|
||||
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
|
||||
);
|
||||
const provinceCandidates = options.forceNational
|
||||
? []
|
||||
: eligible.filter((item) => isProvinceChannel(item, options.province));
|
||||
const nationalCandidates = eligible.filter((item) => isNationalChannel(item));
|
||||
return [...provinceCandidates, ...nationalCandidates].find((item) => isChannelSendAvailable(item.channel));
|
||||
const scope = provinceCandidates.some((item) => isChannelSendAvailable(item.channel))
|
||||
? provinceCandidates
|
||||
: nationalCandidates;
|
||||
const available = scope.filter((item) => isChannelSendAvailable(item.channel));
|
||||
if (available.length === 0) return undefined;
|
||||
const priority = Math.min(...available.map((item) => item.priority ?? 100));
|
||||
const priorityPool = available.filter((item) => (item.priority ?? 100) === priority);
|
||||
const primaryPool = priorityPool.filter((item) => !item.isBackup);
|
||||
const pool = primaryPool.length > 0 ? primaryPool : priorityPool;
|
||||
if (pool.length === 1 || !options.routingKey) return pool[0];
|
||||
const totalWeight = pool.reduce((sum, item) => sum + Math.max(1, item.weight ?? 1), 0);
|
||||
let hash = 2166136261;
|
||||
for (const character of options.routingKey) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619) >>> 0;
|
||||
}
|
||||
let slot = hash % totalWeight;
|
||||
for (const item of pool) {
|
||||
slot -= Math.max(1, item.weight ?? 1);
|
||||
if (slot < 0) return item;
|
||||
}
|
||||
return pool[0];
|
||||
}
|
||||
|
||||
export type ReceiptSegmentAudit = {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { AdminSendChainController } from './admin-send-chain.controller';
|
||||
import { ClientSendChainController } from './client-send-chain.controller';
|
||||
import { GatewayEventsController } from './gateway-events.controller';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
import { SecurityDetectionModule } from '../security-detection/security-detection.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],
|
||||
providers: [SendChainService],
|
||||
exports: [SendChainService],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,15 @@ import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import { MetricsService } from '../metrics/metrics.service';
|
||||
import { OpenApiService } from '../open-api/open-api.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
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 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 { SendDownstreamRequeueTaskService, type DownstreamRequeueFilter } from './send-downstream-requeue-task.service';
|
||||
|
||||
@Injectable()
|
||||
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -37,8 +39,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
|
||||
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private downstreamRequeueTaskIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private readonly submission: SendSubmissionService;
|
||||
private readonly completion: SendCompletionService;
|
||||
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -47,6 +51,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
@Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService,
|
||||
@Optional() phoneRouting?: PhoneRoutingLookupService,
|
||||
@Optional() metrics?: MetricsService,
|
||||
) {
|
||||
const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma);
|
||||
this.submission = new SendSubmissionService(
|
||||
@@ -61,6 +66,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
recordCmppFailureReceipt: (message, errorCode, reason) =>
|
||||
this.recordCmppFailureReceipt(message, errorCode, reason),
|
||||
},
|
||||
metrics,
|
||||
);
|
||||
this.completion = new SendCompletionService(
|
||||
prisma,
|
||||
@@ -68,12 +74,22 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
openApi,
|
||||
this as unknown as SendCompletionFacade,
|
||||
);
|
||||
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'all';
|
||||
if (processRole === 'api' || processRole === 'callback') return;
|
||||
if (processRole === 'outbox') {
|
||||
this.submission.startSubmitOutboxPublisher();
|
||||
return;
|
||||
}
|
||||
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
|
||||
this.startWorker();
|
||||
}
|
||||
if (process.env.CMPP_INBOUND_WORKFLOW_WORKER_ENABLED === 'true') {
|
||||
this.submission.startInboundWorkflowWorker();
|
||||
}
|
||||
if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') {
|
||||
this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS);
|
||||
this.receiptTimeoutInitialTimer.unref?.();
|
||||
@@ -129,6 +145,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
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() {
|
||||
@@ -140,10 +163,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
|
||||
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
|
||||
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
|
||||
if (this.downstreamRequeueTaskIntervalTimer) clearInterval(this.downstreamRequeueTaskIntervalTimer);
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
await this.submission.onModuleDestroy();
|
||||
}
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
@@ -337,8 +362,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.submission.confirmImport(data);
|
||||
}
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
return this.submission.enqueueBatchTask(taskId);
|
||||
async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
|
||||
return this.submission.enqueueBatchTask(taskId, preparedMessage);
|
||||
}
|
||||
|
||||
async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
@@ -481,6 +506,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.completion.requeueGatewaySubmitDeadLetter(id, data);
|
||||
}
|
||||
|
||||
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
|
||||
return this.completion.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
return this.completion.recoverStaleGatewaySubmitRequeues(now);
|
||||
}
|
||||
@@ -497,6 +526,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
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) {
|
||||
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
||||
}
|
||||
@@ -536,8 +589,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendChainService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
requestedMessageIds?: string[],
|
||||
workflowKey?: string,
|
||||
) {
|
||||
return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId);
|
||||
return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
|
||||
}
|
||||
|
||||
private async collectInboundLongMessageFragment(
|
||||
@@ -556,10 +611,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
application: NonNullable<Awaited<ReturnType<SendChainService['findInboundApplication']>>>,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
workflowItemKey?: string,
|
||||
) {
|
||||
return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection);
|
||||
return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,8 +631,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}) {
|
||||
return this.submission.evaluateRiskWithPhoneFrequency(input);
|
||||
}, reservationKey?: string) {
|
||||
return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey);
|
||||
}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
@@ -648,8 +705,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.submission.selectChannelForMessage(message, options);
|
||||
}
|
||||
|
||||
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
return this.submission.findApplicationRoute(tenantId, applicationId, carrier);
|
||||
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
|
||||
return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
|
||||
}
|
||||
|
||||
private async identifyCarrier(phoneNumber: string) {
|
||||
@@ -722,16 +779,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||
}
|
||||
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
return this.submission.validateSendResources(tenantId, applicationId, templateId);
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
|
||||
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
|
||||
}
|
||||
|
||||
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.submission.reserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
private async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.submission.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
private async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
|
||||
return this.submission.tryReserveDailySendQuota(applicationId, requestedCount, reservationKey);
|
||||
}
|
||||
|
||||
private async chargeAcceptedMessage(message: {
|
||||
@@ -770,8 +827,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
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 }) {
|
||||
@@ -782,8 +840,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.submission.waitForChannelRateLimit(channelId, tps);
|
||||
}
|
||||
|
||||
private async refreshTaskProgress(batchTaskId: string) {
|
||||
return this.submission.refreshTaskProgress(batchTaskId);
|
||||
private async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
|
||||
return this.submission.refreshTaskProgress(batchTaskId, knownSingleMessageStatus);
|
||||
}
|
||||
|
||||
private smsMessageSegmentAuditDelegate() {
|
||||
|
||||
@@ -158,6 +158,10 @@ export class SendCompletionService {
|
||||
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
|
||||
}
|
||||
|
||||
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
|
||||
return this.retry.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
return this.retry.recoverStaleGatewaySubmitRequeues(now);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
@@ -28,6 +28,10 @@ export class SendDownstreamDeliveryService {
|
||||
) {}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
if (data.eventId) {
|
||||
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
|
||||
if (existing) return existing;
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
@@ -35,6 +39,7 @@ export class SendDownstreamDeliveryService {
|
||||
const match = await this.facade.resolveUplinkMatch(data, channel);
|
||||
const record = await this.prisma.smsUplinkMessage.create({
|
||||
data: {
|
||||
eventId: data.eventId,
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
@@ -205,6 +210,7 @@ export class SendDownstreamDeliveryService {
|
||||
},
|
||||
});
|
||||
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
|
||||
const cmppDeliveryAllowed = deliveryAllowed || data.allowBusinessRejectionCmppDelivery === true;
|
||||
if (deliveryAllowed && data.queueHttpWebhook !== false) {
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
@@ -224,7 +230,9 @@ export class SendDownstreamDeliveryService {
|
||||
if (data.queueCmppDelivery === false) {
|
||||
return null;
|
||||
}
|
||||
if (application?.interfaceEnabled !== true) {
|
||||
if (!application?.cmppAccount || (
|
||||
application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
@@ -244,11 +252,11 @@ export class SendDownstreamDeliveryService {
|
||||
dedupeKey,
|
||||
deliveryType: data.deliveryType,
|
||||
payload,
|
||||
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
|
||||
retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink'
|
||||
? application?.downstreamUplinkRetryEnabled ?? true
|
||||
: application?.downstreamReceiptRetryEnabled ?? true),
|
||||
status: deliveryAllowed ? 'pending' : 'abandoned',
|
||||
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
|
||||
status: cmppDeliveryAllowed ? 'pending' : 'abandoned',
|
||||
lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -273,24 +281,43 @@ export class SendDownstreamDeliveryService {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!deliveryAllowed) {
|
||||
if (!cmppDeliveryAllowed) {
|
||||
return delivery;
|
||||
}
|
||||
const claimId = `api-direct:${process.pid}:${randomUUID()}`;
|
||||
const claim = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: delivery.id, status: 'pending' },
|
||||
data: {
|
||||
status: 'dispatching',
|
||||
connectionId: claimId,
|
||||
ackDeadlineAt: new Date(Date.now() + 30_000),
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
if (claim.count !== 1) {
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } });
|
||||
}
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(
|
||||
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
|
||||
{ deliveryId: delivery.id, ...payload },
|
||||
{ deliveryId: delivery.id, claimId, ...payload },
|
||||
) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
await this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
await this.facade.markDownstreamDeliverySent({ id: delivery.id, claimId, ...result });
|
||||
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
|
||||
return delivery;
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
'claim_released',
|
||||
{ id: delivery.id, claimId, ...result },
|
||||
);
|
||||
} else {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
{ id: delivery.id, ...result },
|
||||
{ id: delivery.id, claimId, ...result },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -454,6 +481,7 @@ export class SendDownstreamDeliveryService {
|
||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
||||
{
|
||||
message,
|
||||
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: `PLATFORM:${message.messageId}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user