77 Commits
Author SHA1 Message Date
hectorzhao b1e297245c docs: record tenant batching capacity results 2026-08-25 18:10:37 +08:00
hectorzhao b5005f21d5 fix: make downstream connection events idempotent 2026-08-25 17:53:05 +08:00
hectorzhao 394949f9f6 fix: align api keepalive with gateway pool 2026-08-25 17:35:18 +08:00
hectorzhao 76e7c8c401 perf: batch inbound workflows by tenant 2026-08-25 17:11:55 +08:00
hectorzhao d3ceeb1e16 docs: diagnose paid single-tenant throughput ceiling 2026-08-25 16:38:59 +08:00
hectorzhao 9292352be1 perf: expand gateway capacity and prevent receipt replay 2026-08-25 16:08:30 +08:00
hectorzhao 761c123b65 perf: batch gateway submits and isolate callbacks 2026-08-25 11:39:59 +08:00
hectorzhao c6f11014d6 test: verify CMPP send guards and channel retry 2026-08-21 12:05:47 +08:00
hectorzhao 03f72c87de docs: record CMPP main flow regression 2026-08-21 11:40:26 +08:00
hectorzhao 2b5256d4a1 docs: record phase four paid stress results 2026-08-21 11:18:19 +08:00
hectorzhao 90345fba22 perf: coalesce batch progress refreshes 2026-08-21 11:07:20 +08:00
hectorzhao 3c6f1beed1 perf: collapse short submit result callbacks 2026-08-21 10:46:27 +08:00
hectorzhao fcf6d3e6b4 perf: remove paid result account lock contention 2026-08-21 10:40:28 +08:00
hectorzhao 487b5282a6 perf: batch paid CMPP accounting and weighted routes 2026-08-21 10:25:40 +08:00
hectorzhao 0176aa6952 docs: record CMPP phase three pressure results 2026-08-21 09:56:31 +08:00
hectorzhao 52028b9bbd perf: batch CMPP inbound workflow processing 2026-08-21 09:44:51 +08:00
hectorzhao 57192b7586 docs: record CMPP phase two pressure results 2026-08-20 19:13:46 +08:00
hectorzhao f4560479d3 fix: type CMPP inbox JSON parameters 2026-08-20 19:04:12 +08:00
hectorzhao 99fb346566 perf: merge CMPP inbox validation and persistence 2026-08-20 18:55:09 +08:00
hectorzhao 633e7a7055 docs(cmpp): record phase one capacity ceiling 2026-08-20 18:39:29 +08:00
hectorzhao 229a0b28fd perf(cmpp): reserve transport for inbound submit 2026-08-20 18:23:01 +08:00
hectorzhao 6708f1f7c5 perf(cmpp): isolate inbound transport capacity 2026-08-20 18:09:29 +08:00
hectorzhao 53073461e9 fix(cmpp): honor UTC inbox retry leases 2026-08-20 17:50:00 +08:00
hectorzhao 0b63bcd74e perf(cmpp): add durable inbound fast path 2026-08-20 17:34:45 +08:00
hectorzhao 26ef67fb6a docs(test): record v5 pressure and priority results 2026-08-20 16:26:44 +08:00
hectorzhao 14f993c1f8 perf(cmpp): reduce inbound database round trips 2026-08-20 15:29:45 +08:00
hectorzhao 67b760a599 perf(cmpp): decouple supplier result callbacks 2026-08-20 14:19:42 +08:00
hectorzhao 485af688d2 docs(cmpp): record V3 test pressure results 2026-08-20 12:34:08 +08:00
hectorzhao e9c73333b3 fix(cmpp): preserve inbound window after message registration 2026-08-20 12:08:49 +08:00
hectorzhao 0757a699ff perf(cmpp): process inbound submits within client window 2026-08-20 11:45:16 +08:00
hectorzhao b9a71fe0b9 perf(cmpp): instrument inbound flow and unbatch submit worker 2026-08-20 11:27:35 +08:00
hectorzhao c4f36fc50d docs: record monitoring read deployment 2026-08-16 15:11:52 +08:00
hectorzhao 482f7ac1ae feat: add monitoring alert read state 2026-08-16 15:04:26 +08:00
hectorzhao 79f5d3f215 docs: record monitoring test deployment 2026-08-14 18:05:19 +08:00
hectorzhao 2216d00d51 fix: preserve Prometheus rule group access 2026-08-14 18:02:42 +08:00
hectorzhao 0cd09441da fix: guard configurable monitoring ratios 2026-08-14 17:59:22 +08:00
hectorzhao 6ccc102830 feat: add configurable infrastructure alerts 2026-08-14 17:51:42 +08:00
hectorzhao 1ef4380422 feat: 完善服务监控与下游重投 2026-08-14 17:21:29 +08:00
hectorzhao d30d9ea4d0 feat: add Fail2ban security detection console 2026-08-14 10:58:18 +08:00
hectorzhao b78faa1aa2 feat: add Prometheus system monitoring 2026-08-14 10:10:28 +08:00
hectorzhao 96e475d60d docs: record downstream requeue release 2026-08-13 12:13:41 +08:00
hectorzhao 433b2ee56f feat: harden downstream requeue tasks 2026-08-13 12:05:57 +08:00
hectorzhao 67fee21616 feat: refine operations UI and transport limits 2026-08-13 10:42:59 +08:00
hectorzhao fb02cbcf39 docs: 记录签名质量热修复发布 2026-08-12 17:56:26 +08:00
hectorzhao 4c70978da4 fix: 修复未报备签名聚合查询 2026-08-12 17:53:30 +08:00
hectorzhao 16135e5a3e style: 更新运营商标签配色 2026-08-12 17:22:43 +08:00
hectorzhao 4994841709 feat: 增强下游重投与签名质量检测 2026-08-12 17:05:53 +08:00
hectorzhao 1d8d6701a6 docs: 记录通道界面优化部署 2026-08-12 13:25:26 +08:00
hectorzhao f350bf5ef3 feat: 优化通道运营商与金额展示 2026-08-12 13:16:25 +08:00
hectorzhao dc358798e9 docs: 记录签名热力图预发布结果 2026-08-12 11:57:25 +08:00
hectorzhao 0cd353450a feat: 优化签名热力图与金额展示 2026-08-12 11:41:47 +08:00
hectorzhao e64b5e23fe docs: record legacy signature migration deployment 2026-08-10 22:59:16 +08:00
hectorzhao 2ecb24cf8d feat: remove legacy signature confirmation 2026-08-10 22:40:13 +08:00
hectorzhao 827d8921a8 docs: record signature retirement deployment 2026-08-10 21:06:09 +08:00
hectorzhao 0eb27e4ac0 fix: wait for services during deployment 2026-08-10 21:00:18 +08:00
hectorzhao 55aa054005 feat: add carrier-aware signature retirement alerts 2026-08-10 20:54:05 +08:00
hectorzhao 232d1c22a3 revert: roll back signature quality period trends 2026-08-09 23:24:50 +08:00
hectorzhao e0c8f82bcf docs: record 35de17a2 preproduction deployment 2026-08-09 23:03:02 +08:00
hectorzhao 35de17a2d4 feat: add signature quality period trends 2026-08-09 22:54:32 +08:00
hectorzhao 608662a054 docs: record 78b839f4 preproduction deployment 2026-08-09 21:04:25 +08:00
hectorzhao 78b839f468 feat: refine template deletion and channel group filters 2026-08-09 20:54:52 +08:00
hectorzhao 482d332f49 docs: record 7804f64c preproduction deployment 2026-08-09 19:17:04 +08:00
hectorzhao 7804f64ced feat: support governed cascade deletion 2026-08-09 19:08:38 +08:00
hectorzhao 6add563ee8 docs: record preproduction deployment for 4724b9db 2026-08-09 15:11:15 +08:00
hectorzhao 4724b9db6a feat: improve operations diagnostics and channel management 2026-08-09 14:27:19 +08:00
hectorzhao 44352aeb2f docs: record RealeseV2.3 preproduction deployment 2026-08-06 11:32:32 +08:00
hectorzhao 8ad8e61793 release: prepare RealeseV2.3 2026-08-06 10:48:36 +08:00
hectorzhao 57b58f1c40 docs: record 530a65de preproduction release 2026-08-03 16:36:04 +08:00
hectorzhao 530a65de80 feat: integrate analytics and fragment receipt improvements 2026-08-03 15:34:41 +08:00
hectorzhao 3357ace7e1 docs: record 94e997ec preproduction release 2026-08-02 20:46:22 +08:00
hectorzhao 94e997ec4d fix: improve retry feedback and deletion stability 2026-08-02 20:39:49 +08:00
hectorzhao ecc3d7a504 docs: record 37ffce40 preproduction release 2026-07-31 23:51:50 +08:00
hectorzhao 37ffce40b2 docs: record successful workspace push 2026-07-31 22:57:39 +08:00
hectorzhao 4b9127e1ab docs: record remote authentication blocker 2026-07-31 22:28:31 +08:00
hectorzhao 461a65f810 docs: record ca4f591a validation and push scope 2026-07-31 22:26:08 +08:00
hectorzhao ca4f591a13 feat: add phone frequency controls and modularize codebase 2026-07-31 22:25:23 +08:00
hectorzhao 0af671b4ed docs: record c0a4317a preproduction release 2026-07-29 22:58:06 +08:00
431 changed files with 66154 additions and 24610 deletions
+21
View File
@@ -8,8 +8,20 @@ REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_URL=redis://127.0.0.1:6379
HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters
# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters.
HTTP_API_PUBLIC_ORIGIN=https://api.example.com
API_ENABLE_SEND_WORKER=true
API_SEND_WORKER_CONCURRENCY=50
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
@@ -23,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
@@ -37,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,131 @@
CREATE TABLE "PhoneFrequencyHit" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"ruleId" TEXT,
"ruleCode" TEXT NOT NULL,
"ruleName" TEXT NOT NULL,
"phoneNumber" TEXT NOT NULL,
"thresholdValue" INTEGER NOT NULL,
"actualValue" INTEGER NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"windowEndsAt" TIMESTAMP(3) NOT NULL,
"generation" INTEGER NOT NULL DEFAULT 0,
"action" TEXT NOT NULL DEFAULT 'block',
"sourceType" TEXT,
"releasedAt" TIMESTAMP(3),
"releasedById" TEXT,
"releaseReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PhoneFrequencyHit_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PhoneFrequencyState" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"ruleId" TEXT,
"ruleCode" TEXT NOT NULL,
"phoneNumber" TEXT NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"windowEndsAt" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"generation" INTEGER NOT NULL DEFAULT 0,
"activeHitId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PhoneFrequencyState_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PhoneFrequencyHit_applicationId_ruleCode_phoneNumber_windowStartedAt_generation_key"
ON "PhoneFrequencyHit"("applicationId", "ruleCode", "phoneNumber", "windowStartedAt", "generation");
CREATE INDEX "PhoneFrequencyHit_tenantId_applicationId_createdAt_idx"
ON "PhoneFrequencyHit"("tenantId", "applicationId", "createdAt");
CREATE INDEX "PhoneFrequencyHit_applicationId_phoneNumber_createdAt_idx"
ON "PhoneFrequencyHit"("applicationId", "phoneNumber", "createdAt");
CREATE INDEX "PhoneFrequencyHit_windowEndsAt_releasedAt_idx"
ON "PhoneFrequencyHit"("windowEndsAt", "releasedAt");
CREATE INDEX "PhoneFrequencyHit_ruleId_idx" ON "PhoneFrequencyHit"("ruleId");
CREATE INDEX "PhoneFrequencyHit_releasedById_idx" ON "PhoneFrequencyHit"("releasedById");
CREATE UNIQUE INDEX "PhoneFrequencyState_activeHitId_key" ON "PhoneFrequencyState"("activeHitId");
CREATE UNIQUE INDEX "PhoneFrequencyState_applicationId_ruleCode_phoneNumber_key"
ON "PhoneFrequencyState"("applicationId", "ruleCode", "phoneNumber");
CREATE INDEX "PhoneFrequencyState_tenantId_applicationId_windowEndsAt_idx"
ON "PhoneFrequencyState"("tenantId", "applicationId", "windowEndsAt");
CREATE INDEX "PhoneFrequencyState_ruleId_idx" ON "PhoneFrequencyState"("ruleId");
ALTER TABLE "PhoneFrequencyHit"
ADD CONSTRAINT "PhoneFrequencyHit_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyHit"
ADD CONSTRAINT "PhoneFrequencyHit_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyHit"
ADD CONSTRAINT "PhoneFrequencyHit_ruleId_fkey"
FOREIGN KEY ("ruleId") REFERENCES "RiskRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyHit"
ADD CONSTRAINT "PhoneFrequencyHit_releasedById_fkey"
FOREIGN KEY ("releasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyState"
ADD CONSTRAINT "PhoneFrequencyState_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyState"
ADD CONSTRAINT "PhoneFrequencyState_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyState"
ADD CONSTRAINT "PhoneFrequencyState_ruleId_fkey"
FOREIGN KEY ("ruleId") REFERENCES "RiskRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyState"
ADD CONSTRAINT "PhoneFrequencyState_activeHitId_fkey"
FOREIGN KEY ("activeHitId") REFERENCES "PhoneFrequencyHit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "RiskRule" (
"id", "tenantId", "applicationId", "code", "name", "description", "metric",
"thresholdValue", "action", "status", "priority", "config", "createdAt", "updatedAt"
)
SELECT
'default-phone-frequency-24h', NULL, NULL, 'PHONE_FREQUENCY_24H',
'单号码24小时发送频次', '同一企业应用下,单个号码在北京时间自然日内最多允许10条业务短信。',
'phoneFrequencyCount', 10, 'block', 'active', 40,
'{"periodSeconds":86400,"timeZone":"Asia/Shanghai","alignment":"fixed"}'::jsonb,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM "RiskRule"
WHERE "applicationId" IS NULL
AND "code" = 'PHONE_FREQUENCY_24H'
AND "status" <> 'deleted'
);
INSERT INTO "RiskRule" (
"id", "tenantId", "applicationId", "code", "name", "description", "metric",
"thresholdValue", "action", "status", "priority", "config", "createdAt", "updatedAt"
)
SELECT
'default-phone-frequency-5m', NULL, NULL, 'PHONE_FREQUENCY_5M',
'单号码5分钟发送频次', '同一企业应用下,单个号码在固定5分钟周期内最多允许5条业务短信。',
'phoneFrequencyCount', 5, 'block', 'active', 50,
'{"periodSeconds":300,"timeZone":"Asia/Shanghai","alignment":"fixed"}'::jsonb,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM "RiskRule"
WHERE "applicationId" IS NULL
AND "code" = 'PHONE_FREQUENCY_5M'
AND "status" <> 'deleted'
);
@@ -0,0 +1,34 @@
CREATE TABLE "PhoneFrequencyWhitelist" (
"id" TEXT NOT NULL,
"phoneNumber" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"reason" TEXT NOT NULL,
"remark" TEXT,
"createdById" TEXT NOT NULL,
"updatedById" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PhoneFrequencyWhitelist_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PhoneFrequencyWhitelist_phoneNumber_key"
ON "PhoneFrequencyWhitelist"("phoneNumber");
CREATE INDEX "PhoneFrequencyWhitelist_status_updatedAt_idx"
ON "PhoneFrequencyWhitelist"("status", "updatedAt");
CREATE INDEX "PhoneFrequencyWhitelist_createdById_idx"
ON "PhoneFrequencyWhitelist"("createdById");
CREATE INDEX "PhoneFrequencyWhitelist_updatedById_idx"
ON "PhoneFrequencyWhitelist"("updatedById");
ALTER TABLE "PhoneFrequencyWhitelist"
ADD CONSTRAINT "PhoneFrequencyWhitelist_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PhoneFrequencyWhitelist"
ADD CONSTRAINT "PhoneFrequencyWhitelist_updatedById_fkey"
FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
CREATE TABLE "DrainageDetectionRule" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"pattern" TEXT NOT NULL,
"flags" TEXT NOT NULL DEFAULT 'giu',
"priority" INTEGER NOT NULL DEFAULT 100,
"status" TEXT NOT NULL DEFAULT 'active',
"description" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DrainageDetectionRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DrainageDetectionRule_code_key" ON "DrainageDetectionRule"("code");
CREATE INDEX "DrainageDetectionRule_status_priority_idx" ON "DrainageDetectionRule"("status", "priority");
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "hasDrainageContent" BOOLEAN,
ADD COLUMN "drainageDetection" JSONB,
ADD COLUMN "drainageDetectionVersion" TEXT,
ADD COLUMN "drainageEvaluatedAt" TIMESTAMP(3);
CREATE INDEX "SmsMessageRecord_hasDrainageContent_queuedAt_idx"
ON "SmsMessageRecord"("hasDrainageContent", "queuedAt");
INSERT INTO "DrainageDetectionRule"
("id", "code", "name", "category", "pattern", "flags", "priority", "status", "description", "version", "updatedAt")
VALUES
('drainage-rule-url', 'URL', 'URL及裸域名', 'url', $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,;!?<>]*)?$regex$, 'giu', 10, 'active', '识别协议链接、裸域名、短链接及IP地址链接;邮箱区间由检测器排除', 1, CURRENT_TIMESTAMP),
('drainage-rule-mobile', 'MOBILE', '手机号码', 'mobile', $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$, 'giu', 20, 'active', '规范化后识别+86、空格、短横线及中文标点拆分手机号', 1, CURRENT_TIMESTAMP),
('drainage-rule-landline', 'LANDLINE', '固定电话号码', 'landline', $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:||ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$, 'giu', 30, 'active', '识别区号括号、分隔符和分机号', 1, CURRENT_TIMESTAMP);
@@ -0,0 +1,23 @@
-- PostgreSQL standard-conforming strings preserve backslashes literally. The initial
-- seed used JavaScript-style escaping, so already-migrated databases need their three
-- built-in patterns normalized to the single backslashes expected by RegExp.
UPDATE "DrainageDetectionRule"
SET
"pattern" = $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,;!?<>]*)?$regex$,
"version" = "version" + 1,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'URL';
UPDATE "DrainageDetectionRule"
SET
"pattern" = $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$,
"version" = "version" + 1,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'MOBILE';
UPDATE "DrainageDetectionRule"
SET
"pattern" = $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:||ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$,
"version" = "version" + 1,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'LANDLINE';
@@ -0,0 +1,12 @@
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "cmppRegisteredDelivery" BOOLEAN,
ADD COLUMN "timeoutReceiptQueuedAt" TIMESTAMP(3);
ALTER TABLE "CmppInboundLongMessageSegment"
ADD COLUMN "registeredDelivery" BOOLEAN NOT NULL DEFAULT true;
-- Historical CMPP submissions were accepted before Registered_Delivery was
-- persisted. Preserve their existing receipt-enabled behavior.
UPDATE "SmsMessageRecord"
SET "cmppRegisteredDelivery" = true
WHERE "cmppSubmitSequenceId" IS NOT NULL;
@@ -0,0 +1,43 @@
CREATE TABLE "SmsReceiptAnomaly" (
"id" TEXT NOT NULL,
"anomalyKey" TEXT NOT NULL,
"tenantId" TEXT,
"applicationId" TEXT,
"channelId" TEXT,
"messageRecordId" TEXT,
"submitRecordId" TEXT,
"receiptRecordId" TEXT,
"anomalyType" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"previousStatus" TEXT,
"incomingStatus" TEXT,
"rawStatus" TEXT,
"errorCode" TEXT,
"detail" JSONB,
"occurrenceCount" INTEGER NOT NULL DEFAULT 1,
"firstOccurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastOccurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedAt" TIMESTAMP(3),
"resolutionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SmsReceiptAnomaly_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SmsReceiptAnomaly_anomalyKey_key" ON "SmsReceiptAnomaly"("anomalyKey");
CREATE INDEX "SmsReceiptAnomaly_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("status", "lastOccurredAt");
CREATE INDEX "SmsReceiptAnomaly_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("lastOccurredAt");
CREATE INDEX "SmsReceiptAnomaly_anomalyType_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("anomalyType", "lastOccurredAt");
CREATE INDEX "SmsReceiptAnomaly_tenantId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("tenantId", "status", "lastOccurredAt");
CREATE INDEX "SmsReceiptAnomaly_applicationId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("applicationId", "status", "lastOccurredAt");
CREATE INDEX "SmsReceiptAnomaly_channelId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("channelId", "status", "lastOccurredAt");
CREATE INDEX "SmsReceiptAnomaly_messageRecordId_idx" ON "SmsReceiptAnomaly"("messageRecordId");
CREATE INDEX "SmsReceiptAnomaly_submitRecordId_idx" ON "SmsReceiptAnomaly"("submitRecordId");
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_submitRecordId_fkey" FOREIGN KEY ("submitRecordId") REFERENCES "SmsSubmitRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_receiptRecordId_fkey" FOREIGN KEY ("receiptRecordId") REFERENCES "SmsReceiptRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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");
@@ -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");
+646 -27
View File
@@ -32,6 +32,8 @@ model Tenant {
riskRules RiskRule[]
smsSendTasks SmsSendTask[]
riskHitRecords RiskHitRecord[]
phoneFrequencyStates PhoneFrequencyState[]
phoneFrequencyHits PhoneFrequencyHit[]
smsBatchTasks SmsBatchTask[]
smsMessageRecords SmsMessageRecord[]
smsApiRequests SmsApiRequest[]
@@ -41,13 +43,18 @@ model Tenant {
smsUplinkMessages SmsUplinkMessage[]
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
cmppDownstreamDeliveries CmppDownstreamDelivery[]
downstreamRequeueTasks DownstreamRequeueTask[]
cmppDownstreamConnections CmppDownstreamConnection[]
cmppConnectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
smsReceiptAnomalies SmsReceiptAnomaly[]
openApiRequests OpenApiRequest[]
httpWebhookEvents HttpWebhookEvent[]
cmppInboundLongMessages CmppInboundLongMessage[]
cmppInboundSubmissionInboxes CmppInboundSubmissionInbox[]
smsApplicationDailyReservations SmsApplicationDailyReservation[]
phoneFrequencyReservations PhoneFrequencyReservation[]
}
model EnterpriseCertification {
@@ -88,13 +95,18 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id])
roles UserRole[]
operationLogs OperationLog[]
auditRecords AuditRecord[]
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
tenant Tenant? @relation(fields: [tenantId], references: [id])
roles UserRole[]
operationLogs OperationLog[]
auditRecords AuditRecord[]
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
createdDownstreamRequeueTasks DownstreamRequeueTask[] @relation("DownstreamRequeueTaskCreator")
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
infrastructureAlertReads InfrastructureAlertRead[]
}
model Role {
@@ -197,6 +209,7 @@ model ProtocolInteractionLog {
traceId String?
requestId String?
phoneMasked String?
phoneNumber String?
resultCode String?
durationMs Int?
payloadBytes Int?
@@ -300,6 +313,23 @@ model DrainageField {
commonReportFields CommonReportField[]
}
model DrainageDetectionRule {
id String @id @default(cuid())
code String @unique
name String
category String
pattern String
flags String @default("giu")
priority Int @default(100)
status String @default("active")
description String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, priority])
}
model TenantAccount {
id String @id @default(cuid())
tenantId String
@@ -427,10 +457,12 @@ model SmsApplication {
uplinkMessages SmsUplinkMessage[]
uplinkMatchCandidates SmsUplinkMatchCandidate[]
downstreamDeliveries CmppDownstreamDelivery[]
downstreamRequeueTasks DownstreamRequeueTask[]
downstreamConnections CmppDownstreamConnection[]
connectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
receiptAnomalies SmsReceiptAnomaly[]
httpConfig SmsApplicationHttpConfig?
httpIpAllowlist SmsApplicationHttpIpAllowlist[]
httpApiCredentials HttpApiCredential[]
@@ -438,8 +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])
@@ -502,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
@@ -779,6 +835,7 @@ model SmsChannel {
code String @unique
name String
carrier String?
carriers String[] @default([])
sendRegion String @default("全国")
protocol String @default("CMPP")
gatewayHost String
@@ -810,6 +867,7 @@ model SmsChannel {
uplinkMessages SmsUplinkMessage[]
connectionStates CmppConnectionState[]
gatewaySubmitDeadLetters GatewaySubmitDeadLetter[]
receiptAnomalies SmsReceiptAnomaly[]
@@index([status])
@@index([status, createdAt])
@@ -1027,17 +1085,20 @@ model DrainageReportMaterial {
}
model ChannelSignatureReportTask {
id String @id @default(cuid())
id String @id @default(cuid())
tenantId String
signatureId String
channelId String
reportType String @default("signature")
carrier String?
approvedAt DateTime?
approvalScope String @default("legacy_channel")
reportType String @default("signature")
drainageItemId String?
status String @default("pending")
status String @default("pending")
reason String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signature SmsSignature @relation(fields: [signatureId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id])
@@ -1050,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
@@ -1268,9 +1471,11 @@ model RiskRule {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade)
hits RiskHitRecord[]
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade)
hits RiskHitRecord[]
phoneFrequencyStates PhoneFrequencyState[]
phoneFrequencyHits PhoneFrequencyHit[]
@@unique([applicationId, code])
@@index([tenantId, applicationId, status, priority])
@@ -1339,6 +1544,100 @@ model RiskHitRecord {
@@index([ruleCode])
}
model PhoneFrequencyState {
id String @id @default(cuid())
tenantId String
applicationId String
ruleId String?
ruleCode String
phoneNumber String
windowStartedAt DateTime
windowEndsAt DateTime
count Int @default(0)
generation Int @default(0)
activeHitId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
rule RiskRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull)
activeHit PhoneFrequencyHit? @relation("ActivePhoneFrequencyHit", fields: [activeHitId], references: [id], onDelete: SetNull)
@@unique([applicationId, ruleCode, phoneNumber])
@@index([tenantId, applicationId, windowEndsAt])
@@index([ruleId])
}
model PhoneFrequencyHit {
id String @id @default(cuid())
tenantId String
applicationId String
ruleId String?
ruleCode String
ruleName String
phoneNumber String
thresholdValue Int
actualValue Int
windowStartedAt DateTime
windowEndsAt DateTime
generation Int @default(0)
action String @default("block")
sourceType String?
releasedAt DateTime?
releasedById String?
releaseReason String?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id])
application SmsApplication @relation(fields: [applicationId], references: [id])
rule RiskRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull)
releasedBy User? @relation("PhoneFrequencyHitReleaser", fields: [releasedById], references: [id])
activeForState PhoneFrequencyState? @relation("ActivePhoneFrequencyHit")
@@unique([applicationId, ruleCode, phoneNumber, windowStartedAt, generation])
@@index([tenantId, applicationId, createdAt])
@@index([applicationId, phoneNumber, createdAt])
@@index([windowEndsAt, releasedAt])
@@index([ruleId])
@@index([releasedById])
}
model 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
status String @default("active")
reason String
remark String?
createdById String
updatedById String
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation("PhoneFrequencyWhitelistCreator", fields: [createdById], references: [id])
updatedBy User @relation("PhoneFrequencyWhitelistUpdater", fields: [updatedById], references: [id])
@@index([status, updatedAt])
@@index([createdById])
@@index([updatedById])
}
model SmsBatchTask {
id String @id @default(cuid())
tenantId String
@@ -1414,6 +1713,10 @@ model SmsMessageRecord {
carrier String?
province String?
content String
hasDrainageContent Boolean?
drainageDetection Json?
drainageDetectionVersion String?
drainageEvaluatedAt DateTime?
billingUnits Int @default(1)
unitPrice BigInt @default(0)
amountCents BigInt @default(0)
@@ -1423,6 +1726,7 @@ model SmsMessageRecord {
gatewayMessageId String?
cmppSubmitSequenceId String?
cmppSubmitGroupMessageId String?
cmppRegisteredDelivery Boolean?
clientSrcId String?
applicationExtension String?
status String @default("queued")
@@ -1435,6 +1739,7 @@ model SmsMessageRecord {
submittedAt DateTime?
deliveredAt DateTime?
timeoutAt DateTime?
timeoutReceiptQueuedAt DateTime?
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id])
@@ -1448,6 +1753,7 @@ model SmsMessageRecord {
submitRecords SmsSubmitRecord[]
receiptRecords SmsReceiptRecord[]
segmentAudits SmsMessageSegmentAudit[]
receiptAnomalies SmsReceiptAnomaly[]
matchedUplinks SmsUplinkMessage[] @relation("SmsUplinkMatchedMessage")
uplinkMatchCandidates SmsUplinkMatchCandidate[]
downstreamDeliveries CmppDownstreamDelivery[]
@@ -1460,6 +1766,7 @@ model SmsMessageRecord {
@@index([phoneNumber])
@@index([gatewayMessageId])
@@index([drainageInfoId, queuedAt])
@@index([hasDrainageContent, queuedAt])
}
model CmppSubmitSession {
@@ -1493,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?
@@ -1510,6 +1819,7 @@ model SmsSubmitRecord {
retryOfSubmitRecord SmsSubmitRecord? @relation("SmsSubmitRetry", fields: [retryOfSubmitRecordId], references: [id])
retrySubmit SmsSubmitRecord? @relation("SmsSubmitRetry")
segmentAudits SmsMessageSegmentAudit[]
receiptAnomalies SmsReceiptAnomaly[]
@@index([tenantId, createdAt])
@@index([messageRecordId])
@@ -1518,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
@@ -1666,14 +1998,15 @@ model CmppInboundLongMessage {
}
model CmppInboundLongMessageSegment {
id String @id @default(cuid())
groupId String
segmentIndex Int
sequenceId String?
content String
contentHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
groupId String
segmentIndex Int
sequenceId String?
registeredDelivery Boolean @default(true)
content String
contentHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group CmppInboundLongMessage @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@ -1681,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?
@@ -1699,10 +2061,11 @@ model SmsReceiptRecord {
deliveredAt DateTime
createdAt DateTime @default(now())
tenant Tenant? @relation(fields: [tenantId], references: [id])
batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id])
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
channel SmsChannel? @relation(fields: [channelId], references: [id])
tenant Tenant? @relation(fields: [tenantId], references: [id])
batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id])
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
channel SmsChannel? @relation(fields: [channelId], references: [id])
anomalies SmsReceiptAnomaly[]
@@index([tenantId, createdAt])
@@index([messageId])
@@ -1711,8 +2074,50 @@ model SmsReceiptRecord {
@@index([channelId, gatewayMessageId, phoneNumber])
}
model SmsReceiptAnomaly {
id String @id @default(cuid())
anomalyKey String @unique
tenantId String?
applicationId String?
channelId String?
messageRecordId String?
submitRecordId String?
receiptRecordId String?
anomalyType String
status String @default("pending")
previousStatus String?
incomingStatus String?
rawStatus String?
errorCode String?
detail Json?
occurrenceCount Int @default(1)
firstOccurredAt DateTime @default(now())
lastOccurredAt DateTime @default(now())
resolvedAt DateTime?
resolutionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: SetNull)
channel SmsChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull)
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id], onDelete: SetNull)
submitRecord SmsSubmitRecord? @relation(fields: [submitRecordId], references: [id], onDelete: SetNull)
receiptRecord SmsReceiptRecord? @relation(fields: [receiptRecordId], references: [id], onDelete: SetNull)
@@index([status, lastOccurredAt])
@@index([lastOccurredAt])
@@index([anomalyType, lastOccurredAt])
@@index([tenantId, status, lastOccurredAt])
@@index([applicationId, status, lastOccurredAt])
@@index([channelId, status, lastOccurredAt])
@@index([messageRecordId])
@@index([submitRecordId])
}
model SmsUplinkMessage {
id String @id @default(cuid())
eventId String? @unique
tenantId String?
applicationId String?
channelId String
@@ -1800,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])
@@ -1809,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
@@ -1937,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])
}
+8
View File
@@ -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],
+30 -3
View File
@@ -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 },
+2 -1
View File
@@ -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,
+43 -8
View File
@@ -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);
+84 -13
View File
@@ -163,18 +163,27 @@ export class BillingService {
return orders;
}
const transactions = await this.prisma.accountTransaction.findMany({
where: {
relatedType: 'recharge_order',
relatedId: { in: orderIds },
},
select: { relatedId: true, balanceAfter: true },
});
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({
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
select: { relatedId: true, balanceAfter: true },
}) : [];
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,
@@ -26,8 +26,8 @@ export class AdminCertificationController {
constructor(private readonly certifications: CertificationService) {}
@Get()
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.certifications.list(tenantId, status, keyword);
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
return this.certifications.list(tenantId, status, keyword, submittedAtFrom, submittedAtTo);
}
@Get(':id')
@@ -38,6 +38,22 @@ describe('CertificationService', () => {
]);
});
it('filters enterprise certification submissions by Shanghai date range', async () => {
const prisma = createPrismaMock();
const service = new CertificationService(prisma as never);
await service.list(undefined, 'pending', undefined, '2026-08-01', '2026-08-03');
expect(prisma.enterpriseCertification.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
submittedAt: {
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
},
}),
}));
});
it('submits certification and marks tenant pending', async () => {
const prisma = createPrismaMock();
const service = new CertificationService(prisma as never);
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
export interface SubmitCertificationDto {
tenantId: string;
@@ -20,11 +21,12 @@ export interface ReviewCertificationDto {
export class CertificationService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId?: string, status?: string, keyword?: string) {
async list(tenantId?: string, status?: string, keyword?: string, submittedAtFrom?: string, submittedAtTo?: string) {
const records = await this.prisma.enterpriseCertification.findMany({
where: {
tenantId,
status: status && status !== 'all' ? status : undefined,
submittedAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
OR: keyword ? [
{ companyName: { contains: keyword } },
{ licenseNo: { contains: keyword } },
@@ -0,0 +1,250 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, 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. */
export class ChannelConfigurationService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
listChannels() {
return this.prisma.smsChannel.findMany({
include: { connectionStates: true },
orderBy: { createdAt: 'desc' },
});
}
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsChannelWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined,
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
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 };
}
async createChannel(data: CreateChannelDto) {
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => {
const value = data[field as keyof CreateChannelDto];
return value === undefined || value === null || value === '';
});
if (missingFields.length > 0) {
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
}
const gatewayPort = Number(data.gatewayPort ?? 7890);
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
const config = normalizeChannelRuntimeConfig(
undefined,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
);
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
const carriers = normalizeChannelCarriers(data.carriers, data.carrier);
const channel = await this.prisma.smsChannel.create({
data: {
code: data.code,
name: data.name,
carrier: legacyCarrierFromCapabilities(carriers),
carriers,
sendRegion: data.sendRegion ?? '全国',
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice ?? 0,
status: data.status ?? 'active',
config: config as Prisma.InputJsonValue,
},
});
if (channel.status === 'active') {
await this.connection.requestChannelConnection(channel, 'channel_created');
}
return channel;
}
async updateChannel(channelId: string, data: UpdateChannelDto) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (data.unitPrice !== undefined) {
assertMoneyUnits(data.unitPrice, '通道单价');
}
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined
|| data.desiredConnections !== undefined
|| data.windowSize !== undefined
|| data.heartbeatIntervalSeconds !== undefined
|| data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
data.desiredConnections,
data.windowSize,
data.heartbeatIntervalSeconds,
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const 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,
account: data.account ?? channel.account,
passwordCipher: data.passwordCipher ?? channel.passwordCipher,
cmppVersion: cmppVersion ?? channel.cmppVersion,
config: config ?? channel.config,
});
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
code: data.code,
name: data.name,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
enterpriseCode: data.enterpriseCode,
account: data.account,
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
},
});
await this.prisma.operationLog.create({
data: {
action: 'sms_channel.update',
resource: 'sms_channel',
resourceId: channelId,
detail: {
before: {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
enterpriseCode: channel.enterpriseCode,
account: channel.account,
srcId: channel.srcId,
unitPrice: moneyToNumber(channel.unitPrice),
},
after: data,
} as Prisma.InputJsonValue,
},
});
const updatedStatus = data.status ?? channel.status;
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
await this.connection.requestChannelConnection(updated, 'channel_updated');
} else if (updatedStatus !== 'active' && channel.status === 'active') {
await this.connection.requestChannelDisconnection(updated, 'channel_disabled');
}
return updated;
}
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } });
await this.prisma.operationLog.create({
data: {
userId: data.operatorId,
action: `sms_channel.${data.status}`,
resource: 'sms_channel',
resourceId: channelId,
detail: {
statusBefore: channel.status,
statusAfter: data.status,
reason: data.reason,
} as Prisma.InputJsonValue,
},
});
if (data.status === 'active') {
await this.connection.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
} else if (channel.status === 'active' || data.status === 'deleted') {
await this.connection.requestChannelDisconnection(
updated,
data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled',
data.operatorId,
);
}
return updated;
}
}
@@ -0,0 +1,616 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelConnectionService {
private readonly logger = new Logger(ChannelConnectionService.name);
private gatewayConnectionQueue?: Queue;
private gatewaySubmitQueue?: Queue;
private redis?: IORedis;
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED !== 'true') {
this.connectionTimeoutTimer = setInterval(() => {
void this.markTimedOutConnectingChannels().catch((error) => {
this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`);
});
}, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS));
this.connectionTimeoutTimer.unref?.();
}
this.gatewayStartupReconnectTimer = setTimeout(() => {
void this.reconnectActiveChannelsAfterGatewayRestart();
}, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS));
this.gatewayStartupReconnectTimer.unref?.();
if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') {
this.gatewayReconcileTimer = setInterval(() => {
void this.reconcileGatewayConnections().catch((error) => {
this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`);
});
}, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS));
this.gatewayReconcileTimer.unref?.();
}
}
async onModuleDestroy() {
if (this.connectionTimeoutTimer) {
clearInterval(this.connectionTimeoutTimer);
}
if (this.gatewayStartupReconnectTimer) {
clearTimeout(this.gatewayStartupReconnectTimer);
}
if (this.gatewayReconcileTimer) {
clearInterval(this.gatewayReconcileTimer);
}
await this.gatewayConnectionQueue?.close();
await this.gatewaySubmitQueue?.close();
this.redis?.disconnect();
}
listChannelMetrics(channelId: string) {
return this.prisma.channelHealthMetric.findMany({
where: { channelId },
orderBy: { windowStart: 'desc' },
take: 100,
});
}
listChannelConnections(channelId: string) {
return this.prisma.cmppConnectionState.findMany({
where: { channelId },
orderBy: { updatedAt: 'desc' },
});
}
async listChannelConnectionLogs(channelId: string) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
const [connectionStates, logs] = await Promise.all([
this.prisma.cmppConnectionState.findMany({
where: { channelId },
orderBy: { updatedAt: 'desc' },
take: 50,
}),
this.prisma.operationLog.findMany({
where: {
OR: [
{ resource: 'sms_channel', resourceId: channelId },
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
],
},
orderBy: { createdAt: 'desc' },
take: 100,
}),
]);
return {
channelId,
connectionStates,
logs: logs.map((log) => ({
id: log.id,
time: log.createdAt,
event: normalizeLinkEvent(log.action),
action: log.action,
resourceId: log.resourceId,
detail: log.detail,
})),
};
}
listTenantConnections(tenantId: string) {
return this.prisma.cmppConnectionState.findMany({
where: { tenantId },
include: { channel: true },
orderBy: { updatedAt: 'desc' },
});
}
async upsertConnectionState(data: UpsertConnectionStateDto) {
const rawStatus = data.status;
const status = normalizeGatewayConnectionStatus(rawStatus);
if (data.applicationId) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
if (!application) {
throw new BadRequestException('applicationId does not reference an existing application');
}
if (data.tenantId && data.tenantId !== application.tenantId) {
throw new BadRequestException('applicationId does not belong to tenantId');
}
data.tenantId = application.tenantId;
}
const payload = {
tenantId: data.tenantId,
applicationId: data.applicationId,
status,
desiredConnections: data.desiredConnections ?? 1,
currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0),
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
reconnectCount: data.reconnectCount ?? 0,
lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined,
nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined,
lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory,
lastError: status === 'connected' ? null : data.lastError,
};
const existing = await this.prisma.cmppConnectionState.findFirst({
where: {
applicationId: data.applicationId ?? null,
channelId: data.channelId,
connectionId: data.connectionId,
},
});
let state;
if (existing) {
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload });
} else {
try {
state = await this.prisma.cmppConnectionState.create({
data: {
channelId: data.channelId,
connectionId: data.connectionId,
...payload,
},
});
} catch (error) {
if ((error as { code?: string }).code !== 'P2002') {
throw error;
}
const concurrent = await this.prisma.cmppConnectionState.findFirst({
where: {
applicationId: data.applicationId ?? null,
channelId: data.channelId,
connectionId: data.connectionId,
},
});
if (!concurrent) {
throw error;
}
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload });
}
}
const action = normalizeConnectionAction(
['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status,
);
const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date();
const shouldWriteAudit = action !== 'heartbeat'
|| !existing?.lastHeartbeatAt
|| heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS;
if (shouldWriteAudit) {
await this.prisma.operationLog.create({
data: {
tenantId: data.tenantId,
action: `cmpp_connection.${action}`,
resource: 'cmpp_connection',
resourceId: `${data.channelId}:${data.connectionId}`,
detail: {
status,
applicationId: state.applicationId,
desiredConnections: state.desiredConnections,
currentConnections: state.currentConnections,
lastError: state.lastError,
} as Prisma.InputJsonValue,
},
});
}
return state;
}
async markTimedOutConnectingChannels(now = new Date()) {
const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS);
const cutoff = new Date(now.getTime() - timeoutMs);
const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`;
const states = await this.prisma.cmppConnectionState.findMany({
where: {
status: 'connecting',
updatedAt: { lte: cutoff },
},
select: {
id: true,
tenantId: true,
applicationId: true,
channelId: true,
connectionId: true,
desiredConnections: true,
currentConnections: true,
updatedAt: true,
},
take: 100,
});
let failed = 0;
for (const state of states) {
const result = await this.prisma.cmppConnectionState.updateMany({
where: {
id: state.id,
status: 'connecting',
updatedAt: { lte: cutoff },
},
data: {
status: 'failed',
currentConnections: 0,
lastDisconnectedAt: now,
nextReconnectAt: now,
lastErrorCategory: 'timeout',
lastError,
},
});
if (result.count === 0) {
continue;
}
failed += result.count;
await this.prisma.operationLog.create({
data: {
tenantId: state.tenantId,
action: 'cmpp_connection.failed',
resource: 'cmpp_connection',
resourceId: `${state.channelId}:${state.connectionId}`,
detail: {
reason: 'connect_timeout',
applicationId: state.applicationId,
status: 'failed',
previousStatus: 'connecting',
desiredConnections: state.desiredConnections,
currentConnectionsBefore: state.currentConnections,
currentConnections: 0,
timeoutMs,
lastError,
} as Prisma.InputJsonValue,
},
});
}
return { checked: states.length, failed };
}
async requestChannelConnection(
channel: {
id: string;
code: string;
name: string;
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
srcId: string;
cmppVersion: string;
rateLimitPerSecond: number;
config?: Prisma.JsonValue | null;
},
reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',
operatorId?: string,
) {
const desiredConnections = getDesiredConnections(channel.config);
const connectionId = defaultChannelConnectionId(channel.id);
const existing = await this.prisma.cmppConnectionState.findFirst({
where: {
applicationId: null,
channelId: channel.id,
connectionId,
},
});
const data = {
applicationId: null,
status: 'connecting',
desiredConnections,
currentConnections: 0,
lastError: null,
lastReconnectAttemptAt: new Date(),
nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)),
};
let state;
if (existing) {
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data });
} else {
try {
state = await this.prisma.cmppConnectionState.create({
data: {
channelId: channel.id,
connectionId,
...data,
},
});
} catch (error) {
if ((error as { code?: string }).code !== 'P2002') {
throw error;
}
const concurrent = await this.prisma.cmppConnectionState.findFirst({
where: { applicationId: null, channelId: channel.id, connectionId },
});
if (!concurrent) {
throw error;
}
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data });
}
}
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_connection',
resourceId: `${channel.id}:${connectionId}`,
detail: {
reason,
status: state.status,
desiredConnections: state.desiredConnections,
currentConnections: state.currentConnections,
} as Prisma.InputJsonValue,
},
});
const command = {
schemaVersion: 'v1',
messageType: 'ConnectChannel',
traceId: randomUUID(),
channelId: channel.id,
connectionId,
createdAt: new Date().toISOString(),
reason,
desiredConnections,
channel: {
code: channel.code,
name: channel.name,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
account: channel.account,
passwordCipher: channel.passwordCipher,
srcId: channel.srcId,
cmppVersion: channel.cmppVersion,
rateLimitPerSecond: channel.rateLimitPerSecond,
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
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,
'heartbeatIntervalSeconds',
),
heartbeatMissThreshold: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
),
},
};
const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, {
jobId: `gateway-connect-${channel.id}-${command.traceId}`,
removeOnComplete: 1000,
removeOnFail: 1000,
}).catch((error) => {
this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
});
try {
await this.notifyGatewayConnect(command);
} finally {
if (queuedJob) {
await queuedJob.remove().catch((error) => {
this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
});
}
}
return state;
}
async reconnectActiveChannelsAfterGatewayRestart() {
const channels = await this.prisma.smsChannel.findMany({ where: { status: 'active' } });
const results = await Promise.allSettled(
channels.map((channel) => this.requestChannelConnection(channel, 'gateway_restarted')),
);
results.forEach((result, index) => {
if (result.status === 'rejected') {
const channel = channels[index];
this.logger.error(
`Failed to restore active CMPP channel ${channel?.code ?? channel?.id ?? index}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
);
}
});
}
async reconcileGatewayConnections(now = new Date()) {
const channels = await this.prisma.smsChannel.findMany({
where: { status: { in: ['active', 'disabled', 'deleted'] } },
include: {
connectionStates: {
where: { applicationId: null },
},
},
take: 200,
});
let reconnectRequested = 0;
let disconnectRequested = 0;
for (const channel of channels) {
const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id));
if (channel.status !== 'active') {
if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) {
await this.withGatewayReconcileLock(channel.id, async () => {
await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile');
disconnectRequested++;
});
}
continue;
}
const desiredConnections = getDesiredConnections(channel.config);
const heartbeatIntervalSeconds = getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
);
const heartbeatMissThreshold = getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
);
const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000);
const connectedAndFresh = state?.status === 'connected'
&& state.currentConnections >= desiredConnections
&& Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff);
const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now;
if (!connectedAndFresh && retryDue) {
await this.withGatewayReconcileLock(channel.id, async () => {
await this.requestChannelConnection(channel, 'automatic_reconnect');
reconnectRequested++;
});
}
}
return { scanned: channels.length, reconnectRequested, disconnectRequested };
}
async withGatewayReconcileLock(channelId: string, action: () => Promise<void>) {
const redis = this.getRedis();
const key = `cmpp:gateway:reconcile:${channelId}`;
const token = randomUUID();
const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX');
if (acquired !== 'OK') {
return;
}
try {
await action();
} finally {
await redis.eval(
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
1,
key,
token,
);
}
}
async requestChannelDisconnection(
channel: { id: string },
reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile',
operatorId?: string,
) {
const connectionId = defaultChannelConnectionId(channel.id);
const now = new Date();
await this.prisma.cmppConnectionState.updateMany({
where: {
applicationId: null,
channelId: channel.id,
connectionId,
},
data: {
status: 'disconnected',
currentConnections: 0,
lastDisconnectedAt: now,
nextReconnectAt: null,
lastErrorCategory: null,
lastError: null,
},
});
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'cmpp_connection.disconnect_requested',
resource: 'cmpp_connection',
resourceId: `${channel.id}:${connectionId}`,
detail: { reason } as Prisma.InputJsonValue,
},
});
const command = {
schemaVersion: 'v1',
messageType: 'DisconnectChannel',
traceId: randomUUID(),
channelId: channel.id,
connectionId,
createdAt: now.toISOString(),
reason,
};
const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, {
jobId: `gateway-disconnect-${channel.id}-${command.traceId}`,
removeOnComplete: 1000,
removeOnFail: 1000,
}).catch((error) => {
this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
});
try {
await this.notifyGatewayDisconnect(command);
} finally {
if (queuedJob) {
await queuedJob.remove().catch((error) => {
this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
});
}
}
}
getGatewayConnectionQueue() {
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
return this.gatewayConnectionQueue;
}
getGatewaySubmitQueue() {
this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
return this.gatewaySubmitQueue;
}
getRedis() {
if (!this.redis) {
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
maxRetriesPerRequest: null,
});
}
return this.redis;
}
async publishGatewaySubmitCommand(command: unknown) {
return this.getRedis().xadd(
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
'*',
'messageType',
'SubmitCommand',
'data',
JSON.stringify(command),
);
}
async notifyGatewayConnect(command: Record<string, unknown>) {
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
let response: { ok: boolean; status: number; text: () => Promise<string> };
try {
response = await fetch(`${baseUrl}/connections/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
});
} catch (error) {
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (!response.ok) {
const responseText = await response.text();
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
}
}
async notifyGatewayDisconnect(command: Record<string, unknown>) {
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
let response: { ok: boolean; status: number; text: () => Promise<string> };
try {
response = await fetch(`${baseUrl}/connections/disconnect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
});
} catch (error) {
throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (!response.ok) {
const responseText = await response.text();
throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`);
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelCopyService {
constructor(private readonly prisma: PrismaService) {}
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
const source = await this.prisma.smsChannel.findUnique({
where: { id: channelId },
include: { reportFields: true },
});
if (!source) {
throw new NotFoundException('Channel not found');
}
const suffix = Date.now().toString(36).toUpperCase();
const nextName = data.name ?? `${source.name}副本`;
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
const copied = await this.prisma.$transaction(async (tx) => {
const nextChannel = await tx.smsChannel.create({
data: {
code: nextCode,
name: nextName,
carrier: source.carrier,
carriers: source.carriers,
protocol: source.protocol,
gatewayHost: source.gatewayHost,
gatewayPort: source.gatewayPort,
enterpriseCode: source.enterpriseCode,
account: source.account,
passwordCipher: source.passwordCipher,
srcId: source.srcId,
sendRegion: source.sendRegion,
cmppVersion: source.cmppVersion,
rateLimitPerSecond: source.rateLimitPerSecond,
unitPrice: source.unitPrice,
status: 'disabled',
config: source.config as Prisma.InputJsonValue | undefined,
reportFields: {
create: source.reportFields.map((field) => ({
drainageFieldId: field.drainageFieldId,
reportType: field.reportType,
code: field.code,
name: field.name,
fieldType: field.fieldType,
required: field.required,
description: field.description,
sortOrder: field.sortOrder,
status: field.status,
})),
},
},
include: { reportFields: true },
});
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
if (reportMaterials.length > 0) {
await tx.signatureReportMaterial.createMany({
data: reportMaterials.map((material) => ({
signatureId: material.signatureId,
channelId: nextChannel.id,
fieldCode: material.fieldCode,
fieldValue: material.fieldValue,
fileObjectId: material.fileObjectId,
})),
skipDuplicates: true,
});
}
await tx.operationLog.create({
data: {
userId: data.operatorId,
action: 'sms_channel.copy',
resource: 'sms_channel',
resourceId: nextChannel.id,
detail: {
sourceChannelId: source.id,
sourceCode: source.code,
sourceStatus: source.status,
copiedStatus: 'disabled',
copiedReportFields: source.reportFields.length,
copiedReportMaterials: reportMaterials.length,
} as Prisma.InputJsonValue,
},
});
return nextChannel;
});
return copied;
}
}
@@ -0,0 +1,19 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConfigurationService } from './channel-configuration.service';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelDeletionService {
constructor(private readonly prisma: PrismaService, private readonly configuration: ChannelConfigurationService) {}
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
return this.configuration.changeChannelStatus(channelId, { ...data, status: 'deleted' });
}
}
@@ -0,0 +1,277 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelGroupRoutingService {
constructor(private readonly prisma: PrismaService) {}
listGroups() {
return this.prisma.smsChannelGroup.findMany({
where: { status: { not: 'deleted' } },
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
orderBy: { createdAt: 'desc' },
});
}
createGroup(data: CreateChannelGroupDto) {
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720);
const carrier = normalizeBusinessCarrier(data.carrier);
return this.prisma.smsChannelGroup.create({
data: {
code: data.code,
name: data.name,
carrier,
description: data.description,
status: data.status ?? 'active',
retryEnabled: data.retryEnabled ?? true,
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
},
});
}
async addGroupItem(data: CreateChannelGroupItemDto) {
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
if (!group) {
throw new NotFoundException('Channel group not found');
}
const groupCarrier = normalizeBusinessCarrier(group.carrier);
const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier;
if (itemCarrier !== groupCarrier) {
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
}
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
throw new BadRequestException('Province route must use a channel with the same sendRegion');
}
const existing = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, channelId: data.channelId },
});
if (existing) {
throw new BadRequestException('通道组内不能重复配置同一通道');
}
if (data.province) {
const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, province: data.province },
});
if (existingProvince) {
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
}
} else {
const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 },
});
if (existingPriority) {
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
}
}
return this.prisma.smsChannelGroupItem.create({
data: {
groupId: data.groupId,
channelId: data.channelId,
carrier: itemCarrier,
province: data.province,
priority: data.priority ?? 100,
weight: data.weight ?? 1,
isBackup: data.isBackup ?? false,
},
});
}
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
const current = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
if (!current) {
throw new NotFoundException('Channel group not found');
}
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(
data.retryTimeLimitMinutes,
data.retryTimeLimitHours,
current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60,
);
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
const items = data.items ?? [];
const channelIds = [...new Set(items.map((item) => item.channelId))];
const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } });
const channelById = new Map(channels.map((channel) => [channel.id, channel]));
validateGroupItems(carrier, items, channelById);
return this.prisma.$transaction(async (tx) => {
await tx.smsChannelGroupItem.deleteMany({ where: { groupId } });
await tx.smsChannelGroup.update({
where: { id: groupId },
data: {
code: data.code ?? current.code,
name: data.name ?? current.name,
carrier,
description: data.description,
status: data.status ?? current.status,
retryEnabled: data.retryEnabled ?? current.retryEnabled,
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
},
});
if (items.length > 0) {
await tx.smsChannelGroupItem.createMany({
data: items.map((item) => ({
groupId,
channelId: item.channelId,
carrier,
province: item.province,
priority: item.priority ?? 100,
weight: item.weight ?? 1,
isBackup: item.isBackup ?? false,
})),
});
}
const updated = await tx.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
await tx.operationLog.create({
data: {
action: 'sms_channel_group.update',
resource: 'sms_channel_group',
resourceId: groupId,
detail: {
before: channelGroupAuditSnapshot(current),
after: updated ? channelGroupAuditSnapshot(updated) : null,
} as Prisma.InputJsonValue,
},
});
return updated;
});
}
async 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 routes = await this.prisma.channelRouteRule.findMany({
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
select: { applicationId: true },
});
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,
};
}
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() {
return this.prisma.channelRouteRule.findMany({
include: { group: true, channel: true },
orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }],
});
}
async createRouteRule(data: CreateRouteRuleDto) {
if (!data.applicationId) {
throw new BadRequestException('applicationId is required for channel group routing');
}
if (!data.carrier) {
throw new BadRequestException('carrier is required for application channel group routing');
}
const carrier = normalizeBusinessCarrier(data.carrier);
if (data.channelId) {
throw new BadRequestException('Route rules can only bind channel groups, not single channels');
}
if (data.province) {
throw new BadRequestException('Province routing must be configured inside the channel group');
}
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
if (!group) {
throw new NotFoundException('Channel group not found');
}
if (normalizeBusinessCarrier(group.carrier) !== carrier) {
throw new BadRequestException('Route rule carrier must match the channel group carrier');
}
return this.prisma.channelRouteRule.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
groupId: data.groupId,
channelId: undefined,
carrier,
province: undefined,
priority: data.priority ?? 100,
status: data.status ?? 'active',
},
});
}
}
@@ -0,0 +1,582 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelReportingService {
constructor(private readonly prisma: PrismaService) {}
listReportFields(channelId?: string) {
return this.prisma.channelReportField.findMany({
where: channelId ? { channelId } : undefined,
include: { drainageField: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
}
async createReportField(data: CreateReportFieldDto) {
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') {
throw new BadRequestException('报备字段库字段不存在或已停用');
}
const reportType = normalizeReportType(data.reportType);
return this.prisma.channelReportField.create({
data: {
channelId: data.channelId,
drainageFieldId: field.id,
reportType,
code: field.code,
name: field.name,
exportName: data.exportName?.trim() || field.name,
fieldType: field.fieldType,
required: data.required ?? field.required,
description: data.description ?? field.description,
sortOrder: data.sortOrder ?? 100,
columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80),
imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600),
imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600),
defaultValue: data.defaultValue,
transform: data.transform,
status: data.status ?? 'active',
},
});
}
async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('Channel not found');
const ids = data.fields.map((field) => field.drainageFieldId);
if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段');
const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } });
if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用');
const fieldById = new Map(libraryFields.map((field) => [field.id, field]));
return this.prisma.$transaction(async (tx) => {
const oppositeType = reportType === 'signature' ? 'drainage' : 'signature';
const [legacyBoth, oppositeFields] = await Promise.all([
tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }),
tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }),
]);
const oppositeCodes = new Set(oppositeFields.map((field) => field.code));
await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } });
for (const legacy of legacyBoth) {
if (oppositeCodes.has(legacy.code)) continue;
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
}
for (const [index, configured] of data.fields.entries()) {
const field = fieldById.get(configured.drainageFieldId)!;
await tx.channelReportField.create({
data: {
channelId,
drainageFieldId: field.id,
reportType,
code: field.code,
name: field.name,
exportName: configured.exportName?.trim() || field.name,
fieldType: field.fieldType,
required: configured.required ?? field.required,
description: configured.description ?? field.description,
sortOrder: configured.sortOrder ?? (index + 1) * 10,
columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80),
imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600),
imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600),
defaultValue: configured.defaultValue,
transform: configured.transform,
status: configured.status ?? 'active',
},
});
}
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
});
}
listReportMaterials(signatureId?: string, channelId?: string) {
return this.prisma.signatureReportMaterial.findMany({
where: {
signatureId,
channelId,
},
orderBy: { createdAt: 'desc' },
});
}
upsertReportMaterial(data: CreateReportMaterialDto) {
return this.prisma.signatureReportMaterial.upsert({
where: {
signatureId_channelId_fieldCode: {
signatureId: data.signatureId,
channelId: data.channelId,
fieldCode: data.fieldCode,
},
},
update: {
fieldValue: data.fieldValue,
fileObjectId: data.fileObjectId,
},
create: {
signatureId: data.signatureId,
channelId: data.channelId,
fieldCode: data.fieldCode,
fieldValue: data.fieldValue,
fileObjectId: data.fileObjectId,
},
});
}
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: {
tenantId,
status,
channelId,
reportType,
signature: { auditStatus: { not: 'deleted' } },
},
include: {
signature: { include: { tenant: true, application: true } },
channel: true,
drainageInfo: true,
exportItems: {
include: { exportFile: true, batchItem: { include: { batch: true } } },
orderBy: { id: 'desc' },
take: 1,
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: { createdAt: 'desc' },
});
if (tasks.length === 0) {
return tasks;
}
const channelIds = [...new Set(tasks.map((task) => task.channelId))];
const signatureIds = [...new Set(tasks.map((task) => task.signatureId))];
const day = currentShanghaiDayRange();
const rows = await this.prisma.$queryRaw<ChannelReportDeliveryRow[]>(Prisma.sql`
WITH base AS (
SELECT
submit."channelId" AS channel_id,
message."signatureId" AS signature_id,
message."drainageInfoId" AS drainage_info_id,
submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
THEN segment_summary.completed_at
WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at
END AS successful_at,
CASE
WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed'
WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure'
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success'
WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure'
WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success'
ELSE 'unknown'
END AS delivery_status
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id
) segment_summary ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS delivered_at
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) delivered_receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS failed_at
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)})
)
SELECT
channel_id AS "channelId",
signature_id AS "signatureId",
drainage_info_id AS "drainageInfoId",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total,
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND submit_status = 'accepted'
)::integer AS "acceptedCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'submit_failed'
)::integer AS "submitFailureCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'success'
)::integer AS "successCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'unknown'
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'failure'
)::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base
GROUP BY channel_id, signature_id, drainage_info_id
`);
return tasks.map((task) => {
const taskRows = rows.filter((row) => (
row.channelId === task.channelId
&& row.signatureId === task.signatureId
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
));
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
...task,
deliveryStats,
lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)),
};
});
}
async listReportTasksPage(query: {
tenantId?: string;
status?: string;
channelId?: string;
reportType?: string;
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim();
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
tenantId: query.tenantId,
status: query.status,
channelId: query.channelId,
reportType: query.reportType,
signature: { auditStatus: { not: 'deleted' } },
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
OR: keyword ? [
{ id: { contains: keyword } },
{ channel: { name: { contains: keyword } } },
{ signature: { name: { contains: keyword } } },
{ signature: { tenant: { name: { contains: keyword } } } },
{ signature: { application: { name: { contains: keyword } } } },
{ drainageInfo: { siteName: { contains: keyword } } },
{ drainageInfo: { url: { contains: keyword } } },
] : undefined,
};
const [items, total] = await Promise.all([
this.prisma.channelSignatureReportTask.findMany({
where,
include: {
signature: { include: { tenant: true, application: true } },
channel: true,
drainageInfo: true,
exportItems: {
include: { exportFile: true, batchItem: { include: { batch: true } } },
orderBy: { id: 'desc' },
take: 1,
},
records: { orderBy: { createdAt: 'desc' }, take: 20 },
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSignatureReportTask.count({ where }),
]);
return { items, total, page, pageSize };
}
async createReportTask(data: CreateReportTaskDto) {
const reportType = data.reportType ?? 'signature';
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage') {
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
}
const 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,
status: 'pending',
},
});
await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending');
return task;
}
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
if (!data.items.length) throw new BadRequestException('items is required');
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
for (const item of data.items) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
const sourceEntry = data.sourceEntry ?? 'report_task';
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
for (const item of data.items) {
const reportType = item.reportType ?? 'signature';
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
if (reportType === 'drainage') {
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const 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, ...(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 });
}
const summaries = [];
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
return [...summaries, ...drainageResults];
});
}
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
}) : [];
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
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 = ['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 };
}
async createReportExport(taskId: string, data: CreateReportExportDto) {
const task = await this.getReportTaskOrThrow(taskId);
const exported = await this.prisma.reportExportFile.create({
data: {
taskId,
fileObjectId: data.fileObjectId,
fileName: data.fileName,
rowCount: data.rowCount ?? 0,
},
});
await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export');
return exported;
}
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
const task = await this.getReportTaskOrThrow(taskId);
const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined;
const rowCount = data.rowCount ?? parsed?.rowCount ?? 0;
const successCount = data.successCount ?? parsed?.successCount ?? 0;
const failedCount = data.failedCount ?? parsed?.failedCount ?? 0;
const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount);
const imported = await this.prisma.reportReceiptImport.create({
data: {
taskId,
fileObjectId: data.fileObjectId,
fileName: data.fileName,
rowCount,
successCount,
failedCount,
status: 'imported',
result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined,
},
});
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
if ((task.reportType ?? 'signature') === 'signature') {
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
}
return imported;
}
listReportRecords(taskId?: string, channelId?: string) {
return this.prisma.channelSignatureReportRecord.findMany({
where: { taskId, channelId },
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
orderBy: { createdAt: 'desc' },
});
}
async listReportRecordsPage(query: {
taskId?: string;
channelId?: string;
keyword?: string;
reportType?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const keyword = query.keyword?.trim();
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
taskId: query.taskId,
channelId: query.channelId,
task: query.reportType ? { reportType: query.reportType } : undefined,
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
OR: keyword ? [
{ taskId: { contains: keyword } },
{ action: { contains: keyword } },
{ reason: { contains: keyword } },
{ channel: { name: { contains: keyword } } },
{ task: { signature: { name: { contains: keyword } } } },
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
{ task: { drainageInfo: { url: { contains: keyword } } } },
] : undefined,
};
const [items, total] = await Promise.all([
this.prisma.channelSignatureReportRecord.findMany({
where,
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSignatureReportRecord.count({ where }),
]);
return { items, total, page, pageSize };
}
async getReportTaskOrThrow(taskId: string) {
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
if (!task) {
throw new NotFoundException('Report task not found');
}
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
}
return task;
}
async updateReportTaskStatus(
taskId: string,
channelId: string,
statusBefore: string,
statusAfter: string,
action: string,
reason?: string,
) {
await this.prisma.channelSignatureReportTask.update({
where: { id: taskId },
data: {
status: statusAfter,
reason,
...((await this.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);
}
recordReportTask(
taskId: string,
channelId: string,
action: string,
statusBefore: string | undefined,
statusAfter: string,
reason?: string,
) {
return this.prisma.channelSignatureReportRecord.create({
data: {
taskId,
channelId,
action,
statusBefore,
statusAfter,
reason,
},
});
}
}
+120
View File
@@ -0,0 +1,120 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelTestService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
async testChannel(channelId: string, data: TestChannelDto = {}) {
const phoneNumbers = normalizeTestPhones(data);
const content = normalizeTestContent(data.content);
const channel = await this.prisma.smsChannel.findUnique({
where: { id: channelId },
include: { connectionStates: true },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (channel.status !== 'active') {
throw new BadRequestException('通道未启用,不能发送测试短信');
}
const connectedState = channel.connectionStates.find((state) =>
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
);
if (!connectedState) {
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
}
const createdAt = new Date();
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const drainageDetection = await detectDrainageContent(this.prisma, content);
const results = [];
for (const [index, phoneNumber] of phoneNumbers.entries()) {
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const session = await this.prisma.cmppSubmitSession.upsert({
where: { sessionNo: `OPEN-${channel.id}` },
update: { submitTotal: { increment: 1 } },
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
});
const messageRecord = await this.prisma.smsMessageRecord.create({
data: {
messageId,
phoneNumber,
content,
...drainageDetection,
billingUnits: calculateBillingUnits(content),
unitPrice: 0,
amountCents: 0,
queuePriority: 'normal',
channelId: channel.id,
submitId,
status: 'submit_queued',
submitStatus: 'queued',
},
});
await this.prisma.smsSubmitRecord.create({
data: {
messageRecordId: messageRecord.id,
channelId: channel.id,
sessionId: session.id,
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice,
costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits,
},
});
const command = buildChannelTestSubmitCommand({
channel,
content,
phoneNumber,
messageId,
submitId,
testNo,
attempt: index,
accessNo: data.accessNo,
});
await this.connection.getGatewaySubmitQueue().add('submit-command', command);
const streamMessageId = await this.connection.publishGatewaySubmitCommand(command);
results.push({
phoneNumber,
messageRecordId: messageRecord.id,
submitId,
streamMessageId,
});
}
await this.prisma.operationLog.create({
data: {
userId: data.operatorId,
action: 'sms_channel.test_submit',
resource: 'sms_channel',
resourceId: channel.id,
detail: {
testNo,
phoneTotal: phoneNumbers.length,
messageRecordIds: results.map((item) => item.messageRecordId),
connectionId: connectedState.connectionId,
} as Prisma.InputJsonValue,
},
});
return {
channelId,
status: 'submit_queued',
testNo,
submitted: results.length,
messages: results,
queuedAt: createdAt,
};
}
}
+176
View File
@@ -0,0 +1,176 @@
/** Stable request contracts shared by the channel controller and R5 domains. */
export interface CreateChannelDto {
code: string;
name: string;
carrier?: string;
carriers?: string[];
sendRegion?: string;
protocol?: string;
gatewayHost: string;
gatewayPort?: number;
enterpriseCode?: string;
account: string;
passwordCipher: string;
srcId: string;
cmppVersion?: string;
rateLimitPerSecond?: number;
unitPrice?: number;
status?: string;
desiredConnections?: number;
windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
config?: Record<string, unknown>;
}
export type UpdateChannelDto = Partial<CreateChannelDto>;
export interface CreateChannelGroupDto {
code: string;
name: string;
carrier: string;
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
}
export interface CreateChannelGroupItemDto {
groupId: string;
channelId: string;
carrier?: string;
province?: string;
priority?: number;
weight?: number;
isBackup?: boolean;
}
export interface UpdateChannelGroupDto {
code?: string;
name?: string;
carrier?: string;
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
}
export interface CreateRouteRuleDto {
tenantId?: string;
applicationId?: string;
groupId: string;
channelId?: string;
carrier?: string;
province?: string;
priority?: number;
status?: string;
}
export interface CreateReportFieldDto {
channelId: string;
drainageFieldId: string;
reportType: 'signature' | 'drainage' | 'both';
code?: string;
name?: string;
fieldType?: string;
required?: boolean;
description?: string;
sortOrder?: number;
exportName?: string;
columnWidth?: number;
imageWidth?: number;
imageHeight?: number;
defaultValue?: string;
transform?: string;
status?: string;
}
export interface ReplaceReportFieldsDto {
fields: Array<Omit<CreateReportFieldDto, 'channelId' | 'reportType'>>;
}
export interface CreateReportMaterialDto {
signatureId: string;
channelId: string;
fieldCode: string;
fieldValue?: string;
fileObjectId?: string;
}
export interface CreateReportTaskDto {
tenantId: string;
signatureId: string;
channelId: string;
carrier?: string;
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
createdById?: string;
}
export interface ChangeReportTaskStatusesDto {
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';
}
export interface CreateReportExportDto {
fileObjectId?: string;
fileName: string;
rowCount?: number;
}
export interface CreateReceiptImportDto {
fileObjectId?: string;
fileName: string;
fileContent?: string;
delimiter?: ',' | '\t';
rowCount?: number;
successCount?: number;
failedCount?: number;
statusAfter?: string;
reason?: string;
result?: Record<string, unknown>;
}
export interface UpsertConnectionStateDto {
tenantId?: string;
applicationId?: string;
channelId: string;
connectionId: string;
status: string;
desiredConnections?: number;
currentConnections?: number;
lastConnectedAt?: string;
lastDisconnectedAt?: string;
lastHeartbeatAt?: string;
reconnectCount?: number;
lastReconnectAttemptAt?: string;
nextReconnectAt?: string;
lastErrorCategory?: string;
lastError?: string;
}
export interface ChangeChannelStatusDto {
status: string;
operatorId?: string;
reason?: string;
}
export interface CopyChannelDto {
name?: string;
code?: string;
operatorId?: string;
}
export interface TestChannelDto {
phoneNumber?: string;
phones?: string[] | string;
content?: string;
accessNo?: string;
operatorId?: string;
}
+7 -2
View File
@@ -4,7 +4,6 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import {
ChannelsService,
ChangeChannelStatusDto,
CopyChannelDto,
CreateChannelDto,
@@ -22,7 +21,8 @@ import {
UpsertConnectionStateDto,
UpdateChannelDto,
UpdateChannelGroupDto,
} from './channels.service';
} from './channels.contracts';
import { ChannelsService } from './channels.service';
@ApiTags('channels')
@Controller('admin')
@@ -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);
});
});
+22
View File
@@ -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);
});
});
+720
View File
@@ -0,0 +1,720 @@
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';
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
export const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
export const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
export const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
export const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
export const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
export const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
export const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
export const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
export const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
export const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
export const DEFAULT_CMPP_VERSION = '2.0';
export function normalizeTestPhones(data: TestChannelDto) {
const rawPhones = Array.isArray(data.phones)
? data.phones
: String(data.phoneNumber ?? data.phones ?? '').split(/[,\n\s]+/u);
const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean);
const uniquePhones = Array.from(new Set(phones));
if (uniquePhones.length === 0) {
throw new BadRequestException('请填写测试手机号');
}
if (uniquePhones.length > 10) {
throw new BadRequestException('测试手机号最多允许 10 个');
}
for (const phone of uniquePhones) {
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException(`手机号格式不正确:${phone}`);
}
}
return uniquePhones;
}
export function normalizeTestContent(content?: string) {
const normalized = (content ?? '').trim();
if (!normalized) {
throw new BadRequestException('请填写测试短信内容');
}
if (normalized.length > 1000) {
throw new BadRequestException('测试短信内容不能超过 1000 字符');
}
return normalized;
}
export function calculateBillingUnits(content: string) {
return Math.max(1, Math.ceil([...content].length / 67));
}
export function buildChannelTestSubmitCommand({
channel,
content,
phoneNumber,
messageId,
submitId,
testNo,
attempt,
accessNo,
}: {
channel: {
id: string;
code: string;
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
srcId: string;
cmppVersion: string;
rateLimitPerSecond: number;
config?: Prisma.JsonValue | null;
};
content: string;
phoneNumber: string;
messageId: string;
submitId: string;
testNo: string;
attempt: number;
accessNo?: string;
}) {
const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId;
return {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: randomUUID(),
messageId,
channelId: channel.id,
createdAt: new Date().toISOString(),
tenantId: 'platform-channel-test',
applicationId: 'admin-channel-test',
taskId: testNo,
submitId,
queuePriority: 'normal',
phoneNumber,
content,
signature: 'CHANNEL_TEST',
templateId: 'admin-channel-test',
billingUnits: calculateBillingUnits(content),
route: {
channelCode: channel.code,
cmppAccountCode: channel.account,
priority: attempt,
rateLimitPerSecond: channel.rateLimitPerSecond,
},
cmpp: {
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
srcId,
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
registeredDelivery: 1,
msgFmt: 8,
},
upstream: {
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
account: channel.account,
passwordCipher: channel.passwordCipher,
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
'heartbeatIntervalSeconds',
),
heartbeatMissThreshold: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatMissThreshold'),
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
),
},
retry: { attempt: 0, maxAttempts: 1 },
};
}
export function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) {
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
return config[key as keyof typeof config];
}
return undefined;
}
export function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) {
const value = getConfigValue(config, key);
if (value === undefined || value === null || value === '') {
return fallback;
}
return String(value);
}
export function normalizeConnectionAction(status: string) {
const normalized = status.toLowerCase();
if (normalized === 'connected') {
return 'connected';
}
if (['heartbeat', 'active_test'].includes(normalized)) {
return 'heartbeat';
}
if (['reconnecting', 'reconnect'].includes(normalized)) {
return 'reconnecting';
}
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
return 'disconnected';
}
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
return 'failed';
}
return 'updated';
}
export function normalizeCmppVersion(version?: string) {
const normalized = (version ?? DEFAULT_CMPP_VERSION).trim();
if (normalized === '2.0' || normalized === '3.0') {
return normalized;
}
throw new BadRequestException('cmppVersion must be 2.0 or 3.0');
}
export function normalizeGatewayConnectionStatus(status: string) {
const normalized = status.toLowerCase();
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
return 'connected';
}
if (['connecting', 'connect_requested'].includes(normalized)) {
return 'connecting';
}
if (['reconnecting', 'reconnect'].includes(normalized)) {
return 'reconnecting';
}
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
return 'disconnected';
}
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
return 'failed';
}
return normalized;
}
export function defaultChannelConnectionId(channelId: string) {
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
}
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
const value = Number(config.desiredConnections);
if (Number.isInteger(value) && value >= 1 && value <= 8) {
return value;
}
}
return 1;
}
export type ChannelConnectionSettings = {
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
cmppVersion: string;
config?: Prisma.JsonValue | Record<string, unknown> | null;
};
export function getRuntimeConfigInteger(
config: Prisma.JsonValue | Record<string, unknown> | null | undefined,
key: string,
fallback: number,
) {
if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback;
const value = Number((config as Record<string, unknown>)[key]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
export function channelConnectionSettingsChanged(
before: ChannelConnectionSettings,
after: ChannelConnectionSettings,
) {
return before.gatewayHost !== after.gatewayHost
|| before.gatewayPort !== after.gatewayPort
|| before.account !== after.account
|| before.passwordCipher !== after.passwordCipher
|| before.cmppVersion !== after.cmppVersion
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
}
export function channelGroupAuditSnapshot(group: {
code: string;
name: string;
carrier: string;
description?: string | null;
status: string;
retryEnabled: boolean;
retryTimeLimitMinutes: number;
items?: Array<{
channelId: string;
carrier?: string | null;
province?: string | null;
priority: number;
weight: number;
isBackup: boolean;
channel?: { code?: string; name?: string } | null;
}>;
}) {
return {
code: group.code,
name: group.name,
carrier: group.carrier,
description: group.description ?? null,
status: group.status,
retryEnabled: group.retryEnabled,
retryTimeLimitMinutes: group.retryTimeLimitMinutes,
items: (group.items ?? []).map((item) => ({
channelId: item.channelId,
channelCode: item.channel?.code ?? null,
channelName: item.channel?.name ?? null,
carrier: item.carrier ?? null,
province: item.province ?? null,
priority: item.priority,
weight: item.weight,
isBackup: item.isBackup,
})),
};
}
export function normalizeChannelRuntimeConfig(
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
incomingConfig?: Record<string, unknown> | null,
desiredConnections?: number,
windowSize?: number,
heartbeatIntervalSeconds?: number,
heartbeatMissThreshold?: number,
) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown>
: {};
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
? incomingConfig
: {};
const base = { ...existing, ...incoming };
base.desiredConnections = 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,
'heartbeatIntervalSeconds',
);
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
'heartbeatMissThreshold',
);
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
base.serviceId = normalizeCmppServiceId(base.serviceId);
base.longMessageReceiptMode = normalizeLongMessageReceiptMode(base.longMessageReceiptMode);
return base;
}
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)) {
throw new BadRequestException('longMessageReceiptMode must be per_segment or message_level');
}
return normalized;
}
export function normalizeCmppServiceId(value: unknown) {
const normalized = String(value ?? 'SMS').trim() || 'SMS';
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
}
return normalized;
}
export function normalizeChannelRateLimit(value: unknown) {
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
if (normalized > 2000) {
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
}
return normalized;
}
export function normalizeExtensionDigits(value: unknown) {
if (value === undefined || value === null || value === '') {
return 0;
}
const normalized = Number(value);
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) {
throw new BadRequestException('extensionDigits must be an integer between 0 and 20');
}
return normalized;
}
export function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const normalized = Number(value);
if (!Number.isInteger(normalized) || normalized <= 0) {
throw new BadRequestException(`${fieldName} must be a positive integer`);
}
return normalized;
}
export function bullmqConnection() {
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return {
host: redisUrl.hostname,
port: Number(redisUrl.port || 6379),
username: redisUrl.username || undefined,
password: redisUrl.password || undefined,
maxRetriesPerRequest: null,
};
}
export function getPositiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
if (Number.isInteger(value) && value > 0) {
return value;
}
return fallback;
}
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) {
throw new BadRequestException('Receipt file is empty');
}
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
const firstCells = splitReceiptLine(lines[0], separator);
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
const header = hasHeader ? firstCells : [];
const rows = hasHeader ? lines.slice(1) : lines;
const statusIndex = findReceiptStatusIndex(header);
let successCount = 0;
let failedCount = 0;
const resultRows = rows.map((line, index) => {
const cells = splitReceiptLine(line, separator);
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
const normalizedStatus = normalizeReceiptStatus(rawStatus);
if (normalizedStatus === 'success') {
successCount += 1;
} else {
failedCount += 1;
}
return {
rowNumber: (hasHeader ? index + 2 : index + 1),
phone: cells[0] ?? '',
status: normalizedStatus,
rawStatus,
raw: cells,
};
});
return {
rowCount: resultRows.length,
successCount,
failedCount,
result: {
delimiter: separator === '\t' ? 'tab' : 'comma',
hasHeader,
rows: resultRows,
},
};
}
export function splitReceiptLine(line: string, delimiter: ',' | '\t') {
if (delimiter === '\t') {
return line.split('\t').map((cell) => stripReceiptCell(cell));
}
const cells: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
index += 1;
} else if (char === '"') {
quoted = !quoted;
} else if (char === ',' && !quoted) {
cells.push(stripReceiptCell(current));
current = '';
} else {
current += char;
}
}
cells.push(stripReceiptCell(current));
return cells;
}
export function stripReceiptCell(value: string) {
return value.trim().replace(/^"|"$/g, '').trim();
}
export function findReceiptStatusIndex(header: string[]) {
if (header.length === 0) {
return 1;
}
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
return index >= 0 ? index : Math.max(0, header.length - 1);
}
export function normalizeReceiptStatus(value: string) {
const normalized = value.trim().toLowerCase();
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
return 'success';
}
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
return 'failed';
}
return 'failed';
}
export function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
if (rowCount <= 0 || successCount <= 0) {
return 'failed';
}
if (failedCount > 0) {
return 'partial';
}
return 'completed';
}
export type ChannelReportDeliveryRow = {
channelId: string;
signatureId: string;
drainageInfoId: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
lastSuccessfulSentAt: Date | null;
};
export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
const total = sumReportDelivery(rows, 'total');
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
const successCount = sumReportDelivery(rows, 'successCount');
const unknownCount = sumReportDelivery(rows, 'unknownCount');
const failureCount = sumReportDelivery(rows, 'failureCount');
return {
total,
acceptedCount,
submitFailureCount,
submitFailureRate: percentage(submitFailureCount, total),
successCount,
successRate: percentage(successCount, acceptedCount),
unknownCount,
unknownRate: percentage(unknownCount, acceptedCount),
failureCount,
failureRate: percentage(failureCount, acceptedCount),
};
}
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>) {
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
}
export function percentage(count: number, total: number) {
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
}
export function latestDate(values: Array<Date | null>) {
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
}
export function currentShanghaiDayRange(now = new Date()) {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
const localDate = shifted.toISOString().slice(0, 10);
const startAt = new Date(`${localDate}T00:00:00+08:00`);
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
}
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
}
return value;
}
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.round(value)));
}
export function normalizeBusinessCarrier(carrier?: string | null) {
const normalized = normalizeChannelCarrier(carrier);
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
throw new BadRequestException('carrier must be mobile, unicom, or telecom');
}
return normalized;
}
export function normalizeChannelCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
return value;
}
export 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) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
}
export function validateGroupItems(
groupCarrier: string,
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
) {
const channelIds = new Set<string>();
const provinces = new Set<string>();
const nationalPriorities = new Set<number>();
for (const item of items) {
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
if (itemCarrier !== groupCarrier) {
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
}
const channel = channels.get(item.channelId);
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (channelIds.has(item.channelId)) {
throw new BadRequestException('通道组内不能重复配置同一通道');
}
channelIds.add(item.channelId);
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (item.province) {
const province = normalizeRegion(item.province);
if (provinces.has(province)) {
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
}
provinces.add(province);
if (!isRegionCompatible(channel.sendRegion, item.province)) {
throw new BadRequestException('Province route must use a channel with the same sendRegion');
}
} else {
const priority = item.priority ?? 100;
if (nationalPriorities.has(priority)) {
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
}
nationalPriorities.add(priority);
}
}
}
export function normalizeReportType(value?: string) {
if (value === 'signature' || value === 'drainage' || value === 'both') return value;
throw new BadRequestException('reportType must be signature, drainage or both');
}
export function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) {
return '连接请求';
}
if (action.includes('connected')) {
return '连接成功';
}
if (action.includes('heartbeat')) {
return '心跳';
}
if (action.includes('reconnecting')) {
return '重连';
}
if (action.includes('disconnected')) {
return '断开';
}
if (action.includes('failed')) {
return '连接失败';
}
if (action.includes('copy')) {
return '复制';
}
if (action.includes('deleted')) {
return '删除';
}
return '更新';
}
+174 -18
View File
@@ -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 })),
},
@@ -104,6 +106,9 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
@@ -112,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 })),
},
@@ -134,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') }),
@@ -150,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(),
@@ -166,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);
@@ -306,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' } }] } }]) },
@@ -320,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 = {
@@ -350,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();
});
@@ -582,6 +679,7 @@ describe('ChannelsService', () => {
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20');
await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters');
await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow('longMessageReceiptMode must be per_segment or message_level');
});
it('updates CMPP channel configuration without requiring password changes', async () => {
@@ -683,6 +781,20 @@ describe('ChannelsService', () => {
}));
});
it('persists a message-level long-message receipt mode without requesting a reconnect', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.updateChannel('channel-1', { config: { longMessageReceiptMode: 'message_level' } });
expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }),
}),
}));
expect(mockFetch).not.toHaveBeenCalled();
});
it('rejects invalid channel update ports', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -848,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 () => {
@@ -883,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',
@@ -898,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' },
@@ -933,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' },
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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);
});
});
+29
View File
@@ -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 };
}
+4 -1
View File
@@ -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);
}
}
@@ -0,0 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import { shanghaiDateRange } from './shanghai-date-range';
describe('shanghaiDateRange', () => {
it('builds an inclusive Asia/Shanghai day range', () => {
expect(shanghaiDateRange('2026-08-01', '2026-08-03')).toEqual({
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
});
});
it('rejects malformed or reversed ranges', () => {
expect(() => shanghaiDateRange('2026/08/01', undefined)).toThrow(BadRequestException);
expect(() => shanghaiDateRange('2026-02-31', undefined)).toThrow('日期无效');
expect(() => shanghaiDateRange('2026-08-03', '2026-08-01')).toThrow('开始日期不能晚于结束日期');
});
});
+24
View File
@@ -0,0 +1,24 @@
import { BadRequestException } from '@nestjs/common';
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function parseBoundary(value: string | undefined, endOfDay: boolean) {
if (!value) return undefined;
if (!DATE_PATTERN.test(value)) throw new BadRequestException('日期格式必须为 YYYY-MM-DD');
const [year, month, day] = value.split('-').map(Number);
const calendarDate = new Date(Date.UTC(year, month - 1, day));
if (calendarDate.getUTCFullYear() !== year || calendarDate.getUTCMonth() !== month - 1 || calendarDate.getUTCDate() !== day) {
throw new BadRequestException('日期无效');
}
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00'}+08:00`);
if (Number.isNaN(parsed.getTime())) throw new BadRequestException('日期无效');
return parsed;
}
/** Converts UI calendar dates to an inclusive Asia/Shanghai database range. */
export function shanghaiDateRange(from?: string, to?: string) {
const gte = parseBoundary(from, false);
const lte = parseBoundary(to, true);
if (gte && lte && gte > lte) throw new BadRequestException('开始日期不能晚于结束日期');
return gte || lte ? { gte, lte } : undefined;
}
@@ -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;
}
@@ -1,9 +1,12 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import {
CreateCommonReportFieldDto,
CreateBlacklistDto,
CreateDrainageFieldDto,
UpsertDrainageDetectionRuleDto,
TestDrainageDetectionDto,
CreatePhoneCarrierRuleDto,
CreatePhoneSegmentDto,
CreateSensitiveWordDto,
@@ -16,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,
@@ -125,6 +133,31 @@ export class DictionariesController {
return this.dictionaries.deleteDrainageField(id);
}
@Get('drainage-detection-rules')
listDrainageDetectionRules(@Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listDrainageDetectionRules({ keyword, status });
}
@Post('drainage-detection-rules/test')
testDrainageDetection(@Body() body: TestDrainageDetectionDto) {
return this.dictionaries.testDrainageDetection(body);
}
@Post('drainage-detection-rules')
createDrainageDetectionRule(@Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) {
return this.dictionaries.createDrainageDetectionRule({ ...body, operatorId });
}
@Put('drainage-detection-rules/:id')
updateDrainageDetectionRule(@Param('id') id: string, @Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) {
return this.dictionaries.updateDrainageDetectionRule(id, { ...body, operatorId });
}
@Post('drainage-detection-rules/:id/status')
changeDrainageDetectionRuleStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto, @CurrentSessionUserId() operatorId?: string) {
return this.dictionaries.changeDrainageDetectionRuleStatus(id, { ...body, operatorId });
}
@Get('common-report-fields')
listCommonReportFields() {
return this.dictionaries.listCommonReportFields();
@@ -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);
@@ -2,6 +2,11 @@ import { BadRequestException, ConflictException, Injectable, Optional } from '@n
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
import {
detectDrainageContentWithRules,
invalidateDrainageDetectionRuleCache,
validateDrainageDetectionPattern,
} from '../send-chain/drainage-content-detection';
export interface CreatePhoneSegmentDto {
prefix: string;
@@ -58,6 +63,23 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export interface UpsertDrainageDetectionRuleDto {
code: string;
name: string;
category: 'url' | 'mobile' | 'landline';
pattern: string;
flags?: string;
priority?: number;
status?: string;
description?: string;
operatorId?: string;
}
export interface TestDrainageDetectionDto {
content: string;
rule?: UpsertDrainageDetectionRuleDto;
}
export interface CreateCommonReportFieldDto {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
@@ -89,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)));
@@ -374,6 +417,97 @@ export class DictionariesService {
});
}
listDrainageDetectionRules(query: { keyword?: string; status?: string } = {}) {
const keyword = query.keyword?.trim();
return this.prisma.drainageDetectionRule.findMany({
where: {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
OR: keyword ? [
{ code: { contains: keyword, mode: 'insensitive' } },
{ name: { contains: keyword, mode: 'insensitive' } },
{ description: { contains: keyword, mode: 'insensitive' } },
] : undefined,
},
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
}
async createDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) {
this.validateDrainageDetectionRule(data);
const created = await this.prisma.drainageDetectionRule.create({
data: {
code: data.code.trim().toUpperCase(),
name: data.name.trim(),
category: data.category,
pattern: data.pattern,
flags: data.flags ?? 'giu',
priority: data.priority ?? 100,
status: data.status ?? 'active',
description: data.description?.trim() || null,
},
});
invalidateDrainageDetectionRuleCache();
await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.create', 'drainage_detection_rule', created.id, { code: created.code });
return created;
}
async updateDrainageDetectionRule(id: string, data: UpsertDrainageDetectionRuleDto) {
this.validateDrainageDetectionRule(data);
const updated = await this.prisma.drainageDetectionRule.update({
where: { id },
data: {
code: data.code.trim().toUpperCase(),
name: data.name.trim(),
category: data.category,
pattern: data.pattern,
flags: data.flags ?? 'giu',
priority: data.priority ?? 100,
status: data.status ?? 'active',
description: data.description?.trim() || null,
version: { increment: 1 },
},
});
invalidateDrainageDetectionRuleCache();
await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.update', 'drainage_detection_rule', id, { code: updated.code, version: updated.version });
return updated;
}
async changeDrainageDetectionRuleStatus(id: string, data: DictionaryStatusDto) {
const status = data.status === 'inactive' ? 'inactive' : 'active';
const updated = await this.prisma.drainageDetectionRule.update({
where: { id },
data: { status, version: { increment: 1 } },
});
invalidateDrainageDetectionRuleCache();
await this.writeOperationLog(data.operatorId, `drainage_detection_rule.${status}`, 'drainage_detection_rule', id, { reason: data.reason });
return updated;
}
async testDrainageDetection(data: TestDrainageDetectionDto) {
if (!data.content?.trim()) throw new BadRequestException('测试短信内容不能为空');
const rules = data.rule
? [{
id: 'preview',
code: data.rule.code?.trim().toUpperCase() || 'PREVIEW',
name: data.rule.name?.trim() || '预览规则',
category: data.rule.category,
pattern: data.rule.pattern,
flags: data.rule.flags ?? 'giu',
priority: data.rule.priority ?? 100,
version: 1,
}]
: await this.prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: { priority: 'asc' } });
if (data.rule) this.validateDrainageDetectionRule(data.rule);
return detectDrainageContentWithRules(data.content, rules);
}
private validateDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) {
if (!data.code?.trim() || !data.name?.trim()) throw new BadRequestException('规则编码和名称不能为空');
if (!['url', 'mobile', 'landline'].includes(data.category)) throw new BadRequestException('规则类型仅支持 URL、手机号或固话');
if (data.status && !['active', 'inactive'].includes(data.status)) throw new BadRequestException('规则状态不正确');
validateDrainageDetectionPattern(data.pattern, data.flags ?? 'giu');
}
listCommonReportFields() {
return this.prisma.commonReportField.findMany({
include: { drainageField: true },
@@ -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() {
+31
View File
@@ -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 {}
+44
View File
@@ -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();
+66
View File
@@ -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);
});
}
+25
View File
@@ -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 });
}
+20
View File
@@ -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 });
});
});
+21
View File
@@ -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
View File
@@ -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();
+24
View File
@@ -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);
}));
}
}
+11
View File
@@ -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 {}
+35
View File
@@ -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();
});
});
+283
View File
@@ -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();
}
}
+18 -2
View File
@@ -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) {
+2 -1
View File
@@ -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],
+15
View File
@@ -11,6 +11,21 @@ describe('OpenApiService', () => {
expect(decryptSecret(encrypted)).toBe('customer-secret');
});
it('returns the configured public HTTPS origin for customer integration parameters', async () => {
const previous = process.env.HTTP_API_PUBLIC_ORIGIN;
process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/';
const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
};
try {
const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }));
} finally {
if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previous;
}
});
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
+18 -1
View File
@@ -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 });
}
@@ -68,6 +72,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
return {
applicationId,
applicationName: application.name,
publicOrigin: httpApiPublicOrigin(),
config: application.httpConfig,
ipAllowlist: application.httpIpAllowlist.map((item) => item.ipCidr),
};
@@ -86,7 +91,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }),
...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []),
]);
return { applicationId, config, ipAllowlist };
return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist };
}
async listCredentials(applicationId: string, tenantId?: string) {
@@ -419,6 +424,18 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
}
function httpApiPublicOrigin() {
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
if (!configured) return undefined;
const url = new URL(configured);
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
// This value is copied into customer integration parameters, so fail closed instead of
// publishing an insecure or path-dependent endpoint when deployment config is wrong.
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址');
}
return url.origin;
}
function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) {
const value = error.getResponse();
@@ -43,6 +43,7 @@ export class AdminOperationsController {
@Query('queuedAtFrom') queuedAtFrom?: string,
@Query('queuedAtTo') queuedAtTo?: string,
@Query('status') status?: string,
@Query('hasDrainage') hasDrainage?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
@@ -59,6 +60,7 @@ export class AdminOperationsController {
queuedAtFrom,
queuedAtTo,
status,
hasDrainage,
page: Number(page),
pageSize: Number(pageSize),
});
@@ -76,6 +78,7 @@ export class AdminOperationsController {
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
@Query('queuedAtTo') queuedAtTo: string | undefined,
@Query('status') status: string | undefined,
@Query('hasDrainage') hasDrainage: string | undefined,
@Res() response: DownloadResponse,
) {
const exported = await this.operations.exportMessages({
@@ -89,6 +92,7 @@ export class AdminOperationsController {
queuedAtFrom,
queuedAtTo,
status,
hasDrainage,
});
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
@@ -123,6 +127,11 @@ export class AdminOperationsController {
return this.operations.dashboard({ tenantId });
}
@Get('pending-audits')
pendingAudits(@Query('tenantId') tenantId?: string) {
return this.operations.pendingAudits(tenantId);
}
@Get('dashboard/statistics')
dashboardStatistics(@Query('tenantId') tenantId?: string) {
return this.operations.dashboard({ tenantId });
@@ -216,6 +225,37 @@ 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,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('anomalyType') anomalyType?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.listReceiptAnomalies({
tenantId,
applicationId,
channelId,
anomalyType,
status,
keyword,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Get('downstream-deliveries')
downstreamDeliveries(
@Query('tenantId') tenantId?: string,
@@ -322,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')
@@ -0,0 +1,99 @@
// Stable controller/query contracts extracted in R2.
export interface MessageQuery {
tenantId?: string;
applicationId?: string;
channelId?: string;
channelKeyword?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
contentKeyword?: string;
carrier?: string;
status?: string;
hasDrainage?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
page?: number;
pageSize?: number;
}
export interface TraceQuery extends MessageQuery {
messageId?: string;
}
export interface OperationLogQuery {
tenantId?: string;
userId?: string;
keyword?: string;
level?: string;
module?: string;
range?: string;
page?: number;
pageSize?: number;
}
export interface GatewaySubmitDeadLetterQuery {
tenantId?: string;
applicationId?: string;
channelId?: string;
status?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
export interface ReceiptAnomalyQuery {
tenantId?: string;
applicationId?: string;
channelId?: string;
anomalyType?: string;
status?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
export interface DownstreamDeliveryQuery {
tenantId?: string;
applicationId?: string;
deliveryType?: string;
status?: string;
keyword?: string;
page?: number;
pageSize?: number;
createdAtFrom?: string;
createdAtTo?: string;
}
export interface DownstreamDeliveryDashboardQuery {
tenantId?: string;
applicationId?: string;
deliveryType?: string;
createdAtFrom?: string;
createdAtTo?: string;
}
export interface DownstreamRecoveryStatusQuery {
tenantId?: string;
applicationId?: string;
state?: string;
failureCategory?: string;
keyword?: string;
updatedAtFrom?: string;
updatedAtTo?: string;
page?: number;
pageSize?: number;
}
export interface MessageSegmentAuditQuery {
messageId?: string;
messageRecordId?: string;
}
export interface SignatureQualityQuery {
date?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
+523
View File
@@ -0,0 +1,523 @@
import { Prisma } from '@prisma/client';
import { BadRequestException } from '@nestjs/common';
import { moneyToNumber } from '../common/money';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts';
// Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere = query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
batchTaskId: query.taskId,
messageId: query.messageId,
phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier),
...statusWhere,
...(query.hasDrainage === 'true' ? { hasDrainageContent: true }
: query.hasDrainage === 'false' ? { hasDrainageContent: false }
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null }
: {}),
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
} : {}),
};
}
export const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动',
'unicom', 'cucc', '联通', '中国联通',
'telecom', 'ctcc', '电信', '中国电信',
];
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {};
// Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized.
if (carrier === 'unknown') {
return {
AND: [
{
OR: [
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
},
],
};
}
const valuesByCarrier: Record<string, string[]> = {
mobile: ['mobile', 'cmcc', '移动', '中国移动'],
unicom: ['unicom', 'cucc', '联通', '中国联通'],
telecom: ['telecom', 'ctcc', '电信', '中国电信'],
};
return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {};
}
export function startOfShanghaiDay(value: string) {
return new Date(`${value}T00:00:00+08:00`);
}
export function endOfShanghaiDay(value: string) {
return new Date(`${value}T23:59:59.999+08:00`);
}
export function qualityBusinessDay(value?: string) {
const key = value || shanghaiDateKey();
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) {
throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD');
}
const startAt = startOfShanghaiDay(key);
if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) {
throw new BadRequestException('统计日期无效');
}
return {
key,
startAt,
endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000),
};
}
export function shanghaiDateKey(value = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const byType = new Map(parts.map((part) => [part.type, part.value]));
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
}
export function normalizeGroupBy(groupBy?: string) {
if (groupBy === 'tenant' || groupBy === 'tenantId') {
return 'tenantId';
}
if (groupBy === 'application' || groupBy === 'applicationId') {
return 'applicationId';
}
return 'channelId';
}
export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
return {
tenantId,
createdAt: { gte: since },
OR: [
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
};
}
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
if (!range || range === 'all') {
return undefined;
}
const date = new Date();
date.setHours(0, 0, 0, 0);
if (range === '7d') {
date.setDate(date.getDate() - 6);
} else if (range === '30d') {
date.setDate(date.getDate() - 29);
}
return { gte: date };
}
export function downstreamAlertPendingMinutes() {
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10);
return Number.isFinite(value) && value > 0 ? value : 10;
}
export function downstreamAlertRecentFailedHours() {
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1);
return Number.isFinite(value) && value > 0 ? value : 1;
}
export function downstreamAlertWindows(now = new Date()) {
return {
now,
stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000),
recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000),
};
}
export function downstreamAlertWhere(
scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput,
window: ReturnType<typeof downstreamAlertWindows>,
): Prisma.CmppDownstreamDeliveryWhereInput {
return {
AND: [
scopedWhere,
{
OR: [
stalledPendingWhere(window.stalledPendingAt),
{ status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } },
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
],
},
],
};
}
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
return {
status: 'pending',
OR: [
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
};
}
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined,
};
}
export function parseDateBoundary(value?: string, endOfDay = false) {
if (!value) return undefined;
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false);
const updatedAtTo = parseDateBoundary(query.updatedAtTo, true);
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
state: query.state && query.state !== 'all' ? query.state : undefined,
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: query.keyword ? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
};
}
export function escapeCsvCell(value: string) {
let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
if (/^[=+\-@]/.test(normalized)) {
normalized = `'${normalized}`;
}
if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) {
return `"${normalized.replace(/"/g, '""')}"`;
}
return normalized;
}
export function formatCsvDate(value?: Date | string | null) {
if (!value) {
return '';
}
return value instanceof Date ? value.toISOString() : value;
}
export function formatExportTimestamp(date: Date) {
const parts = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
String(date.getHours()).padStart(2, '0'),
String(date.getMinutes()).padStart(2, '0'),
String(date.getSeconds()).padStart(2, '0'),
];
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
}
export function clientApplicationView(application?: Record<string, any> | null) {
if (!application) return null;
return { id: application.id, name: application.name };
}
export function clientReceiptView(receipt: Record<string, any>) {
return {
id: receipt.id,
messageId: receipt.messageId,
receiptStatus: receipt.receiptStatus,
rawStatus: receipt.rawStatus,
errorCode: receipt.errorCode ?? null,
errorMessage: receipt.errorMessage ?? null,
deliveredAt: receipt.deliveredAt,
createdAt: receipt.createdAt,
};
}
export function clientMessageView(message: Record<string, any>) {
return {
id: message.id,
batchTaskId: message.batchTaskId ?? null,
applicationId: message.applicationId ?? null,
messageId: message.messageId,
phoneNumber: message.phoneNumber,
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents),
status: message.status,
submitStatus: message.submitStatus ?? null,
receiptStatus: message.receiptStatus ?? null,
errorCode: message.errorCode ?? null,
errorMessage: message.errorMessage ?? null,
queuedAt: message.queuedAt,
submittedAt: message.submittedAt ?? null,
deliveredAt: message.deliveredAt ?? null,
application: clientApplicationView(message.application),
receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [],
};
}
export function clientBatchTaskView(task: Record<string, any>) {
return {
id: task.id,
taskNo: task.taskNo,
applicationId: task.applicationId ?? null,
templateId: task.templateId ?? null,
content: task.content,
category: task.category ?? null,
phoneTotal: task.phoneTotal,
status: task.status,
auditStatus: task.auditStatus ?? null,
reviewReason: task.reviewReason ?? null,
rejectReason: task.rejectReason ?? null,
progressTotal: task.progressTotal,
progressSent: task.progressSent ?? 0,
progressDelivered: task.progressDelivered ?? 0,
progressFailed: task.progressFailed ?? 0,
submittedTotal: task.submittedTotal ?? 0,
successTotal: task.successTotal ?? 0,
failedTotal: task.failedTotal ?? 0,
unknownTotal: task.unknownTotal ?? 0,
timeoutTotal: task.timeoutTotal ?? 0,
scheduledAt: task.scheduledAt ?? null,
canceledAt: task.canceledAt ?? null,
createdAt: task.createdAt,
application: clientApplicationView(task.application),
messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [],
};
}
export function clientUplinkView(message: Record<string, any>) {
return {
id: message.id,
applicationId: message.applicationId ?? null,
messageRecordId: message.messageRecordId ?? null,
messageId: message.messageId ?? null,
phoneNumber: message.phoneNumber,
destId: message.destId,
content: message.content,
matchStatus: message.matchStatus,
matchReason: message.matchReason ?? null,
receivedAt: message.receivedAt,
createdAt: message.createdAt,
application: clientApplicationView(message.application),
messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null,
};
}
export function clientAccountView(account: Record<string, any>) {
return {
id: account.id,
tenantId: account.tenantId,
balanceCents: moneyToNumber(account.balanceCents),
creditCents: moneyToNumber(account.creditCents),
status: account.status,
updatedAt: account.updatedAt,
tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null,
};
}
export function clientRechargeView(order: Record<string, any>) {
return {
id: order.id,
orderNo: order.orderNo,
amountCents: moneyToNumber(order.amountCents),
status: order.status,
payMethod: order.payMethod,
remark: order.remark ?? null,
createdAt: order.createdAt,
completedAt: order.completedAt ?? null,
};
}
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
return groups.reduce(
(summary, group) => {
const count = group._count._all;
summary.total += count;
summary.amountCents += moneyToNumber(group._sum.amountCents);
summary.billingUnits += group._sum.billingUnits ?? 0;
if (group.status === 'delivered') {
summary.delivered += count;
} else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) {
summary.failed += count;
} else if (group.status === 'unknown') {
summary.unknown += count;
}
return summary;
},
{ total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 },
);
}
export function groupDownstreamByType(
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
) {
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
current.total += item._count._all;
if (item.status === 'pending') {
current.pending += item._count._all;
} else if (item.status === 'awaiting_ack') {
current.awaitingAck += item._count._all;
} else if (item.status === 'delivered') {
current.delivered += item._count._all;
} else if (item.status === 'failed') {
current.failed += item._count._all;
} else if (item.status === 'unconfirmed') {
current.unconfirmed += item._count._all;
} else if (item.status === 'rejected') {
current.rejected += item._count._all;
}
accumulator[item.deliveryType] = current;
return accumulator;
}, {});
}
export function groupDownstreamByApplication(
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
applicationMap: Map<string, string>,
applicationAlertMap: Map<string, number>,
) {
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
groups.forEach((item) => {
const current = summaryMap.get(item.applicationId) ?? {
applicationId: item.applicationId,
name: applicationMap.get(item.applicationId) ?? item.applicationId,
pending: 0,
awaitingAck: 0,
failed: 0,
unconfirmed: 0,
rejected: 0,
delivered: 0,
alertCount: 0,
};
if (item.status === 'pending') {
current.pending += item._count._all;
} else if (item.status === 'awaiting_ack') {
current.awaitingAck += item._count._all;
} else if (item.status === 'failed') {
current.failed += item._count._all;
} else if (item.status === 'unconfirmed') {
current.unconfirmed += item._count._all;
} else if (item.status === 'rejected') {
current.rejected += item._count._all;
} else if (item.status === 'delivered') {
current.delivered += item._count._all;
}
current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0;
summaryMap.set(item.applicationId, current);
});
return [...summaryMap.values()];
}
export function positiveInteger(value: number | undefined, fallback: number) {
const normalized = Number(value);
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
}
export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput {
const error: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'failed' } },
{ action: { contains: 'reject' } },
{ detail: { path: ['result'], string_contains: 'fail' } },
{ detail: { path: ['status'], string_contains: 'fail' } },
],
};
const warning: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
};
const success: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'approve' } },
{ action: { contains: 'recharge' } },
{ action: { contains: 'connected' } },
],
};
if (level === 'error') {
return error;
}
if (level === 'warning') {
return { AND: [{ NOT: error }, warning] };
}
if (level === 'success') {
return { AND: [{ NOT: error }, { NOT: warning }, success] };
}
if (level === 'info') {
return { NOT: { OR: [error, warning, success] } };
}
return {};
}
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? '');
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
return {
id: log.id,
time: log.createdAt,
level,
tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'),
module: log.resource,
operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system',
action: log.action,
resourceId: log.resourceId ?? '',
detail,
ip: log.ipAddress ?? '',
userAgent: log.userAgent ?? '',
};
}
export function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return {
...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
channel: channel ? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
} : null,
rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null,
};
}
export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
if (Array.isArray(value)) {
return value.map((item) => redactGatewayCommandValue(item));
}
if (value && typeof value === 'object') {
const redacted: Record<string, Prisma.JsonValue | null> = {};
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
'token', 'apikey', 'accesskey', 'secretkey',
].includes(normalizedKey)
? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue);
}
return redacted;
}
return value;
}
@@ -6,6 +6,9 @@ function createPrismaMock() {
user: {
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
},
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '企业A' }),
},
smsBatchTask: {
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
count: jest.fn().mockResolvedValue(3),
@@ -50,6 +53,7 @@ function createPrismaMock() {
},
enterpriseCertification: {
count: jest.fn().mockResolvedValue(1),
findFirst: jest.fn().mockResolvedValue({ id: 'certification-1' }),
},
smsApplication: {
findMany: jest.fn().mockResolvedValue([
@@ -92,6 +96,26 @@ function createPrismaMock() {
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
},
smsReceiptAnomaly: {
findMany: jest.fn().mockResolvedValue([{
id: 'receipt-anomaly-1',
anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1',
anomalyType: 'aggregate_success_then_failure',
status: 'pending',
occurrenceCount: 1,
firstOccurredAt: new Date('2026-08-06T01:00:00.000Z'),
lastOccurredAt: new Date('2026-08-06T01:00:00.000Z'),
tenant: { name: '租户A' },
application: { name: '应用A' },
channel: { name: '通道A' },
messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' },
submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' },
receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' },
}]),
count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }),
},
gatewayDownstreamRecoveryStatus: {
findMany: jest.fn().mockResolvedValue([{
id: 'recover-1',
@@ -206,6 +230,7 @@ describe('OperationsService', () => {
queuedAtFrom: '2026-07-01',
queuedAtTo: '2026-07-02',
status: 'delivered',
hasDrainage: 'true',
});
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
@@ -218,6 +243,7 @@ describe('OperationsService', () => {
phoneNumber: '13800000001',
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
status: 'delivered',
hasDrainageContent: true,
content: { contains: '验证码', mode: 'insensitive' },
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
queuedAt: {
@@ -388,6 +414,22 @@ describe('OperationsService', () => {
expect(dashboard.gatewayConnections).toEqual([]);
expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]);
expect(dashboard.clientOverview).toEqual({
enterpriseName: '企业A',
certificationStatus: 'certified',
signatureCount: 1,
pendingBatchTaskCount: 3,
});
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', sourceType: 'client', status: 'pending_review' },
});
expect(prisma.enterpriseCertification.findFirst).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', status: 'approved' },
select: { id: true },
});
expect(prisma.smsSignature.count).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
});
expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/);
});
@@ -480,6 +522,10 @@ describe('OperationsService', () => {
updatedAt: { gte: expect.any(Date) },
},
});
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
expect(hourlyTrendQuery.sql).toContain(
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
);
expect(prisma.accountTransaction.aggregate).toHaveBeenCalledWith({
where: {
tenantId: 'tenant-1',
@@ -558,6 +604,7 @@ describe('OperationsService', () => {
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
date: '2026-07-24',
drainageSignatures: [],
summary: {
total: 5,
successCount: 3,
@@ -585,6 +632,27 @@ describe('OperationsService', () => {
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
});
it('returns pending audit counts without running the full dashboard aggregation', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.pendingAudits('tenant-1')).resolves.toEqual({
enterpriseCertifications: 1,
smsAudits: 2,
templates: 1,
signatures: 1,
drainageInfos: 0,
total: 5,
});
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending' } });
expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending_review' } });
expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it('rejects invalid send quality dates', async () => {
const service = new OperationsService(createPrismaMock() as never);
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
@@ -615,6 +683,7 @@ describe('OperationsService', () => {
channelId: 'channel-1',
channelName: '通道一',
carrier: 'mobile',
drainageState: 'with',
total: 4,
acceptedCount: 4,
submitFailureCount: 0,
@@ -629,6 +698,7 @@ describe('OperationsService', () => {
channelId: 'channel-2',
channelName: '通道二',
carrier: 'telecom',
drainageState: 'without',
total: 2,
acceptedCount: 1,
submitFailureCount: 1,
@@ -689,6 +759,10 @@ describe('OperationsService', () => {
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
],
drainageBreakdowns: [
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
],
})],
total: 12,
page: 2,
@@ -851,6 +925,49 @@ describe('OperationsService', () => {
});
});
it('returns paginated receipt anomalies with status summary', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.listReceiptAnomalies({
tenantId: 'tenant-1',
channelId: 'channel-1',
status: 'pending',
anomalyType: 'aggregate_success_then_failure',
keyword: 'MSG-1',
page: 1,
pageSize: 10,
})).resolves.toEqual(expect.objectContaining({
total: 1,
page: 1,
pageSize: 10,
summary: {
pending: 1,
resolved: 0,
ignored: 0,
oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'),
},
}));
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
tenantId: 'tenant-1',
channelId: 'channel-1',
status: 'pending',
anomalyType: 'aggregate_success_then_failure',
}),
orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }],
take: 10,
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, code: true, name: true, status: true } },
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
submitRecord: { select: { submitId: true, submitStatus: true } },
receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } },
},
}));
});
it('returns paginated downstream deliveries', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsDashboardQueries {
constructor(private readonly prisma: PrismaService) {}
async dashboard(query: { tenantId?: string }) {
const businessDay = qualityBusinessDay();
const sinceToday = businessDay.startAt;
const downstreamAlertWindow = downstreamAlertWindows();
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
const todayMessageWhereClause = {
...messageWhereClause,
queuedAt: { gte: sinceToday, lt: businessDay.endAt },
};
const [
taskCount,
messageGroups,
todayMessageGroups,
uplinkCount,
billingAggregate,
transactionAggregate,
connectionGroups,
pendingAudits,
tenantAccounts,
recentTasks,
recentRecharges,
enterpriseSpendRows,
downstreamPendingCount,
downstreamFailedCount,
downstreamDeliveredCount,
downstreamStalledPendingCount,
downstreamStalledAckCount,
downstreamRecentFailedCount,
hourlySendRows,
auditSpeedRows,
] = await Promise.all([
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsMessageRecord.groupBy({
by: ['status'],
where: messageWhereClause,
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.smsMessageRecord.groupBy({
by: ['status'],
where: todayMessageWhereClause,
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsBillingRecord.aggregate({
where: { tenantId: query.tenantId },
_sum: { amountCents: true, billingUnits: true },
_count: { _all: true },
}),
this.prisma.accountTransaction.aggregate({
where: returnedTransactionWhere(sinceToday, query.tenantId),
_sum: { amountCents: true },
_count: { _all: true },
}),
this.prisma.cmppConnectionState.groupBy({
by: ['status'],
where: { tenantId: query.tenantId },
_count: { _all: true },
_sum: { currentConnections: true, desiredConnections: true },
}),
this.pendingAudits(query.tenantId),
this.prisma.tenantAccount.findMany({
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
include: { tenant: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.smsBatchTask.findMany({
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
include: { application: true, messages: { take: 1, include: { channel: true } } },
orderBy: { createdAt: 'desc' },
take: 10,
}),
this.prisma.rechargeOrder.findMany({
where: {
tenantId: query.tenantId,
payMethod: 'manual_topup',
},
include: { tenant: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
this.prisma.$queryRaw<Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>>(Prisma.sql`
SELECT
tenant.id AS "tenantId",
tenant.name AS "tenantName",
COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents",
account."balanceCents" AS "balanceCents",
account."creditCents" AS "creditCents"
FROM "TenantAccount" account
JOIN "Tenant" tenant ON tenant.id = account."tenantId"
LEFT JOIN "SmsBillingRecord" billing
ON billing."tenantId" = tenant.id
AND billing."createdAt" >= ${businessDay.startAt}
AND billing."createdAt" < ${businessDay.endAt}
WHERE tenant.status <> 'deleted'
AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
ORDER BY "todaySpendCents" DESC, tenant.name ASC
`),
this.prisma.cmppDownstreamDelivery.count({
where: { tenantId: query.tenantId, status: 'pending' },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { tenantId: query.tenantId, status: 'failed' },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { tenantId: query.tenantId, status: 'delivered' },
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
tenantId: query.tenantId,
...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt),
},
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
tenantId: query.tenantId,
status: 'awaiting_ack',
ackDeadlineAt: { lte: downstreamAlertWindow.now },
},
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
tenantId: query.tenantId,
status: { in: ['failed', 'unconfirmed', 'rejected'] },
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
},
}),
this.prisma.$queryRaw<Array<{
hour: number;
submittedCount: bigint;
successCount: bigint;
}>>(Prisma.sql`
SELECT
EXTRACT(
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
)::integer AS hour,
COUNT(*)::bigint AS "submittedCount",
COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
GROUP BY 1
ORDER BY 1
`),
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
this.prisma.$queryRaw<Array<{
category: string;
count: bigint;
averageProcessingMs: bigint | null;
}>>(Prisma.sql`
WITH review_samples AS (
SELECT
'enterpriseCertifications'::text AS category,
certification."submittedAt" AS "submittedAt",
certification."reviewedAt" AS "reviewedAt"
FROM "EnterpriseCertification" certification
WHERE certification."reviewedAt" >= ${businessDay.startAt}
AND certification."reviewedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null})
UNION ALL
SELECT
'smsAudits'::text,
task."createdAt",
task."reviewedAt"
FROM "SmsSendTask" task
WHERE task."reviewedAt" >= ${businessDay.startAt}
AND task."reviewedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null})
UNION ALL
SELECT
'drainageInfos'::text,
drainage."submittedAt",
drainage."reviewedAt"
FROM "SmsDrainageInfo" drainage
WHERE drainage."reviewedAt" >= ${businessDay.startAt}
AND drainage."reviewedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null})
UNION ALL
SELECT
CASE review."targetType"
WHEN 'sms_signature' THEN 'signatures'
WHEN 'sms_template' THEN 'templates'
END,
submission."createdAt",
review."createdAt"
FROM "AuditRecord" review
JOIN LATERAL (
SELECT pending."createdAt"
FROM "AuditRecord" pending
WHERE pending."targetType" = review."targetType"
AND pending."targetId" = review."targetId"
AND pending."statusAfter" = 'pending'
AND pending."createdAt" <= review."createdAt"
ORDER BY pending."createdAt" DESC
LIMIT 1
) submission ON true
WHERE review."targetType" IN ('sms_signature', 'sms_template')
AND review."statusBefore" = 'pending'
AND review."statusAfter" IN ('approved', 'rejected')
AND review."createdAt" >= ${businessDay.startAt}
AND review."createdAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null})
)
SELECT
category,
COUNT(*)::bigint AS count,
ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs"
FROM review_samples
WHERE "reviewedAt" >= "submittedAt"
GROUP BY category
`),
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
const row = hourlyRowsByHour.get(hour);
return {
hour,
label: `${String(hour).padStart(2, '0')}:00`,
submittedCount: Number(row?.submittedCount ?? 0),
successCount: Number(row?.successCount ?? 0),
};
});
const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row]));
const auditProcessingSpeed = [
['enterpriseCertifications', '企业认证'],
['smsAudits', '短信审核'],
['templates', '模板'],
['signatures', '签名'],
['drainageInfos', '引流信息'],
].map(([category, label]) => {
const row = auditSpeedByCategory.get(category);
return {
category,
label,
count: Number(row?.count ?? 0),
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
};
});
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
return {
taskCount,
messageStatus: messageGroups,
today: {
sent: todayTotals.total,
delivered: todayTotals.delivered,
failed: todayTotals.failed,
unknown: todayTotals.unknown,
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
spendCents: todayTotals.amountCents,
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
billingUnits: todayTotals.billingUnits,
},
uplinkCount,
billing: billingAggregate,
transactions: transactionAggregate,
gatewayConnections: connectionGroups,
pendingAuditCount: pendingAudits.total,
pendingAudits,
hourlySendTrend,
auditProcessingSpeed,
downstreamDeliverySummary: {
pending: downstreamPendingCount,
failed: downstreamFailedCount,
delivered: downstreamDeliveredCount,
stalledPending: downstreamStalledPendingCount,
stalledAck: downstreamStalledAckCount,
recentFailed: downstreamRecentFailedCount,
alertCount: downstreamAlertCount,
},
accounts: tenantAccounts,
enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({
tenantId: row.tenantId,
tenantName: row.tenantName,
todaySpendCents: moneyToNumber(row.todaySpendCents),
balanceCents: moneyToNumber(row.balanceCents),
creditCents: moneyToNumber(row.creditCents),
})),
recentTasks,
recentRecharges,
};
}
async clientDashboard(query: { tenantId?: string }) {
const tenantId = query.tenantId;
const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([
this.dashboard(query),
tenantId
? this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true } })
: Promise.resolve(null),
tenantId
? this.prisma.enterpriseCertification.findFirst({
where: { tenantId, status: 'approved' },
select: { id: true },
})
: Promise.resolve(null),
tenantId
? this.prisma.smsSignature.count({
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
})
: Promise.resolve(0),
tenantId
? this.prisma.smsBatchTask.count({
where: { tenantId, sourceType: 'client', status: 'pending_review' },
})
: Promise.resolve(0),
]);
return {
taskCount: dashboard.taskCount,
messageStatus: dashboard.messageStatus,
today: dashboard.today,
uplinkCount: dashboard.uplinkCount,
billing: dashboard.billing,
transactions: dashboard.transactions,
gatewayConnections: [],
pendingAuditCount: dashboard.pendingAuditCount,
pendingAudits: dashboard.pendingAudits,
hourlySendTrend: dashboard.hourlySendTrend,
auditProcessingSpeed: dashboard.auditProcessingSpeed,
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
accounts: dashboard.accounts.map(clientAccountView),
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
recentTasks: dashboard.recentTasks.map(clientBatchTaskView),
recentRecharges: dashboard.recentRecharges.map(clientRechargeView),
clientOverview: {
enterpriseName: tenant?.name ?? null,
certificationStatus: approvedCertification ? 'certified' : 'uncertified',
signatureCount,
pendingBatchTaskCount,
},
};
}
pendingAudits(tenantId?: string) {
return Promise.all([
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
templates,
signatures,
drainageInfos,
enterpriseCertifications,
smsAudits,
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
}));
}
}
@@ -0,0 +1,432 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, ReceiptAnomalyQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 downstream query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsDownstreamQueries {
constructor(private readonly prisma: PrismaService) {}
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
OR: query.keyword ? [
{ streamMessageId: { contains: query.keyword } },
{ traceId: { contains: query.keyword } },
{ messageId: { contains: query.keyword } },
{ submitId: { contains: query.keyword } },
{ failureCode: { contains: query.keyword } },
{ failureMessage: { contains: query.keyword } },
] : undefined,
};
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
...baseWhere,
status: query.status && query.status !== 'all' ? query.status : undefined,
};
const [items, total, statusGroups, oldestPending] = await Promise.all([
this.prisma.gatewaySubmitDeadLetter.findMany({
where,
include: { tenant: true, application: true, channel: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.gatewaySubmitDeadLetter.count({ where }),
this.prisma.gatewaySubmitDeadLetter.groupBy({
by: ['status'],
where: baseWhere,
_count: { _all: true },
}),
this.prisma.gatewaySubmitDeadLetter.findFirst({
where: { ...baseWhere, status: 'pending' },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
]);
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value));
const messageStates = messageIds.length > 0
? await this.prisma.smsMessageRecord.findMany({
where: { messageId: { in: messageIds } },
select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true },
})
: [];
const messageStateById = new Map(messageStates.map((item) => [item.messageId, item]));
return {
items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)),
total,
page,
pageSize,
summary: {
pending: statusCounts.get('pending') ?? 0,
requeueing: statusCounts.get('requeueing') ?? 0,
requeued: statusCounts.get('requeued') ?? 0,
resolved: statusCounts.get('resolved') ?? 0,
oldestPendingAt: oldestPending?.createdAt ?? null,
},
};
}
async listReceiptAnomalies(query: ReceiptAnomalyQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const baseWhere: Prisma.SmsReceiptAnomalyWhereInput = {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
anomalyType: query.anomalyType && query.anomalyType !== 'all' ? query.anomalyType : undefined,
OR: query.keyword ? [
{ anomalyKey: { contains: query.keyword } },
{ rawStatus: { contains: query.keyword } },
{ errorCode: { contains: query.keyword } },
{ messageRecord: { messageId: { contains: query.keyword } } },
{ submitRecord: { submitId: { contains: query.keyword } } },
] : undefined,
};
const where: Prisma.SmsReceiptAnomalyWhereInput = {
...baseWhere,
status: query.status && query.status !== 'all' ? query.status : undefined,
};
const [items, total, statusGroups, oldestPending] = await Promise.all([
this.prisma.smsReceiptAnomaly.findMany({
where,
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, code: true, name: true, status: true } },
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
submitRecord: { select: { submitId: true, submitStatus: true } },
receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } },
},
orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.smsReceiptAnomaly.count({ where }),
this.prisma.smsReceiptAnomaly.groupBy({
by: ['status'],
where: baseWhere,
_count: { _all: true },
}),
this.prisma.smsReceiptAnomaly.findFirst({
where: { ...baseWhere, status: 'pending' },
orderBy: { firstOccurredAt: 'asc' },
select: { firstOccurredAt: true },
}),
]);
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
return {
items,
total,
page,
pageSize,
summary: {
pending: statusCounts.get('pending') ?? 0,
resolved: statusCounts.get('resolved') ?? 0,
ignored: statusCounts.get('ignored') ?? 0,
oldestPendingAt: oldestPending?.firstOccurredAt ?? null,
},
};
}
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const where: Prisma.CmppDownstreamDeliveryWhereInput = {
...downstreamDeliveryScopedWhere(query),
status: query.status && query.status !== 'all' ? query.status : undefined,
OR: query.keyword ? [
{ messageId: { contains: query.keyword } },
{ payload: { path: ['account'], string_contains: query.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: query.keyword } },
{ lastError: { contains: query.keyword } },
] : undefined,
};
const [items, total] = await Promise.all([
this.prisma.cmppDownstreamDelivery.findMany({
where,
include: {
tenant: true,
application: true,
messageRecord: true,
attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.cmppDownstreamDelivery.count({ where }),
]);
return { items, total, page, pageSize };
}
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
const scopedWhere = downstreamDeliveryScopedWhere(query);
const downstreamAlertWindow = downstreamAlertWindows();
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }),
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }),
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }),
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }),
this.prisma.cmppDownstreamDelivery.count({
where: {
...scopedWhere,
...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt),
},
}),
this.prisma.cmppDownstreamDelivery.count({
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } },
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
...scopedWhere,
status: { in: ['failed', 'unconfirmed', 'rejected'] },
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
},
}),
this.prisma.cmppDownstreamDelivery.groupBy({
by: ['deliveryType', 'status'],
where: scopedWhere,
_count: { _all: true },
}),
this.prisma.cmppDownstreamDelivery.groupBy({
by: ['applicationId', 'status'],
where: scopedWhere,
_count: { _all: true },
}),
this.prisma.cmppDownstreamDelivery.groupBy({
by: ['applicationId'],
where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow),
_count: { _all: true },
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
...scopedWhere,
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
retryCount: 0,
},
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
...scopedWhere,
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
retryCount: { gte: 1, lte: 3 },
},
}),
this.prisma.cmppDownstreamDelivery.count({
where: {
...scopedWhere,
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
retryCount: { gte: 4 },
},
}),
]);
const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))];
const applications: Array<{ id: string; name: string }> = applicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: applicationIds } },
select: { id: true, name: true },
})
: [];
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
const applicationAlertMap = new Map<string, number>(
applicationAlertGroups.map((item) => [item.applicationId, item._count._all]),
);
const groupedByType = groupDownstreamByType(typeGroups);
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap);
return {
summary: {
total,
pending,
awaitingAck,
delivered,
failed,
unconfirmed,
rejected,
stalledPending,
stalledAck,
recentFailed,
alertCount: stalledPending + stalledAck + recentFailed,
},
typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({
deliveryType,
total: groupedByType[deliveryType]?.total ?? 0,
pending: groupedByType[deliveryType]?.pending ?? 0,
awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0,
delivered: groupedByType[deliveryType]?.delivered ?? 0,
failed: groupedByType[deliveryType]?.failed ?? 0,
unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0,
rejected: groupedByType[deliveryType]?.rejected ?? 0,
})),
retryBuckets: [
{ label: '0次', count: retryZero },
{ label: '1-3次', count: retryLow },
{ label: '4次及以上', count: retryHigh },
],
topApplications: groupedByApplication
.sort((left, right) => (
right.alertCount - left.alertCount
|| right.failed - left.failed
|| right.pending - left.pending
|| left.name.localeCompare(right.name, 'zh-CN')
))
.slice(0, 5),
};
}
async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const where = downstreamRecoveryStatusWhere(query);
const now = new Date();
const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([
recoveryStatuses.findMany({
where,
include: { tenant: true, application: true },
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
recoveryStatuses.count({ where }),
recoveryStatuses.count({ where: { ...where, state: 'running' } }),
recoveryStatuses.count({ where: { ...where, state: 'success' } }),
recoveryStatuses.count({ where: { ...where, state: 'failed' } }),
recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }),
recoveryStatuses.count({
where: {
...where,
nextRetryAt: { gt: now },
},
}),
recoveryStatuses.groupBy({
by: ['failureCategory'],
where,
_count: { _all: true },
}),
]);
return {
items,
total,
page,
pageSize,
summary: {
total,
running: runningCount,
success: successCount,
failed: failedCount,
waitingConnection: waitingConnectionCount,
backoff: backoffCount,
failureCategories: categoryGroups
.filter((item) => item.failureCategory)
.map((item) => ({
category: String(item.failureCategory),
count: item._count?._all ?? 0,
}))
.sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)),
},
};
}
async listMessageSegmentAudits(query: MessageSegmentAuditQuery) {
const segmentAudits = (this.prisma as PrismaService & {
smsMessageSegmentAudit: {
findMany: (args: Record<string, unknown>) => Promise<any[]>;
};
}).smsMessageSegmentAudit;
if (!query.messageId && !query.messageRecordId) {
return [];
}
return segmentAudits.findMany({
where: {
messageRecordId: query.messageRecordId,
messageRecord: query.messageId ? { messageId: query.messageId } : undefined,
},
include: { channel: true, submitRecord: true },
orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }],
});
}
async getDownstreamRecoveryStatus(id: string) {
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
const item = await recoveryStatuses.findUnique({
where: { id },
include: { tenant: true, application: true },
});
if (!item) {
throw new NotFoundException('Recovery status not found');
}
return item;
}
async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
const where = downstreamRecoveryStatusWhere(query);
const items = await recoveryStatuses.findMany({
where,
include: { tenant: true, application: true },
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
take: 5000,
});
const rows = [
[
'账号',
'企业',
'应用',
'Gateway实例',
'恢复状态',
'锁持有实例',
'锁过期时间',
'失败分类',
'尝试次数',
'最后尝试时间',
'恢复成功时间',
'恢复失败时间',
'下次恢复时间',
'最后错误',
'最后跳过原因',
'创建时间',
'更新时间',
],
...items.map((item) => [
item.account ?? '',
item.tenant?.name ?? '',
item.application?.name ?? '',
item.gatewayInstanceId ?? '',
item.state ?? '',
(item as { lockOwner?: string | null }).lockOwner ?? '',
formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt),
(item as { failureCategory?: string | null }).failureCategory ?? '',
String(item.attemptCount ?? 0),
formatCsvDate(item.lastAttemptAt),
formatCsvDate(item.lastSuccessAt),
formatCsvDate(item.lastFailureAt),
formatCsvDate(item.nextRetryAt),
item.lastError ?? '',
item.lastSkipReason ?? '',
formatCsvDate(item.createdAt),
formatCsvDate(item.updatedAt),
]),
];
return {
fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`,
content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'),
total: items.length,
};
}
private gatewayDownstreamRecoveryStatusDelegate() {
return (this.prisma as PrismaService & {
gatewayDownstreamRecoveryStatus: {
findMany: (args: Record<string, unknown>) => Promise<any[]>;
count: (args: Record<string, unknown>) => Promise<number>;
findUnique: (args: Record<string, unknown>) => Promise<any | null>;
groupBy: (args: Record<string, unknown>) => Promise<any[]>;
};
}).gatewayDownstreamRecoveryStatus;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 logs query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsLogQueries {
constructor(private readonly prisma: PrismaService) {}
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId };
const [items, total] = await Promise.all([
this.prisma.operationLog.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.operationLog.count({ where }),
]);
return { items, total, page, pageSize };
}
async systemLogs(query: OperationLogQuery) {
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 10));
const where: Prisma.OperationLogWhereInput = {
tenantId: query.tenantId,
userId: query.userId,
createdAt: createdAtRange(query.range),
resource: query.module && query.module !== 'all' ? query.module : undefined,
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
OR: query.keyword ? [
{ action: { contains: query.keyword } },
{ resource: { contains: query.keyword } },
{ resourceId: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ user: { displayName: { contains: query.keyword } } },
{ user: { username: { contains: query.keyword } } },
] : undefined,
};
const [items, total, modules] = await Promise.all([
this.prisma.operationLog.findMany({
where,
include: { tenant: true, user: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.operationLog.count({ where }),
this.prisma.operationLog.groupBy({
by: ['resource'],
where: { tenantId: query.tenantId },
_count: { _all: true },
orderBy: { resource: 'asc' },
}),
]);
return {
items: items.map((item) => normalizeOperationLog(item)),
total,
page,
pageSize,
modules: modules.map((item) => item.resource),
};
}
async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) {
const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined;
const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId };
const where: Prisma.OperationLogWhereInput = {
tenantId: effectiveQuery.tenantId,
userId: effectiveQuery.userId,
createdAt: createdAtRange(effectiveQuery.range),
resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined,
AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined,
OR: effectiveQuery.keyword ? [
{ action: { contains: effectiveQuery.keyword } },
{ resource: { contains: effectiveQuery.keyword } },
{ resourceId: { contains: effectiveQuery.keyword } },
{ tenant: { name: { contains: effectiveQuery.keyword } } },
{ user: { displayName: { contains: effectiveQuery.keyword } } },
{ user: { username: { contains: effectiveQuery.keyword } } },
] : undefined,
};
const rows = await this.prisma.operationLog.findMany({
where,
include: { tenant: true, user: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 10_001,
});
const truncated = rows.length > 10_000;
const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog);
const clientExport = Boolean(clientUserId);
const headers = clientExport
? ['时间', '级别', '模块', '操作人', '动作', '资源ID']
: ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP'];
const values = exportedRows.map((item) => clientExport
? [item.time, item.level, item.module, item.operator, item.action, item.resourceId]
: [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]);
return {
operationId: randomUUID(),
status: 'completed' as const,
fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`,
recordCount: exportedRows.length,
truncated,
content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'),
filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range },
};
}
private async resolveClientTenantId(userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
select: { tenantId: true },
});
if (!user?.tenantId) throw new NotFoundException('Client tenant not found');
return user.tenantId;
}
}
@@ -0,0 +1,149 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsMessageQueries {
constructor(private readonly prisma: PrismaService) {}
listBatchTasks(query: { tenantId?: string; status?: string }) {
return this.prisma.smsBatchTask.findMany({
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
include: { apiRequests: true },
orderBy: { createdAt: 'desc' },
});
}
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
const items = await this.listBatchTasks(query);
return items.map(clientBatchTaskView);
}
listMessages(query: MessageQuery) {
return this.prisma.smsMessageRecord.findMany({
where: messageWhere(query),
include: {
tenant: true,
application: true,
channel: true,
submitRecords: { include: { channel: true, channelGroup: true } },
receiptRecords: { include: { channel: true } },
downstreamDeliveries: {
where: { deliveryType: 'receipt' },
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
},
},
orderBy: { queuedAt: 'desc' },
});
}
async listMessagesPage(query: MessageQuery) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25)));
const where = messageWhere(query);
const [items, total] = await Promise.all([
this.prisma.smsMessageRecord.findMany({
where,
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } },
submitRecords: {
select: {
id: true,
submitId: true,
channelId: true,
channelGroupId: true,
channelGroupName: true,
gatewayMessageId: true,
submitStatus: true,
submittedAt: true,
createdAt: true,
channel: { select: { id: true, name: true } },
channelGroup: { select: { id: true, name: true } },
},
},
receiptRecords: {
select: {
id: true,
messageId: true,
gatewayMessageId: true,
receiptStatus: true,
rawStatus: true,
errorCode: true,
errorMessage: true,
deliveredAt: true,
createdAt: true,
channelId: true,
channel: { select: { id: true, name: true } },
},
},
downstreamDeliveries: {
where: { deliveryType: 'receipt' },
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
},
},
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.smsMessageRecord.count({ where }),
]);
return { items, total, page, pageSize };
}
async exportMessages(query: MessageQuery) {
const items = await this.prisma.smsMessageRecord.findMany({
where: messageWhere(query),
select: {
messageId: true,
queuedAt: true,
phoneNumber: true,
province: true,
carrier: true,
billingUnits: true,
amountCents: true,
status: true,
submitStatus: true,
deliveredAt: true,
content: true,
hasDrainageContent: true,
tenant: { select: { name: true } },
application: { select: { name: true } },
channel: { select: { name: true } },
},
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
});
const rows = [
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'],
...items.map((item) => [
item.messageId,
item.tenant?.name ?? '',
item.application?.name ?? '',
item.queuedAt.toISOString(),
item.phoneNumber,
item.province ?? '',
item.carrier ?? '',
String(item.billingUnits),
String(moneyToNumber(item.amountCents)),
item.channel?.name ?? '',
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测',
item.deliveredAt?.toISOString() ?? '',
item.content,
]),
];
return {
fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`,
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
};
}
async listClientMessages(query: MessageQuery) {
const items = await this.listMessages(query);
return items.map(clientMessageView);
}
async listClientMessagesPage(query: MessageQuery) {
const result = await this.listMessagesPage(query);
return { ...result, items: result.items.map(clientMessageView) };
}
}
@@ -0,0 +1,677 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsQualityQueries {
constructor(private readonly prisma: PrismaService) {}
async statistics(query: { tenantId?: string; groupBy?: string }) {
const groupBy = normalizeGroupBy(query.groupBy);
if (groupBy === 'tenantId') {
return this.prisma.smsMessageRecord.groupBy({
by: ['tenantId'],
where: messageWhere({ tenantId: query.tenantId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
}
if (groupBy === 'applicationId') {
return this.prisma.smsMessageRecord.groupBy({
by: ['applicationId'],
where: messageWhere({ tenantId: query.tenantId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
}
return this.prisma.smsMessageRecord.groupBy({
by: ['channelId'],
where: messageWhere({ tenantId: query.tenantId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
}
async sendQuality(date?: string) {
const day = qualityBusinessDay(date);
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
this.prisma.$queryRaw<Array<{
channelId: string;
channelName: string;
total: number;
acceptedCount: number;
submitFailureCount: number;
submitFailureRate: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
unknownRate: number;
failureRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
submit."channelId" AS channel_id,
channel.name AS channel_name,
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
COALESCE(segment_summary.segment_count, 0) AS segment_count,
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
END AS arrival_ms
FROM "SmsSubmitRecord" submit
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id
) segment_summary ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "failedAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
), classified AS (
SELECT
*,
CASE
WHEN submit_status <> 'accepted' THEN 'submit_failed'
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
ELSE 'unknown'
END AS delivery_status
FROM base
)
SELECT
channel_id AS "channelId",
MAX(channel_name) AS "channelName",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate",
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate",
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate",
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified
GROUP BY channel_id
ORDER BY COUNT(*) DESC, channel_id
`),
this.prisma.$queryRaw<Array<{
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
(message."hasDrainageContent" IS TRUE) AS has_drainage,
message.status,
message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status,
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
END AS arrival_ms
FROM "SmsMessageRecord" message
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
)
SELECT
signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id,
signature.id AS "signatureId",
signature.name AS "signatureName",
tenant.id AS "tenantId",
tenant.name AS "tenantName",
base.has_drainage AS "hasDrainage",
COUNT(*)::integer AS total,
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(*) FILTER (
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
),
1
)::double precision
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
ORDER BY "successCount" DESC, total DESC, signature.name
`),
this.prisma.$queryRaw<Array<{
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>>(Prisma.sql`
WITH base AS (
SELECT message.status, message."receiptStatus" AS receipt_status
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
AND COALESCE(message.status, '') <> 'rejected'
)
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0 / COUNT(*),
1
)::double precision
END AS "successRate"
FROM base
`),
this.prisma.$queryRaw<Array<{
applicationId: string;
applicationName: string;
tenantId: string;
tenantName: string;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."applicationId" AS application_id,
message.status,
message."receiptStatus" AS receipt_status
FROM "SmsMessageRecord" message
WHERE message."applicationId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
AND COALESCE(message.status, '') <> 'rejected'
)
SELECT
application.id AS "applicationId",
application.name AS "applicationName",
tenant.id AS "tenantId",
tenant.name AS "tenantName",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0 / COUNT(*),
1
)::double precision
END AS "successRate"
FROM base
JOIN "SmsApplication" application ON application.id = base.application_id
JOIN "Tenant" tenant ON tenant.id = application."tenantId"
GROUP BY application.id, application.name, tenant.id, tenant.name
ORDER BY total DESC, application.name
`),
]);
const signatures = aggregateSignatureRows(signatureSplits);
const drainageSignatures = signatureSplits.filter((item) => item.hasDrainage);
const summary = summaryRows[0] ?? {
total: 0,
successCount: 0,
unknownCount: 0,
failureCount: 0,
successRate: 0,
};
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
}
async signatureQuality(query: SignatureQualityQuery) {
const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<Array<{
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationNames: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
rowCount: number;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
message."applicationId" AS application_id,
message.status,
message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status,
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
END AS arrival_ms
FROM "SmsMessageRecord" message
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
)
SELECT
signature.id AS "signatureId",
signature.name AS "signatureName",
tenant.id AS "tenantId",
tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total,
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(*) FILTER (
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
),
1
)::double precision
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
WHERE (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
OR tenant.name ILIKE ${keywordPattern}
OR application.name ILIKE ${keywordPattern}
)
GROUP BY signature.id, signature.name, tenant.id, tenant.name
ORDER BY total DESC, signature.name
LIMIT ${pageSize}
OFFSET ${(page - 1) * pageSize}
`);
const signatureIds = summaries.map((item) => item.signatureId);
const drainageBreakdowns = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
submit."channelId" AS channel_id,
channel.name AS channel_name,
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
CASE
WHEN message."hasDrainageContent" IS TRUE THEN 'with'
WHEN message."hasDrainageContent" IS FALSE THEN 'without'
ELSE 'unknown'
END AS drainage_state,
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
COALESCE(segment_summary.segment_count, 0) AS segment_count,
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
END AS arrival_ms
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id
) segment_summary ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "failedAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
), classified AS (
SELECT
*,
CASE
WHEN submit_status <> 'accepted' THEN 'submit_failed'
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
ELSE 'unknown'
END AS delivery_status
FROM base
)
SELECT
signature_id AS "signatureId",
channel_id AS "channelId",
MAX(channel_name) AS "channelName",
carrier,
drainage_state AS "drainageState",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE delivery_status = 'success')
* 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'),
1
)::double precision
END AS "successRate",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified
GROUP BY signature_id, channel_id, carrier, drainage_state
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
`);
const carrierOverview = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
carrier: string;
businessMessageCount: number;
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
SELECT
message."signatureId" AS "signatureId",
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
COUNT(*)::integer AS "businessMessageCount",
COUNT(*) FILTER (
WHERE message.status = 'delivered'
OR message."receiptStatus" = 'delivered'
)::integer AS "finalSuccessCount",
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (
WHERE message.status = 'delivered'
OR message."receiptStatus" = 'delivered'
) * 100.0 / COUNT(*),
1
)::double precision
END AS "finalSuccessRate",
ROUND(AVG(
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
END
))::integer AS "averageArrivalMs"
FROM "SmsMessageRecord" message
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown')
ORDER BY message."signatureId", COUNT(*) DESC, carrier
`);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
return {
...summary,
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId),
breakdowns: signatureBreakdowns,
drainageBreakdowns: signatureDrainageBreakdowns,
};
});
return {
date: day.key,
items,
total: summaries[0]?.rowCount ?? 0,
page,
pageSize,
};
}
}
type SignatureSplitRow = {
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
};
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const grouped = new Map<string, SignatureSplitRow[]>();
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
return [...grouped.values()].map((parts) => {
const first = parts[0];
const total = parts.reduce((sum, item) => sum + item.total, 0);
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
...first,
id: first.signatureId,
hasDrainage: false,
total,
acceptedCount,
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
};
}).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName));
}
type DrainageBreakdownRow = {
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
};
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const grouped = new Map<string, DrainageBreakdownRow[]>();
rows.forEach((row) => {
const key = `${row.signatureId}\u0000${row.channelId}\u0000${row.carrier}`;
grouped.set(key, [...(grouped.get(key) ?? []), row]);
});
return [...grouped.values()].map((parts) => {
const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
signatureId: first.signatureId,
channelId: first.channelId,
channelName: first.channelName,
carrier: first.carrier,
total: parts.reduce((sum, item) => sum + item.total, 0),
acceptedCount,
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
};
});
}
@@ -0,0 +1,92 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 trace query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsTraceQueries {
constructor(private readonly prisma: PrismaService) {}
auditSummary(query: { tenantId?: string }) {
return this.prisma.operationLog.groupBy({
by: ['action', 'resource'],
where: { tenantId: query.tenantId },
_count: { _all: true },
orderBy: { _count: { action: 'desc' } },
take: 100,
});
}
async trace(query: TraceQuery) {
const messages = await this.prisma.smsMessageRecord.findMany({
where: {
...messageWhere(query),
messageId: query.messageId,
},
include: {
batchTask: { include: { apiRequests: true } },
submitRecords: { include: { session: true } },
receiptRecords: true,
},
orderBy: { queuedAt: 'desc' },
take: 100,
});
const messageIds = messages.map((message) => message.messageId);
const [billingRecords, uplinks] = await Promise.all([
this.prisma.smsBillingRecord.findMany({
where: {
tenantId: query.tenantId,
taskId: query.taskId,
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.smsUplinkMessage.findMany({
where: {
tenantId: query.tenantId,
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
},
orderBy: { receivedAt: 'desc' },
}),
]);
return { messages, billingRecords, uplinks };
}
async reconciliation(query: { tenantId?: string; taskId?: string }) {
const [messages, billing, transactions] = await Promise.all([
this.prisma.smsMessageRecord.aggregate({
where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.smsBillingRecord.aggregate({
where: { tenantId: query.tenantId, taskId: query.taskId },
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.accountTransaction.aggregate({
where: {
tenantId: query.tenantId,
relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined,
relatedId: query.taskId,
},
_count: { _all: true },
_sum: { amountCents: true },
}),
]);
const messageAmount = moneyToNumber(messages._sum.amountCents);
const billingAmount = moneyToNumber(billing._sum.amountCents);
const transactionAmount = moneyToNumber(transactions._sum.amountCents);
return {
messages,
billing,
transactions,
diff: {
messageVsBillingAmountCents: messageAmount - billingAmount,
billingVsTransactionAmountCents: billingAmount + transactionAmount,
messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0),
},
};
}
}
@@ -0,0 +1,95 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsUplinkQueries {
constructor(private readonly prisma: PrismaService) {}
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
return this.prisma.smsUplinkMessage.findMany({
where: {
tenantId: query.tenantId,
channelId: query.channelId,
applicationId: query.applicationId,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
content: query.keyword ? { contains: query.keyword } : undefined,
receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined,
},
include: {
tenant: true,
application: true,
channel: true,
messageRecord: { include: { application: true } },
matchCandidates: {
include: {
tenant: true,
application: true,
messageRecord: { include: { application: true, tenant: true, channel: true } },
},
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
},
},
orderBy: { receivedAt: 'desc' },
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
take: query.pageSize ?? 500,
});
}
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
const items = await this.listUplinkMessages(query);
return items.map(clientUplinkView);
}
async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsUplinkMessageWhereInput = {
tenantId: query.tenantId,
channelId: query.channelId,
applicationId: query.applicationId,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
content: query.keyword ? { contains: query.keyword } : undefined,
receivedAt: query.startTime || query.endTime ? {
gte: query.startTime ? new Date(query.startTime) : undefined,
lte: query.endTime ? new Date(query.endTime) : undefined,
} : undefined,
};
const [rawItems, total] = await Promise.all([
this.listUplinkMessages({ ...query, page, pageSize }),
this.prisma.smsUplinkMessage.count({ where }),
]);
return {
items: clientView ? rawItems.map(clientUplinkView) : rawItems,
total,
page,
pageSize,
};
}
async monitor(query: { tenantId?: string; channelId?: string }) {
const where = messageWhere(query);
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }),
this.prisma.smsMessageRecord.findMany({
where,
include: { submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
take: 20,
}),
this.prisma.smsReceiptRecord.findMany({
where: { tenantId: query.tenantId, channelId: query.channelId },
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
]);
return {
byStatus,
recentMessages,
recentReceipts,
recentUplinks: recentUplinks.slice(0, 20),
};
}
}
+18
View File
@@ -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;
}
});
});
+48 -5
View File
@@ -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();
}
+9
View File
@@ -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 {}
+59
View File
@@ -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' } }]),
}),
}));
});
});
+31 -13
View File
@@ -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() {
this.flushTimer = setInterval(() => void this.flush(), positiveEnv('PROTOCOL_LOG_FLUSH_INTERVAL_MS', 500));
this.flushTimer.unref?.();
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;
@@ -0,0 +1,252 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportBatchGenerationService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {}
async listBatches(query: PagedQuery = {}) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const where: Prisma.ReportMaterialBatchWhereInput = {
createdAt: dateRange(query.startAt, query.endAt),
batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [batches, total] = await Promise.all([
this.prisma.reportMaterialBatch.findMany({
where,
include: {
exportFiles: {
include: {
items: { include: { task: { select: { id: true, status: true } } } },
},
},
items: true,
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.reportMaterialBatch.count({ where }),
]);
return {
items: batches.map((batch) => {
const reportItems = batch.exportFiles.flatMap((file) => file.items);
const reportTotal = reportItems.length;
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
return {
...batch,
reportTotal,
successCount,
successRate: reportTotal ? successCount / reportTotal : 0,
};
}),
total,
page,
pageSize,
};
}
async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
if (claimed.replayed) return claimed.result;
let preflight: Awaited<ReturnType<ReportBatchGenerationService['preflightBatch']>>;
try {
preflight = await this.preflightBatch({ items: uniqueItems });
} catch (error) {
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
throw error;
}
if (preflight.eligibleTargetCount === 0) {
await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
}
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
const batch = await this.prisma.reportMaterialBatch.create({
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length },
});
try {
const prepared = [];
for (const inspection of eligibleInspections) {
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
}
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
for (const item of prepared) {
for (const channel of item.channels) {
const current = channelMap.get(channel.id) ?? [];
current.push({ ...item, channels: [channel] });
channelMap.set(channel.id, current);
}
}
const exportedFiles = [];
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
let failedTargetCount = 0;
for (const [channelId, items] of channelMap) {
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
failedTargetCount += result.incompleteBatchItemIds.length;
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
}
for (const item of prepared) {
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
}
const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
const result = {
...completed,
operationId: claimed.operationId,
replayed: false,
result: {
successCount: preflight.eligibleTargetCount - failedTargetCount,
skippedCount: preflight.skippedTargetCount,
failedCount: failedTargetCount,
items: preflight.items,
},
};
await this.operations.completeBatchOperation(claimed.operationId, batch.id, result);
return result;
} catch (error) {
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
throw error;
}
}
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
for (const item of data.items) {
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
}
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
return {
checkedAt: new Date().toISOString(),
eligible: items.some((item) => item.eligible),
eligibleItemCount: items.filter((item) => item.eligible).length,
blockedItemCount: items.filter((item) => !item.eligible).length,
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
items,
};
}
async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过');
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()];
const snapshot = selected.reportType === 'signature'
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
}
async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature) throw new NotFoundException('签名不存在');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
const blockedReasons: string[] = [];
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`);
if (selected.reportType === 'drainage') {
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
else {
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
}
}
const snapshot = selected.reportType === 'signature'
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
for (const route of routes) {
if (route.group.status !== 'active') continue;
for (const entry of route.group.items) {
if (entry.channel.status !== 'active') continue;
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
channelCarriers.set(entry.channel.id, current);
}
}
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
const previous = await this.prisma.reportMaterialBatchItem.findMany({
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
orderBy: { createdAt: 'desc' },
});
const priorKeys = new Map<string, string>();
for (const item of previous) {
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
}
}
const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) {
const carrier = [...carriers].sort().join(',');
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
else {
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
}
return {
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
reportType: selected.reportType,
signatureId: signature.id,
drainageItemId: drainageInfo?.id,
materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用',
eligible: targets.some((target) => target.eligible),
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
targets,
};
}
}
@@ -0,0 +1,40 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportBatchOperationService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`;
const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } });
if (existing) {
const detail = jsonRecord(existing.detail);
if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' });
if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } };
throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' });
}
const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } });
return { operationId: operation.id, replayed: false as const, result: null };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
async completeBatchOperation(operationId: string, batchId: string, result: Record<string, unknown>) {
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } });
}
async failBatchOperation(operationId: string, message: string, batchId?: string) {
const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } });
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } });
}
}
@@ -0,0 +1,88 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import type { ReportBatchGenerationService } from './batch-generation.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportChannelExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('通道不存在');
const reportTypes = [...new Set(items.map((item) => item.reportType))];
const workbook = new ExcelJS.Workbook();
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
const incompleteBatchItemIds: string[] = [];
let totalRows = 0;
for (const reportType of reportTypes) {
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.properties.defaultRowHeight = 22;
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
styleHeader(sheet.getRow(1));
for (const item of items.filter((current) => current.reportType === reportType)) {
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
const 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, ...(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);
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)));
totalRows += 1;
let targetHeight = 22;
for (const [index, value] of values.entries()) {
if (!isFileRef(value)) continue;
const downloaded = await this.files.getDownload(value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
}
row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
}
}
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
const empty = workbook.addWorksheet('无可导出数据');
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
empty.getColumn(1).width = 64;
}
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
}
recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
}
}
@@ -0,0 +1,117 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportParserService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
listImportProfiles(reportType?: 'signature' | 'drainage') {
return this.prisma.reportMaterialImportProfile.findMany({
where: { reportType, status: 'active' },
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
orderBy: { updatedAt: 'desc' },
});
}
async saveImportProfile(data: CreateImportProfileDto) {
validateProfile(data);
return this.prisma.$transaction(async (tx) => {
const profile = data.id
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
await tx.reportMaterialImportProfileColumn.createMany({
data: data.columns.map((column, index) => ({
profileId: profile.id,
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind,
fieldType: column.fieldType,
required: column.required ?? false,
transform: column.transform,
sortOrder: column.sortOrder ?? (index + 1) * 10,
})),
});
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
});
}
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
if (!options.tenantId) throw new BadRequestException('tenantId is required');
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
const workbook = await loadWorkbook(file.buffer);
assertSafeWorkbook(workbook);
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
const headerRowCount = clamp(options.headerRowCount, 1, 5);
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
const images = readEmbeddedImages(workbook, worksheet);
const columnCount = Math.min(worksheet.columnCount, 200);
const columns = Array.from({ length: columnCount }, (_, offset) => {
const sourceColumnIndex = offset + 1;
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
const sourceHeaderPath = [...new Set(parts)].join('/');
return {
sourceColumnIndex,
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
sourceHeader: parts.at(-1) || `${sourceColumnIndex}`,
sourceHeaderPath,
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
};
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
const previewRows = [];
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
}
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
const profileMappings = profile?.columns.map((column) => ({
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind as ImportMapping['targetKind'],
fieldType: column.fieldType as ImportMapping['fieldType'],
required: column.required,
transform: column.transform ?? undefined,
sortOrder: column.sortOrder,
}));
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
const batch = await this.prisma.reportMaterialImportBatch.create({
data: {
tenantId: options.tenantId,
applicationId: options.applicationId,
profileId: options.profileId,
fileObjectId: sourceFile.id,
fileName: sourceFile.fileName,
reportType: options.reportType,
sheetName: worksheet.name,
headerRowCount,
dataStartRow,
mapping: suggestedMappings as Prisma.InputJsonValue,
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
},
});
await this.prisma.operationLog.create({ data: {
tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id,
detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
}
}
@@ -0,0 +1,342 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportImportParserService } from './import-parser.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportReviewService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly importParser: ReportImportParserService) {}
async commitImport(batchId: string, data: ImportCommitDto) {
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException('导入批次不存在');
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
if (data.profile) await this.importParser.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
const { content } = await this.files.getDownload(batch.fileObjectId);
const workbook = await loadWorkbook(content);
assertSafeWorkbook(workbook);
const worksheet = workbook.getWorksheet(batch.sheetName);
if (!worksheet) throw new BadRequestException('导入工作表不存在');
const images = readEmbeddedImages(workbook, worksheet);
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
let successCount = 0;
const failures: Array<{ rowNumber: number; reason: string }> = [];
const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = [];
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
const values: Record<string, unknown> = {};
try {
for (const mapping of data.mappings) {
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
if (image && mapping.fieldType !== 'string') {
const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, {
originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
mimetype: imageContentType(image.extension),
size: image.buffer.length,
buffer: image.buffer,
});
values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType };
} else {
values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform);
}
}
if (!Object.values(values).some(hasValue)) continue;
for (const mapping of data.mappings.filter((item) => item.required)) {
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
}
const staged = batch.reportType === 'signature'
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
stagedItems.push({
batchId,
rowNumber,
reportType: batch.reportType,
operation: staged.operation,
targetId: staged.targetId,
status: 'pending_review',
payload: staged.payload as Prisma.InputJsonValue,
originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined,
});
successCount += 1;
} catch (error) {
const reason = error instanceof Error ? error.message : '导入失败';
failures.push({ rowNumber, reason });
stagedItems.push({
batchId,
rowNumber,
reportType: batch.reportType,
operation: 'invalid',
status: 'invalid',
payload: values as Prisma.InputJsonValue,
errorMessage: reason,
});
}
}
const updated = await this.prisma.$transaction(async (tx) => {
if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems });
return tx.reportMaterialImportBatch.update({
where: { id: batchId },
data: {
status: successCount ? 'pending_review' : 'failed',
mapping: data.mappings as Prisma.InputJsonValue,
result: { failures } as Prisma.InputJsonValue,
successCount,
failedCount: failures.length,
},
include: { items: { orderBy: { rowNumber: 'asc' } } },
});
});
await this.prisma.operationLog.create({ data: {
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id,
detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue,
} });
return updated;
}
async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) {
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
const where: Prisma.ReportMaterialImportBatchWhereInput = {
reportType: query.reportType,
status: query.status && query.status !== 'all' ? query.status : undefined,
createdAt: dateRange(query.startAt, query.endAt),
OR: query.keyword?.trim() ? [
{ fileName: { contains: query.keyword.trim() } },
{ id: { contains: query.keyword.trim() } },
] : undefined,
};
const [batches, total] = await Promise.all([
this.prisma.reportMaterialImportBatch.findMany({
where,
include: { items: { orderBy: { rowNumber: 'asc' } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.reportMaterialImportBatch.count({ where }),
]);
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))];
const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))];
const reviewerIds = [...new Set(batches.flatMap((batch) => [
batch.reviewedById,
...batch.items.map((item) => item.reviewedById),
]).filter((id): id is string => Boolean(id)))];
const [tenants, applications, reviewers] = await Promise.all([
tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [],
applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [],
reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [],
]);
const tenantById = new Map(tenants.map((item) => [item.id, item]));
const applicationById = new Map(applications.map((item) => [item.id, item]));
const reviewerById = new Map(reviewers.map((item) => [item.id, item]));
return {
items: batches.map((batch) => ({
...batch,
tenant: tenantById.get(batch.tenantId) ?? null,
application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null,
reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null,
items: batch.items.map((item) => ({
...item,
reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null,
})),
})),
total,
page,
pageSize,
};
}
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision');
const batch = await this.prisma.reportMaterialImportBatch.findUnique({
where: { id: batchId },
include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } },
});
if (!batch) throw new NotFoundException('导入审核批次不存在');
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
let approvedCount = 0;
let rejectedCount = 0;
const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = [];
for (const item of batch.items) {
if (data.decision === 'reject') {
await this.prisma.reportMaterialImportItem.update({
where: { id: item.id },
data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() },
});
rejectedCount += 1;
continue;
}
try {
const targetId = await this.applyImportItem(batch, item, data.reviewerId);
await this.prisma.reportMaterialImportItem.update({
where: { id: item.id },
data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null },
});
approvedCount += 1;
} catch (error) {
const reason = error instanceof Error ? error.message : '导入审核应用失败';
failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason });
await this.prisma.reportMaterialImportItem.update({
where: { id: item.id },
data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() },
});
}
}
const counts = await this.prisma.reportMaterialImportItem.groupBy({
by: ['status'],
where: { batchId },
_count: { _all: true },
});
const countByStatus = new Map(counts.map((item) => [item.status, item._count._all]));
const pendingCount = countByStatus.get('pending_review') ?? 0;
const totalApproved = countByStatus.get('approved') ?? 0;
const totalRejected = countByStatus.get('rejected') ?? 0;
const totalInvalid = countByStatus.get('invalid') ?? 0;
const status = pendingCount
? 'partially_reviewed'
: totalApproved && (totalRejected || totalInvalid)
? 'partially_approved'
: totalApproved
? 'approved'
: totalRejected
? 'rejected'
: 'failed';
await this.prisma.reportMaterialImportBatch.update({
where: { id: batchId },
data: {
status,
reviewedById: pendingCount ? undefined : data.reviewerId,
reviewedAt: pendingCount ? undefined : new Date(),
completedAt: pendingCount ? undefined : new Date(),
},
});
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
}
async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
const name = mappedCoreValue(mappings, values, 'signatureName');
if (!name) throw new Error('缺少短信签名');
const purpose = mappedCoreValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
payload: {
tenantId,
applicationId,
name,
purpose,
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues },
},
originalSnapshot: existing ? {
id: existing.id,
applicationId: existing.applicationId,
name: existing.name,
purpose: existing.purpose,
drainageInfo: existing.drainageInfo,
auditStatus: existing.auditStatus,
updatedAt: existing.updatedAt,
} : undefined,
};
}
async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
const siteName = mappedCoreValue(mappings, values, 'siteName');
const url = mappedCoreValue(mappings, values, 'url');
if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL');
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } });
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
const remark = mappedCoreValue(mappings, values, 'remark');
const reportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } });
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues },
originalSnapshot: existing ? {
id: existing.id,
siteName: existing.siteName,
url: existing.url,
remark: existing.remark,
reportValues: existing.reportValues,
auditStatus: existing.auditStatus,
updatedAt: existing.updatedAt,
} : undefined,
};
}
async applyImportItem(
batch: { tenantId: string; applicationId: string | null; reportType: string },
item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },
reviewerId: string,
) {
const payload = jsonRecord(item.payload);
if (item.reportType === 'signature') {
const name = String(payload.name ?? '');
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
const body = {
applicationId,
name,
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
drainageInfo: jsonRecord(payload.drainageInfo),
};
let targetId = item.targetId;
if (targetId) {
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
} else {
const duplicate = await this.prisma.smsSignature.findFirst({
where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
} else {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body });
targetId = created.id;
}
}
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
return targetId;
}
const signatureId = String(payload.signatureId ?? '');
const siteName = String(payload.siteName ?? '');
const url = String(payload.url ?? '');
const body = {
siteName,
url,
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
reportValues: jsonRecord(payload.reportValues),
};
let targetId = item.targetId;
if (targetId) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } });
if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改');
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
} else {
const duplicate = await this.prisma.smsDrainageInfo.findFirst({
where: { signatureId, url, auditStatus: { not: 'deleted' } },
});
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
} else {
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
targetId = created.id;
}
}
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` });
return targetId;
}
}
@@ -0,0 +1,58 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import { ReportPendingQueryService } from './pending-query.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportOfficialExportService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {}
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'CMPP短信平台';
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
const headers = reportType === 'signature'
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
: ['短信签名', '站点名称', 'URL', '备注', '网站截图'];
sheet.addRow(headers);
sheet.addRow(reportType === 'signature'
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
: ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']);
styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => { column.width = 24; });
sheet.getRow(2).height = 48;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
await this.prisma.operationLog.create({ data: {
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { fileName, content };
}
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
const items = await this.pending.findPendingItems(query);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
styleHeader(sheet.getRow(1));
for (const item of items) sheet.addRow([
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
]);
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; });
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
await this.prisma.operationLog.create({ data: {
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
} });
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
}
}
@@ -0,0 +1,73 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportPendingQueryService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
const items = await this.findPendingItems(query);
const page = normalizePage(query.page);
const pageSize = normalizePageSize(query.pageSize);
return {
items: items.slice((page - 1) * pageSize, page * pageSize),
total: items.length,
page,
pageSize,
};
}
async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
const changedAt = dateRange(query.startAt, query.endAt);
const keyword = query.keyword?.trim();
const [signatures, drainageInfos] = await Promise.all([
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
where: {
pendingReport: true,
auditStatus: 'approved',
tenantId: query.tenantId,
applicationId: query.applicationId,
reportChangedAt: changedAt,
OR: keyword ? [
{ name: { contains: keyword } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
] : undefined,
},
include: { tenant: true, application: true },
orderBy: { reportChangedAt: 'desc' },
}),
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
where: {
pendingReport: true,
auditStatus: 'approved',
tenantId: query.tenantId,
applicationId: query.applicationId,
reportChangedAt: changedAt,
OR: keyword ? [
{ siteName: { contains: keyword } },
{ url: { contains: keyword } },
{ signature: { name: { contains: keyword } } },
{ tenant: { name: { contains: keyword } } },
{ application: { name: { contains: keyword } } },
] : undefined,
},
include: { tenant: true, application: true, signature: true },
orderBy: { reportChangedAt: 'desc' },
}),
]);
return [
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
}
}

Some files were not shown because too many files have changed in this diff Show More