26 Commits
Author SHA1 Message Date
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
155 changed files with 7633 additions and 833 deletions
@@ -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");
+229 -5
View File
@@ -43,6 +43,7 @@ model Tenant {
smsUplinkMessages SmsUplinkMessage[]
smsUplinkMatchCandidates SmsUplinkMatchCandidate[]
cmppDownstreamDeliveries CmppDownstreamDelivery[]
downstreamRequeueTasks DownstreamRequeueTask[]
cmppDownstreamConnections CmppDownstreamConnection[]
cmppConnectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
@@ -98,6 +99,7 @@ model User {
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
createdSmsBatchTasks SmsBatchTask[] @relation("SmsBatchTaskCreator")
createdDownstreamRequeueTasks DownstreamRequeueTask[] @relation("DownstreamRequeueTaskCreator")
releasedPhoneFrequencyHits PhoneFrequencyHit[] @relation("PhoneFrequencyHitReleaser")
createdPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistCreator")
updatedPhoneFrequencyWhitelistEntries PhoneFrequencyWhitelist[] @relation("PhoneFrequencyWhitelistUpdater")
@@ -203,6 +205,7 @@ model ProtocolInteractionLog {
traceId String?
requestId String?
phoneMasked String?
phoneNumber String?
resultCode String?
durationMs Int?
payloadBytes Int?
@@ -450,6 +453,7 @@ model SmsApplication {
uplinkMessages SmsUplinkMessage[]
uplinkMatchCandidates SmsUplinkMatchCandidate[]
downstreamDeliveries CmppDownstreamDelivery[]
downstreamRequeueTasks DownstreamRequeueTask[]
downstreamConnections CmppDownstreamConnection[]
connectionStates CmppConnectionState[]
gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[]
@@ -805,6 +809,7 @@ model SmsChannel {
code String @unique
name String
carrier String?
carriers String[] @default([])
sendRegion String @default("全国")
protocol String @default("CMPP")
gatewayHost String
@@ -1054,17 +1059,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])
@@ -1077,10 +1085,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
@@ -1960,6 +2110,7 @@ model CmppDownstreamDelivery {
application SmsApplication @relation(fields: [applicationId], references: [id])
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id])
attempts CmppDownstreamDeliveryAttempt[]
requeueItems DownstreamRequeueTaskItem[]
@@index([tenantId, status, createdAt])
@@index([applicationId, status, createdAt])
@@ -1969,6 +2120,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
+2
View File
@@ -23,6 +23,7 @@ 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';
@Module({
imports: [
@@ -49,6 +50,7 @@ import { UsersModule } from './users/users.module';
SendChainModule,
OperationsModule,
OpenApiModule,
SignatureRetirementModule,
],
controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
+11 -3
View File
@@ -7,6 +7,9 @@ function createPrismaMock() {
tenant: {
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
},
user: {
findMany: jest.fn().mockResolvedValue([]),
},
tenantAccount: {
findMany: jest.fn(),
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
@@ -181,9 +184,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 +195,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 +205,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 () => {
+33 -12
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,
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
/** R5 channel domain service composed behind ChannelsService. */
@@ -25,19 +25,36 @@ export class ChannelConfigurationService {
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsChannelWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined,
carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined,
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [items, total] = await Promise.all([
this.prisma.smsChannel.findMany({
where,
include: { connectionStates: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.smsChannel.count({ where }),
]);
const candidates = await this.prisma.smsChannel.findMany({ where, select: { id: true, name: true } });
const total = candidates.length;
if (total === 0) return { items: [], total, page, pageSize };
const day = currentShanghaiDayRange();
const counts = await this.prisma.$queryRaw<Array<{ channelId: string; total: number }>>(Prisma.sql`
SELECT submit."channelId" AS "channelId", COUNT(*)::integer AS total
FROM "SmsSubmitRecord" submit
WHERE submit."channelId" IN (${Prisma.join(candidates.map((channel) => channel.id))})
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
GROUP BY submit."channelId"
`);
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
const pageIds = candidates
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|| left.name.localeCompare(right.name, 'zh-CN')
|| left.id.localeCompare(right.id))
.slice((page - 1) * pageSize, page * pageSize)
.map((channel) => channel.id);
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } });
const itemById = new Map(pageItems.map((item) => [item.id, item]));
const items = pageIds.flatMap((id) => {
const item = itemById.get(id);
return item ? [item] : [];
});
return { items, total, page, pageSize };
}
@@ -64,11 +81,13 @@ export class ChannelConfigurationService {
data.heartbeatMissThreshold,
);
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
const carriers = normalizeChannelCarriers(data.carriers, data.carrier);
const channel = await this.prisma.smsChannel.create({
data: {
code: data.code,
name: data.name,
carrier: data.carrier,
carrier: legacyCarrierFromCapabilities(carriers),
carriers,
sendRegion: data.sendRegion ?? '全国',
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
@@ -120,6 +139,22 @@ export class ChannelConfigurationService {
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
const carriers = data.carriers !== undefined || data.carrier !== undefined
? normalizeChannelCarriers(data.carriers, data.carrier)
: existingCarriers;
if (data.carriers !== undefined || data.carrier !== undefined) {
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
if (removed.length) {
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
include: { group: true },
});
if (blockingGroups.length) {
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
}
}
}
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort,
@@ -133,7 +168,8 @@ export class ChannelConfigurationService {
data: {
code: data.code,
name: data.name,
carrier: data.carrier,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
gatewayHost: data.gatewayHost,
@@ -159,6 +195,7 @@ export class ChannelConfigurationService {
code: channel.code,
name: channel.name,
carrier: channel.carrier,
carriers: channel.carriers,
sendRegion: channel.sendRegion,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
+1
View File
@@ -32,6 +32,7 @@ export class ChannelCopyService {
code: nextCode,
name: nextName,
carrier: source.carrier,
carriers: source.carriers,
protocol: source.protocol,
gatewayHost: source.gatewayHost,
gatewayPort: source.gatewayPort,
@@ -15,6 +15,7 @@ export class ChannelGroupRoutingService {
listGroups() {
return this.prisma.smsChannelGroup.findMany({
where: { status: { not: 'deleted' } },
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
orderBy: { createdAt: 'desc' },
});
@@ -51,7 +52,7 @@ export class ChannelGroupRoutingService {
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
@@ -158,23 +159,78 @@ export class ChannelGroupRoutingService {
});
}
async deleteGroup(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
async getGroupDeletionImpact(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, items: { select: { id: true } } },
});
if (!group) {
throw new NotFoundException('Channel group not found');
}
const boundRoute = await this.prisma.channelRouteRule.findFirst({
where: {
groupId,
status: 'active',
},
select: { id: true },
const routes = await this.prisma.channelRouteRule.findMany({
where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } },
select: { applicationId: true },
});
if (boundRoute) {
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))];
const [applications, pendingSupplierSubmitCount] = await Promise.all([
this.prisma.smsApplication.findMany({
where: { id: { in: applicationIds } },
select: { id: true, status: true },
}),
this.prisma.smsSubmitRecord.count({
where: { channelGroupId: groupId, submitStatus: 'queued' },
}),
]);
const applicationStatusById = new Map(applications.map((application) => [application.id, application.status]));
const deletedApplicationCount = applicationIds.filter((applicationId) => {
const status = applicationStatusById.get(applicationId);
return status === undefined || status === 'deleted';
}).length;
return {
groupId: group.id,
groupName: group.name,
normalApplicationCount: applicationIds.length - deletedApplicationCount,
deletedApplicationCount,
channelCount: group.items.length,
pendingSupplierSubmitCount,
};
}
async deleteGroup(groupId: string) {
const group = await this.prisma.smsChannelGroup.findUnique({
where: { id: groupId },
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
});
if (!group) {
throw new NotFoundException('Channel group not found');
}
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
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() {
+50 -9
View File
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
@@ -325,11 +325,24 @@ export class ChannelReportingService {
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
}
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!channel) throw new NotFoundException('Channel not found');
if (!data.carrier) throw new BadRequestException('签名报备任务必须指定运营商');
const carrier = normalizeBusinessCarrier(data.carrier);
if (!normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
const existing = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null },
});
if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务');
const task = await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: data.tenantId,
signatureId: data.signatureId,
channelId: data.channelId,
carrier,
approvalScope: 'carrier_specific',
reportType,
drainageItemId: undefined,
createdById: data.createdById,
@@ -364,11 +377,25 @@ export class ChannelReportingService {
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null;
if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) {
throw new BadRequestException('报备运营商不在通道支持范围内');
}
const existing = await tx.channelSignatureReportTask.findFirst({ where: {
signatureId: item.signatureId,
channelId: item.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? item.drainageItemId : null,
carrier: reportType === 'signature' ? carrier : null,
} });
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商');
const approvedAt = item.status === 'approved'
? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date()
: null;
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, carrier, approvalScope: 'carrier_specific', approvedAt, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
}
@@ -389,13 +416,21 @@ export class ChannelReportingService {
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = targets.map((channel) => {
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
return task?.status ?? 'pending';
});
return [carrier, summarizeReportStatuses(statuses)];
}));
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => {
const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status
?? 'pending');
});
const reportStatus = summarizeReportStatuses(allStatuses).status;
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
return { signatureId, reportStatus, carrierReportSummary };
@@ -514,7 +549,13 @@ export class ChannelReportingService {
) {
await this.prisma.channelSignatureReportTask.update({
where: { id: taskId },
data: { status: statusAfter, reason },
data: {
status: statusAfter,
reason,
...((await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, select: { reportType: true, status: true, approvedAt: true } }))?.reportType === 'signature'
? { approvedAt: statusAfter === 'approved' ? statusBefore === 'approved' ? undefined : new Date() : null }
: {}),
},
});
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
}
+3 -1
View File
@@ -4,6 +4,7 @@ export interface CreateChannelDto {
code: string;
name: string;
carrier?: string;
carriers?: string[];
sendRegion?: string;
protocol?: string;
gatewayHost: string;
@@ -104,13 +105,14 @@ export interface CreateReportTaskDto {
tenantId: string;
signatureId: string;
channelId: string;
carrier?: string;
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
createdById?: string;
}
export interface ChangeReportTaskStatusesDto {
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
items: Array<{ signatureId: string; channelId: string; carrier?: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
reason?: string;
operatorId?: string;
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
+5
View File
@@ -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) {
+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);
});
});
+30 -16
View File
@@ -1,8 +1,13 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
export function summarizeReportStatuses(statuses: string[]) {
return summarizeCommonReportStatuses(statuses);
}
/** Constants and pure validation/normalization helpers shared by R5 domains. */
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
@@ -593,9 +598,29 @@ export function normalizeChannelCarrier(carrier?: string | null) {
return value;
}
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
const normalized = normalizeChannelCarrier(channelCarrier);
return normalized === 'all' || normalized === groupCarrier;
export const SUPPORTED_CHANNEL_CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
export function normalizeChannelCarriers(carriers?: string[] | null, legacyCarrier?: string | null): string[] {
const source = carriers?.length
? carriers
: normalizeChannelCarrier(legacyCarrier ?? 'mobile') === 'all'
? [...SUPPORTED_CHANNEL_CARRIERS]
: [normalizeChannelCarrier(legacyCarrier ?? 'mobile')];
const normalized = [...new Set(source.map((carrier) => normalizeBusinessCarrier(carrier)))];
if (normalized.length === 0) throw new BadRequestException('至少选择一个运营商');
return SUPPORTED_CHANNEL_CARRIERS.filter((carrier) => normalized.includes(carrier));
}
export function legacyCarrierFromCapabilities(carriers: string[]) {
if (carriers.length === 1) return carriers[0];
if (carriers.length === SUPPORTED_CHANNEL_CARRIERS.length) return 'all';
// Old readers must fail closed for a two-carrier channel instead of treating
// it as three-network capable and accidentally routing unsupported traffic.
return 'multi';
}
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
}
export function normalizeRegion(region?: string | null) {
@@ -609,7 +634,7 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
export function validateGroupItems(
groupCarrier: string,
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
) {
const channelIds = new Set<string>();
const provinces = new Set<string>();
@@ -627,7 +652,7 @@ export function validateGroupItems(
throw new BadRequestException('通道组内不能重复配置同一通道');
}
channelIds.add(item.channelId);
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (item.province) {
@@ -654,17 +679,6 @@ export function normalizeReportType(value?: string) {
throw new BadRequestException('reportType must be signature, drainage or both');
}
export function summarizeReportStatuses(statuses: string[]) {
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
const approved = statuses.filter((status) => status === 'approved').length;
let status = 'pending';
if (approved === statuses.length) status = 'approved';
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
return { status, approved, total: statuses.length };
}
export function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) {
return '连接请求';
+156 -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 })),
},
@@ -115,6 +117,7 @@ function createPrismaMock() {
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
create: jest.fn().mockResolvedValue(reportTask),
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
},
@@ -137,6 +140,7 @@ function createPrismaMock() {
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
tenant: {
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
@@ -153,6 +157,7 @@ function createPrismaMock() {
},
smsSubmitRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
count: jest.fn().mockResolvedValue(0),
},
cmppConnectionState: {
findMany: jest.fn(),
@@ -169,6 +174,51 @@ function createPrismaMock() {
}
describe('ChannelsService', () => {
it('sorts all filtered channels by today submit count before pagination', async () => {
const prisma = createPrismaMock();
const candidates = [
{ id: 'channel-low', name: '乙通道' },
{ id: 'channel-high', name: '甲通道' },
{ id: 'channel-zero', name: '丙通道' },
];
const fullChannels = candidates.map((channel) => ({ ...channel, connectionStates: [] }));
prisma.smsChannel.findMany
.mockResolvedValueOnce(candidates)
.mockResolvedValueOnce([fullChannels[0], fullChannels[1]]);
prisma.$queryRaw.mockResolvedValue([
{ channelId: 'channel-low', total: 3 },
{ channelId: 'channel-high', total: 12 },
]);
const service = new ChannelsService(prisma as never);
const result = await service.listChannelsPage({ page: 1, pageSize: 2 });
expect(result.items.map((channel) => channel.id)).toEqual(['channel-high', 'channel-low']);
expect(result.total).toBe(3);
expect(prisma.smsChannel.findMany).toHaveBeenNthCalledWith(2, {
where: { id: { in: ['channel-high', 'channel-low'] } },
include: { connectionStates: true },
});
});
it('uses channel name and id as a stable tie breaker for zero-submit channels', async () => {
const prisma = createPrismaMock();
const candidates = [
{ id: 'channel-b', name: 'A通道' },
{ id: 'channel-a', name: 'A通道' },
{ id: 'channel-c', name: 'B通道' },
];
prisma.smsChannel.findMany
.mockResolvedValueOnce(candidates)
.mockResolvedValueOnce(candidates);
prisma.$queryRaw.mockResolvedValue([]);
const service = new ChannelsService(prisma as never);
const result = await service.listChannelsPage({ page: 1, pageSize: 10 });
expect(result.items.map((channel) => channel.id)).toEqual(['channel-a', 'channel-b', 'channel-c']);
});
it('creates channel report requirements only from the report field library', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -309,13 +359,13 @@ describe('ChannelsService', () => {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }),
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]),
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' } }]),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
@@ -323,13 +373,57 @@ describe('ChannelsService', () => {
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
]);
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'task-1' },
data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }),
});
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
const prisma = createPrismaMock();
const tx = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn() },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
update: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })),
findMany: jest.fn().mockResolvedValue([
{ id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' } },
]),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([]) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await service.changeReportTaskStatuses({
items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }],
sourceEntry: 'enterprise_signature',
});
expect(tx.channelSignatureReportTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
signatureId: 'sig-1',
channelId: 'channel-1',
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
approvedAt: expect.any(Date),
}),
});
});
it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock();
const tx = {
@@ -353,7 +447,7 @@ describe('ChannelsService', () => {
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
reason: '引流信息已报备',
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } });
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', carrier: null } });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.smsSignature.update).not.toHaveBeenCalled();
});
@@ -866,16 +960,60 @@ describe('ChannelsService', () => {
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('deletes channel groups only when no active route rule is bound', async () => {
it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] });
prisma.channelRouteRule.findMany.mockResolvedValueOnce([
{ applicationId: 'app-active' },
{ applicationId: 'app-active' },
{ applicationId: 'app-deleted' },
{ applicationId: 'app-missing' },
]);
prisma.smsApplication.findMany.mockResolvedValueOnce([
{ id: 'app-active', status: 'active' },
{ id: 'app-deleted', status: 'deleted' },
]);
prisma.smsSubmitRecord.count.mockResolvedValueOnce(2);
await service.deleteGroup('group-1');
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({
groupId: 'group-1',
groupName: '移动主通道组',
normalApplicationCount: 1,
deletedApplicationCount: 2,
channelCount: 2,
pendingSupplierSubmitCount: 2,
});
expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({
where: { channelGroupId: 'group-1', submitStatus: 'queued' },
});
});
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
it('logically deletes channel groups without removing application bindings or group items', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' });
const operationLogCreate = jest.fn();
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]);
prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]);
prisma.$transaction.mockImplementationOnce((callback) => callback({
smsChannelGroup: { update: groupUpdate },
operationLog: { create: operationLogCreate },
}));
await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' });
expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } });
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled();
expect(operationLogCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'sms_channel_group.delete',
detail: expect.objectContaining({
deletionMode: 'soft_delete',
impact: expect.objectContaining({ normalApplicationCount: 1 }),
}),
}),
});
});
it('upserts signature report material per channel field', async () => {
@@ -901,7 +1039,7 @@ describe('ChannelsService', () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', createdById: 'user-1' });
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', createdById: 'user-1' });
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
await service.importReportReceipt('report-task-1', {
fileName: 'receipt.csv',
@@ -916,11 +1054,11 @@ describe('ChannelsService', () => {
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'exporting', reason: undefined },
data: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }),
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'partial', reason: 'one rejected' },
data: expect.objectContaining({ status: 'partial', reason: 'one rejected', approvedAt: null }),
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
@@ -951,7 +1089,7 @@ describe('ChannelsService', () => {
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'partial', reason: 'carrier receipt' },
data: expect.objectContaining({ status: 'partial', reason: 'carrier receipt', approvedAt: null }),
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
+4
View File
@@ -114,6 +114,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return this.groups.deleteGroup(groupId);
}
getGroupDeletionImpact(groupId: string) {
return this.groups.getGroupDeletionImpact(groupId);
}
listRouteRules() {
return this.groups.listRouteRules();
}
+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 };
}
@@ -7,9 +7,13 @@ describe('DeletionGovernanceService', () => {
function setup() {
const tx = {
operationLog: { findFirst: jest.fn(), create: jest.fn() },
smsChannel: { updateMany: jest.fn() },
smsSignature: { updateMany: jest.fn() },
smsTemplate: { updateMany: jest.fn() },
smsChannel: { findUnique: jest.fn(), updateMany: jest.fn() },
smsSignature: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
smsTemplate: { findFirst: jest.fn(), updateMany: jest.fn() },
smsDrainageInfo: { updateMany: jest.fn() },
channelSignatureReportTask: { findMany: jest.fn(), update: jest.fn() },
channelSignatureReportRecord: { create: jest.fn() },
channelRouteRule: { findMany: jest.fn() },
};
const prisma = {
operationLog: { findFirst: jest.fn() },
@@ -35,12 +39,47 @@ describe('DeletionGovernanceService', () => {
expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10']);
});
it('allows channel deletion with unfinished report tasks only after the cascade selection is confirmed', async () => {
const { service, prisma, tx } = setup();
const channel = {
id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now,
groupItems: [], routeRules: [], connectionStates: [],
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
};
prisma.smsChannel.findUnique.mockResolvedValue(channel);
const preflight = await service.preflight('channel', 'channel-1');
expect(preflight.allowedActions).toEqual(['delete']);
expect(preflight.requiredSelections).toEqual(expect.arrayContaining([
expect.objectContaining({ action: 'abandon_associated_report_tasks', count: 1 }),
]));
prisma.operationLog.findFirst.mockResolvedValue(null);
tx.operationLog.findFirst.mockResolvedValue(null);
tx.smsChannel.findUnique.mockResolvedValue(channel);
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
tx.smsChannel.updateMany.mockResolvedValue({ count: 1 });
tx.smsSignature.findUnique.mockResolvedValue({ id: 'signature-1', applicationId: null, auditStatus: 'approved' });
tx.channelSignatureReportTask.findMany.mockResolvedValue([{ channelId: 'channel-1', status: 'abandoned', channel: { id: 'channel-1', status: 'deleted' } }]);
tx.smsSignature.update.mockResolvedValue({ id: 'signature-1' });
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
await expect(service.delete('channel', 'channel-1', {
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-channel-1', abandonAssociatedReportTasks: true,
})).resolves.toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }),
}));
expect(tx.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ data: { reportStatus: 'not_applicable' } }));
});
it('returns an allowed template preflight scoped to the client tenant', async () => {
const { service, prisma } = setup();
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' },
sendTasks: [], batchTasks: [],
});
const result = await service.preflight('template', 'template-1', 'tenant-1');
@@ -48,30 +87,79 @@ describe('DeletionGovernanceService', () => {
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } }));
expect(result.allowedActions).toEqual(['delete']);
expect(result.identity.tenant).toBe('示例企业');
expect(result.requiredSelections).toEqual([]);
expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({
include: expect.not.objectContaining({ sendTasks: expect.anything(), batchTasks: expect.anything() }),
}));
expect(result.impacts).toContain('已创建任务继续使用保存的内容快照');
});
it('blocks signature deletion and exposes the referencing template and drainage items', async () => {
it('turns signature dependencies into mandatory cascade selections', async () => {
const { service, prisma } = setup();
prisma.smsSignature.findFirst.mockResolvedValue({
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [{ id: 'template-1', name: '验证码模板' }],
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [],
});
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
expect(result.allowedActions).toEqual([]);
expect(result.allowedActions).toEqual(['delete']);
expect(result.dependencies).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1'] }),
expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1'] }),
]));
expect(result.requiredSelections.map((item) => item.action)).toEqual([
'delete_associated_templates', 'delete_associated_drainage',
]);
});
it('requires version, idempotency key and a meaningful reason', async () => {
it('does not expose report task ids or statuses to the client but still requires confirmation', async () => {
const { service, prisma } = setup();
prisma.smsSignature.findFirst.mockResolvedValue({
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [], drainageItems: [], reportTasks: [{ id: 'internal-task-1', status: 'reporting' }],
});
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
expect(result.dependencies).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'report_tasks', count: 1, items: [], detailsVisible: false }),
]));
expect(JSON.stringify(result)).not.toContain('internal-task-1');
expect(result.requiredSelections).toEqual(expect.arrayContaining([
expect.objectContaining({ action: 'abandon_associated_report_tasks' }),
]));
});
it('does not classify approved or abandoned report history as unfinished', async () => {
const { service, prisma } = setup();
prisma.smsSignature.findFirst.mockResolvedValue({
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [], drainageItems: [], reportTasks: [],
});
const result = await service.preflight('signature', 'signature-1', 'tenant-1');
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith(expect.objectContaining({
include: expect.objectContaining({
reportTasks: expect.objectContaining({
where: { status: { notIn: expect.arrayContaining(['approved', 'abandoned']) } },
}),
}),
}));
expect(result.dependencies).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'report_tasks', count: 0 }),
]));
expect(result.allowedActions).toEqual(['delete']);
});
it('requires version and idempotency key but allows an omitted reason', async () => {
const { service } = setup();
await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException);
await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException);
});
it('soft deletes once and writes an auditable operation number', async () => {
@@ -80,19 +168,71 @@ describe('DeletionGovernanceService', () => {
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
sendTasks: [], batchTasks: [],
});
tx.operationLog.findFirst.mockResolvedValue(null);
tx.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', tenantId: 'tenant-1',
});
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
const result = await service.delete('template', 'template-1', {
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1',
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', operatorId: 'user-1',
}, 'tenant-1');
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
expect(tx.smsTemplate.findFirst).toHaveBeenCalledWith({
where: { id: 'template-1', tenantId: 'tenant-1' },
select: { id: true, tenantId: true },
});
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
});
it('cascades all selected signature dependencies in one transaction with task history', async () => {
const { service, prisma, tx } = setup();
const preflightItem = {
id: 'signature-1', tenantId: 'tenant-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }],
drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }],
reportTasks: [{ id: 'report-1', channelId: 'channel-1', signatureId: 'signature-1', reportType: 'signature', status: 'reporting' }],
};
prisma.operationLog.findFirst.mockResolvedValue(null);
prisma.smsSignature.findFirst.mockResolvedValue(preflightItem);
tx.operationLog.findFirst.mockResolvedValue(null);
tx.smsSignature.findFirst.mockResolvedValue(preflightItem);
tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 });
tx.smsDrainageInfo.updateMany.mockResolvedValue({ count: 1 });
tx.channelSignatureReportTask.update.mockResolvedValue({ id: 'report-1' });
tx.channelSignatureReportRecord.create.mockResolvedValue({ id: 'record-1' });
tx.smsSignature.updateMany.mockResolvedValue({ count: 1 });
tx.operationLog.create.mockResolvedValue({ id: 'operation-1' });
const result = await service.delete('signature', 'signature-1', {
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-signature-1', operatorId: 'user-1',
deleteAssociatedTemplates: true, deleteAssociatedDrainage: true, abandonAssociatedReportTasks: true,
}, 'tenant-1');
expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false });
expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } }));
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) }));
expect(tx.smsDrainageInfo.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted', pendingReport: false } }));
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'abandoned' }) }));
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ statusBefore: 'reporting', statusAfter: 'abandoned', sourceEntry: 'deletion_governance' }) }));
});
it('rejects deletion until every discovered cascade selection is confirmed', async () => {
const { service, prisma } = setup();
prisma.operationLog.findFirst.mockResolvedValue(null);
prisma.smsSignature.findFirst.mockResolvedValue({
id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' },
templates: [{ id: 'template-1', name: '验证码模板', sendTasks: [], batchTasks: [] }], drainageItems: [], reportTasks: [],
});
await expect(service.delete('signature', 'signature-1', {
expectedUpdatedAt: now.toISOString(), idempotencyKey: 'missing-selection',
}, 'tenant-1')).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a stale optimistic-lock version', async () => {
@@ -101,7 +241,6 @@ describe('DeletionGovernanceService', () => {
prisma.smsTemplate.findFirst.mockResolvedValue({
id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now,
tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null,
sendTasks: [], batchTasks: [],
});
await expect(service.delete('template', 'template-1', {
expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突',
@@ -1,17 +1,54 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { summarizeReportStatuses } from '../common/report-status';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeleteTargetDto = {
expectedUpdatedAt?: string;
idempotencyKey?: string;
reason?: string;
operatorId?: string;
deleteAssociatedTemplates?: boolean;
deleteAssociatedDrainage?: boolean;
abandonAssociatedReportTasks?: boolean;
};
type Dependency = { kind: string; label: string; count: number; items: string[] };
type Dependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
type RequiredSelection = {
action: DeletionResolutionAction;
dependencyKind: string;
label: string;
description: string;
count: number;
};
// 报备任务、人工审核任务和批量发送任务使用不同的状态词汇。这里分别维护终态,
// 是为了避免把已完成历史误判成活动依赖,也避免删除正在发送的数据配置。
const TERMINAL_REPORT_TASK_STATUSES = ['approved', 'completed', 'failed', 'cancelled', 'rejected', 'abandoned', 'partial', 'partial_success'];
const TERMINAL_SEND_TASK_STATUSES = ['approved', 'rejected'];
const TERMINAL_BATCH_TASK_STATUSES = ['finished', 'canceled', 'rejected', 'failed', 'completed', 'cancelled'];
const RESOLUTION_COPY: Record<DeletionResolutionAction, Omit<RequiredSelection, 'dependencyKind' | 'count'>> = {
delete_associated_templates: {
action: 'delete_associated_templates',
label: '同时删除关联的模板',
description: '发现关联的短信模板。勾选后将一并逻辑删除这些模板,历史发送和审核记录继续保留。',
},
delete_associated_drainage: {
action: 'delete_associated_drainage',
label: '同时删除引流信息',
description: '发现关联的引流信息。勾选后将一并逻辑删除这些引流信息,历史发送、审核和报备记录继续保留。',
},
abandon_associated_report_tasks: {
action: 'abandon_associated_report_tasks',
label: '同时结束关联的报备任务',
description: '发现关联的未结束报备任务。勾选后将全部置为“放弃报备”,历史任务和报备记录继续保留。',
},
};
export type DeletionPreflight = {
type: DeletionTargetType;
@@ -19,6 +56,7 @@ export type DeletionPreflight = {
expectedUpdatedAt: string;
identity: Record<string, string>;
dependencies: Dependency[];
requiredSelections: RequiredSelection[];
impacts: string[];
blockedReasons: string[];
allowedActions: Array<'delete'>;
@@ -40,9 +78,8 @@ export class DeletionGovernanceService {
this.assertType(type);
const expectedUpdatedAt = body.expectedUpdatedAt?.trim();
const idempotencyKey = body.idempotencyKey?.trim();
const reason = body.reason?.trim();
const reason = body.reason?.trim() || undefined;
if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检');
if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因');
const replay = await this.prisma.operationLog.findFirst({
where: {
@@ -58,6 +95,7 @@ export class DeletionGovernanceService {
if (!preflight.allowedActions.includes('delete')) {
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons });
}
this.assertSelections(preflight.requiredSelections, body);
return this.prisma.$transaction(async (tx) => {
const existing = await tx.operationLog.findFirst({
@@ -68,6 +106,12 @@ export class DeletionGovernanceService {
});
if (existing) return { operationId: existing.id, status: 'deleted', replayed: true };
const cascade = type === 'channel'
? await this.prepareChannelDeletion(tx, id, body, reason)
: type === 'signature'
? await this.prepareSignatureDeletion(tx, id, tenantId, body, reason)
: await this.prepareTemplateDeletion(tx, id, tenantId);
const updated = type === 'channel'
? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } })
: type === 'signature'
@@ -75,10 +119,22 @@ export class DeletionGovernanceService {
: await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检');
if (type === 'channel') {
for (const signatureId of cascade.affectedSignatureIds) await this.recomputeSignatureReportSummary(tx, signatureId);
}
const log = await tx.operationLog.create({
data: {
tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts },
tenantId: cascade.tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id,
detail: {
idempotencyKey,
reason: reason ?? null,
expectedUpdatedAt,
dependencies: preflight.dependencies,
impacts: preflight.impacts,
selections: preflight.requiredSelections.map((selection) => selection.action),
cascade: cascade.detail,
} as Prisma.InputJsonValue,
},
});
return { operationId: log.id, status: 'deleted', replayed: false };
@@ -93,7 +149,7 @@ export class DeletionGovernanceService {
groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } },
routeRules: { where: { status: 'active' } },
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } },
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } } },
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('通道不存在');
@@ -101,10 +157,11 @@ export class DeletionGovernanceService {
dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority}`)),
dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)),
dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)),
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => row.id)),
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}${row.status}`)),
];
return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies,
['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']);
['删除后不再参与新消息路由', '所选未结束报备任务将置为“放弃报备”', '历史发送、回执和审计记录继续保留'],
{ report_tasks: 'abandon_associated_report_tasks' });
}
private async signaturePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
@@ -112,20 +169,34 @@ export class DeletionGovernanceService {
where: { id, ...(tenantId ? { tenantId } : {}) },
include: {
tenant: { select: { name: true } }, application: { select: { name: true } },
templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } },
templates: {
where: { auditStatus: { not: 'deleted' } },
select: {
id: true, name: true,
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true, status: true } },
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true, status: true } },
},
},
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } },
reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('签名不存在或无权访问');
const activeTemplateTasks = item.templates.flatMap((template) => [
...template.sendTasks.map((task) => `${template.name}:发送任务 ${task.id}${task.status}`),
...template.batchTasks.map((task) => `${template.name}:批量任务 ${task.id}${task.status}`),
]);
const dependencies: Dependency[] = [
dep('templates', '仍在使用该签名的模板', item.templates.map((row) => `${row.name}${row.id}`)),
dep('templates', '关联短信模板', item.templates.map((row) => `${row.name}${row.id}`)),
dep('template_active_tasks', '关联模板仍有未结束发送任务', activeTemplateTasks),
dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}${row.id}`)),
dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}${row.status}`)),
dep('report_tasks', '未结束报备任务', tenantId ? [] : item.reportTasks.map((row) => `${row.id}${row.status}`), item.reportTasks.length, !tenantId),
];
return buildPreflight('signature', item.id, item.updatedAt, {
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定',
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']);
}, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '勾选的关联配置将同步逻辑删除或结束', '历史消息、审核与报备记录继续保留'], {
templates: 'delete_associated_templates', drainage: 'delete_associated_drainage', report_tasks: 'abandon_associated_report_tasks',
});
}
private async templatePreflight(id: string, tenantId?: string): Promise<DeletionPreflight> {
@@ -133,19 +204,153 @@ export class DeletionGovernanceService {
where: { id, ...(tenantId ? { tenantId } : {}) },
include: {
tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } },
sendTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
batchTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } },
},
});
if (!item) throw new NotFoundException('模板不存在或无权访问');
const dependencies: Dependency[] = [
dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}${row.status}`)),
dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}${row.status}`)),
];
return buildPreflight('template', item.id, item.updatedAt, {
name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name,
signature: item.signature?.name ?? '未绑定',
}, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']);
}, item.auditStatus, [], ['删除后不能用于新发送任务', '已创建任务继续使用保存的内容快照', '历史消息、计费和审核记录继续保留']);
}
private async prepareChannelDeletion(tx: Prisma.TransactionClient, id: string, body: DeleteTargetDto, reason?: string) {
const item = await tx.smsChannel.findUnique({
where: { id },
include: {
groupItems: { where: { group: { status: { not: 'deleted' } } }, select: { id: true } },
routeRules: { where: { status: 'active' }, select: { id: true } },
connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } }, select: { id: true } },
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } },
},
});
if (!item) throw new NotFoundException('通道不存在');
const blockers = [
item.groupItems.length ? `引用该通道的通道组共 ${item.groupItems.length} 项,请先解除或完成` : '',
item.routeRules.length ? `直接路由规则共 ${item.routeRules.length} 项,请先解除或完成` : '',
item.connectionStates.length ? `活动网关连接共 ${item.connectionStates.length} 项,请先解除或完成` : '',
].filter(Boolean);
if (blockers.length) throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: blockers });
this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label);
const abandonReason = reason ?? '删除通道时同步放弃关联报备任务';
await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, abandonReason);
return {
tenantId: undefined,
affectedSignatureIds: [...new Set(item.reportTasks.filter((task) => task.reportType === 'signature').map((task) => task.signatureId))],
detail: { abandonedReportTaskIds: item.reportTasks.map((task) => task.id) },
};
}
private async prepareSignatureDeletion(tx: Prisma.TransactionClient, id: string, tenantId: string | undefined, body: DeleteTargetDto, reason?: string) {
const item = await tx.smsSignature.findFirst({
where: { id, ...(tenantId ? { tenantId } : {}) },
include: {
templates: {
where: { auditStatus: { not: 'deleted' } },
select: {
id: true, name: true,
sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true } },
batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true } },
},
},
drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true } },
reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, channelId: true, signatureId: true, reportType: true, status: true } },
},
});
if (!item) throw new NotFoundException('签名不存在或无权访问');
const activeTemplateTaskCount = item.templates.reduce((sum, template) => sum + template.sendTasks.length + template.batchTasks.length, 0);
if (activeTemplateTaskCount) {
throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: [`关联模板仍有未结束发送任务共 ${activeTemplateTaskCount} 项,请先解除或完成`] });
}
this.assertRuntimeSelection(item.templates.length, body.deleteAssociatedTemplates, RESOLUTION_COPY.delete_associated_templates.label);
this.assertRuntimeSelection(item.drainageItems.length, body.deleteAssociatedDrainage, RESOLUTION_COPY.delete_associated_drainage.label);
this.assertRuntimeSelection(item.reportTasks.length, body.abandonAssociatedReportTasks, RESOLUTION_COPY.abandon_associated_report_tasks.label);
const templateIds = item.templates.map((template) => template.id);
const drainageIds = item.drainageItems.map((drainage) => drainage.id);
if (templateIds.length) {
await tx.smsTemplate.updateMany({ where: { id: { in: templateIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } });
for (const template of item.templates) {
await tx.operationLog.create({
data: {
tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'template', resourceId: template.id,
detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除关联模板' } as Prisma.InputJsonValue,
},
});
}
}
if (drainageIds.length) {
await tx.smsDrainageInfo.updateMany({ where: { id: { in: drainageIds }, auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } });
for (const drainageId of drainageIds) {
await tx.operationLog.create({
data: {
tenantId: item.tenantId, userId: body.operatorId, action: 'governance.cascade_delete', resource: 'drainage', resourceId: drainageId,
detail: { parentType: 'signature', parentId: id, reason: reason ?? '删除签名时同步删除引流信息' } as Prisma.InputJsonValue,
},
});
}
}
await this.abandonReportTasks(tx, item.reportTasks, body.operatorId, reason ?? '删除签名时同步放弃关联报备任务');
return {
tenantId: item.tenantId,
affectedSignatureIds: [] as string[],
detail: { deletedTemplateIds: templateIds, deletedDrainageIds: drainageIds, abandonedReportTaskIds: item.reportTasks.map((task) => task.id) },
};
}
private async prepareTemplateDeletion(tx: Prisma.TransactionClient, id: string, tenantId?: string) {
const item = await tx.smsTemplate.findFirst({
where: { id, ...(tenantId ? { tenantId } : {}) },
select: { id: true, tenantId: true },
});
if (!item) throw new NotFoundException('模板不存在或无权访问');
return { tenantId: item.tenantId, affectedSignatureIds: [] as string[], detail: {} };
}
private async abandonReportTasks(
tx: Prisma.TransactionClient,
tasks: Array<{ id: string; channelId: string; status: string }>,
operatorId: string | undefined,
reason: string,
) {
for (const task of tasks) {
// 每条任务分别留存状态前后值,便于解释一次级联删除为何结束了哪些报备任务。
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason } });
await tx.channelSignatureReportRecord.create({
data: {
taskId: task.id, channelId: task.channelId, action: 'delete_cascade_abandon', statusBefore: task.status,
statusAfter: 'abandoned', reason, operatorId, sourceEntry: 'deletion_governance',
},
});
}
}
private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature || signature.auditStatus === 'deleted') return;
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
}) : [];
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const fallbackChannels = tasks.map((task) => task.channel).filter((channel) => channel.status !== 'deleted');
const uniqueChannels = [...new Map((configuredChannels.length ? configuredChannels : fallbackChannels).map((channel) => [channel.id, channel])).values()];
const statuses = uniqueChannels.flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => (
tasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
?? tasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
?? 'pending'
)));
const reportStatus = summarizeReportStatuses(statuses).status;
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
}
private assertSelections(requiredSelections: RequiredSelection[], body: DeleteTargetDto) {
const missing = requiredSelections.filter((selection) => !selectionSelected(selection.action, body));
if (missing.length) throw new BadRequestException(`请先确认:${missing.map((selection) => selection.label).join('、')}`);
}
private assertRuntimeSelection(count: number, selected: boolean | undefined, label: string) {
if (count > 0 && selected !== true) throw new ConflictException(`关联数据已变化,请重新预检并勾选“${label}`);
}
private assertType(type: string): asserts type is DeletionTargetType {
@@ -153,16 +358,38 @@ export class DeletionGovernanceService {
}
}
function dep(kind: string, label: string, items: string[]): Dependency {
return { kind, label, count: items.length, items: items.slice(0, 8) };
function dep(kind: string, label: string, items: string[], count = items.length, detailsVisible = true): Dependency {
return { kind, label, count, items: detailsVisible ? items.slice(0, 8) : [], detailsVisible };
}
function buildPreflight(type: DeletionTargetType, id: string, updatedAt: Date, identity: Record<string, string>, status: string, dependencies: Dependency[], impacts: string[]): DeletionPreflight {
const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}${item.count} 项,请先解除或完成`);
function buildPreflight(
type: DeletionTargetType,
id: string,
updatedAt: Date,
identity: Record<string, string>,
status: string,
dependencies: Dependency[],
impacts: string[],
resolutions: Partial<Record<string, DeletionResolutionAction>> = {},
): DeletionPreflight {
const requiredSelections = dependencies.flatMap((dependency) => {
const action = resolutions[dependency.kind];
if (!action || dependency.count === 0) return [];
return [{ ...RESOLUTION_COPY[action], dependencyKind: dependency.kind, count: dependency.count }];
});
const blockedReasons = dependencies
.filter((dependency) => dependency.count > 0 && !resolutions[dependency.kind])
.map((dependency) => `${dependency.label}${dependency.count} 项,请先解除或完成`);
if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作');
return {
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons,
type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, requiredSelections, impacts, blockedReasons,
allowedActions: blockedReasons.length ? [] : ['delete'],
recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' },
};
}
function selectionSelected(action: DeletionResolutionAction, body: DeleteTargetDto) {
if (action === 'delete_associated_templates') return body.deleteAssociatedTemplates === true;
if (action === 'delete_associated_drainage') return body.deleteAssociatedDrainage === true;
return body.abandonAssociatedReportTasks === true;
}
@@ -19,6 +19,11 @@ import {
export class DictionariesController {
constructor(private readonly dictionaries: DictionariesService) {}
@Get('administrative-regions')
listAdministrativeRegions() {
return this.dictionaries.listAdministrativeRegions();
}
@Get('phone-segments')
listPhoneSegments(
@Query('keyword') keyword?: string,
@@ -58,6 +58,28 @@ function createPrismaMock() {
}
describe('DictionariesService', () => {
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
{ province: '山东', city: '青岛' },
{ province: '山东', city: '济南' },
{ province: '山东', city: '济南' },
{ province: '江苏', city: '苏州' },
{ province: ' ', city: '无效' },
]);
const service = new DictionariesService(prisma as never);
await expect(service.listAdministrativeRegions()).resolves.toEqual([
{ province: '江苏', cities: ['苏州'] },
{ province: '山东', cities: ['济南', '青岛'] },
]);
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
where: { province: { not: null } },
select: { province: true, city: true },
distinct: ['province', 'city'],
});
});
it('deletes a phone segment from the real dictionary table', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
@@ -111,6 +111,27 @@ export class DictionariesService {
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
) {}
async listAdministrativeRegions() {
const rows = await this.prisma.phoneSegment.findMany({
where: { province: { not: null } },
select: { province: true, city: true },
distinct: ['province', 'city'],
});
const citiesByProvince = new Map<string, Set<string>>();
for (const row of rows) {
const province = row.province?.trim();
if (!province) continue;
const cities = citiesByProvince.get(province) ?? new Set<string>();
const city = row.city?.trim();
if (city) cities.add(city);
citiesByProvince.set(province, cities);
}
return Array.from(citiesByProvince, ([province, cities]) => ({
province,
cities: Array.from(cities).sort((left, right) => left.localeCompare(right, 'zh-CN')),
})).sort((left, right) => left.province.localeCompare(right.province, 'zh-CN'));
}
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
+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 });
}
+4 -1
View File
@@ -1,8 +1,10 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { OpenApiModule } from './open-api/open-api.module';
import { configureHttpBodyParsers } from './http-body-limits';
Object.defineProperty(BigInt.prototype, 'toJSON', {
configurable: true,
@@ -16,8 +18,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')
@@ -225,6 +225,14 @@ export class AdminOperationsController {
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
}
@Post('gateway-submit-dead-letters/:id/resolve')
resolveGatewaySubmitDeadLetter(
@Param('id') id: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
}
@Get('receipt-anomalies')
receiptAnomalies(
@Query('tenantId') tenantId?: string,
@@ -354,6 +362,57 @@ export class AdminOperationsController {
batchRequeueDownstreamDeliveries(@Body() body: { ids?: string[] }) {
return this.sendChain.batchRequeueDownstreamDeliveries(body.ids ?? []);
}
@Post('downstream-requeue-tasks/preview')
previewDownstreamRequeueTask(
@Body() body: { filter?: Record<string, string | undefined> },
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.previewDownstreamRequeueTask(body.filter ?? {}, operatorId);
}
@Post('downstream-requeue-tasks')
createDownstreamRequeueTask(
@Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.createDownstreamRequeueTask({
previewToken: body.previewToken ?? '',
reason: body.reason ?? '',
ratePerSecond: body.ratePerSecond,
consecutiveFailureLimit: body.consecutiveFailureLimit,
}, operatorId);
}
@Get('downstream-requeue-tasks')
listDownstreamRequeueTasks(@Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
}
@Get('downstream-requeue-tasks/:id')
getDownstreamRequeueTask(@Param('id') id: string) {
return this.sendChain.getDownstreamRequeueTask(id);
}
@Get('downstream-requeue-tasks/:id/items')
listDownstreamRequeueTaskItems(
@Param('id') id: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.sendChain.listDownstreamRequeueTaskItems(id, { status, keyword, page: Number(page), pageSize: Number(pageSize) });
}
@Post('downstream-requeue-tasks/:id/:action')
changeDownstreamRequeueTaskStatus(
@Param('id') id: string,
@Param('action') action: 'pause' | 'resume' | 'terminate',
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.changeDownstreamRequeueTaskStatus(id, action, operatorId);
}
}
@ApiTags('admin-system-logs')
@@ -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' } }]),
}),
}));
});
});
@@ -77,7 +77,7 @@ 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),
@@ -102,7 +102,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,
};
@@ -151,12 +151,6 @@ function clean(value: unknown, max = 191) {
return text ? text.slice(0, max) : null;
}
function maskPhone(value: unknown) {
const text = String(value ?? '').replace(/\D/g, '');
if (!text) return null;
return text.length >= 7 ? `${text.slice(0, 3)}****${text.slice(-4)}` : `***${text.slice(-2)}`;
}
function safeInteger(value: unknown) {
const number = Number(value);
return Number.isSafeInteger(number) && number >= 0 ? number : null;
@@ -9,6 +9,7 @@ import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import type { ReportBatchGenerationService } from './batch-generation.service';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportChannelExportService {
@@ -32,13 +33,19 @@ export class ReportChannelExportService {
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
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);
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'waiting_material', task.reason ?? undefined);
continue;
}
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
@@ -58,7 +65,7 @@ export class ReportChannelExportService {
}
row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting');
}
}
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
+55 -6
View File
@@ -8,9 +8,9 @@ describe('ReportsService', () => {
$executeRaw: jest.fn(),
};
const prisma = {
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() },
dailyProfitReport: { findMany: jest.fn(), count: jest.fn() },
dailyQualityReport: { findMany: jest.fn(), count: jest.fn() },
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
dailyProfitReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
dailyQualityReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
let service: ReportsService;
@@ -23,10 +23,13 @@ describe('ReportsService', () => {
tx.$executeRaw.mockResolvedValue(0);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
service = new ReportsService(prisma as never);
});
@@ -54,6 +57,19 @@ describe('ReportsService', () => {
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
});
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
);
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
expect(profitQueries).toContain('message."submitId" = submit."submitId"');
expect(profitQueries).not.toContain('"billingStatus"');
expect(profitQueries).not.toContain('billing.refund');
});
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({
dateFrom: '2026-07-01',
@@ -62,7 +78,7 @@ describe('ReportsService', () => {
applicationId: 'app-1',
page: 2,
pageSize: 500,
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 });
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100,
@@ -71,7 +87,12 @@ describe('ReportsService', () => {
});
it('keeps application and channel profit filters separate', async () => {
await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
expect(result).toEqual(expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
}));
expect(result.summary).not.toHaveProperty('refundCents');
expect(result.items[0]).not.toHaveProperty('refundCents');
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
@@ -82,9 +103,37 @@ describe('ReportsService', () => {
}));
});
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
});
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
}));
});
it('exports income without refund columns', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
}]);
const exported = await service.exportProfit({ dimensionType: 'application' });
expect(exported.content).toContain('收入金额(元)');
expect(exported.content).not.toContain('净消费金额(元)');
expect(exported.content).not.toContain('返还金额(元)');
});
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
});
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
+80 -34
View File
@@ -45,31 +45,52 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async listReconciliation(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const where = reconciliationWhere(query);
const [items, total] = await Promise.all([
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyReconciliationReport.count({ where }),
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]);
return { items, total, page, pageSize };
return { items, total, page, pageSize, summary: volumeSummary(aggregate._sum) };
}
async listProfit(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query);
const [items, total] = await Promise.all([
const [storedItems, total, aggregate] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.count({ where }),
this.prisma.dailyProfitReport.aggregate({
where,
_sum: { ...reportVolumeSumSelection, revenueCents: true, costCents: true, profitCents: true },
}),
]);
return { items, total, page, pageSize, dimensionType };
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
const summary = {
...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
costCents: Number(aggregate._sum.costCents ?? 0),
profitCents: Number(aggregate._sum.profitCents ?? 0),
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
profitRateBps: ratioBps(Number(aggregate._sum.profitCents ?? 0), Number(aggregate._sum.revenueCents ?? 0)),
};
return { items, total, page, pageSize, dimensionType, summary };
}
async listQuality(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = qualityWhere(query);
const [items, total] = await Promise.all([
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyQualityReport.count({ where }),
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]);
return { items, total, page, pageSize, dimensionType };
const summary = {
...volumeSummary(aggregate._sum),
// 成功率按全量筛选结果的成功量/发送量重新计算,避免分页和分组大小导致失真。
successRateBps: ratioBps(Number(aggregate._sum.successUnits ?? 0), Number(aggregate._sum.sentUnits ?? 0)),
};
return { items, total, page, pageSize, dimensionType, summary };
}
async exportReconciliation(query: ReportListQuery) {
@@ -80,7 +101,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
}
async exportQuality(query: ReportListQuery) {
@@ -149,13 +170,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId",
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
FROM "SmsBillingRecord"
GROUP BY "messageId"
), costs AS (
WITH costs AS (
SELECT
submit."messageRecordId",
SUM(submit."costUnitPrice" * CASE
@@ -210,18 +225,24 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(billing.revenue), 0)::bigint,
COALESCE(SUM(billing.refund), 0)::bigint,
-- 收入按每条最终成功短信的计费条数和发送时客户价快照计算,不能依赖随后可能变为 refunded 的账单状态。
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
0::bigint,
COALESCE(SUM(costs.cost), 0)::bigint,
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
(COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
CASE WHEN COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 /
SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN costs ON costs."messageRecordId" = message.id
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
@@ -229,13 +250,6 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId",
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
FROM "SmsBillingRecord"
GROUP BY "messageId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
@@ -277,31 +291,41 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
-- 补发可能产生多次提交,收入只归属最终成功提交,避免同一短信在多个通道重复计收。
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
0::bigint,
COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0)::bigint,
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0))::bigint,
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0)) * 10000.0 /
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS audit_count,
@@ -516,6 +540,28 @@ function pagination(query: ReportListQuery) {
return { page, pageSize, skip: (page - 1) * pageSize };
}
const reportVolumeSumSelection = {
submittedUnits: true,
sentUnits: true,
unknownUnits: true,
successUnits: true,
failedUnits: true,
} as const;
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) {
return {
submittedUnits: Number(sum.submittedUnits ?? 0),
sentUnits: Number(sum.sentUnits ?? 0),
unknownUnits: Number(sum.unknownUnits ?? 0),
successUnits: Number(sum.successUnits ?? 0),
failedUnits: Number(sum.failedUnits ?? 0),
};
}
function ratioBps(numerator: number, denominator: number) {
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator);
}
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
}
@@ -11,7 +11,6 @@ describe('drainage content detection', () => {
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
['短横线手机号', '电话 138-0013-8000', 'mobile'],
['括号区号和分机', '致电(0108888-8888 转 123', 'landline'],
@@ -26,6 +25,23 @@ describe('drainage content detection', () => {
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
});
it.each([' ', '\t', '\n', '\u3000'])('stops a URL match at whitespace %p', (separator) => {
const url = 'https://example.com/path';
const suffix = '后续字符不属于链接';
const content = `详情 ${url}${separator}${suffix}`;
const result = detectDrainageContentWithRules(content, rules);
const urlMatches = (result.drainageDetection as { matches: Array<{ category: string; text: string; normalizedText: string }> })
.matches.filter((item) => item.category === 'url');
expect(urlMatches).toHaveLength(1);
expect(urlMatches[0]).toMatchObject({ text: url, normalizedText: url });
});
it('does not join a domain split by spaces into one URL', () => {
const result = detectDrainageContentWithRules('请访问 ex ample . com 领取', rules);
expect(result.hasDrainageContent).toBe(false);
});
it('keeps original offsets for record-page highlighting', () => {
const content = '📨详情请看 example。com/path,谢谢';
const result = detectDrainageContentWithRules(content, rules);
@@ -83,7 +83,10 @@ function normalizeContent(content: string, category: DrainageDetectionCategory):
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[]/g, '+');
if (category === 'url') {
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
normalized = normalized.replace(/。/g, '.');
} else if (category === 'email') {
// Email exclusion keeps its broader normalization so spaced emails cannot leak into phone/URL matches.
normalized = normalized.replace(/\s+/gu, '').replace(//g, '.');
} else if (category === 'mobile' || category === 'landline') {
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
@@ -131,7 +134,7 @@ export function detectDrainageContentWithRules(
): DrainageDetectionResult {
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'url');
const emailNormalized = normalizeContent(content, 'email');
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
validateDrainageDetectionPattern(rule.pattern, rule.flags);
+13 -3
View File
@@ -14,7 +14,7 @@ import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/**
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -469,7 +469,12 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
return rejected;
}
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options: SendResourceValidationOptions = {},
) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant || tenant.status !== 'active') {
throw new BadRequestException('企业客户不存在或已停用');
@@ -494,7 +499,12 @@ async validateSendResources(tenantId: string, applicationId?: string, templateId
where: { id: templateId },
include: { signature: true },
});
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') {
const templateBelongsToApplication = template
&& template.tenantId === tenantId
&& template.applicationId === applicationId;
// 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务,
// 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。
if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) {
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
}
if (!template.signature || template.signature.auditStatus !== 'approved') {
@@ -25,6 +25,8 @@ export interface GatewayInboundAuthDto {
authSource?: string;
timestamp?: number;
remoteIp?: string;
version?: string;
requestedVersion?: number;
}
export interface GatewayInboundSubmitDto {
+4 -2
View File
@@ -226,7 +226,8 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba
return fallback;
}
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string, channelCarriers?: string[] | null) {
if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier));
const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier;
}
@@ -448,6 +449,7 @@ export type ChannelCandidate = {
province?: string | null;
channel: {
carrier?: string | null;
carriers?: string[] | null;
sendRegion?: string | null;
status: string;
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
@@ -481,7 +483,7 @@ export function selectChannelCandidate<T extends ChannelCandidate>(
!options.excludedChannelIds.has(item.channelId)
&& options.approvedChannelIds.has(item.channelId)
&& normalizeCarrier(item.carrier) === options.carrier
&& isCarrierCompatible(item.channel.carrier, options.carrier),
&& isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers),
);
const provinceCandidates = options.forceNational
? []
+151 -2
View File
@@ -666,6 +666,22 @@ describe('SendChainService', () => {
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('rejects a new task that selects a deleted template', async () => {
const { service, prisma } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
content: 'hello', auditStatus: 'deleted',
signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' },
});
await expect(service.createBatchTask({
tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1',
content: 'hello', phones: ['13800000001'],
})).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用');
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('rejects free content without an approved leading signature', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findUnique.mockResolvedValue({
@@ -837,12 +853,14 @@ describe('SendChainService', () => {
const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
const { service } = createService();
const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000';
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(1_000);
expect(dispatch).toHaveBeenCalledTimes(1);
@@ -854,6 +872,8 @@ describe('SendChainService', () => {
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval;
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
jest.useRealTimers();
}
});
@@ -927,6 +947,47 @@ describe('SendChainService', () => {
});
});
it('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => {
const { service, prisma, billing } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
signature: { id: 'sig-1', auditStatus: 'approved' },
});
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
}]);
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ relatedId: 'task-1' }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
it('still blocks scheduled dispatch when the persisted template signature is no longer approved', async () => {
const { service, prisma, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted',
signature: { id: 'sig-1', auditStatus: 'deleted' },
});
prisma.smsBatchTask.findMany.mockResolvedValue([{
id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled',
}]);
await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({
dispatched: 0,
results: [{ taskId: 'task-1', status: 'failed', reason: '短信签名未审核通过' }],
});
expect(billing.freeze).not.toHaveBeenCalled();
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
});
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
const { service, prisma } = createService();
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
@@ -983,19 +1044,34 @@ describe('SendChainService', () => {
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('returns the application enterprise code after Gateway authentication', async () => {
const { service } = createService();
it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => {
const { service, prisma } = createService();
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({
account: '100001',
enterpriseCode: 'SP0001',
maxConnections: 2,
status: 'authenticated',
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: 'app-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({
result: 'authenticated',
request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }),
}),
}),
});
});
it('rejects Gateway authentication when application interface is disabled', async () => {
@@ -1015,6 +1091,38 @@ describe('SendChainService', () => {
password: 'secret-hash',
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP interface is disabled for this application');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }),
}),
});
});
it('audits an unknown Gateway authentication account with its source IP', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue(null);
await expect(service.authenticateInboundApplication({
account: 'ATTACKER',
authSource: 'invalid-auth-source',
timestamp: 120000000,
remoteIp: '203.0.113.9',
version: 'cmpp30',
requestedVersion: 48,
})).rejects.toThrow('CMPP account is invalid or disabled');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: undefined,
resourceId: 'ATTACKER',
ipAddress: '203.0.113.9',
detail: expect.objectContaining({
result: 'failed',
request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }),
}),
}),
});
});
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
@@ -3272,6 +3380,41 @@ describe('SendChainService', () => {
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('marks a pending gateway submit exception as resolved without requeueing it', async () => {
const { service, prisma } = createService();
prisma.gatewaySubmitDeadLetter.findUnique
.mockResolvedValueOnce({
id: 'dead-1',
tenantId: 'tenant-1',
status: 'pending',
messageId: 'MSG-1',
submitId: 'SUB-1',
})
.mockResolvedValueOnce({ id: 'dead-1', status: 'resolved', resolvedStatus: 'manually_resolved' });
prisma.gatewaySubmitDeadLetter.updateMany.mockResolvedValueOnce({ count: 1 });
await expect(service.resolveGatewaySubmitDeadLetter('dead-1', 'user-1')).resolves.toEqual(
expect.objectContaining({ status: 'resolved', resolvedStatus: 'manually_resolved' }),
);
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: expect.objectContaining({
status: 'resolved',
resolvedAt: expect.any(Date),
resolvedStatus: 'manually_resolved',
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 'user-1',
action: 'gateway.submit_dead_letter_resolved',
resourceId: 'dead-1',
}),
});
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => {
const { service, prisma } = createService();
@@ -3308,6 +3451,8 @@ describe('SendChainService', () => {
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
await expect(service.submitInboundMessage({
account: '100001',
@@ -4102,12 +4247,14 @@ describe('SendChainService', () => {
jest.useFakeTimers();
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
const previousDownstreamRequeueEnabled = process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
const { service } = createService();
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 });
try {
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false';
process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = 'false';
service.onModuleInit();
await jest.advanceTimersByTimeAsync(60_000);
expect(scan).toHaveBeenCalledWith({});
@@ -4118,6 +4265,8 @@ describe('SendChainService', () => {
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED;
else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled;
if (previousDownstreamRequeueEnabled === undefined) delete process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED;
else process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED = previousDownstreamRequeueEnabled;
jest.useRealTimers();
}
});
+45 -4
View File
@@ -17,8 +17,9 @@ import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto,
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService } from './send-submission.service';
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
import { SendDownstreamRequeueTaskService, type DownstreamRequeueFilter } from './send-downstream-requeue-task.service';
@Injectable()
export class SendChainService implements OnModuleInit, OnModuleDestroy {
@@ -37,8 +38,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
private downstreamRequeueTaskIntervalTimer?: ReturnType<typeof setInterval>;
private readonly submission: SendSubmissionService;
private readonly completion: SendCompletionService;
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
constructor(
private readonly prisma: PrismaService,
@@ -68,6 +71,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
openApi,
this as unknown as SendCompletionFacade,
);
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
}
onModuleInit() {
@@ -129,6 +133,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
);
this.upstreamReceiptInboxIntervalTimer.unref?.();
}
if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
this.downstreamRequeueTaskIntervalTimer = setInterval(
() => void this.downstreamRequeueTasks.runScan().catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
);
this.downstreamRequeueTaskIntervalTimer.unref?.();
}
}
async onModuleDestroy() {
@@ -140,6 +151,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
if (this.downstreamRequeueTaskIntervalTimer) clearInterval(this.downstreamRequeueTaskIntervalTimer);
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
@@ -481,6 +493,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.completion.requeueGatewaySubmitDeadLetter(id, data);
}
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
return this.completion.resolveGatewaySubmitDeadLetter(id, operatorId);
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
return this.completion.recoverStaleGatewaySubmitRequeues(now);
}
@@ -497,6 +513,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.completion.batchRequeueDownstreamDeliveries(ids);
}
previewDownstreamRequeueTask(filter: DownstreamRequeueFilter, operatorId?: string) {
return this.downstreamRequeueTasks.preview(filter, operatorId);
}
createDownstreamRequeueTask(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) {
return this.downstreamRequeueTasks.create(data, operatorId);
}
listDownstreamRequeueTasks(query: { status?: string; page?: number; pageSize?: number }) {
return this.downstreamRequeueTasks.list(query);
}
getDownstreamRequeueTask(id: string) {
return this.downstreamRequeueTasks.get(id);
}
listDownstreamRequeueTaskItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
return this.downstreamRequeueTasks.listItems(id, query);
}
changeDownstreamRequeueTaskStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
return this.downstreamRequeueTasks.changeStatus(id, action, operatorId);
}
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
@@ -722,8 +762,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
return this.submission.validateSendResources(tenantId, applicationId, templateId);
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
}
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
@@ -770,8 +810,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
signature?: { id?: string | null; name?: string | null } | null;
},
channelId: string,
carrier: string,
) {
return this.submission.ensureSignatureReportedForChannel(message, channelId);
return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier);
}
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
@@ -158,6 +158,10 @@ export class SendCompletionService {
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
}
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
return this.retry.resolveGatewaySubmitDeadLetter(id, operatorId);
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
return this.retry.recoverStaleGatewaySubmitRequeues(now);
}
@@ -0,0 +1,105 @@
import { BadRequestException } from '@nestjs/common';
import { SendDownstreamRequeueTaskService } from './send-downstream-requeue-task.service';
function prismaMock(): Record<string, any> {
const result: Record<string, any> = {
cmppDownstreamDelivery: { count: jest.fn(), groupBy: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
cmppDownstreamConnection: { count: jest.fn() },
downstreamRequeueTask: { findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
downstreamRequeueTaskItem: { createMany: jest.fn(), count: jest.fn(), groupBy: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
operationLog: { create: jest.fn() },
downstreamRequeueRateWindow: { deleteMany: jest.fn() },
$queryRaw: jest.fn(),
};
result.$transaction = jest.fn(async (callback: (tx: unknown) => unknown) => callback(result));
return result;
}
let mock: Record<string, any>;
let service: SendDownstreamRequeueTaskService;
describe('SendDownstreamRequeueTaskService', () => {
beforeEach(() => {
process.env.DATABASE_URL = 'postgresql://test:test@127.0.0.1/test';
mock = prismaMock();
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: jest.fn() });
});
it('keeps the selected status in matched counts and returns a signed preview token', async () => {
mock.cmppDownstreamDelivery.count.mockResolvedValueOnce(8).mockResolvedValueOnce(8);
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'pending', _count: { _all: 8 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 8 } }]);
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date('2026-08-11T00:00:00Z') });
const result = await service.preview({ status: 'pending' }, 'user-1');
expect(result).toEqual(expect.objectContaining({ matchedCount: 8, replayableCount: 8, skippedCount: 0, previewToken: expect.stringContaining('.') }));
expect(mock.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: expect.objectContaining({ status: 'pending' }) }));
});
it('rejects a tampered preview token and short reasons', async () => {
await expect(service.create({ previewToken: 'invalid.token', reason: '处理事故积压' }, 'user-1')).rejects.toBeInstanceOf(BadRequestException);
await expect(service.create({ previewToken: 'invalid.token', reason: '短' }, 'user-1')).rejects.toBeInstanceOf(BadRequestException);
});
it('materializes only the server-signed preview range', async () => {
mock.cmppDownstreamDelivery.count.mockResolvedValue(2);
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'failed', _count: { _all: 2 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 2 } }]);
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date() });
const preview = await service.preview({ applicationId: 'app-1', status: 'failed' }, 'user-1');
mock.downstreamRequeueTask.findFirst.mockResolvedValue(null);
mock.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'd-1', applicationId: 'app-1', status: 'failed' }]);
mock.downstreamRequeueTask.create.mockResolvedValue({ id: 'task-1' });
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
await service.create({ previewToken: preview.previewToken, reason: '处理历史回执积压' }, 'user-1');
expect(mock.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ AND: expect.any(Array) }) }));
expect(mock.downstreamRequeueTaskItem.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ deliveryId: 'd-1', applicationId: 'app-1' })] });
});
it('paginates all task items with status and keyword filters', async () => {
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
mock.downstreamRequeueTaskItem.findMany.mockResolvedValue([{ id: 'item-1' }]);
mock.downstreamRequeueTaskItem.count.mockResolvedValue(21);
await expect(service.listItems('task-1', { status: 'failed', keyword: 'MSG-1', page: 2, pageSize: 20 })).resolves.toEqual({ items: [{ id: 'item-1' }], total: 21, page: 2, pageSize: 20 });
expect(mock.downstreamRequeueTaskItem.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 20, where: expect.objectContaining({ status: 'failed', OR: expect.any(Array) }) }));
});
it('recovers an expired processing claim before scanning queued work', async () => {
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'running', startedAt: new Date(), ratePerSecond: 10, consecutiveFailureLimit: 10, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
mock.downstreamRequeueTaskItem.findMany.mockResolvedValue([]);
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
await service.runScan();
expect(mock.downstreamRequeueTaskItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ status: 'processing', claimedAt: expect.any(Object) }), data: expect.objectContaining({ status: 'queued', claimedAt: null }) }));
});
it('moves an offline application to waiting_connection without calling Gateway', async () => {
const requeue = jest.fn();
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'queued', startedAt: null, ratePerSecond: 10, consecutiveFailureLimit: 10, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
mock.downstreamRequeueTaskItem.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'item-1', deliveryId: 'd-1', applicationId: 'app-1' }]).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'waiting_connection', _count: { _all: 1 } }]);
mock.$queryRaw.mockResolvedValue([{ consumed: 1 }]);
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'failed', payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
mock.downstreamRequeueTaskItem.findFirst.mockResolvedValue(null);
mock.cmppDownstreamConnection.count.mockResolvedValue(0);
await service.runScan();
expect(requeue).not.toHaveBeenCalled();
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'waiting_connection' }) }));
});
it('counts ACK failures by application and auto-pauses at the threshold', async () => {
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'running', startedAt: new Date(), ratePerSecond: 10, consecutiveFailureLimit: 1, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
mock.downstreamRequeueTaskItem.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'item-1', applicationId: 'app-1', status: 'waiting_ack', delivery: { status: 'rejected', ackResult: 1, ackDeadlineAt: new Date(), lastError: 'ACK Result=1' } }]).mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'failed', _count: { _all: 1 } }]);
await service.runScan();
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) }));
expect(mock.downstreamRequeueTask.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'paused', lastError: expect.stringContaining('app-1') }) }));
expect(mock.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'gateway.downstream_requeue_task_auto_paused' }) }));
});
});
@@ -0,0 +1,353 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { parseDateBoundary } from '../operations/operations.helpers';
export type DownstreamRequeueFilter = {
tenantId?: string;
applicationId?: string;
deliveryType?: string;
status?: string;
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
};
type RequeueFacade = { requeueDownstreamDelivery(id: string): Promise<unknown> };
const REPLAYABLE_STATUSES = ['pending', 'failed', 'unconfirmed', 'rejected'];
const ACTIVE_TASK_STATUSES = ['queued', 'running', 'paused'];
const PROCESSING_LEASE_MS = 2 * 60_000;
const SCAN_LEASE_MS = 15_000;
const PREVIEW_TOKEN_TTL_MS = 15 * 60_000;
function normalizedFilter(filter: DownstreamRequeueFilter): DownstreamRequeueFilter {
return {
tenantId: filter.tenantId || 'all',
applicationId: filter.applicationId || 'all',
deliveryType: filter.deliveryType || 'all',
status: filter.status || 'all',
keyword: filter.keyword?.trim() || undefined,
createdAtFrom: filter.createdAtFrom || undefined,
createdAtTo: filter.createdAtTo || undefined,
};
}
function taskWhere(filter: DownstreamRequeueFilter, snapshotAt: Date, replayableByDefault = true): Prisma.CmppDownstreamDeliveryWhereInput {
const normalized = normalizedFilter(filter);
const from = parseDateBoundary(normalized.createdAtFrom, false);
const to = parseDateBoundary(normalized.createdAtTo, true);
return {
tenantId: normalized.tenantId !== 'all' ? normalized.tenantId : undefined,
applicationId: normalized.applicationId !== 'all' ? normalized.applicationId : undefined,
deliveryType: normalized.deliveryType !== 'all' ? normalized.deliveryType : undefined,
status: normalized.status !== 'all' ? normalized.status : replayableByDefault ? { in: REPLAYABLE_STATUSES } : undefined,
createdAt: { ...(from ? { gte: from } : {}), lte: to && to < snapshotAt ? to : snapshotAt },
OR: normalized.keyword ? [
{ messageId: { contains: normalized.keyword } },
{ payload: { path: ['account'], string_contains: normalized.keyword } },
{ payload: { path: ['phoneNumber'], string_contains: normalized.keyword } },
{ lastError: { contains: normalized.keyword } },
{ tenant: { name: { contains: normalized.keyword } } },
{ application: { name: { contains: normalized.keyword } } },
] : undefined,
};
}
function previewSecret() {
const source = process.env.DOWNSTREAM_REQUEUE_PREVIEW_SECRET || process.env.DATABASE_URL;
if (!source) throw new BadRequestException('后台重投预检签名密钥未配置');
return createHash('sha256').update(`cmpp-downstream-requeue-preview\0${source}`).digest();
}
function signPreview(payload: Record<string, unknown>) {
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', previewSecret()).update(encoded).digest('base64url');
return `${encoded}.${signature}`;
}
function verifyPreview(token: string, operatorId?: string) {
const [encoded, supplied] = String(token || '').split('.');
if (!encoded || !supplied) throw new BadRequestException('预检凭证无效,请重新预检');
const expected = createHmac('sha256', previewSecret()).update(encoded).digest();
let actual: Buffer;
try { actual = Buffer.from(supplied, 'base64url'); } catch { throw new BadRequestException('预检凭证无效,请重新预检'); }
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new BadRequestException('预检凭证无效,请重新预检');
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { filter: DownstreamRequeueFilter; snapshotAt: string; operatorId?: string; expiresAt: number };
if (payload.expiresAt < Date.now()) throw new BadRequestException('预检凭证已过期,请重新预检');
if ((payload.operatorId || '') !== (operatorId || '')) throw new BadRequestException('预检凭证与当前操作人不一致');
return payload;
}
function jsonFailures(value: unknown): Record<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).map(([key, count]) => [key, Math.max(0, Number(count) || 0)]));
}
export class SendDownstreamRequeueTaskService {
constructor(private readonly prisma: PrismaService, private readonly facade: RequeueFacade) {}
async preview(filter: DownstreamRequeueFilter, operatorId?: string) {
const snapshotAt = new Date();
const normalized = normalizedFilter(filter);
const base = taskWhere(normalized, snapshotAt, false);
const replayableWhere = { AND: [base, { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput;
const [matchedCount, replayableCount, statusGroups, appGroups, oldest] = await Promise.all([
this.prisma.cmppDownstreamDelivery.count({ where: base }),
this.prisma.cmppDownstreamDelivery.count({ where: replayableWhere }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['status'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: base, _count: { _all: true } }),
this.prisma.cmppDownstreamDelivery.findFirst({ where: base, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
]);
const tokenPayload = { filter: normalized, snapshotAt: snapshotAt.toISOString(), operatorId: operatorId || '', expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS };
return {
snapshotAt,
previewToken: signPreview(tokenPayload),
matchedCount,
replayableCount,
skippedCount: matchedCount - replayableCount,
applicationCount: appGroups.length,
oldestCreatedAt: oldest?.createdAt ?? null,
statusCounts: Object.fromEntries(statusGroups.map((item) => [item.status, item._count._all])),
filter: normalized,
};
}
async create(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, createdById?: string) {
const reason = data.reason?.trim();
if (!reason || reason.length < 5) throw new BadRequestException('任务原因至少填写5个字');
const preview = verifyPreview(data.previewToken, createdById);
const filter = normalizedFilter(preview.filter);
const snapshotAt = new Date(preview.snapshotAt);
if (filter.status === 'delivered' || filter.status === 'awaiting_ack') throw new BadRequestException('第一版后台任务不支持已确认或等待ACK记录');
const activeTask = await this.prisma.downstreamRequeueTask.findFirst({ where: {
status: { in: ACTIVE_TASK_STATUSES },
...(filter.applicationId !== 'all' ? { OR: [{ applicationId: filter.applicationId }, { applicationId: null }] } : {}),
}, select: { taskNo: true } });
if (activeTask) throw new BadRequestException(`当前应用范围已有未结束任务 ${activeTask.taskNo}`);
const where = { AND: [taskWhere(filter, snapshotAt), { status: { in: REPLAYABLE_STATUSES } }] } as Prisma.CmppDownstreamDeliveryWhereInput;
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ where, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], take: 100001, select: { id: true, applicationId: true, status: true } });
if (!deliveries.length) throw new BadRequestException('当前筛选条件下没有可重投记录');
if (deliveries.length > 100000) throw new BadRequestException('单个任务最多处理100000条,请缩小日期范围');
const ratePerSecond = Math.min(50, Math.max(1, Number(data.ratePerSecond ?? 10)));
const failureLimit = Math.min(100, Math.max(1, Number(data.consecutiveFailureLimit ?? 10)));
const taskNo = `DRT-${Date.now()}-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`;
const task = await this.prisma.$transaction(async (tx) => {
const created = await tx.downstreamRequeueTask.create({ data: {
taskNo,
tenantId: filter.tenantId !== 'all' ? filter.tenantId : null,
applicationId: filter.applicationId !== 'all' ? filter.applicationId : null,
filterSnapshot: filter as Prisma.InputJsonValue,
snapshotAt, reason, ratePerSecond, consecutiveFailureLimit: failureLimit,
totalCount: deliveries.length, createdById,
} });
await tx.downstreamRequeueTaskItem.createMany({ data: deliveries.map((item) => ({ taskId: created.id, deliveryId: item.id, applicationId: item.applicationId, previousStatus: item.status })) });
await tx.operationLog.create({ data: { userId: createdById, action: 'gateway.downstream_requeue_task_created', resource: 'downstream_requeue_task', resourceId: created.id, detail: { taskNo, reason, totalCount: deliveries.length, snapshotAt, filter, ratePerSecond, consecutiveFailureLimit: failureLimit } } });
return created;
});
return this.get(task.id);
}
async list(query: { status?: string; page?: number; pageSize?: number }) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize ?? 10)));
const where = { status: query.status && query.status !== 'all' ? query.status : undefined };
const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTask.findMany({ where, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.downstreamRequeueTask.count({ where }),
]);
return { items, total, page, pageSize };
}
async get(id: string) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, include: { tenant: true, application: true, createdBy: { select: { id: true, displayName: true, username: true } } } });
if (!task) throw new NotFoundException('后台重投任务不存在');
const itemGroups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId: id }, _count: { _all: true } });
return { ...task, itemCounts: Object.fromEntries(itemGroups.map((item) => [item.status, item._count._all])) };
}
async listItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id }, select: { id: true } });
if (!task) throw new NotFoundException('后台重投任务不存在');
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 20)));
const keyword = query.keyword?.trim();
const where: Prisma.DownstreamRequeueTaskItemWhereInput = {
taskId: id,
status: query.status && query.status !== 'all' ? query.status : undefined,
OR: keyword ? [
{ delivery: { messageId: { contains: keyword } } },
{ skipReason: { contains: keyword } },
{ errorMessage: { contains: keyword } },
] : undefined,
};
const [items, total] = await Promise.all([
this.prisma.downstreamRequeueTaskItem.findMany({ where, include: { delivery: { select: { messageId: true, deliveryType: true, status: true, lastError: true } } }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.downstreamRequeueTaskItem.count({ where }),
]);
return { items, total, page, pageSize };
}
async changeStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
if (!['pause', 'resume', 'terminate'].includes(action)) throw new BadRequestException('不支持的任务操作');
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id } });
if (!task) throw new NotFoundException('后台重投任务不存在');
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
if (!allowed.includes(task.status)) throw new BadRequestException('当前任务状态不允许此操作');
const status = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'terminated';
const updated = await this.prisma.downstreamRequeueTask.update({ where: { id }, data: { status, pausedAt: status === 'paused' ? new Date() : null, finishedAt: status === 'terminated' ? new Date() : undefined, scanLeaseOwner: null, scanLeaseUntil: null } });
if (status === 'terminated') await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId: id, status: { in: ['queued', 'waiting_connection'] } }, data: { status: 'unprocessed', skipReason: '任务已终止', completedAt: new Date() } });
await this.prisma.operationLog.create({ data: { userId: operatorId, action: `gateway.downstream_requeue_task_${action}`, resource: 'downstream_requeue_task', resourceId: id, detail: { taskNo: task.taskNo, previousStatus: task.status, status } } });
return updated;
}
async runScan() {
await this.prisma.downstreamRequeueRateWindow.deleteMany({ where: { windowStartedAt: { lt: new Date(Date.now() - 5 * 60_000) } } });
const tasks = await this.prisma.downstreamRequeueTask.findMany({ where: { status: { in: ['queued', 'running'] } }, orderBy: { createdAt: 'asc' }, take: 3, select: { id: true } });
for (const task of tasks) await this.processTask(task.id);
}
private async processTask(taskId: string) {
const leaseOwner = randomUUID();
const now = new Date();
const lease = await this.prisma.downstreamRequeueTask.updateMany({
where: { id: taskId, status: { in: ['queued', 'running'] }, OR: [{ scanLeaseUntil: null }, { scanLeaseUntil: { lt: now } }] },
data: { scanLeaseOwner: leaseOwner, scanLeaseUntil: new Date(now.getTime() + SCAN_LEASE_MS) },
});
if (!lease.count) return;
try {
const task = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId } });
if (!task || !['queued', 'running'].includes(task.status)) return;
// A process may die after the database claim but before the Gateway call. The lease makes that
// ambiguous window visible and recoverable; every recovered item is revalidated before replay.
await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { taskId, status: 'processing', claimedAt: { lt: new Date(Date.now() - PROCESSING_LEASE_MS) } }, data: { status: 'queued', claimedAt: null, errorMessage: '执行进程中断,已回收并等待重新复核' } });
let failures = await this.reconcileWaiting(taskId, jsonFailures(task.applicationFailures));
const existingFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (existingFailureEntry) {
await this.autoPause(task, existingFailureEntry[0], existingFailureEntry[1]);
await this.refreshTask(taskId);
return;
}
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status: 'running', startedAt: task.startedAt ?? new Date() } });
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'queued' }, orderBy: { createdAt: 'asc' }, take: Math.min(200, task.ratePerSecond * 3), select: { id: true, deliveryId: true, applicationId: true } });
for (const item of items) {
const latestTask = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } });
if (!latestTask || !['queued', 'running'].includes(latestTask.status)) break;
if (!(await this.consumeRate(item.applicationId, task.ratePerSecond))) continue;
const claimed = await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'queued' }, data: { status: 'processing', claimedAt: new Date() } });
if (!claimed.count) continue;
const outcome = await this.processItem(item.id, item.deliveryId);
if (outcome === 'success') failures[item.applicationId] = 0;
if (outcome === 'failed') failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } });
if ((failures[item.applicationId] ?? 0) >= task.consecutiveFailureLimit) {
await this.autoPause(task, item.applicationId, failures[item.applicationId]);
break;
}
}
failures = await this.reconcileWaiting(taskId, failures);
const maxFailures = Math.max(0, ...Object.values(failures));
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, status: { in: ['queued', 'running'] } }, data: { applicationFailures: failures as Prisma.InputJsonValue, consecutiveFailures: maxFailures } });
const ackFailureEntry = Object.entries(failures).find(([, count]) => count >= task.consecutiveFailureLimit);
if (ackFailureEntry) await this.autoPause(task, ackFailureEntry[0], ackFailureEntry[1]);
await this.refreshTask(taskId);
} finally {
await this.prisma.downstreamRequeueTask.updateMany({ where: { id: taskId, scanLeaseOwner: leaseOwner }, data: { scanLeaseOwner: null, scanLeaseUntil: null } });
}
}
private async processItem(itemId: string, deliveryId: string): Promise<'success' | 'failed' | 'waiting' | 'skipped'> {
try {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { id: deliveryId },
include: { application: { select: { status: true, interfaceEnabled: true } } },
});
if (!delivery) return this.finishItem(itemId, 'skipped', '投递记录已不存在');
if (!REPLAYABLE_STATUSES.includes(delivery.status)) {
if (delivery.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_external_ack', skipReason: null } }); return 'waiting'; }
return this.finishItem(itemId, 'skipped', delivery.status === 'delivered' ? '已被客户确认' : '执行前状态已变化');
}
if (delivery.application.status !== 'active' || !delivery.application.interfaceEnabled) return this.finishItem(itemId, 'skipped', '应用或投递能力已停用');
if (!delivery.payload || !['receipt', 'uplink'].includes(delivery.deliveryType)) return this.finishItem(itemId, 'skipped', '投递数据不完整');
const activeOther = await this.prisma.downstreamRequeueTaskItem.findFirst({ where: { deliveryId, id: { not: itemId }, status: { in: ['processing', 'waiting_ack', 'success'] } }, select: { id: true } });
if (activeOther) return this.finishItem(itemId, 'skipped', '已被其他任务处理');
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: delivery.applicationId, status: 'connected' } });
if (connected === 0) { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_connection', claimedAt: null, errorMessage: '客户当前离线,等待连接恢复' } }); return 'waiting'; }
const result = await this.facade.requeueDownstreamDelivery(deliveryId) as { status?: string; lastError?: string | null };
if (result?.status === 'delivered') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'success', completedAt: new Date() } }); return 'success'; }
if (result?.status === 'awaiting_ack') { await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'waiting_ack', completedAt: null } }); return 'waiting'; }
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: 'failed', errorMessage: result?.lastError ?? 'Gateway未进入等待ACK状态', completedAt: new Date() } });
return 'failed';
} catch (error) {
const message = error instanceof Error ? error.message : '后台重投失败';
const skipReason = /已被其他操作处理|状态|等待客户端确认/.test(message) ? '执行前状态已变化'
: /payload|投递类型/.test(message) ? '投递数据不完整'
: /Submit|Msg_Id|Sequence/.test(message) ? '缺少原Submit映射,无法安全重投' : null;
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status: skipReason ? 'skipped' : 'failed', skipReason, errorMessage: skipReason ? null : message, completedAt: new Date() } });
return skipReason ? 'skipped' : 'failed';
}
}
private async finishItem(itemId: string, status: 'skipped', reason: string): Promise<'skipped'> {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: itemId }, data: { status, skipReason: reason, completedAt: new Date() } });
return 'skipped';
}
private async consumeRate(applicationId: string, limit: number) {
const windowStartedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
const rows = await this.prisma.$queryRaw<Array<{ consumed: number }>>(Prisma.sql`
INSERT INTO "DownstreamRequeueRateWindow" ("id", "applicationId", "windowStartedAt", "consumed", "updatedAt")
VALUES (${randomUUID()}, ${applicationId}, ${windowStartedAt}, 1, NOW())
ON CONFLICT ("applicationId", "windowStartedAt") DO UPDATE
SET "consumed" = "DownstreamRequeueRateWindow"."consumed" + 1, "updatedAt" = NOW()
WHERE "DownstreamRequeueRateWindow"."consumed" < ${limit}
RETURNING "consumed"
`);
return rows.length === 1;
}
private async reconcileWaiting(taskId: string, currentFailures?: Record<string, number>) {
const task = currentFailures ? null : await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { applicationFailures: true } });
const failures = currentFailures ?? jsonFailures(task?.applicationFailures);
const connectionItems = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: 'waiting_connection' }, select: { id: true, applicationId: true } });
for (const item of connectionItems) {
const connected = await this.prisma.cmppDownstreamConnection.count({ where: { applicationId: item.applicationId, status: 'connected' } });
if (connected > 0) await this.prisma.downstreamRequeueTaskItem.updateMany({ where: { id: item.id, status: 'waiting_connection' }, data: { status: 'queued', errorMessage: null, claimedAt: null } });
}
const items = await this.prisma.downstreamRequeueTaskItem.findMany({ where: { taskId, status: { in: ['waiting_ack', 'waiting_external_ack'] } }, include: { delivery: { select: { status: true, ackResult: true, ackDeadlineAt: true, lastError: true } } }, take: 500 });
const now = new Date();
for (const item of items) {
if (item.delivery.status === 'delivered' && item.delivery.ackResult === 0) {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: item.status === 'waiting_external_ack' ? { status: 'skipped', skipReason: '已由其他投递链路完成', completedAt: now } : { status: 'success', completedAt: now } });
if (item.status === 'waiting_ack') failures[item.applicationId] = 0;
} else if (['failed', 'rejected', 'unconfirmed'].includes(item.delivery.status) || (item.delivery.ackDeadlineAt && item.delivery.ackDeadlineAt <= now)) {
if (item.status === 'waiting_external_ack') {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'queued', skipReason: null, errorMessage: null, claimedAt: null } });
} else {
await this.prisma.downstreamRequeueTaskItem.update({ where: { id: item.id }, data: { status: 'failed', errorMessage: item.delivery.lastError ?? '客户端ACK失败或超时', completedAt: now } });
failures[item.applicationId] = (failures[item.applicationId] ?? 0) + 1;
}
}
}
return failures;
}
private async autoPause(task: { id: string; taskNo: string; consecutiveFailureLimit: number }, applicationId: string, count: number) {
const pausedAt = new Date();
const message = `应用 ${applicationId} 连续失败达到安全阈值 ${task.consecutiveFailureLimit} 条,任务已自动暂停`;
const updated = await this.prisma.downstreamRequeueTask.updateMany({ where: { id: task.id, status: { in: ['queued', 'running'] } }, data: { status: 'paused', pausedAt, lastError: message } });
if (updated.count) await this.prisma.operationLog.create({ data: { action: 'gateway.downstream_requeue_task_auto_paused', resource: 'downstream_requeue_task', resourceId: task.id, detail: { taskNo: task.taskNo, applicationId, consecutiveFailures: count, failureLimit: task.consecutiveFailureLimit, pausedAt } } });
}
private async refreshTask(taskId: string) {
const groups = await this.prisma.downstreamRequeueTaskItem.groupBy({ by: ['status'], where: { taskId }, _count: { _all: true } });
const counts = new Map(groups.map((item) => [item.status, item._count._all]));
const queued = counts.get('queued') ?? 0;
const active = (counts.get('processing') ?? 0) + (counts.get('waiting_connection') ?? 0) + (counts.get('waiting_ack') ?? 0) + (counts.get('waiting_external_ack') ?? 0);
const failed = counts.get('failed') ?? 0;
const current = await this.prisma.downstreamRequeueTask.findUnique({ where: { id: taskId }, select: { status: true } });
const status = current?.status === 'paused' || current?.status === 'terminated' ? current.status : queued + active === 0 ? (failed > 0 ? 'partial_completed' : 'completed') : 'running';
await this.prisma.downstreamRequeueTask.update({ where: { id: taskId }, data: { status, successCount: counts.get('success') ?? 0, failedCount: failed, skippedCount: counts.get('skipped') ?? 0, waitingCount: active, ...(status === 'completed' || status === 'partial_completed' ? { finishedAt: new Date() } : {}) } });
}
}
@@ -162,7 +162,7 @@ async submitMessageToGateway(
) {
const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.facade.ensureSignatureReportedForChannel(message, channel.id);
await this.facade.ensureSignatureReportedForChannel(message, channel.id, routed.carrier);
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
const submitId = `SUB-${randomUUID()}`;
try {
@@ -321,7 +321,13 @@ async selectChannelForMessage(
const signatureId = await this.facade.resolveMessageSignatureId(message);
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
where: {
signatureId,
reportType: 'signature',
status: 'approved',
channelId: { in: route.group.items.map((item) => item.channelId) },
OR: signatureReportApprovalScopes(carrier),
},
select: { channelId: true },
});
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
@@ -386,13 +392,20 @@ async ensureSignatureReportedForChannel(
signature?: { id?: string | null; name?: string | null } | null;
},
channelId: string,
carrier: string,
) {
const signatureId = await this.facade.resolveMessageSignatureId(message);
if (!signatureId) {
throw new BadRequestException('短信签名未配置,不能提交到通道');
}
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
where: {
signatureId,
channelId,
reportType: 'signature',
status: 'approved',
OR: signatureReportApprovalScopes(carrier),
},
select: { id: true },
});
if (!reportTask) {
@@ -489,3 +502,11 @@ return streamId`,
return typeof result === 'string' ? result : String(result ?? '');
}
}
function signatureReportApprovalScopes(carrier: string) {
const exact = { carrier, approvalScope: 'carrier_specific' };
// 迁移期保留双读用于平滑发布;自动转换migration完成且兼容命中清零后再切换严格口径。
return process.env.SIGNATURE_REPORT_STRICT_CARRIER === 'true'
? [exact]
: [exact, { carrier: null, approvalScope: 'legacy_channel' }];
}
@@ -59,31 +59,77 @@ export class SendInboundEntryService {
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
const application = await this.facade.findInboundApplication(data.account);
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
let tenantId: string | undefined;
let applicationId: string | undefined;
try {
const application = await this.facade.findInboundApplication(data.account);
tenantId = application?.tenantId;
applicationId = application?.id;
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' });
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
} catch (error) {
await this.recordInboundConnectRequest(data, {
tenantId,
applicationId,
result: 'failed',
error: error instanceof Error ? error.message : 'unknown error',
});
throw error;
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
}
private recordInboundConnectRequest(
data: GatewayInboundAuthDto,
outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string },
) {
return this.prisma.operationLog.create({
data: {
tenantId: outcome.tenantId,
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: outcome.applicationId ?? data.account,
ipAddress: data.remoteIp?.trim() || undefined,
detail: {
direction: 'client_to_platform',
result: outcome.result,
applicationId: outcome.applicationId ?? null,
request: {
remoteIp: data.remoteIp?.trim() || null,
account: data.account,
// Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful.
password: data.password ?? null,
authSource: data.authSource ?? null,
timestamp: data.timestamp ?? null,
version: data.version ?? null,
requestedVersion: data.requestedVersion ?? null,
},
error: outcome.error ?? null,
} as Prisma.InputJsonValue,
},
});
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
+41
View File
@@ -166,6 +166,47 @@ export class SendRetryService {
return updated;
}
async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
if (!deadLetter) {
throw new NotFoundException('Gateway提交异常记录不存在');
}
if (deadLetter.status === 'resolved') {
return deadLetter;
}
if (deadLetter.status !== 'pending') {
throw new BadRequestException('只有待处理的提交异常可以标记为已处理');
}
const resolvedAt = new Date();
const resolved = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id, status: 'pending' },
data: {
status: 'resolved',
resolvedAt,
resolvedStatus: 'manually_resolved',
},
});
if (resolved.count !== 1) {
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
}
await this.prisma.operationLog.create({
data: {
tenantId: deadLetter.tenantId ?? undefined,
userId: operatorId,
action: 'gateway.submit_dead_letter_resolved',
resource: 'gateway_submit_dead_letter',
resourceId: deadLetter.id,
detail: {
previousStatus: deadLetter.status,
resolvedStatus: 'manually_resolved',
messageId: deadLetter.messageId,
submitId: deadLetter.submitId,
},
},
});
return this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
@@ -87,7 +87,12 @@ async dispatchDueScheduledTasks(now = new Date()) {
let reservationEstablished = false;
let dispatchPrepared = false;
try {
await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
await this.facade.validateSendResources(
task.tenantId,
task.applicationId ?? undefined,
task.templateId ?? undefined,
{ usePersistedTemplateSnapshot: true },
);
const messages = await this.prisma.smsMessageRecord.findMany({
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
select: { id: true, amountCents: true, billingUnits: true },
@@ -34,6 +34,10 @@ export type SendSubmissionCallbacks = {
) => Promise<unknown>;
};
export type SendResourceValidationOptions = {
usePersistedTemplateSnapshot?: boolean;
};
/**
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
*/
@@ -109,8 +113,8 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
}
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId);
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options);
}
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
@@ -273,8 +277,9 @@ async ensureSignatureReportedForChannel(
signature?: { id?: string | null; name?: string | null } | null;
},
channelId: string,
carrier: string,
) {
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId);
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier);
}
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
@@ -0,0 +1,49 @@
export type RetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel';
export interface UpsertRetirementRuleDto {
ruleType: RetirementRuleType;
targetId?: string;
enabled?: boolean;
mobileWindowDays: number;
mobileThreshold: number;
unicomWindowDays: number;
unicomThreshold: number;
telecomWindowDays: number;
telecomThreshold: number;
messageTemplate?: string;
}
export interface CreateRetirementWebhookDto {
name: string;
platform: 'wecom' | 'feishu';
url: string;
}
export interface SuppressRetirementMessageDto {
mode: 'temporary' | 'permanent';
days?: number;
reason?: string;
}
export interface CancelRetirementSuppressionDto {
reason: string;
}
export interface UnreportedSignatureQuery {
date?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
export interface RetirementMessageQuery {
dateFrom?: string;
dateTo?: string;
dimensionType?: string;
tenantId?: string;
applicationId?: string;
signatureKeyword?: string;
channelId?: string;
page?: number;
pageSize?: number;
}
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
import { SignatureRetirementService } from './signature-retirement.service';
@ApiTags('signature-retirement')
@Controller('admin/signature-retirement')
export class SignatureRetirementController {
constructor(private readonly service: SignatureRetirementService) {}
@Get('configuration')
getConfiguration() {
return this.service.getConfiguration();
}
@Put('rules')
@RequireRecentAuthentication()
upsertRule(@Body() body: UpsertRetirementRuleDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.upsertRule(body, operatorId);
}
@Post('webhooks')
@RequireRecentAuthentication()
createWebhook(@Body() body: CreateRetirementWebhookDto) {
return this.service.createWebhook(body);
}
@Delete('webhooks/:id')
@RequireRecentAuthentication()
deleteWebhook(@Param('id') id: string) {
return this.service.deleteWebhook(id);
}
@Get('messages')
listMessages(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('dimensionType') dimensionType?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('signatureKeyword') signatureKeyword?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const query: RetirementMessageQuery = { dateFrom, dateTo, dimensionType, tenantId, applicationId, signatureKeyword, channelId, page: Number(page), pageSize: Number(pageSize) };
return this.service.listMessages(query);
}
@Get('unread-count')
unreadCount() {
return this.service.unreadCount();
}
@Post('messages/:id/read')
markRead(@Param('id') id: string) {
return this.service.markRead(id);
}
@Post('messages/read-all-today')
markAllTodayRead() {
return this.service.markAllTodayRead();
}
@Post('messages/:id/suppress')
@RequireRecentAuthentication()
suppress(@Param('id') id: string, @Body() body: SuppressRetirementMessageDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.suppressMessage(id, body, operatorId);
}
@Get('suppressions')
listSuppressions() {
return this.service.listSuppressions();
}
@Post('suppressions/:id/cancel')
@RequireRecentAuthentication()
cancelSuppression(@Param('id') id: string, @Body() body: CancelRetirementSuppressionDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.cancelSuppression(id, body, operatorId);
}
@Get('heatmap')
heatmap(@Query('date') date?: string) {
return this.service.heatmap(date);
}
@Get('unreported-signatures')
unreportedSignatures(
@Query('date') date?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const query: UnreportedSignatureQuery = { date, keyword, page: Number(page), pageSize: Number(pageSize) };
return this.service.unreportedSignatures(query);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SignatureRetirementController } from './signature-retirement.controller';
import { SignatureRetirementService } from './signature-retirement.service';
@Module({
controllers: [SignatureRetirementController],
providers: [SignatureRetirementService],
exports: [SignatureRetirementService],
})
export class SignatureRetirementModule {}
@@ -0,0 +1,168 @@
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
describe('SignatureRetirementService dimensions', () => {
const service = new SignatureRetirementService({} as never);
it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => {
const rules = [
rule('enterprise_global', ''),
rule('enterprise_application', 'app-1'),
rule('channel_global', ''),
rule('channel', 'channel-2'),
];
const tasks = [
task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'),
task('channel-2', '移动二号', 'mobile', '2026-06-05T00:00:00Z'),
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
];
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks);
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
});
it('does not monitor legacy carrier-null reporting facts', () => {
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
[rule('enterprise_global', ''), rule('channel_global', '')],
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
);
expect(dimensions).toEqual([]);
});
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T23:30:00.000Z'), 8)).toBe(30 * 60_000);
});
it('publishes frozen alert content only in the notification phase', async () => {
const detection = {
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
};
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
};
const notificationService = new SignatureRetirementService(prisma as never);
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
});
it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
channel: { name: '移动通道' },
}]) },
};
const heatmapService = new SignatureRetirementService(prisma as never);
const result = await heatmapService.heatmap('2026-08-10');
expect(result.dimensions).toEqual(expect.arrayContaining([
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]));
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
});
it('persists daily observing snapshots without opening alert cycles', async () => {
const prisma = {
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
};
const observingService = new SignatureRetirementService(prisma as never);
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
}]),
};
const unreportedService = new SignatureRetirementService(prisma as never);
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
date: '2026-08-10',
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
total: 3,
page: 2,
pageSize: 10,
});
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
const sql = query.strings?.join('?') ?? '';
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
expect(sql).toContain('message."signatureId" IS NULL');
expect(sql).toContain('FROM "SmsSignature" signature');
expect(sql).toContain('signature."applicationId" = extracted.application_id');
expect(sql).toContain('GROUP BY\n extracted.tenant_id,\n extracted.application_id,');
expect(sql).not.toContain('FROM "ChannelSignatureReportTask" report');
});
it('returns a filtered historical message page with application metadata', async () => {
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([detection]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'signature-1', name: '测试签名' }]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '测试通道' }]) },
tenant: { findMany: jest.fn().mockResolvedValue([{ id: 'tenant-1', name: '测试企业' }]) },
smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', name: '测试应用' }]) },
};
const messageService = new SignatureRetirementService(prisma as never);
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
total: 21,
page: 2,
pageSize: 10,
});
});
it('requires a reason for temporary and permanent suppression', async () => {
const prisma = {
signatureRetirementMessage: {
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
findFirst: jest.fn().mockResolvedValue(null),
},
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
};
const suppressionService = new SignatureRetirementService(prisma as never);
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
});
});
function rule(ruleType: string, targetKey: string) {
return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 };
}
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } };
}
@@ -0,0 +1,703 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { lookup } from 'node:dns/promises';
import { decryptSecret, encryptSecret } from '../open-api/open-api.crypto';
import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
const DAY_MS = 86_400_000;
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
const DEFAULT_DELIVERY_INTERVAL_MS = 60_000;
const carrierLabels: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
type RuleRecord = Awaited<ReturnType<PrismaService['signatureRetirementRule']['findFirst']>>;
type DetectionDimension = {
dimensionType: 'enterprise' | 'channel';
tenantId: string;
applicationId: string | null;
signatureId: string;
signatureName: string;
tenantName: string;
channelId: string | null;
channelName: string | null;
carrier: string;
approvedAt: Date;
rule: NonNullable<RuleRecord>;
};
type ActivityCounts = {
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
};
@Injectable()
export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SignatureRetirementService.name);
private detectionTimer?: ReturnType<typeof setTimeout>;
private notificationTimer?: ReturnType<typeof setTimeout>;
private deliveryTimer?: ReturnType<typeof setInterval>;
private startupTimer?: ReturnType<typeof setTimeout>;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (process.env.NODE_ENV === 'test') return;
// 04:00检测、08:00发消息分别调度;启动补偿与数据库唯一键共同保证当天不漏、不重。
this.startupTimer = setTimeout(() => void this.runStartupCompensation(), 10_000);
this.startupTimer.unref?.();
this.scheduleDetection();
this.scheduleNotification();
this.deliveryTimer = setInterval(() => void this.deliverPendingWebhooks(), positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS));
this.deliveryTimer.unref?.();
}
onModuleDestroy() {
if (this.startupTimer) clearTimeout(this.startupTimer);
if (this.detectionTimer) clearTimeout(this.detectionTimer);
if (this.notificationTimer) clearTimeout(this.notificationTimer);
if (this.deliveryTimer) clearInterval(this.deliveryTimer);
}
async getConfiguration() {
const [rules, webhooks] = await Promise.all([
this.prisma.signatureRetirementRule.findMany({ orderBy: [{ ruleType: 'asc' }, { targetKey: 'asc' }] }),
this.prisma.signatureRetirementWebhook.findMany({ orderBy: { createdAt: 'asc' } }),
]);
return { rules, webhooks };
}
async upsertRule(data: UpsertRetirementRuleDto, operatorId?: string) {
assertRuleType(data.ruleType);
if (['enterprise_application', 'channel'].includes(data.ruleType) && !data.targetId?.trim()) {
throw new BadRequestException('特殊规则必须选择目标');
}
const values = CARRIERS.flatMap((carrier) => [
Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]),
Number(data[`${carrier}Threshold` as keyof UpsertRetirementRuleDto]),
]);
if (values.some((value) => !Number.isInteger(value) || value < 0)) throw new BadRequestException('检测天数和阈值必须为非负整数');
if (CARRIERS.some((carrier) => Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) < 1 || Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) > 365)) {
throw new BadRequestException('检测天数必须在1至365天之间');
}
const targetId = data.targetId?.trim() || null;
const targetKey = targetId ?? '';
const existing = await this.prisma.signatureRetirementRule.findUnique({ where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } } });
const rule = await this.prisma.signatureRetirementRule.upsert({
where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } },
create: { ...data, targetId, targetKey, createdById: operatorId },
update: { ...data, targetId, targetKey, version: { increment: 1 } },
});
await this.prisma.operationLog.create({ data: { userId: operatorId, action: existing ? 'signature_retirement.rule_updated' : 'signature_retirement.rule_created', resource: 'signature_retirement_rule', resourceId: rule.id, detail: { ruleType: rule.ruleType, targetId, version: rule.version } as Prisma.InputJsonValue } });
return rule;
}
async createWebhook(data: CreateRetirementWebhookDto) {
if (!data.name?.trim()) throw new BadRequestException('Webhook名称不能为空');
if (!['wecom', 'feishu'].includes(data.platform)) throw new BadRequestException('仅支持企业微信或飞书');
await assertSafeWebhookUrl(data.url);
return this.prisma.signatureRetirementWebhook.create({
data: { name: data.name.trim(), platform: data.platform, urlEncrypted: encryptSecret(data.url.trim()), urlMasked: maskWebhookUrl(data.url.trim()) },
});
}
async deleteWebhook(id: string) {
const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id } });
if (!webhook) throw new NotFoundException('Webhook不存在');
return this.prisma.signatureRetirementWebhook.update({ where: { id }, data: { status: 'deleted' } });
}
async listMessages(query: RetirementMessageQuery) {
const page = Math.max(1, Math.floor(query.page || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize || 10)));
const range = shanghaiDateRange(query.dateFrom || shanghaiDateKey(), query.dateTo || query.dateFrom || shanghaiDateKey());
const dimensionType = query.dimensionType && query.dimensionType !== 'all' ? query.dimensionType : null;
if (dimensionType && !['enterprise', 'channel'].includes(dimensionType)) throw new BadRequestException('不支持的预警类型');
const tenantId = query.tenantId?.trim() || null;
const applicationId = query.applicationId?.trim() || null;
const signatureKeyword = query.signatureKeyword?.trim() || null;
const signaturePattern = signatureKeyword ? `%${signatureKeyword}%` : null;
const channelId = query.channelId?.trim() || null;
const messageRows = await this.prisma.$queryRaw<Array<{ id: string; totalCount: number }>>(Prisma.sql`
SELECT message.id, COUNT(*) OVER()::integer AS "totalCount"
FROM "SignatureRetirementMessage" message
JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId"
JOIN "SmsSignature" signature ON signature.id = detection."signatureId"
LEFT JOIN "SmsApplication" application ON application.id = detection."applicationId"
WHERE message."createdAt" >= ${range?.gte}
AND message."createdAt" <= ${range?.lte}
AND (${dimensionType}::text IS NULL OR detection."dimensionType" = ${dimensionType})
AND (${tenantId}::text IS NULL OR detection."tenantId" = ${tenantId})
AND (${applicationId}::text IS NULL OR application.id = ${applicationId})
AND (${signatureKeyword}::text IS NULL OR signature.name ILIKE ${signaturePattern})
AND (${channelId}::text IS NULL OR detection."channelId" = ${channelId})
ORDER BY message."createdAt" DESC, message.id DESC
LIMIT ${pageSize}
OFFSET ${(page - 1) * pageSize}
`);
const orderedIds = messageRows.map((item) => item.id);
const unorderedItems = orderedIds.length ? await this.prisma.signatureRetirementMessage.findMany({ where: { id: { in: orderedIds } } }) : [];
const itemMap = new Map(unorderedItems.map((item) => [item.id, item]));
const items = orderedIds.flatMap((id) => itemMap.has(id) ? [itemMap.get(id)!] : []);
const total = messageRows[0]?.totalCount ?? 0;
const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { id: { in: items.map((item) => item.detectionId) } } });
const detectionMap = new Map(detections.map((item) => [item.id, item]));
const [signatures, channels, tenants, applications] = await Promise.all([
this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }),
this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }),
this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }),
this.prisma.smsApplication.findMany({ where: { id: { in: detections.flatMap((item) => item.applicationId ? [item.applicationId] : []) } }, select: { id: true, name: true } }),
]);
const signatureMap = new Map(signatures.map((item) => [item.id, item.name]));
const channelMap = new Map(channels.map((item) => [item.id, item.name]));
const tenantMap = new Map(tenants.map((item) => [item.id, item.name]));
const applicationMap = new Map(applications.map((item) => [item.id, item.name]));
return {
items: items.map((item) => {
const detection = detectionMap.get(item.detectionId);
return { ...item, detection, signatureName: detection ? signatureMap.get(detection.signatureId) : undefined, channelName: detection?.channelId ? channelMap.get(detection.channelId) : undefined, tenantName: detection ? tenantMap.get(detection.tenantId) : undefined, applicationName: detection?.applicationId ? applicationMap.get(detection.applicationId) : undefined };
}),
total,
page,
pageSize,
};
}
async unreadCount() {
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
const count = await this.prisma.signatureRetirementMessage.count({ where: { createdAt: range, isRead: false, suppressed: false } });
return { count };
}
async markRead(id: string) {
return this.prisma.signatureRetirementMessage.update({ where: { id }, data: { isRead: true, readAt: new Date() } });
}
async markAllTodayRead() {
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
const result = await this.prisma.signatureRetirementMessage.updateMany({ where: { createdAt: range, isRead: false }, data: { isRead: true, readAt: new Date() } });
return { count: result.count };
}
async suppressMessage(id: string, data: SuppressRetirementMessageDto, operatorId?: string) {
const message = await this.prisma.signatureRetirementMessage.findUnique({ where: { id } });
if (!message) throw new NotFoundException('预警消息不存在');
const newerMessage = await this.prisma.signatureRetirementMessage.findFirst({ where: { cycleId: message.cycleId, createdAt: { gt: message.createdAt } }, select: { id: true } });
if (newerMessage) throw new BadRequestException('只能从当前预警周期的最新消息设置抑制');
const detection = await this.prisma.signatureRetirementDetection.findUnique({ where: { id: message.detectionId } });
if (!detection) throw new NotFoundException('预警检测记录不存在');
if (!['temporary', 'permanent'].includes(data.mode)) throw new BadRequestException('不支持的抑制类型');
if (!data.reason?.trim()) throw new BadRequestException('抑制原因不能为空');
const days = data.mode === 'temporary' ? Math.floor(Number(data.days)) : undefined;
if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) throw new BadRequestException('临时抑制天数必须在1至3650之间');
const muteUntil = days ? databaseDate(addDays(shanghaiDateKey(), days)) : null;
const suppression = await this.prisma.signatureRetirementSuppression.upsert({
where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelKey: detection.channelKey, carrier: detection.carrier } },
create: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelId: detection.channelId, channelKey: detection.channelKey, carrier: detection.carrier, mode: data.mode, muteUntil, reason: data.reason?.trim(), operatorId },
update: { channelId: detection.channelId, mode: data.mode, muteUntil, active: true, reason: data.reason?.trim(), operatorId, cancelledAt: null, cancelledById: null, cancelReason: null },
});
await Promise.all([
this.prisma.signatureRetirementMessage.update({ where: { id }, data: { suppressed: true } }),
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppressed', resource: 'signature_retirement_suppression', resourceId: suppression.id, detail: { mode: data.mode, days, reason: data.reason } as Prisma.InputJsonValue } }),
]);
return suppression;
}
listSuppressions() {
return this.prisma.signatureRetirementSuppression.findMany({
where: { active: true, OR: [{ mode: 'permanent' }, { muteUntil: { gte: databaseDate(shanghaiDateKey()) } }] },
orderBy: { updatedAt: 'desc' },
});
}
async cancelSuppression(id: string, data: CancelRetirementSuppressionDto, operatorId?: string) {
if (!data.reason?.trim()) throw new BadRequestException('取消抑制原因不能为空');
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { id } });
if (!suppression) throw new NotFoundException('抑制记录不存在');
const updated = await this.prisma.signatureRetirementSuppression.update({ where: { id }, data: { active: false, cancelledAt: new Date(), cancelledById: operatorId, cancelReason: data.reason.trim() } });
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppression_cancelled', resource: 'signature_retirement_suppression', resourceId: id, detail: { reason: data.reason.trim() } as Prisma.InputJsonValue } });
return updated;
}
async heatmap(date?: string) {
const endKey = assertDateKey(date || shanghaiDateKey());
const startKey = addDays(endKey, -30);
const detections = await this.prisma.signatureRetirementDetection.findMany({
where: { detectionDate: { gt: databaseDate(startKey), lte: databaseDate(endKey) } },
orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }],
});
const [signatures, channels, tenants, approvedTasks] = await Promise.all([
this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }),
this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }),
this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }),
this.prisma.channelSignatureReportTask.findMany({
where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
include: { signature: { include: { tenant: true, application: true } }, channel: true },
}),
]);
const dimensionMap = new Map<string, { dimensionType: 'enterprise' | 'channel'; signatureId: string; channelId: string | null; carrier: string; approvedAt: Date; signatureName: string; channelName: string | null; tenantName: string; applicationName: string | null }>();
for (const task of approvedTasks) {
if (!task.carrier || !task.approvedAt) continue;
const channelDimension = { dimensionType: 'channel' as const, signatureId: task.signatureId, channelId: task.channelId, carrier: task.carrier, approvedAt: task.approvedAt, signatureName: task.signature.name, channelName: task.channel.name, tenantName: task.signature.tenant.name, applicationName: task.signature.application?.name ?? null };
dimensionMap.set(`channel:${task.signatureId}:${task.channelId}:${task.carrier}`, channelDimension);
const enterpriseKey = `enterprise:${task.signatureId}::${task.carrier}`;
const current = dimensionMap.get(enterpriseKey);
if (!current || task.approvedAt < current.approvedAt) dimensionMap.set(enterpriseKey, { ...channelDimension, dimensionType: 'enterprise', channelId: null, channelName: null });
}
return {
date: endKey,
dimensions: [...dimensionMap.values()],
// 检测在次日04:00运行,因此检测日对应的活动自然日固定为T-1。
items: detections.map((item) => ({ ...item, activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
};
}
async unreportedSignatures(query: UnreportedSignatureQuery) {
const date = assertDateKey(query.date || shanghaiDateKey());
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const rows = await this.prisma.$queryRaw<Array<{
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationId: string | null;
applicationName: string | null;
messageCount: number;
rowCount: number;
}>>(Prisma.sql`
WITH extracted AS (
SELECT
message."tenantId" AS tenant_id,
message."applicationId" AS application_id,
SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${shanghaiStart(date)}
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
AND message."signatureId" IS NULL
), unreported AS (
SELECT
CONCAT('unregistered:', MD5(extracted.tenant_id || ':' || extracted.application_id || ':' || extracted.signature_name)) AS signature_id,
extracted.signature_name,
tenant.id AS tenant_id,
tenant.name AS tenant_name,
application.id AS application_id,
application.name AS application_name,
COUNT(*)::integer AS message_count
FROM extracted
JOIN "Tenant" tenant ON tenant.id = extracted.tenant_id
JOIN "SmsApplication" application ON application.id = extracted.application_id
WHERE extracted.signature_name IS NOT NULL
--
AND NOT EXISTS (
SELECT 1
FROM "SmsSignature" signature
WHERE signature."tenantId" = extracted.tenant_id
AND signature."applicationId" = extracted.application_id
AND signature.name = extracted.signature_name
AND signature."auditStatus" <> 'deleted'
)
AND (
${keyword}::text IS NULL
OR extracted.signature_name ILIKE ${keywordPattern}
OR tenant.name ILIKE ${keywordPattern}
OR application.name ILIKE ${keywordPattern}
)
GROUP BY
extracted.tenant_id,
extracted.application_id,
extracted.signature_name,
tenant.id,
tenant.name,
application.id,
application.name
)
SELECT
signature_id AS "signatureId",
signature_name AS "signatureName",
tenant_id AS "tenantId",
tenant_name AS "tenantName",
application_id AS "applicationId",
application_name AS "applicationName",
message_count AS "messageCount",
COUNT(*) OVER()::integer AS "rowCount"
FROM unreported
ORDER BY message_count DESC, signature_name, application_name NULLS LAST
LIMIT ${pageSize}
OFFSET ${(page - 1) * pageSize}
`);
return {
date,
items: rows.map(({ rowCount: _rowCount, ...item }) => item),
total: rows[0]?.rowCount ?? 0,
page,
pageSize,
};
}
async runDetection(date?: string) {
const detectionKey = assertDateKey(date || shanghaiDateKey());
await this.prisma.signatureRetirementSuppression.updateMany({
where: { active: true, mode: 'temporary', muteUntil: { lt: databaseDate(detectionKey) } },
data: { active: false },
});
const [rules, approvedTasks] = await Promise.all([
this.prisma.signatureRetirementRule.findMany({ where: { enabled: true } }),
this.prisma.channelSignatureReportTask.findMany({
where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
include: { signature: { include: { tenant: true, application: true } }, channel: true },
}),
]);
const dimensions = this.buildDimensions(rules, approvedTasks);
let alerted = 0;
let healthy = 0;
let ineligible = 0;
for (const dimension of dimensions) {
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
const windowStartKey = addDays(detectionKey, -windowDays);
const windowStart = shanghaiStart(windowStartKey);
const activityStart = shanghaiStart(addDays(detectionKey, -1));
const activityEnd = shanghaiStart(detectionKey);
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
if (effectiveActivityStart >= activityEnd) {
ineligible += 1;
continue;
}
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
if (dimension.approvedAt > windowStart) {
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
ineligible += 1;
continue;
}
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
const isAlert = windowCounts.acceptedBusinessCount < threshold;
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, isAlert, false, windowCounts);
if (isAlert) alerted += 1;
else healthy += 1;
}
return { detectionDate: detectionKey, dimensions: dimensions.length, alerted, healthy, ineligible };
}
async publishNotifications(date?: string) {
const notificationKey = assertDateKey(date || shanghaiDateKey());
const detections = await this.prisma.signatureRetirementDetection.findMany({
where: {
detectionDate: databaseDate(notificationKey), status: 'alert', suppressed: false,
cycleId: { not: null }, notificationTitle: { not: null }, notificationContent: { not: null },
},
});
let created = 0;
for (const detection of detections) {
if (!detection.cycleId || !detection.notificationTitle || !detection.notificationContent) continue;
try {
await this.prisma.signatureRetirementMessage.create({
data: { detectionId: detection.id, cycleId: detection.cycleId, tenantId: detection.tenantId, title: detection.notificationTitle, content: detection.notificationContent },
});
created += 1;
} catch (error) {
// 多实例08:00并发发布时,检测ID唯一键保证只产生一条站内消息。
if (!isPrismaUniqueError(error)) throw error;
}
}
await this.enqueueWebhookSummaries(notificationKey);
return { notificationDate: notificationKey, created };
}
private async runStartupCompensation() {
const now = new Date();
const hour = shanghaiHour(now);
try {
if (hour >= 4) await this.runDetection(shanghaiDateKey(now));
if (hour >= 8) {
await this.publishNotifications(shanghaiDateKey(now));
await this.deliverPendingWebhooks();
}
} catch (error) {
this.logger.error(`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private scheduleDetection() {
this.detectionTimer = setTimeout(() => {
void this.runDetection(shanghaiDateKey())
.catch((error) => this.logger.error(`Signature retirement 04:00 detection failed: ${error instanceof Error ? error.message : String(error)}`))
.finally(() => this.scheduleDetection());
}, millisecondsUntilShanghaiHour(new Date(), 4));
this.detectionTimer.unref?.();
}
private scheduleNotification() {
this.notificationTimer = setTimeout(() => {
void this.publishNotifications(shanghaiDateKey())
.then(() => this.deliverPendingWebhooks())
.catch((error) => this.logger.error(`Signature retirement 08:00 notification failed: ${error instanceof Error ? error.message : String(error)}`))
.finally(() => this.scheduleNotification());
}, millisecondsUntilShanghaiHour(new Date(), 8));
this.notificationTimer.unref?.();
}
private buildDimensions(rules: Array<NonNullable<RuleRecord>>, tasks: Array<{ signatureId: string; channelId: string; carrier: string | null; approvedAt: Date | null; signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } }; channel: { name: string } }>) {
const dimensions: DetectionDimension[] = [];
const enterprise = new Map<string, DetectionDimension>();
for (const task of tasks) {
if (!task.carrier || !task.approvedAt) continue;
const channelRule = selectRule(rules, 'channel', task.channelId);
if (channelRule) dimensions.push({ dimensionType: 'channel', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: task.channelId, channelName: task.channel.name, carrier: task.carrier, approvedAt: task.approvedAt, rule: channelRule });
const enterpriseRule = selectRule(rules, 'enterprise', task.signature.applicationId);
if (!enterpriseRule) continue;
const key = `${task.signatureId}:${task.carrier}`;
const current = enterprise.get(key);
if (!current || task.approvedAt < current.approvedAt) enterprise.set(key, { dimensionType: 'enterprise', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: null, channelName: null, carrier: task.carrier, approvedAt: task.approvedAt, rule: enterpriseRule });
}
return [...enterprise.values(), ...dimensions];
}
private async activityCounts(dimension: DetectionDimension, startAt: Date, endAt: Date): Promise<ActivityCounts> {
const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty;
const rows = await this.prisma.$queryRaw<Array<ActivityCounts>>(Prisma.sql`
WITH attempts AS (
SELECT
submit.id,
submit."messageRecordId" AS message_id,
submit."submitStatus" AS submit_status,
CASE
WHEN EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id)
THEN NOT EXISTS (
SELECT 1 FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id AND segment."receiptStatus" IS DISTINCT FROM 'delivered'
)
ELSE EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
)
END AS delivery_success
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
WHERE message."signatureId" = ${dimension.signatureId}
AND message.carrier = ${dimension.carrier}
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${endAt}
${channelFilter}
)
SELECT
COUNT(id)::integer AS "submittedAttempts",
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedBusinessCount",
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted' AND delivery_success)::integer AS "deliveredBusinessCount"
FROM attempts
`);
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
}
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean, observing = false, alertCounts = counts) {
const detectionDate = databaseDate(dateKey);
const channelKey = dimension.channelId ?? '';
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
where: { detectionDate_dimensionType_signatureId_channelKey_carrier: { detectionDate, dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } },
select: { id: true },
});
// 同一检测日的结果冻结规则版本;规则变更只在下一检测日生效。
if (existingDetection) return;
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } });
const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate));
let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
if (observing) {
cycle = null;
} else if (isAlert) {
if (!cycle) {
try {
cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } });
} catch (error) {
if (!isPrismaUniqueError(error)) throw error;
cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
}
} else {
cycle = await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { lastDetectedOn: detectionDate } });
}
} else if (cycle) {
await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate } });
cycle = null;
}
const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, alertCounts.acceptedBusinessCount) : null;
try {
await this.prisma.signatureRetirementDetection.create({
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: observing ? 'observing' : isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
});
} catch (error) {
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。
if (isPrismaUniqueError(error)) return;
throw error;
}
}
private async enqueueWebhookSummaries(dateKey: string) {
const detectionDate = databaseDate(dateKey);
const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate, status: 'alert', suppressed: false } });
if (!detections.length) return;
const [webhooks, messages] = await Promise.all([
this.prisma.signatureRetirementWebhook.findMany({ where: { status: 'active' } }),
this.prisma.signatureRetirementMessage.findMany({ where: { detectionId: { in: detections.map((item) => item.id) }, suppressed: false } }),
]);
const messageMap = new Map(messages.map((item) => [item.detectionId, item.content]));
const groups = new Map<string, string[]>();
for (const detection of detections) {
const key = detection.dimensionType === 'enterprise' ? `enterprise:${detection.tenantId}` : 'channel:all';
const values = groups.get(key) ?? [];
const content = messageMap.get(detection.id);
if (content) values.push(content);
groups.set(key, values);
}
for (const webhook of webhooks) {
for (const [groupKey, contents] of groups) {
await this.prisma.signatureRetirementWebhookDelivery.upsert({
where: { webhookId_detectionDate_groupKey: { webhookId: webhook.id, detectionDate, groupKey } },
create: { webhookId: webhook.id, detectionDate, groupKey, payload: { content: contents.join('\n') } },
update: {},
});
}
}
}
private async deliverPendingWebhooks() {
await this.prisma.signatureRetirementWebhookDelivery.updateMany({
where: { status: 'sending', updatedAt: { lt: new Date(Date.now() - 5 * 60_000) } },
data: { status: 'retrying', nextRetryAt: new Date() },
});
const deliveries = await this.prisma.signatureRetirementWebhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }] }, orderBy: { createdAt: 'asc' }, take: 20 });
for (const delivery of deliveries) {
const claimed = await this.prisma.signatureRetirementWebhookDelivery.updateMany({ where: { id: delivery.id, status: { in: ['pending', 'retrying'] }, attemptCount: delivery.attemptCount }, data: { status: 'sending', attemptCount: { increment: 1 } } });
if (!claimed.count) continue;
const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id: delivery.webhookId } });
if (!webhook || webhook.status !== 'active') {
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastError: 'Webhook已停用' } });
continue;
}
try {
const url = decryptSecret(webhook.urlEncrypted);
await assertSafeWebhookUrl(url);
const content = String((delivery.payload as { content?: unknown }).content ?? '');
const body = webhook.platform === 'feishu' ? { msg_type: 'text', content: { text: content } } : { msgtype: 'text', text: { content } };
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const responseBody = await response.json().catch(() => null) as { errcode?: number; code?: number } | null;
if ((typeof responseBody?.errcode === 'number' && responseBody.errcode !== 0) || (typeof responseBody?.code === 'number' && responseBody.code !== 0)) {
throw new Error(`Webhook业务响应失败:${responseBody.errcode ?? responseBody.code}`);
}
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'delivered', deliveredAt: new Date(), lastHttpStatus: response.status, lastError: null } });
} catch (error) {
const attempts = delivery.attemptCount + 1;
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: attempts >= 5 ? 'failed' : 'retrying', nextRetryAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60 * 60_000, 2 ** attempts * 60_000)), lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500) } });
}
}
}
}
function selectRule(rules: Array<NonNullable<RuleRecord>>, dimension: 'enterprise' | 'channel', targetId: string | null) {
const specialType = dimension === 'enterprise' ? 'enterprise_application' : 'channel';
const globalType = dimension === 'enterprise' ? 'enterprise_global' : 'channel_global';
return (targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined)
?? rules.find((rule) => rule.ruleType === globalType && rule.targetKey === '');
}
function carrierRule(rule: NonNullable<RuleRecord>, carrier: string) {
if (carrier === 'mobile') return { windowDays: rule.mobileWindowDays, threshold: rule.mobileThreshold };
if (carrier === 'unicom') return { windowDays: rule.unicomWindowDays, threshold: rule.unicomThreshold };
return { windowDays: rule.telecomWindowDays, threshold: rule.telecomThreshold };
}
function renderMessage(template: string | null, dimension: DetectionDimension, windowDays: number, threshold: number, actual: number) {
const fallback = dimension.dimensionType === 'enterprise'
? '请通知 {enterprise}{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
return (template?.trim() || fallback)
.replaceAll('{enterprise}', dimension.tenantName)
.replaceAll('{signature}', dimension.signatureName)
.replaceAll('{channel}', dimension.channelName ?? '-')
.replaceAll('{carrier}', carrierLabels[dimension.carrier] ?? dimension.carrier)
.replaceAll('{days}', String(windowDays))
.replaceAll('{threshold}', String(threshold))
.replaceAll('{actual}', String(actual));
}
function shanghaiDateKey(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}
export function shanghaiHour(date = new Date()) {
return Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(date));
}
export function millisecondsUntilShanghaiHour(now: Date, targetHour: number) {
const target = new Date(`${shanghaiDateKey(now)}T${String(targetHour).padStart(2, '0')}:00:00+08:00`);
if (target.getTime() <= now.getTime()) target.setUTCDate(target.getUTCDate() + 1);
return target.getTime() - now.getTime();
}
function assertDateKey(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(`${value}T00:00:00+08:00`).getTime())) throw new BadRequestException('日期格式必须为YYYY-MM-DD');
return value;
}
function addDays(value: string, days: number) {
const date = new Date(`${assertDateKey(value)}T12:00:00+08:00`);
return shanghaiDateKey(new Date(date.getTime() + days * DAY_MS));
}
function shanghaiStart(value: string) {
return new Date(`${assertDateKey(value)}T00:00:00+08:00`);
}
function databaseDate(value: string) {
return new Date(`${assertDateKey(value)}T00:00:00.000Z`);
}
function assertRuleType(value: string): asserts value is RetirementRuleType {
if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) throw new BadRequestException('不支持的规则类型');
}
function positiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
function positiveInteger(value: number | undefined, fallback: number) {
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
}
function isPrismaUniqueError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
async function assertSafeWebhookUrl(value: string) {
let url: URL;
try { url = new URL(value); } catch { throw new BadRequestException('Webhook地址无效'); }
if (url.protocol !== 'https:') throw new BadRequestException('Webhook必须使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook地址不能包含用户名或密码');
if (url.hostname === 'localhost' || url.hostname.endsWith('.local')) throw new BadRequestException('Webhook地址不能指向本地网络');
const addresses = await lookup(url.hostname, { all: true }).catch(() => []);
if (!addresses.length) throw new BadRequestException('Webhook域名无法解析');
if (addresses.some((entry) => isPrivateAddress(entry.address))) throw new BadRequestException('Webhook地址不能指向内网');
}
function isPrivateAddress(address: string) {
const normalized = address.toLowerCase();
if (normalized === '::1' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (!match) return false;
const [a, b] = [Number(match[1]), Number(match[2])];
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function maskWebhookUrl(value: string) {
const url = new URL(value);
const suffix = url.pathname.slice(-6);
return `${url.origin}/***${suffix}`;
}
+18 -12
View File
@@ -10,6 +10,8 @@ import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import { summarizeReportStatuses } from '../common/report-status';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsSignatureService {
@@ -83,8 +85,14 @@ export class SmsSignatureService {
drainageInfo: { ...legacyPayload, links: drainageLinks },
reportTargets: (() => {
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
const tasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature');
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => (
normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => {
const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)
?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel');
return { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific' };
})
));
})(),
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
@@ -107,21 +115,19 @@ export class SmsSignatureService {
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
return [carrier, summarizeReportStatuses(statuses)];
}))];
})),
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier));
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: targets.length }];
const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature');
const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status
?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status
?? 'pending');
return [carrier, summarizeReportStatuses(statuses)];
})),
};
});
+2 -2
View File
@@ -548,8 +548,8 @@ api/src/channels/
```
`docs/contracts/channels-r5-methods.json`
`tools/quality/verify-channels-r5.mjs` 固定 37 个公开方法、14 个内部方法、
17 个契约及 60 个辅助声明,并专项锁定连接参数重连条件、Gateway
`tools/quality/verify-channels-r5.mjs` 固定 38 个公开方法、14 个内部方法、
17 个契约及 61 个辅助声明,并专项锁定连接参数重连条件、Gateway
连接/断开路径、定时器、Redis队列、测试短信单次尝试和控制器兼容入口。
### 版本 R6:拆分 Gateway 入站服务
+12 -4
View File
@@ -255,7 +255,7 @@
},
{
"name": "listReconciliationReports",
"implementationSha256": "7d4dafd20064b8fb5ffec207c7656fc3807c805d8ebb112867751ef79b04c1c0"
"implementationSha256": "09c7c1e5797e2e7f9391db05e0ca8d5140a3496eb7aff34129af572e9cd4d58c"
},
{
"name": "exportReconciliationReports",
@@ -263,7 +263,7 @@
},
{
"name": "listProfitReports",
"implementationSha256": "d037e421b83586ada4758e1f3b0a5d1603c31295093b36f58f287c09ce98aed0"
"implementationSha256": "d5cda61c78b0762941c6dd7661501e1aea3cd33615a51dc86d1af66e48d669ea"
},
{
"name": "exportProfitReports",
@@ -271,7 +271,7 @@
},
{
"name": "listQualityReports",
"implementationSha256": "293d470b44d6ea12ff90d7497d985c3194386ff3ba1f6cedafb7a6d6925352ad"
"implementationSha256": "ba043613917d325272db8c6e5ca8b6d4fd202b03ac6ed522f1064ba271a36bda"
},
{
"name": "exportQualityReports",
@@ -439,7 +439,11 @@
},
{
"name": "updateChannelGroup",
"implementationSha256": "d01b2318706087800997b8f5d2e4dff6677b67bc92c1880211161a7dcea5d04f"
"implementationSha256": "d5f2722429048690cbed422d62729d8eee17bea6de4cc55dc187a629de991710"
},
{
"name": "getChannelGroupDeletionImpact",
"implementationSha256": "61c7fcb2c90244f97a740a32ae44f6a6d04342d5f50dfcd72c9d51167b3503e2"
},
{
"name": "deleteChannelGroup",
@@ -737,6 +741,10 @@
"name": "listPhoneSegments",
"implementationSha256": "3faf0469c44dfdfe69029fa23f77983d7c0c888e181f24e7b8387b1ac87d4c97"
},
{
"name": "listAdministrativeRegions",
"implementationSha256": "3393f57588cef8eaa0e8cfd8a24cedc12fe6fffe4e2b3dadf90921fd1e58fb58"
},
{
"name": "createPhoneSegment",
"implementationSha256": "e93ff81cbf2a93fd2a50356e671755b28f5335cc4293b8aefd22f4dfbe00bf9f"
@@ -13,7 +13,7 @@
},
{
"name": "signatureCardVisual",
"canonicalSha256": "1cea544821c9c03b7c544644d5865e57810ae6f9c8d05d577ed104811ada63ec"
"canonicalSha256": "e4749077295323ea3ce85d7793c78b51ca76748ca7d830265af69f8189904023"
},
{
"name": "AuditStatusTag",
@@ -65,15 +65,15 @@
},
{
"name": "SignatureReportModal",
"canonicalSha256": "7439104e171e7d9eeb8449f497696b6d3736b881195c0b96a1a096079ba0789b"
"canonicalSha256": "4cfec954f564c998af70da441971bede16915f4bb5ac9c10e45da15101957de5"
},
{
"name": "ChannelReportStatusModal",
"canonicalSha256": "d32f8d7d3025ee286ed7898ed41a22404db3d684b80d23819965b3332e0e8310"
"canonicalSha256": "e0717a89302e477fd1e57f18e3f97b993ebe3c7c42ccbfcd2761110054f6abcb"
},
{
"name": "DrainageReportModal",
"canonicalSha256": "716a2d480a4b652ee951de0306854f1d8f6d72759d7b2fbffbfbb600abc8a789"
"canonicalSha256": "d2b41c26eb3992bb1ff3b50ee5ef6c06e7f84d89832e3ec7446b35178520431f"
},
{
"name": "DrainageReportStatusModal",
@@ -84,7 +84,7 @@
"canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da"
}
],
"tableJsxSha256": "203ec16e0ca67f0dafea08d659e84db02dd8346d06a52f41378625e39a43def7",
"tableJsxSha256": "5cae4926ab10a762b26d77d0a0633b85de68fa204b7d5732ba0942e7fc276234",
"apiCalls": [
"listEnterpriseSignaturesPage",
"listTenants",
@@ -9,6 +9,7 @@
"admin-audit-",
"admin-detail-metric-",
"admin-report-filter-",
"admin-report-summary",
"admin-security-",
"admin-split-",
"admin-system-",
+14 -3
View File
@@ -166,7 +166,7 @@
{
"name": "listGroups",
"signature": "listGroups()",
"canonicalBodySha256": "fc4ae9bd701db6f55108aa057f42a56b283901b3401903edb5464058d5a96374",
"canonicalBodySha256": "3a6cbcd0159f9c2a380a34378007648f636e71be25802189d2821135c65351ba",
"originalLines": [
853,
858
@@ -203,10 +203,20 @@
],
"domain": "groups"
},
{
"name": "getGroupDeletionImpact",
"signature": "getGroupDeletionImpact(groupId: string)",
"canonicalBodySha256": "e57295fe8e1dd0f22b7845b8327a8d535af4d8a44d81e3bcccfcf95fb5269aad",
"originalLines": [
162,
198
],
"domain": "groups"
},
{
"name": "deleteGroup",
"signature": "async deleteGroup(groupId: string)",
"canonicalBodySha256": "ca89f1d6861fa4a5808e1d3e6a21062223faeab172246d5615d8447672105800",
"canonicalBodySha256": "1c993f68537f1b6dbd25e904bfaa4bd65a71c05301f08d5d2f69a3dbb24803c6",
"originalLines": [
998,
1015
@@ -765,7 +775,7 @@
},
{
"name": "summarizeReportStatuses",
"sha256": "ca9c3f0eece4b9e04f30cbc317041c52d63dcbb74ef1f87021dae5b20ca154b0"
"sha256": "98abf67ebafb41096119611949ac8105f08705b43abb940ed1e2dbbfa7e66d35"
},
{
"name": "normalizeLinkEvent",
@@ -886,6 +896,7 @@
"createGroup",
"addGroupItem",
"updateGroup",
"getGroupDeletionImpact",
"deleteGroup",
"listRouteRules",
"createRouteRule"
@@ -1,7 +1,7 @@
{
"version": "R10",
"generatedAt": "2026-08-03",
"source": "api/src/send-chain/send-chain.service.ts at R9 local baseline",
"source": "api/src/send-chain/send-chain.service.ts at R9 local baseline; facade extended by 5 downstream requeue task methods on 2026-08-12",
"facade": "api/src/send-chain/send-completion.service.ts",
"methods": [
{
+1 -1
View File
@@ -15,7 +15,7 @@
{
"name": "GatewayInboundAuthDto",
"kind": "interface",
"sha256": "1e55f6a2a393cd72c7aca2b4a1e18e293ae20f513412f627197515448c8da166"
"sha256": "bf53b9c9a55d28920d87d4d2a3154e6365d6a1ccbc94d64fc00c1e1059713ff5"
},
{
"name": "GatewayInboundSubmitDto",
+1 -1
View File
@@ -61,7 +61,7 @@
},
{
"name": "authenticateInboundApplication",
"bodySha256": "2630dd1066b5a3e86c805d13102973e1c928ca2747bd0c3741481d03169eba2f",
"bodySha256": "22fc19937d6e104428f6e112c2881a8096978d46a52b5b883c23eeb58fbf0c1a",
"file": "send-inbound-entry.service.ts"
},
{
+2 -2
View File
@@ -204,8 +204,8 @@
{
"name": "listSignatures",
"signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)",
"bodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a",
"canonicalBodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a",
"bodySha256": "bb60158b4e709ec24ac12b352c4d781bc35e3e87a51ab50cc63720011c4c910b",
"canonicalBodySha256": "bb60158b4e709ec24ac12b352c4d781bc35e3e87a51ab50cc63720011c4c910b",
"originalLines": [
915,
1025
@@ -0,0 +1,306 @@
# 下游投递后台批量重投任务设计与实现符合性审计
> 版本:V1.0<br>
> 需求确认日期:2026-08-12<br>
> 文档整理日期:2026-08-13<br>
> 适用页面:运营端 → 下游投递记录<br>
> 审计基线:当前工作区 `HEAD=67fee216162e638ba21004fcf87e7711facefb91`;本功能相关文件相对 HEAD 无未提交修改<br>
> 本文目的:还原 2026-08-12 已确认的设计口径,并将当前实现逐条映射到设计,不能以“已有代码”代替“符合设计”的结论。
## 1. 背景与目标
下游投递记录原有两种人工操作:单条重投、勾选当前页后批量重投。它们适合少量记录,但不适合事故期间处理跨页、跨应用的大批量积压。
新增“按当前筛选条件创建后台重投任务”的目标是:
1. 后端按当前真实筛选条件确定范围,不受页面分页影响;
2. 创建时冻结任务快照,避免执行期间不断卷入新记录;
3. 后台限速执行,客户离线或 ACK 异常时不得形成重投洪峰;
4. 每条任务项可追踪、可恢复、可审计,成功确认后不得重复投递;
5. 运营人员能够预检、创建、暂停、继续、终止和查看完整结果。
本功能不替代单条重投和当前页勾选重投,三种入口应同时保留。
## 2. 已确认的第一版业务口径
以下七条是 2026-08-12 已写入正式需求文档和测试用例的确认口径:
1. 保留单条重投、当前页勾选批量重投,新增“按筛选条件重投”;投递记录分页支持每页 10/25/50 条。
2. 后台任务使用企业、应用、投递类型、状态、创建日期、关键词组成的筛选快照;页码和每页条数不属于任务范围。预检生成 `snapshotAt`,创建任务后产生的新记录不进入该任务。
3. 第一版后台任务只允许 `pending``failed``unconfirmed``rejected`。不得批量重投客户端已确认的 `delivered`;处于 `awaiting_ack` 的记录不得并发重投。创建前展示真实命中数、可重投数、规则跳过数、状态分布,任务原因必填。
4. 任务按企业应用分批执行,默认每个应用 10 条/秒。单条失败不阻断整批;连续失败达到 10 条,或 ACK 超时/拒绝达到安全阈值时,自动暂停。客户离线、已有链路等待 ACK 属于“等待”,不能记作“跳过”。
5. “跳过”严格表示本任务没有调用 Gateway。第一版跳过原因包括:执行前状态变化、已被客户确认、已被其他任务处理、本任务已成功处理、不属于任务快照、应用或投递能力已停用、投递数据不完整、缺少原 Submit 映射。
6. 任务支持列表、详情、暂停、继续、终止。终止只影响尚未发送的任务项。任务项以 `taskId + deliveryId` 幂等;执行前原子认领并复核当前状态;API 重启后继续执行;已获得成功 ACK 的任务项不得再次发送。
7. 创建、暂停、继续、终止、自动暂停都必须写操作日志。任务必须使用真实 PostgreSQL、Gateway 和客户 ACK,不得使用 mock、静态数据或 localStorage。
## 3. 用户交互设计
### 3.1 投递记录筛选区
筛选条件应包含:
| 条件 | 说明 |
| --- | --- |
| 企业 | 全部企业或指定企业 |
| 企业应用 | 受企业条件联动;全部应用或指定应用 |
| 投递类型 | 全部、状态回执、上行短信 |
| 状态 | 全部或单一状态 |
| 创建日期 | 开始日期、结束日期,按北京时间自然日 |
| 关键词 | 消息 ID、客户账号、手机号、最后错误、企业名称、应用名称 |
“按筛选条件重投”使用上表条件,但明确排除页码、每页条数和当前页勾选状态。
### 3.2 创建任务流程
1. 用户设置筛选条件,点击“按筛选条件重投”。
2. 后端在同一时点生成预检快照,返回:筛选条件、`snapshotAt`、筛选命中数、可重投数、规则跳过数、状态分布、涉及应用数、最早记录时间。
3. 弹窗明确告知第一版允许和禁止的状态。
4. 用户选择执行速度,填写不少于 5 个字的事故原因、工单号或处理说明。
5. 用户确认后,后端必须重新按预检的筛选快照和 `snapshotAt` 物化任务项,而不是使用前端传入的记录 ID 列表。
6. 创建成功后关闭弹窗,任务出现在任务列表,状态为“排队中”。
预检结果必须严格遵守当前筛选条件。例如当前状态选择“待投递”,命中数、可重投数和状态分布都只能基于“待投递”记录,不能擅自扩展为全部状态。
### 3.3 任务列表
任务列表至少展示:任务号、创建时间、企业/应用范围、任务原因、中文状态、总数、成功数、失败数、跳过数、等待数、创建人。
列表应支持分页和状态筛选,保证历史任务可查询,不能只展示固定数量的最近任务。
可执行操作:
| 当前状态 | 可执行操作 |
| --- | --- |
| 排队中、执行中 | 查看详情、暂停、终止 |
| 已暂停 | 查看详情、继续、终止 |
| 已完成、部分完成、已终止 | 查看详情 |
### 3.4 任务详情
任务详情分为三部分:
1. 任务信息:筛选快照、快照时间、原因、创建人、速度、安全阈值、开始/暂停/完成时间;
2. 结果汇总:排队、处理中、等待连接、等待 ACK、成功、失败、跳过、未处理;
3. 完整任务项:支持分页及按结果状态、消息 ID、错误/跳过原因查询。
任务项必须使用中文状态和明确原因,不能只返回内部英文枚举,也不能只展示最近 50 项而使其余项目不可查询。
## 4. 后端数据设计
### 4.1 任务主表
`DownstreamRequeueTask` 保存:
- 任务号、企业范围、应用范围;
- 完整筛选快照及 `snapshotAt`
- 原因、执行速度、失败安全阈值;
- 任务状态和各结果计数;
- 创建人、开始时间、暂停时间、完成时间、最近错误;
- 创建时间、更新时间。
### 4.2 任务项表
`DownstreamRequeueTaskItem` 保存:
- `taskId``deliveryId``applicationId`
- 创建任务时的原状态;
- 当前任务项状态;
- 跳过原因、失败信息;
- 认领时间、完成时间、创建时间、更新时间。
数据库必须有 `taskId + deliveryId` 唯一约束。任务项不能级联删除原下游投递记录,历史下游投递和 ACK 证据必须保留。
## 5. 状态机设计
### 5.1 任务状态
| 状态 | 含义 | 进入条件 |
| --- | --- | --- |
| `queued` | 排队中 | 创建成功或人工继续 |
| `running` | 执行中 | 扫描器开始处理 |
| `paused` | 已暂停 | 人工暂停或触发安全阈值 |
| `completed` | 已完成 | 所有任务项完成且没有失败 |
| `partial_completed` | 部分完成 | 所有任务项结束但存在失败 |
| `terminated` | 已终止 | 人工终止;未认领项不再执行 |
### 5.2 任务项状态
| 状态 | 是否调用过 Gateway | 说明 |
| --- | --- | --- |
| `queued` | 否 | 等待认领 |
| `processing` | 尚不确定 | 已原子认领,正在复核并准备调用 |
| `waiting_connection` | 否或尚未写出 | 客户离线,等待可用连接,不算失败、不算跳过 |
| `waiting_external_ack` | 否 | 其他链路已写出,等待其 ACK 结论 |
| `waiting_ack` | 是 | 本任务已写出,等待客户 ACK |
| `success` | 是 | 客户返回可关联原消息的成功 ACK |
| `failed` | 是或调用失败 | Gateway 调用失败、ACK 超时或 ACK 拒绝 |
| `skipped` | 否 | 复核后确认本任务不应调用 Gateway |
| `unprocessed` | 否 | 任务终止时尚未开始 |
“写出成功”不能直接记为 `success`,只有客户有效 ACK 才是成功。
## 6. 执行与并发控制
1. 扫描器只处理 `queued/running` 任务。
2. 多实例扫描时必须先原子认领任务项;同一任务项只能有一个执行者。
3. 执行前重新读取下游投递及应用状态,再判断等待、跳过或调用 Gateway。
4. 限速维度是“企业应用”,不是任务总量。一个跨 3 个应用的任务配置 10 条/秒时,每个应用各自最多 10 条/秒,并且各应用互不阻塞。
5. 限速必须使用时间窗口或分布式令牌,不能依赖“定时器大约每秒执行一次 + 每轮取 N 条”,否则扫描重叠或执行耗时变化会突破或降低速率。
6. 客户离线时任务项进入等待连接;客户恢复后继续,不应消耗连续失败阈值。
7. 本任务写出后进入 `waiting_ack`;成功 ACK 转 `success`;ACK 超时、拒绝、无效 Msg_Id 转 `failed` 并计入安全阈值。
8. 连续失败只能在真正成功 ACK 后清零,不能在“写出并开始等待 ACK”时提前清零。
9. `processing` 必须有租约/超时恢复。API 进程中断后,超过认领租约的项目恢复为 `queued` 并重新复核,才能满足重启续跑。
10. 暂停或终止与执行器并发时,执行器在认领下一项和调用 Gateway 前都必须复核任务状态。
## 7. 跳过、等待和失败判定
### 7.1 跳过
跳过意味着本任务没有调用 Gateway:
| 原因 | 判定 |
| --- | --- |
| 执行前状态已变化 | 已不属于允许重投状态,且不是等待 ACK/已确认 |
| 已被客户确认 | 当前投递已是 `delivered` 且有效 ACK |
| 已被其他任务处理 | 其他任务已认领、等待 ACK 或成功 |
| 本任务已成功处理 | 同任务项已有成功结果,重复扫描不得再调用 |
| 不属于任务快照 | 创建时间晚于 `snapshotAt` 或不再满足冻结范围 |
| 应用或投递能力已停用 | 应用状态或接口能力不允许投递 |
| 投递数据不完整 | 缺少可重放 payload 或投递类型非法 |
| 缺少原 Submit 映射 | 无法形成可关联原短信的安全回执 |
### 7.2 等待
- 客户离线、没有可用连接:`waiting_connection`
- 其他链路已经写出且仍在 ACK 窗口:`waiting_external_ack`
- 本任务已经写出:`waiting_ack`
等待项不计入跳过数或失败数。等待结束后根据真实状态继续、成功或失败。
### 7.3 失败
- 调用 Gateway 发生非等待型错误;
- Gateway 明确拒绝或无法安全投递;
- 本任务写出后 ACK 超时;
- 客户 ACK 非零;
- 客户 ACK 无法关联原消息。
失败原因必须保留原始错误,同时归一为可统计的失败类别。
## 8. 安全阈值与自动暂停
默认安全策略:
- 每应用默认 10 条/秒;
- 连续失败阈值默认 10 条;
- ACK 超时和 ACK 拒绝纳入失败阈值;
- 达到阈值时,在认领下一条之前将任务原子改为 `paused`
- 写操作日志,记录任务号、失败类别、连续失败数、阈值和暂停时间;
- 页面展示明确的自动暂停原因;
- 人工继续后从待处理/等待项继续,不重放已成功项。
如果多个应用同时执行,连续失败计数至少应按应用隔离,避免一个客户应用故障暂停其他正常应用;第一版若选择整任务暂停,也必须在设计和页面中明确,且仍要保存触发暂停的应用。
## 9. API 设计
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | `/admin/operations/downstream-requeue-tasks/preview` | 按当前筛选条件生成预检快照 |
| POST | `/admin/operations/downstream-requeue-tasks` | 使用预检快照创建任务 |
| GET | `/admin/operations/downstream-requeue-tasks` | 分页查询任务列表,支持状态筛选 |
| GET | `/admin/operations/downstream-requeue-tasks/:id` | 查询任务汇总 |
| GET | `/admin/operations/downstream-requeue-tasks/:id/items` | 分页查询完整任务项及原因 |
| POST | `/admin/operations/downstream-requeue-tasks/:id/pause` | 暂停 |
| POST | `/admin/operations/downstream-requeue-tasks/:id/resume` | 继续 |
| POST | `/admin/operations/downstream-requeue-tasks/:id/terminate` | 终止 |
创建接口不得仅信任前端回传的筛选条件和时间。建议预检生成短期有效、服务端签名的 `previewToken`,绑定筛选条件、`snapshotAt` 和操作人;创建时校验令牌,防止绕过页面篡改范围。
## 10. 验收用例
| 编号 | 验收点 | 预期 |
| --- | --- | --- |
| DRQ-001 | 筛选快照 | 企业、应用、类型、状态、日期、关键词均生效;分页无关;快照后新记录不进入 |
| DRQ-002 | 预检口径 | 命中、可重投、跳过和状态分布严格基于当前筛选条件 |
| DRQ-003 | 状态白名单 | 只物化 `pending/failed/unconfirmed/rejected`;拒绝 `delivered/awaiting_ack` |
| DRQ-004 | 每应用限速 | 多应用任务中每个应用独立达到配置速度,任意扫描重叠都不超速 |
| DRQ-005 | 客户离线 | 进入等待连接,不记失败或跳过,恢复连接后继续 |
| DRQ-006 | ACK 闭环 | 写出只进入等待;有效 ACK 成功;超时、拒绝、无效 Msg_Id 失败 |
| DRQ-007 | 自动暂停 | 立即失败与 ACK 失败均计入阈值;达到阈值自动暂停并审计 |
| DRQ-008 | 并发幂等 | 多实例同时扫描,同一投递最多调用一次 Gateway |
| DRQ-009 | 重启恢复 | 在 `processing`、等待连接、等待 ACK 三种阶段重启 API,任务均可继续且不重复成功项 |
| DRQ-010 | 人工控制 | 暂停、继续、终止与执行并发时状态正确;终止不撤回已写出项 |
| DRQ-011 | 完整可查 | 任务列表和任务项均可分页查询全部历史数据,不限最近 10/50 条 |
| DRQ-012 | 操作审计 | 创建、暂停、继续、终止、自动暂停均有操作人/触发源和完整上下文 |
## 11. 当前实现符合性审计
当前实现的主要文件:
- `api/src/send-chain/send-downstream-requeue-task.service.ts`
- `api/src/send-chain/send-downstream-state.service.ts`
- `api/src/operations/admin-operations.controller.ts`
- `api/prisma/schema.prisma`
- `src/apps/admin/AdminDownstreamDeliveriesPage.tsx`
- `api/src/send-chain/send-downstream-requeue-task.service.spec.ts`
### 11.1 已符合或基本符合
| 设计项 | 结论 | 当前证据 |
| --- | --- | --- |
| 真实持久化 | 符合 | 已有任务表、任务项表和 migration,不使用前端本地状态代替任务 |
| 快照时间上限 | 基本符合 | 创建时按 `snapshotAt` 限制 `createdAt`,快照后记录不物化 |
| 后台状态白名单 | 符合 | `REPLAYABLE_STATUSES``pending/failed/unconfirmed/rejected` |
| 禁止后台任务处理已确认/等待 ACK 筛选 | 符合 | 创建接口明确拒绝 `delivered/awaiting_ack` |
| 原因校验 | 符合 | 少于 5 个字拒绝 |
| 任务项幂等 | 符合 | 数据库唯一约束 `taskId + deliveryId` |
| 执行前任务项认领 | 基本符合 | 通过 `status=queued` 的条件更新认领为 `processing` |
| 执行前复核 | 基本符合 | 重新查询投递、应用、payload 和其他任务状态 |
| 成功 ACK 不再发送 | 基本符合 | 已确认投递会跳过,其他任务 `success` 也会阻止调用 |
| 人工控制 | 基本符合 | 已有暂停、继续、终止接口和页面按钮 |
| 核心操作日志 | 基本符合 | 创建、暂停、继续、终止、自动暂停均写日志 |
| 投递记录分页 | 符合 | 页面支持每页 10/25/50 条并回到第一页 |
### 11.2 明确偏差与缺口
| 优先级 | 偏差 | 当前实现 | 与设计冲突及风险 |
| --- | --- | --- | --- |
| P0 | API 重启后 `processing` 项无法恢复 | 只扫描 `queued`,没有 `processing` 认领租约或超时回收 | 进程在认领后中断会使任务项永久卡住,任务永久 `running`,不满足重启续跑 |
| P0 | ACK 失败不参与自动暂停阈值 | `reconcileWaiting`把 ACK 超时/拒绝改为 `failed`,但不增加`consecutiveFailures`;进入 `waiting_ack` 时反而立即清零 | 客户持续拒绝或 ACK 超时不会触发安全暂停,可能持续向故障客户重投 |
| P0 | 限速不是按应用,且可能被并发扫描突破 | 每个任务每轮最多取 `min(10, ratePerSecond)`;没有按`applicationId`分组,也没有分布式速率令牌;定时器可能重叠执行 | 不符合“每应用10条/秒”;配置20实际单轮最多10,多扫描器/多实例又可能超过配置 |
| P0 | 客户离线被记为任务项失败 | Gateway未写出时,底层通常返回`pending/failed`,任务层将非`awaiting_ack/delivered`结果直接记为`failed` | 不符合“客户离线属于等待”;会错误增加失败数并可能自动暂停 |
| P1 | 预检不严格遵守当前状态筛选 | 计算`matchedCount/statusCounts`时强制把状态改成`all` | 用户选择“待投递”时,命中数和状态分布仍可能包含其他状态;预检范围表达失真 |
| P1 | 页面缺少企业筛选 | 后端类型支持`tenantId`,但页面只有应用筛选,`currentTaskFilter`不传企业 | 未完整实现已确认的“企业 + 应用”筛选范围 |
| P1 | 任务列表不可完整查询 | 页面固定请求第1页、每页10条,没有任务分页和状态筛选 | 第11个以后历史任务在页面不可达,不满足任务列表要求 |
| P1 | 任务详情不可完整查询 | 详情接口固定返回最近50个任务项,没有任务项分页接口 | 大任务无法核对全部失败、跳过和等待项,难以验收和审计 |
| P1 | 扫描器缺少任务级/应用级分布式锁 | `setInterval`直接调用扫描,`runScan`可被重叠触发,多实例也会同时处理同一任务 | 虽有任务项条件认领可降低单项重复,但无法保证整体限速和连续失败统计一致 |
| P1 | 连续失败清零时点错误 | Gateway写出进入`waiting_ack`即把连续失败清零 | 写出不是业务成功;应等有效 ACK 后再清零 |
| P1 | 预检与创建没有不可篡改绑定 | 创建接口信任前端回传的`filter + snapshotAt`,没有预检令牌或服务端预检记录 | 可绕过页面修改筛选范围;虽仍受状态白名单和10万条上限保护,但不等于复用原预检结果 |
| P1 | 自动化覆盖远低于设计风险 | 专项仅4个测试:预检计数、参数拒绝、活动任务冲突、已确认跳过 | 未覆盖限速、多应用、离线等待、ACK阈值、重启恢复、并发扫描、完整分页及所有审计动作 |
| P2 | 状态和详情表达偏内部化 | 任务列表/详情直接展示英文状态;任务详情只展示汇总和最近项 | 运营人员不易区分等待连接、等待外部ACK、任务写出等待ACK等状态 |
| P2 | 终止后的未处理数未进入常规汇总 | 终止把`queued`改为`unprocessed`,但任务汇总只保存成功/失败/跳过/等待 | 任务进度分子可能小于总数,页面没有单独解释未处理数量 |
| P2 | 已确认跳过原因存在口径混用 | `waiting_external_ack`最终由其他链路成功后记为“跳过:已由其他投递链路完成” | 可以接受为“本任务未调用Gateway”,但详情必须明确这是外部链路成功,不应让用户误以为业务未处理 |
### 11.3 综合结论
当前实现完成了数据库模型、基本预检、任务创建、任务项认领、Gateway调用、ACK结果回看和人工控制的骨架,但不能判定为严格按照 2026-08-12 方案完成。
尤其以下四项属于设计中的安全核心,当前存在实质缺口:
1. `processing` 任务项缺少重启恢复;
2. ACK 超时/拒绝未进入自动暂停阈值;
3. 每应用限速没有实现,且扫描重叠可能突破限速;
4. 客户离线没有作为等待状态处理。
在上述 P0 修复并通过真实 PostgreSQL、Gateway、客户 ACK 链路测试前,不应把该后台任务认定为完整满足事故批量恢复方案。
## 12. 建议整改顺序
1. 先补 `processing` 租约恢复、扫描分布式锁和每应用速率令牌;
2. 重构任务项等待/失败状态,让离线、外部 ACK、本任务 ACK 三类等待分开;
3. 将 ACK 超时、拒绝、无效 ACK 纳入按应用安全阈值,并把清零时点改为有效 ACK;
4. 修正预检状态口径,增加企业筛选,并用服务端令牌绑定预检与创建;
5. 增加任务列表和任务项分页、中文状态及完整原因查询;
6. 补齐 DRQ-001DRQ-012 自动化,并使用真实 PostgreSQL、Redis、Gateway 与本地客户连接完成专项验收。
整改应作为独立需求进行,不在未经授权的情况下直接修改或发布现有批量重投逻辑。
+153 -9
View File
@@ -371,7 +371,7 @@
6. 最终失败、超时失败需要退费。
7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。
8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。
9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留四位小数;内部使用 `0.0001 元`整数金额单位持久化,不以浮点数执行账务计算。
9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示,最多保留四位小数并移除末尾无意义的 `0`;内部使用 `0.0001 元`整数金额单位持久化,不以浮点数执行账务计算。通道成本费率作为费率字段固定展示四位小数。
10. API 必须定时扫描提交成功但超过 72 小时仍未收到明确最终回执的短信,转为 timeout 并退还已扣金额;扫描需覆盖 `submitted``unknown`,且用条件更新避免多实例重复退款。
## 5. 功能需求
@@ -559,7 +559,7 @@
- 在“数据详单”之后增加“报表对账”一级菜单,包含“对账单”和“利润报表”两个二级菜单;页面必须读取真实 NestJS API 与 PostgreSQL 报表表,不得在前端按明细临时拼接或使用静态数据。
- 对账单按发送日期、企业、企业应用汇总日发送条数和成功条数。发送条数、成功条数均按短信计费条数 `billingUnits` 统计,成功以最终 `delivered` 状态为准。
- 利润报表按发送日期汇总日发送条数、成功条数、消费金额、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。
- 利润报表按发送日期汇总日发送条数、成功条数、收入、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。收入必须逐条按“最终成功计费条数 × 该短信发送时的客户单价快照”计算后汇总,不能按当前应用单价倒算;退款状态不改变该成功收入口径。通道维度只将收入归属到短信最终提交所在通道,补发链路不得重复计算收入。利润报表页面、筛选结果汇总和 CSV 不展示返还数据。
- 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额按每次真实提交的通道成本单价快照乘以该次提交最终成功的短信分片数计算。补发只有产生成功分片时才增加对应通道成本,失败、未知或尚未收到成功回执的分片不计成本。
- 通道维度按实际上游 `accepted` 提交统计发送量,按分片回执统计成功量和成本;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价必须在提交记录创建时快照,后续修改通道单价不得改写历史成本;历史缺少分片审计但存在明确成功回执时,才按该次短信计费分片数兼容计算。
- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额使用 `0.0001 元`整数金额单位持久化并按四位小数展示。
@@ -570,6 +570,7 @@
- 平均到达时长只使用成功且时间有效的短信,按 `deliveredAt - submittedAt` 计算;每个日期、每个维度组先计算 P95,剔除大于 P95 的最慢 5% 样本后再求平均。无成功或无有效时间样本时展示为空,不以 0 冒充。
- `SmsMessageRecord` 必须固化实际使用的 `drainageInfoId`。新短信在同签名已审核通过的引流信息中按正文精确包含 URL 匹配,优先最长 URL;最长 URL 出现多个同长度候选时视为歧义并不关联。历史短信使用同一规则回填,未命中或歧义统一归入“未关联引流信息”,不得把一条短信复制到签名下所有引流信息造成重复统计。
- 发送质量报表与对账、利润报表共用 T+1 及 T-4 至 T-1 滚动重算任务,每次重算在同一日期事务内重建四个质量维度。
- 签名活跃度热力图每天均记录已报备维度的单日真实提交尝试、上游受理业务短信和最终成功业务短信;报备通过后尚未满足完整观察窗口时状态为“观察中”,仍生成热力图快照但不产生预警消息或 Webhook。热力图每行展示 T-1 至 T-30 的上游受理业务短信合计,并按合计从大到小排序;观察窗口只控制是否预警,不得隐藏真实发送数据。
- 对账单、利润报表、发送质量报表均提供导出功能。导出必须由真实 API 按页面当前筛选条件查询完整结果并生成 CSV,不得只导出当前分页或在浏览器内拼接静态数据。
- 报备字段库采用自适应卡片布局,分开展示统计概览、签名/引流信息通用字段和字段定义;卡片明确展示通道引用数及通用配置数,已被引用的字段不可删除。
- 运营端和客户端用户管理页的新增用户按钮使用标准小尺寸操作按钮,不得占用大块页面空间。
@@ -1558,11 +1559,12 @@
## 2026-07-16 全平台金额精度要求
1. 企业应用客户单价、通道成本单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数页面及导出文件统一展示 4 位小数
1. 企业应用客户单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数页面只读金额最多展示 4 位小数并移除末尾无意义的 0,小数部分与整数使用相同字号、颜色和字重;纯文本导出继续保留业务所需精度。通道成本费率作为费率字段固定展示 4 位小数,不执行末尾 0 裁剪
2. 数据库和计费链路继续使用整数运算,最小金额单位统一为 `0.0001 元`,即 `1 元 = 10000 金额单位`。历史字段名中的 `Cents` 为兼容既有 API 暂不改名,但其数值语义同步调整为金额单位,不再表示人民币“分”。
3. PostgreSQL 金额列统一升级为 `BIGINT`。上线迁移时既有按分保存的数据乘以 100,应用换算除数由 100 改为 10000,确保迁移前后实际人民币金额完全一致。
4. 企业应用单价修改必须写入真实 `SmsApplication.customerUnitPrice`,例如 `0.0325 元/条` 保存为 `325`;后续预估、冻结、扣费、返还和利润统计均使用该整数值,不得在前端或后端再次四舍五入到分。
5. API 返回 `BIGINT` 金额时仅在 JavaScript 安全整数范围内转换为 JSON number;超过安全整数范围必须显式报错,避免静默丢失金额精度。
6. 所有运营端和客户端的只读金额文本不得拆分整数和小数样式;运营看板“今日消费”和企业应用“单价”使用所在指标或表格的正常主数字字号与深色文字。
## 2026-07-16 企业应用接口参数复制与下游接入约束
@@ -1631,8 +1633,8 @@
## 2026-07-21 UI/UX A5删除治理补充
- 通道、签名和模板删除前必须由后端返回对象身份、活动依赖数量与对象摘要、影响范围、`allowedActions``blockedReasons`、状态版本和可恢复说明;前端不得自行推断或只显示通用风险文案。
- 删除提交必须包含预检版本8位以上幂等键和至少4字符原因。后端在Serializable事务中重新以`updatedAt`和未删除状态做条件更新,并写入包含原因、依赖、影响及幂等键的`OperationLog`,返回操作单号和重放标识。
- 客户端只能预检和删除当前会话企业的签名/模板,不得删除运营通道;通道活动通道组/路由/连接/未结束报备引用,签名被模板/引流/未结束报备引用,模板被未结束发送/批量任务引用时,后端必须阻断。
- 删除提交必须包含预检版本8位以上幂等键,删除原因选填。后端在Serializable事务中重新以`updatedAt`和未删除状态做条件更新,并写入包含原因、依赖、影响及幂等键的`OperationLog`,返回操作单号和重放标识。
- 客户端只能预检和删除当前会话企业的签名/模板,不得删除运营通道;通道活动通道组/路由/连接依赖以及签名级联安全规则继续按专项口径处理。单独删除模板不得因已创建发送批量任务阻断。
- 删除采用逻辑删除,历史发送、回执、计费、审核和审计数据继续保留;恢复需有审计依据。所有旧删除入口必须委托同一治理服务,禁止保留绕过路径。
## 2026-07-21 UI/UX A6人工充值治理补充
@@ -1642,7 +1644,7 @@
- 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。
- RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。
- 运营端充值记录必须提供可截图的账户充值回执。回执只能使用真实充值订单、企业和关联账务流水数据,展示系统真实Logo、入账状态、企业名称与编码、订单号、入账时间、前后余额、入账方式和备注;不得使用前端临时数据补齐缺失字段。
- 回执的“本次充值金额”按实际精度显示:整数金额不显示小数部分,存在小数时仅保留有效小数位;前后余额继续遵循平台统一的四位金额精度
- 回执的“本次充值金额”和前后余额均按平台统一金额规则显示:最多保留四位小数,并移除末尾无意义的 `0`
## 2026-07-22 UI/UX A7公共Dialog契约
@@ -1688,10 +1690,10 @@
## 2026-07-24 CMPP/HTTP 通讯交互日志要求
1. 运营端“系统日志”必须将人员操作审计与协议通讯日志分成两个独立页签。通讯日志至少支持协议、交互方向、事件类型、结果、关键字和时间范围过滤,并展示平台消息号、上游消息号或HTTP请求号、脱敏对象、结果码、耗时和安全详情。
1. 运营端“系统日志”必须将人员操作审计与协议通讯日志分成两个独立页签。通讯日志至少支持协议、交互方向、事件类型、结果、关键字和时间范围过滤,并展示平台消息号、上游消息号或HTTP请求号、完整手机号或账号、结果码、耗时和安全详情。
2. CMPP应覆盖客户登录/Submit、供应商SubmitResp、状态报告Deliver、上行Deliver及平台下游投递;HTTP应覆盖客户发送请求和平台回执/上行Webhook。数据库中一条记录必须对应一个真实业务报文,不得把同一报文的“入口收到”和“处理成功”拆成两条记录;处理结果、结果码和耗时写在该报文同一条记录中,失败、重试等后续真实交互另行记录。
3. Gateway收到状态报告或上行后,必须对解包/解码失败及转发NestJS失败输出结构化安全日志;NestJS入口把业务处理结果合并回同一报文记录,以便区分“上游未发”“Gateway未收到”“Gateway转发失败”和“API落库失败”。一条正常短短信的供应商侧完整成功闭环应依次展示四个真实报文:平台→通道 `CMPP_SUBMIT`、通道→平台 `CMPP_SUBMIT_RESP`、通道→平台 `CMPP_DELIVER`、平台→通道 `CMPP_DELIVER_RESP`;箭头只表达报文实际传输方向。
4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。
4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号按完整明文保存、展示并支持关键字查询,不做脱敏。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。
5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。
6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT``SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。
7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered`;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。内部业务终态只聚合一次,但对企业应用的CMPP状态报告必须按其原始Submit分片逐片投递,并分别使用平台当初为该分片返回的`CMPP_SUBMIT_RESP.Msg_Id`HTTP Webhook仍按原HTTP消息投递一个最终事件。
@@ -1890,7 +1892,7 @@
- 本期只识别、记录、查询和统计短信内容是否含引流信息。引流资料是否已报备、审核状态及报备进度均不得拦截或转人工审核;所有发送入口移除`DRAINAGE_NOT_APPROVED`决策,既有模板、签名、余额、黑名单和其他风控规则保持不变。
- 运营端“系统管理”新增“引流识别规则”页面,规则存储在真实数据库,支持 URL、手机号码、固定电话三类表达式的新增、编辑、启停、优先级和测试。变更保留版本并写操作日志,发送入口按当前启用规则生成识别快照和规则版本。
- URL 识别覆盖带协议链接、无`http://`的裸域名、短链接、IP 地址及端口/路径,并支持中文标点相邻、空格或中文句号拆分等规避写法;手机号码支持`+86`、空格、短横线和中文标点拆分;固定电话支持区号括号、分隔符和分机号。邮箱地址不属于引流信息。
- URL 识别覆盖带协议链接、无`http://`的裸域名、短链接、IP 地址及端口/路径,并支持中文标点相邻和中文句号替代域名点号;URL遇到空格、制表符、换行或其他空白字符时必须立即结束,空白后的字符不得拼接进前一个链接,空白拆分的域名也不得恢复成一个URL。手机号码支持`+86`、空格、短横线和中文标点拆分;固定电话支持区号括号、分隔符和分机号。邮箱地址不属于引流信息。
- 识别规范化只作用于检测副本,不得修改真实短信发送内容。消息记录持久化是否含引流、命中类型、原文位置、规则版本和检测时间;历史未检测数据保留为“未检测”,不得伪造为不含引流。
- 运营端短信记录提供“是否含引流信息”筛选,支持含引流、不含引流和未检测;含引流记录使用提示色底色并高亮原文命中片段,CSV 同步导出该维度。
- 数据统计的签名发送质量明细保留原“通道 × 运营商”整体矩阵,并提供按含引流、不含引流、未检测切分的矩阵视图;整体统计必须直接反映全部提交,不得用分组平均值替代。
@@ -1915,3 +1917,145 @@
- 已按整条级成功形成`delivered`终态后,如果同一提交尝试又收到明确失败回执,平台保留原始回执和分片审计,但不得自动把已送达终态改成失败、重复退款或向客户推送互相矛盾的失败结果;系统按稳定异常键写入`SmsReceiptAnomaly`,重复矛盾回执累加发生次数。
- 运营菜单“Gateway提交异常”更名为“网关异常”,原路由保持兼容。页面使用“提交异常”和“回执异常”两个Tab,均查询真实后端和PostgreSQL;每个Tab必须在标题区说明其数据来源、业务含义、不能代表的结论及人工处理注意事项,避免运营人员间隔较久后误判。
- “提交异常”展示Gateway消费提交命令连续失败且没有明确供应商提交结果的死信,可在严格确认未被供应商接收后重新入队;“回执异常”展示供应商回执与平台既有终态冲突的结构化异常判定。回执异常详情必须指明真实回执保存在回执记录、原始CMPP报文在通讯交互日志,不得用异常摘要替代原始证据。
- “提交异常”的待处理记录允许运营人员标记为“已处理”。操作只将异常状态原子更新为`resolved`并记录处理人、处理时间和操作日志,不删除异常证据、不重新入队、也不发送短信;已进入重新入队流程的记录不得并发标记。
## 运营端休眠唤醒与会话锁定恢复(2026-08-09)
- 同一单页应用内完成重新登录时,前端必须把本次成功登录视为新的用户活动起点,不得沿用上一会话或电脑休眠前的内存活动时间,避免新会话登录后被立即误锁。
- 服务端返回`401/SESSION_LOCKED`或前端空闲计时触发锁定后,运营端必须同步暂停当前业务路由和全局待审核角标轮询;锁定期间不得继续请求短信记录、筛选项或`pending-audits`等受保护接口。
- 全局角标轮询的启停必须跟随当前实时锁定状态,不得只读取布局首次挂载时的`session.locked`快照。浏览器重新获得焦点时,仅在会话处于解锁状态后才允许刷新待审核数量。
- 密码解锁成功后恢复原路由并重新挂载页面,由真实后端重新读取短信记录和筛选项;不得用锁定前缓存、静态数据或localStorage伪造恢复后的业务数据。
- 锁定、解锁和跨标签会话事件必须同时更新锁屏界面、业务路由暂停状态和全局轮询状态;重复事件应保持幂等,不得形成额外登录、短信发送或其他业务副作用。
## 签名删除预检与多通道报备汇总修正(2026-08-09)
- 签名删除预检中的“未结束报备任务”只统计仍需处理的过程态任务;`approved``completed``failed``cancelled``rejected``abandoned``partial``partial_success`均属于已结束历史,不得作为活动关联项。
- 签名及运营商报备汇总不得因单个目标通道失败就直接变为整体“报备失败”。全部当前目标通道通过时为“报备成功”;至少一个通过但尚未全部通过时为“部分成功”;没有通过且仍有其他目标待处理时为“报备中”;只有全部当前目标通道均为`failed/rejected`时才为整体“报备失败”。
- 每个通道的失败事实、失败原因和历史报备记录必须继续保留并展示;汇总状态修正只改变整体归因,不得覆盖或删除通道级失败证据。
## 通道组删除风险展示与历史保留(2026-08-09)
- 运营端删除通道组前,必须通过真实后端和数据库统计并展示:关联正常企业应用数、组内通道数、等待供应商提交结果数。企业应用按不同`applicationId`去重;状态为`deleted`或应用记录已不存在的残留关联可继续计入后台审计快照,但不得在删除弹窗展示。
- “等待供应商提交结果”固定为该通道组下`SmsSubmitRecord.submitStatus = queued`的记录数,表示平台已选定该组但尚未收到供应商提交结果;该状态不按三个工作日自动完成,不能与最终回执超时口径混用。
- 正常应用关联、组内通道和等待提交记录均只作风险展示,不得禁用或阻止“确认删除”;弹窗不要求输入通道组名称,不要求填写删除原因,由运营查看真实影响后确认。
- 删除采用逻辑删除,将通道组状态置为`deleted`并从通道组列表及新短信选路中排除;不得删除组内通道配置、企业应用关联、发送记录、回执或审计数据,确保历史查询、回执处理及上行接入号匹配仍可追溯。
- 弹窗标题为“删除通道组:{通道组名称}”,正文依次展示上述三项真实数量,并明确:“删除后该通道组不再参与新短信发送,历史配置、发送、回执和审计数据继续保留。”操作仅保留“取消”和“确认删除”。
## 通道、签名与模板级联删除确认(2026-08-09)
- 通道、签名和模板删除原因统一为选填;未填写时仍允许删除,后端必须继续记录操作人、对象版本、幂等键、真实依赖快照和级联结果。删除仍采用逻辑删除,不物理清除历史发送、计费、审核、报备或审计数据。
- 删除签名时,若存在未删除短信模板、未删除引流信息或未结束报备任务,弹窗必须分别提供“同时删除关联的模板”“同时删除引流信息”“同时结束关联的报备任务”勾选项。发现的勾选项必须全部勾选后才允许确认;后端必须再次校验并在同一个`Serializable`事务中将关联模板和引流信息逻辑删除、将未结束报备任务置为`abandoned`,最后逻辑删除签名。
- 删除通道时,若存在未结束报备任务,必须提供“同时结束关联的报备任务”勾选项;勾选后在同一事务中将任务置为`abandoned`并逻辑删除通道。已有活动通道组引用、直接路由规则或活动网关连接仍属于不能由该勾选项解决的硬依赖,必须先处理后再删除。
- 运营端可以看到未结束报备任务的真实ID和状态;客户端也允许勾选“同时结束关联的报备任务”,但客户端专用预检响应和页面不得展示任务ID、状态、通道或其他内部详情,只展示统一说明:“发现关联的未结束报备任务。勾选后将全部置为‘放弃报备’,历史任务和报备记录继续保留。”
- 每一条被放弃的报备任务必须写`ChannelSignatureReportRecord`,保留变更前状态、`abandoned`变更后状态、操作人、原因和`deletion_governance`来源;级联删除的模板和引流信息必须留下子对象审计记录。删除通道并结束任务后,受影响的未删除签名必须在同一事务内按剩余有效通道重算报备汇总。
- 删除签名时,关联模板若仍存在真实未结束发送或批量任务,不得仅靠“同时删除关联的模板”绕过签名和报备安全约束;该签名级联阻断与单独删除模板的规则分开。
- 单独删除模板只阻止之后新建发送任务,不阻断也不改写已创建的`SmsSendTask``SmsBatchTask``SmsMessageRecord`。模板保持逻辑删除,历史`templateId`关联、短信内容、分类、计费与审核快照继续保留。
- 已接受的定时任务到点时必须使用创建时持久化的内容快照继续处理,不得因模板后续逻辑删除而失败;但仍必须重新校验企业、应用及签名当前可用性,防止绕过停用和签名安全控制。
## 发送质量矩阵与成功率色阶统一(2026-08-09)
- 数据统计“签名通道发送质量”的明细抽屉中,“按引流切分”固定按每个通道三行展示,顺序为“含引流、 不含引流、未检测”;运营商固定为三列,顺序为“移动、联通、电信”。即使某个组合没有真实提交,也必须保留该行列并明确显示`0`,不得省略、错位或用空白代替。
- 上述固定行列只改变真实统计结果的展示,不改变后端签名、通道、运营商、引流状态、提交量、送达结果和到达时间口径;整体统计页签继续展示全部真实提交。
- 签名发送质量列表、明细抽屉、短信通道管理列表和通道报备详情中的成功率数字统一使用六档颜色:`0`为红色,`>0且<=25`为橙色,`>25且<=50`为黄色,`>50且<=75`为蓝色,`>75且<96`为绿色,`>=96`为深绿色。小数成功率必须按该连续边界归档。
- 短信通道管理列表及通道报备详情中的提交失败、回执未知和送达失败比例与数量统一使用黑灰色,不因数值高低显示为红、橙或其他告警色;该展示规则不改变真实失败状态及统计值。
## 报表筛选结果全量汇总(2026-08-09)
- 对账单、利润报表和发送质量报表在每次搜索后都必须展示当前筛选条件匹配的全部结果汇总,不得只对当前分页明细在前端求和。汇总、总数、分页和CSV导出必须复用同一套日期、企业、应用、通道及统计维度筛选口径。
- 三类报表均汇总提交、发送、未知、成功和失败条数;利润报表另汇总收入、成本和利润金额,不展示返还明细或返还合计。综合成功率必须按合计成功量/合计发送量重算,综合利润率必须按合计利润/合计收入重算,不得对每行百分比求和或简单平均;分母为0时显示0%。
- 平均到达时长不属于可加总数据,本汇总区不对各日、各维度均值再求和;明细表仍保留每组的真实P95截尾平均到达时长。
## 新建企业省份与地市字典(2026-08-09)
- 运营端新建和编辑企业的省份、地市必须使用真实后端字典及级联关系,不得在页面写死少量省市选项。本版字典从PostgreSQL `PhoneSegment.province/city`中查询去重后的真实归属关系,与平台手机号段库保持一致。
- 新增`GET /api/admin/dictionaries/administrative-regions`返回省份及其地市数组;前端选中省份后只展示该省真实地市,切换省份必须清空原地市。字典请求失败时必须明确报错,不得回退到Mock或静态列表。
- 编辑历史企业时,若原省市值与当前号段字典格式不同或暂无对应项,页面仍必须保留并显示原值,不得因加载字典而静默清空已存档案。
## 运营端菜单与查询控件细节修正(2026-08-09)
- “风控规则”菜单归入“安全控制”业务域,并同步更新页面面包屑;路由、真实规则接口和数据库数据不变。
- 发送监控页面的通道运营商必须显示中文名称,至少统一映射移动、联通、电信、三网和未识别;无法识别的新增值保留后端原值,避免隐藏真实数据。
- “待生成报备批次”的两个页签标题固定为“待生成资料”和“已生成批次”,不在标题后展示括号及总数;真实后端分页总数仍用于分页,不改变待生成池和批次查询。
- 短信记录的通道筛选使用平台通用可搜索下拉控件,选项来自真实通道接口,显示通道名称及已有通道编码,并向短信记录及导出接口传递精确`channelId`;不得使用静态列表、Mock或浏览器本地数据替代。
## CMPP客户连接请求诊断日志(2026-08-09)
- 每一次客户向平台发起的真实CMPP CONNECT尝试,无论账号是否存在、认证是否成功、IP白名单是否命中或应用是否启用,都必须同步写入`OperationLog`,动作使用`cmpp_connection.connect_requested`,并将TCP真实远端IP写入`ipAddress`;未知或恶意账号也必须以请求账号作为资源标识保留,不得因无法关联企业而丢弃。
- 连接请求详情保存客户实际发送或由Gateway从报文解析的诊断参数,包括远端IP、`Source_Addr`账号、`AuthenticatorSource`、时间戳、协议版本及原始版本值,并保存认证结果、应用ID和失败原因。标准CMPP CONNECT不传输明文密码,页面必须明确说明这一事实,不得把平台配置的密码或密钥伪造成客户请求密码;仅兼容调用真实携带`password`字段时原样保存和展示该字段。
- 上述连接请求日志必须在认证响应返回前持久化,日志写入失败时不得把未经审计的连接当作认证成功。系统与操作日志列表继续直接显示`ipAddress`,并为`cmpp_connection.connect_requested`提供“查看详情”按钮,展示上述结构化参数。
- 供应商通道的既有连接操作日志仍保留;客户入站连接使用`cmpp_downstream_connection`资源区分方向。本功能不改变CMPP认证算法、IP白名单、最大连接数或客户连接状态。
## 通道运营商多选与运营商级签名报备(2026-08-10,本地实现完成、待验收与分阶段发布)
- 2026-08-09暂缓需求已重新纳入“签名清退预警”的前置设计并发布到预生产:`SmsChannel.carriers`保存运营商能力集合,通道管理、通道组校验、发送选路和签名报备均兼容运营商维度;最终删除历史人工确认入口并由第85条migration自动转换存量任务。严格运营商级发送门禁仍保持默认关闭,完整实施状态和后续门禁见`docs/signature-retirement-alert-design.md`
- 目标交互为取消“移动、联通、电信、三网”四选一,将通道能力改为“移动、联通、电信”三个复选项,至少选择一个;同时勾选三个运营商等价于现行“三网”,允许只勾选其中两个运营商。
- 该变化只作用于通道本体的运营商能力集合。`SmsChannelGroup.carrier``SmsChannelGroupItem.carrier``ChannelRouteRule.carrier`及短信号码实际运营商仍保持移动/联通/电信单值;一个通道只有在能力集合包含对应运营商时,才允许加入该运营商通道组并参与选路。
- 同一通道勾选多个运营商时继续共用一个通道单价,不增加分运营商单价;如未来出现分运营商计价需求,必须另立需求并升级为通道运营商明细模型,不能在本需求中隐式扩展。
- 签名报备模型同步从“签名 × 通道”升级为“签名 × 通道 × 运营商”。继续以`ChannelSignatureReportTask`保存当前事实、以`ChannelSignatureReportRecord`保存状态轨迹,不另建重复事实表;任务进入`approved`时记录当前连续通过时间,离开通过状态时结束该连续周期。`reportType``drainageItemId`只是共享表技术字段,不属于本需求维度;本需求不改造引流信息报备。
- 不再提供“历史待确认”页签、人工拆分弹窗或对应管理API。一次性migration按通道能力集合自动转换全部旧签名任务:旧状态为`approved`时,通道支持的全部运营商均记为已通过且通过时间取migration执行当天;旧状态为其他值时分别继承该状态且通过时间为空。已有运营商级任务不覆盖,旧任务完成后改为`legacy_split`并保留历史。企业签名页面以后新建“已通过”运营商任务时继续以保存时刻作为通过时间。
- 生产数据迁移原则为:`mobile→[mobile]``unicom→[unicom]``telecom→[telecom]``all→[mobile,unicom,telecom]`,已删除通道也要保留并迁移历史能力;不得根据当前通道组关联、通道名称或近期流量自动缩减旧`all`通道的能力范围。对历史空值使用旧系统实际兼容口径回填为移动,禁止回填为空集合或伪造为三网。
- 取消某个已勾选运营商时,如果该通道仍被对应运营商的活动通道组引用,后端必须返回真实影响并阻止保存,不得自动删除通道组成员、路由、报备任务或历史发送记录;新增运营商能力也不得自动加入通道组或自动视为报备通过。
- 发布迁移必须采用向前兼容的分阶段顺序:先增加新能力集合、回填并让后端兼容读取,再开放多选写入。出现两个运营商组合后,旧单值代码无法无损解释该数据,回滚下限必须是已经支持新集合的兼容版本,不能直接回滚到仅识别`mobile/unicom/telecom/all`的旧版本。
## 签名清退预警(2026-08-10,已发布到预生产)
- 企业预警按“企业签名 × 运营商”每天检测,通道预警按“签名 × 通道 × 运营商”每天检测。运营商只要存在当前报备通过任务就进入对应监控名单,不等待三网全部成功;历史通道级任务由一次性migration按最终口径自动转换,不再存在人工确认待办。
- 企业签名“报备状态”弹窗按移动、联通、电信三列分区展示真实目标通道;每个分区独立滚动、每个通道独立选择状态,三个分区共用修改原因和一次保存操作。运营商使用全局低饱和胶囊标签。
- 充值回执压缩金额区和垂直留白;常规桌面视口应在不滚动时看到完整回执,异常长备注允许内容区滚动而不得截断真实内容。
- 短信通道管理按北京时间今日真实提交尝试数降序后再分页;同量按通道名称、ID稳定排序。今日提交为0时,提交失败、送达成功、回执未知、送达失败四个比率统一显示深灰色短杠。运营商低饱和胶囊放在通道信息列底部,原“运营商 / 成本”改为“成本费率”并固定展示4位小数。
- 企业预警支持移动、联通、电信通用X天/Y条规则和企业应用特殊规则,特殊规则优先;通道预警支持通用规则和通道特殊规则,特殊规则优先。规则修改从下一检测日生效,预警快照保存命中的规则版本和阈值。
- 清退活跃量按至少有一次上游接受的业务短信去重统计。企业维度同一业务短信只计一次;通道维度按`messageRecordId + channelId`去重,同一通道断连、超时或重试产生多次提交只计一次,切换到不同通道后各通道分别计一次。提交尝试、上游接受和最终送达必须分开展示,不把`SubmitResp status=0`称为最终送达成功。
- 每天按北京时间完整自然日检测`T-X``T-1`。当前连续报备通过时间不足X个完整日时不预警;恢复达标后关闭当前预警周期,以后再次低于阈值形成新周期。每日检测必须以数据库唯一维度保证幂等,多实例或重启不得重复生成消息或Webhook。
- 自动任务每天北京时间04:00生成检测快照并冻结当日规则版本、标题和消息正文,北京时间08:00才生成站内消息并进入Webhook投递。服务在04:00或08:00之后启动时必须按当前时点补偿对应阶段,仍由数据库唯一键保证幂等;运营页面不提供“执行今日检测”按钮,管理API也不暴露手动检测入口,避免人为提前发消息或混淆自动任务口径。
- 临时抑制支持常用天数和自定义天数;永久抑制可在“抑制管理”中取消,取消必须二次确认、填写原因并写操作日志。抑制只停止站内提醒和Webhook,不停止每日检测快照;取消后从下一检测日恢复,不补发历史通知。
- 右上角新增预警铃铛,数字只统计今日未读且未抑制消息;原待审核铃铛更换为任务图标,但原有计数、弹层和跳转不得丢失。安全控制新增“签名清退预警”,展示规则、今日企业/通道预警数量、分页消息列表、日期范围和抑制管理。
- “今日预警”页签和区块统一更名为“预警消息”。消息列表包含历史消息,默认日期区间的开始、结束均为北京时间今日并只查询今日;支持修改日期区间,并分别按企业、企业应用、签名名称和通道查询。所有条件由真实后端共同作用于分页结果和总数,默认及筛选变化后回到第1页,每页10条,不得前端全量截取伪分页。
- 预警消息“抑制”必须使用平台自研弹窗,在同一弹窗中选择临时或永久抑制:临时抑制直接选择截止日期,永久抑制不显示日期;两种方式都必须填写原因。抑制管理的“取消抑制”也必须使用自研确认弹窗并填写取消原因,禁止调用浏览器`confirm``prompt`
- 企业微信和飞书可配置多个Webhook。地址必须加密保存、脱敏显示并经过安全目标校验;投递使用异步队列、幂等键、有限重试和投递日志,企业预警按企业汇总,通道预警按检测批次汇总,不逐条轰炸。
- “签名质量检测”增加企业“企业签名 × 运营商”和通道“签名 × 通道 × 运营商”近30日方格。页面日期为T,展示`T-1``T-30`的提交尝试数、上游接受业务短信数、最终成功数和成功率;尚未报备或早于当前连续通过时间显示“不适用”,无真实提交显示灰色,其余复用现有六档色阶。
- 页面模块顺序固定为“签名通道发送质量”在最上方,其后依次为企业、通道热力图。两张热力图按维度行各自独立分页,每页10行;翻动其中一张不得改变另一张页码,30日日期列继续在各自表格内横向滚动。
- 两张热力图的日期列从左到右按日期由大到小展示,即从`T-1`依次到`T-30`。行首主信息只展示签名名称;通道热力图保留识别维度所必需的通道名称和运营商标签,企业名称与企业应用名称不在行内常驻,鼠标悬停签名时再展示。每张热力图内部提供独立搜索框,可按企业名称、企业应用名称或签名名称筛选,并在筛选后回到第一页,不影响另一张热力图。
- 热力图有真实检测快照的发送量格子悬停文案必须明确区分“提交条数”和“发送成功条数”,同时可补充上游接受条数、成功率和阈值;不得把上游接受或`SubmitResp status=0`写成发送成功。报备前仍显示“不适用”,没有检测快照仍显示当日无快照。
- 页面最下方增加“未报备签名”模块,按所选北京时间自然日统计真实`SmsMessageRecord`中“短信正文以规范`【签名】`开头,但该企业应用的有效签名库中没有同名记录”的业务短信。判定不再依赖通道或运营商报备任务:已有系统签名、仅缺少通道/运营商报备成功事实的短信不进入本模块;无法从正文开头提取规范签名的异常消息也不得伪造成签名。结果按“正文签名 × 实际企业应用”聚合号码级业务短信条数,展示签名、企业、企业应用,支持按三者搜索和每页10行独立分页。
- 移动、联通、电信统一复用全局低饱和胶囊标签:移动使用背景`#E8F1F7`、文字`#2F6F91`、边框`#C9DDE9`;联通使用背景`#F6EAEA`、文字`#875758`、边框`#E8CECE`;电信使用背景`#F0ECF7`、文字`#73538F`、边框`#DDD1EA`。业务状态标签不得套用运营商色值。
- 数据统计菜单改名为“签名质量检测”,并删除企业应用发送排行、通道占比、当天发送量和当天成功率模块。本项删除已确认,不作为后续可选项保留。
## 通道组按通道筛选(2026-08-09)
- 运营端“短信通道组管理”在通道组名称条件之外增加“通道”筛选。选定一个通道后,只展示成员配置中真实包含该`channelId`的未删除通道组;与通道组名称同时输入时按两个条件取交集。
- 通道选项必须来自真实通道API,不使用静态列表、Mock或localStorage。页面首次加载时通道组与通道两个独立请求并行执行;选项同时展示通道名称和编码,已删除通道明确标记“已删除”。未加入任何通道组的真实通道仍可选择,选中后结果为零而不得隐藏该选项。
- 通道选择控件必须为通用下拉可搜索控件,支持按通道名称或编码搜索;“全部通道”表示不按通道限制,点击“重置”必须同时清空通道组名称和通道条件并回到第一页。
# 下游投递后台重投任务(2026-08-12)
## 完整设计口径与安全整改(2026-08-13)
1. 后台任务按企业、应用、投递类型、状态、北京时间创建日期和关键词冻结筛选快照,分页、每页条数和当前页勾选不属于范围。预检的命中数、可重投数、规则跳过数、状态分布、涉及应用和最早记录必须严格基于当前筛选条件,不得把单一状态擅自扩为全部状态。
2. 预检返回短期有效且绑定当前操作人、筛选条件和 `snapshotAt` 的服务端签名凭证;创建接口只接受该凭证、原因、速度和安全阈值,不再信任前端重传的范围。创建时后端按签名快照重新物化真实 PostgreSQL 任务项。
3. 第一版后台任务仅处理 `pending/failed/unconfirmed/rejected``delivered/awaiting_ack` 不得进入批量任务。跳过严格表示本任务没有调用 Gateway;客户离线进入 `waiting_connection`,其他链路等待 ACK 进入 `waiting_external_ack`,本任务写出后进入 `waiting_ack`,三类等待均不计失败或跳过。
4. 限速以企业应用为维度,使用数据库原子秒级窗口在多实例、扫描重叠和执行耗时变化下保持每应用不超过配置速度。任务扫描使用数据库租约;`processing` 项使用认领租约,API 中断后过期恢复为 `queued` 并重新复核,已成功 ACK 的项目不得重放。
5. 连续失败按应用隔离统计。Gateway 立即失败、ACK 超时、ACK 拒绝和无法安全关联均计入阈值;只有有效 ACK 成功才清零。达到阈值前原子暂停整个任务,记录触发应用、失败数、阈值和暂停时间,人工继续后从未完成项恢复。
6. 任务列表支持状态筛选和真实分页,展示任务号、创建时间、企业/应用、原因、中文状态、总数、成功、失败、跳过、等待、创建人及进度。任务详情展示筛选快照、时间、安全参数、完整结果汇总和任务项分页;任务项可按中文结果、消息 ID、错误或跳过原因查询,不得仅返回最近 50 项。
7. 暂停、继续和终止与执行器并发时,认领及调用 Gateway 前必须复核任务状态;终止只把尚未开始及等待连接的项目置为 `unprocessed`,不得撤回已写出内容。创建、暂停、继续、终止和自动暂停均写操作日志。
8. 数据继续使用真实 PostgreSQL、Gateway、客户连接和 ACK;不得使用 mock、静态数据或 localStorage 代替任务状态。任务和任务项保留历史,下游投递及 ACK 证据不得级联删除。
1. 运营端“下游投递记录”必须同时保留单条重投、当前页勾选批量重投,并新增“按筛选条件重投”;分页支持每页 `10/25/50` 条,切换后回到第一页并重新查询真实后端。
2. 后台任务使用当前企业、应用、投递类型、状态、创建日期和关键词的后端筛选快照,分页不属于任务范围;任务创建时固定 `snapshotAt`,之后产生的记录不得被卷入。
3. 第一版只允许 `pending/failed/unconfirmed/rejected`,不支持批量重投客户端已确认的 `delivered``awaiting_ack` 不得并发重投。创建前必须真实预检命中、可重投、跳过和状态分布,原因必填。
4. 任务按应用分批执行,默认每秒 10 条;单条失败不阻断整批,连续失败达到 10 条或 ACK 超时/拒绝达到安全阈值时自动暂停。客户离线、等待 ACK 属于等待状态,不得误记为跳过。
5. 跳过只表示未调用 Gateway,第一版原因包括:状态已变化、已被客户确认、已被其他任务处理、本任务已成功处理、不属于任务快照、应用或投递能力已停用、投递数据不完整、缺少原 Submit 映射。
6. 任务必须支持列表、详情、暂停、继续和终止;终止只影响尚未发送的记录。任务项以 `taskId + deliveryId` 幂等,执行前原子认领并复核状态,API 重启后可继续,成功 ACK 的项目不得再次发送。
7. 所有创建、暂停、继续、终止和自动暂停均写操作日志;任务使用真实 PostgreSQL、Gateway 和客户 ACK,不得使用 mock、静态数据或 localStorage。
# 2026-08-13 HTTP 请求与 Gateway API 响应容量边界
1. Go Gateway 调用 NestJS API 时必须完整读取最多 `4 MiB` 的响应体;超过边界必须返回包含实际容量边界的明确传输错误,禁止静默截断后以 `unexpected end of JSON input` 等语法错误掩盖容量问题。该边界覆盖待投递回执恢复批次等内部响应,不改变单次恢复查询的业务分页口径。
2. NestJS 普通 JSON 与 URL-encoded 请求体统一限制为 `2 MiB`。客户号码文件导入预览和确认接口因现阶段仍在 JSON 中携带 CSV/TSV 正文,单独限制为 `25 MiB`;业务层继续限制原始导入正文不超过 `20 MiB`,为 JSON 字段、转义字符和其他参数保留协议开销。
3. 大容量解析器只能挂载到 `/api/client/send/imports/*`,不得把所有管理、客户和公网 HTTP API 全局放宽到 25 MiB。`rawBody` 必须在两类解析器中继续保留,确保公网 HTTP API 的验签与幂等正文哈希语义不变。
4. 客户导入接口经 `sms.lisglo.com` 私有 API 访问,该入口的 Nginx 请求体上限必须不低于 `30 MiB`,当前标准配置 `50 MiB` 满足要求。`api.lisglo.com` 只开放单条客户 HTTP API、Swagger 和健康检查,不承载文件导入,不得为导入需求扩大其暴露路由。
5. 容量边界必须由自动化测试覆盖:大于旧 `64 KiB` 且小于 `4 MiB` 的合法 Gateway 响应完整解析;超过 `4 MiB` 明确拒绝;同一份大于 `2 MiB` 的 JSON 仅在导入路由可被解析,普通路由返回 HTTP 413。
# 2026-08-13 运营端信息密度与运营商标签统一
1. 运营端充值记录回执必须展示该笔人工充值的操作人员姓名。姓名由充值单已保存的`operatorId`关联真实用户记录取得,优先展示姓名、姓名缺失时回退用户名;历史无操作人的系统记录展示“系统”,不得用前端静态映射伪造。
2. 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个明细模块;“今日活跃签名”指标仍使用当天真实发送聚合。今日消费金额的主数字必须与今日发送总量使用相同字号、字重和深色层级。
3. 企业应用管理列表的状态、到达率、单价列在现有基础上缩窄约20%,提升大屏一次展示完整表格的概率;不得通过隐藏真实字段实现。
4. 移动、联通、电信数据展示统一使用全局`CarrierTag`低饱和标签,包括通道与通道组、监控、报备、签名、短信审核、任务号码、发送记录及客户端发送详情等页面。筛选/表单控件的选项文案、图表图例、导出文本和业务说明仍使用纯文本,避免破坏交互、可访问性和机器可读输出;可取得运营商集合的三网通道按三个运营商标签展示,只有历史通道级字段时使用中性的“三网”标签。
+4
View File
@@ -70,6 +70,8 @@ PROD_ADMIN_PASSWORD='change-me'
`API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。
HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入走`sms.lisglo.com`私有API,因此该虚拟主机的`client_max_body_size`必须不低于`30m`,标准bootstrap配置为`50m``api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载客户文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽到25MiB。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。
Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:<channelId>` 权威上限,实际预约使用 `rate:gateway:channel:<channelId>`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。
服务重启顺序必须是 Gateway 在前、API 在后。API 启动后等待 `GATEWAY_STARTUP_RECONNECT_DELAY_MS`(默认 1 秒),从 PostgreSQL 读取全部 active 通道并重新下发真实连接命令,同时恢复 Gateway 内存连接池和 Redis 权威 TPS key;禁止沿用数据库中重启前的 connected 状态冒充当前连接。
@@ -87,6 +89,8 @@ git reset --hard origin/main
bash tools/deploy/production-deploy.sh
```
部署脚本重启 Gateway、API 和 Nginx 后,会分别对 Gateway、API 健康接口执行最多 60 秒的逐秒就绪检查。Nest 初始化、活动通道恢复或生产数据量增加可能使 API 启动超过固定数秒;发布流程不得用单次固定延时把正常慢启动误判为失败。超过 60 秒仍不健康时才终止发布,并结合 systemd journal 和发布前数据库、源码、环境备份判断回滚方式。
## 账号和密钥
- 生产管理员账号写入 `/root/cmpp-platform-admin.txt`
+185
View File
@@ -0,0 +1,185 @@
# 签名清退预警与运营商级报备改造设计(本地实现稿)
> 状态:2026-08-10已按最终确认口径由提交`2ecb24cf8d09dd428dfab0c682b33581959618ea`发布到预生产,第85条自动转换migration已完成。严格运营商级发送门禁保持默认关闭;兼容命中清零核验、门禁切换和预警运营启用仍必须分阶段执行。
## 0. 实施状态(2026-08-10
- 原签名清退预警功能已经过本地PostgreSQL迁移、专项与全量测试、真实聚合SQL、TypeScript、生产构建及授权后的真实验证码登录浏览器验收;本次最终口径已删除历史人工确认功能,并将自动转换migration发布到预生产。
- 旧报备任务通过后续一次性迁移自动转为运营商级任务:按通道能力集合分别建任务,旧状态为`approved`时对应运营商全部记为已通过且`approvedAt`取迁移执行时间;其他状态原样复制且不写通过时间。已有运营商级任务优先保留、不覆盖;旧任务退出兼容范围并保留历史记录。
- 本地实现未发送真实短信,未修改生产通道、账号、密码、启停状态、企业余额或客户连接;也未提交、推送或部署。
## 1. 目标与范围
本项目解决运营商因签名长期无真实发送而清退的问题,并将通道能力和签名报备状态细化到真实运营商。最终形成以下闭环:
1. 通道支持移动、联通、电信多选,三个全选等价于现行“三网”。
2. 签名报备事实由“签名 × 通道”升级为“签名 × 通道 × 运营商”。
3. 发送选路只使用支持目标运营商且该签名在该运营商报备通过的通道。
4. 每日按“企业签名 × 运营商”和“签名 × 通道 × 运营商”检测清退风险。
5. 提供站内消息、临时/永久抑制、企业微信/飞书 Webhook 和近30日检测数据。
本项目不改造引流信息报备。引流信息任务、任务详情、状态汇总和历史记录继续保持现有“签名 × 引流信息 × 通道”口径,不新增运营商维度。
## 2. 已确认业务口径
- 多运营商通道继续共用一个通道单价,不增加分运营商价格。
- 一个签名最多关联一个企业应用;未绑定应用的签名只使用通用规则。
- “部分成功”按运营商分别判断:任一运营商存在报备通过,就只将该运营商纳入监控,不等待三网全部成功。
- 清退活跃量使用“至少有一次上游接受的去重业务短信数”。企业维度按业务短信去重;通道维度按`messageRecordId + channelId`去重。同一通道因断连、超时或重试产生多次提交只计一个活跃量;切换到另一个通道后,两个通道各计一次。
- 提交尝试数、上游接受业务短信数和最终送达成功数分别展示,不把`SubmitResp status=0`描述为最终送达成功。
- 每天按北京时间完整自然日检测`T-X``T-1`,当天数据不参与。当前连续报备通过时间未满X个完整日时不预警。
- 规则修改从下一检测日生效;恢复正常后再次低于阈值形成新的预警周期。
- 临时抑制天数可配置;永久抑制可以在“抑制管理”中取消。取消后从下一检测日恢复提醒,不补发历史通知。
- 右上角预警数字为“今日未读且未抑制数”。抑制只影响提醒和Webhook,不停止检测快照生成。
- 到达率颜色复用现有统一六档色阶。
- “签名质量检测”页面删除企业应用发送排行、通道占比、当天发送量和当天成功率模块。
## 3. 数据模型与事实来源
### 3.1 通道运营商能力
通道能力从旧单值`mobile/unicom/telecom/all`升级为非空运营商集合。通道组、通道组成员、路由规则和短信号码识别结果继续保持单运营商;只有通道能力集合包含目标运营商时,才能加入对应通道组并参与选路。
### 3.2 签名报备任务
继续以`ChannelSignatureReportTask`作为当前报备事实,以`ChannelSignatureReportRecord`作为不可变状态轨迹,不另建与任务重复的报备事实表。签名任务的业务唯一维度升级为:
```text
签名 × 通道 × 运营商
```
签名任务增加运营商、当前连续通过时间和历史兼容范围。任务每次从非通过状态进入`approved`时写入新的`approvedAt`;离开`approved`时清空。记录表继续保存每次状态变化、操作人、时间、来源和原因。
`reportType``drainageItemId`只是现有共享表的技术字段,不属于本需求业务维度;引流信息任务不增加运营商。
### 3.3 企业运营商报备汇总
“企业签名 × 运营商”不是另一套可编辑事实,而是运营商级签名任务的只读汇总:
- 至少一个当前运营商级任务为`approved`时,该签名的该运营商进入监控。
- 监控起点取当前仍有效的通过任务中最早的`approvedAt`
- 当前有效任务全部退出通过时,该运营商退出监控;后续重新通过形成新的连续周期。
## 4. 现有生产数据兼容
旧单运营商通道能力无损迁移为单元素集合,旧`all`迁移为移动、联通、电信三元素集合。按2026-08-10最终业务口径,不再提供“历史待确认”页签或人工拆分API;历史签名任务统一按通道能力集合自动形成运营商级事实:
- 旧任务为`approved`:通道支持的全部运营商均创建为`approved``approvedAt`使用迁移当天实际执行时间;三网通道即三网均通过。
- 旧任务为其他状态:通道支持的全部运营商继承该状态,`approvedAt`为空。
- 已存在运营商级任务:保持现有状态和通过时间,不被旧任务覆盖。
- 全部适用运营商处理完成后,旧任务改为`legacy_split`并永久保留,状态轨迹不删除;迁移重复执行不得重复创建任务或覆盖现有事实。
企业签名页面以后新建运营商级“已通过”任务时,继续以保存时刻作为`approvedAt`,无需额外填写历史时间。自动转换完成后历史兼容读取不再命中,不保留运营端人工确认入口。
## 5. 预警规则与生命周期
### 5.1 规则优先级
- 企业预警:企业应用特殊规则优先于通用规则。
- 通道预警:通道特殊规则优先于通用规则。
- 每个规则分别配置移动、联通、电信的X天和Y条,并可启停或继承通用规则。
- 规则修改保存后记录版本,下一检测日读取新版本;预警快照保存命中的规则版本和阈值,历史结果不随规则变化漂移。
### 5.2 预警周期
同一检测维度在连续低于阈值期间属于同一个预警周期;每天保存检测快照,但不创建重复周期。恢复达标后关闭当前周期;以后再次低于阈值创建新周期。
每日快照必须使用唯一检测日和唯一维度保证幂等。多实例或任务重启不得重复生成预警、未读消息或Webhook。
### 5.3 抑制与已读
- 临时抑制支持常用天数和自定义天数,到期后的下一检测日自动恢复。
- 永久抑制在“抑制管理”集中展示,取消时必须二次确认、填写原因并写操作日志。
- 取消抑制不补发历史站内消息或Webhook。
- 旧预警只读;最新预警可以进入抑制操作。抑制管理始终可以取消当前抑制。
- 未读状态与抑制状态独立;右上角只统计今日未读且未抑制的消息。
## 6. 30日检测数据
- 企业区按“企业签名 × 运营商”展示。
- 通道区按“签名 × 通道 × 运营商”展示。
- 日期T由页面日期控件决定,展示`T-1``T-30`
- 每日展示提交尝试数、上游接受业务短信数、最终送达成功数和最终成功率。
- 无真实提交显示灰色;尚未报备通过或早于当前连续通过时间显示“不适用”,不得伪装成零发送。
- 其他非零数据复用现有红、橙、黄、蓝、绿、深绿六档色阶。
## 7. 全部实施步骤(共16步)
### 第1步:设计、需求与测试文档
先完成本文、需求文档、规划测试用例和测试进度同步并交由用户评审;用户确认后才进入代码和数据库实现。本步骤已完成。
### 第2步:用户评审与口径冻结
由用户评审字段、交互、统计、历史兼容、抑制和发布顺序。用户已确认按步骤实施,本步骤已完成。
### 第3步:发布基线与真实数据盘点
重新核对Git、运行提交、migration、活动通道、通道组、路由、签名任务、历史记录和近30日业务短信数据;制作数据库与代码恢复方案。只读盘点,不发送短信、不修改通道。
### 第4步:兼容数据库底座
新增通道运营商能力集合;为签名报备任务增加`carrier/approvedAt/approvalScope`等兼容字段和条件唯一约束。旧字段继续可读,迁移只增加能力,不切换发送行为。
### 第5步:通道历史能力回填
将旧单运营商值回填为单元素集合,将`all`回填为三元素能力集合;已删除通道同样保留并迁移。核对记录数和所有外键关联,不改变账号、密码、状态或单价。
### 第6步:通道运营商多选管理
改造通道创建、编辑、复制、详情、列表筛选和审计;后端强制至少选择一个运营商。取消仍被活动通道组使用的能力时返回真实影响并阻止保存。
### 第7步:运营商级签名报备后端
升级签名任务创建、批量生成、状态修改、查询、删除治理和汇总逻辑。运营商必须属于通道能力集合;同一签名、通道、运营商只存在一个当前任务。
### 第8步:报备任务与签名状态页面
在签名页、报备任务页和通道报备详情页展示运营商级状态;同一通道可按三行或可展开三运营商展示。三网摘要只汇总真实运营商任务,不从通道级状态推断。
### 第9步:历史报备自动转换
使用幂等migration按通道能力集合生成运营商级任务;旧任务为已通过时全部适用运营商均按迁移当天记为已通过,其他状态原样转换。已有运营商任务不覆盖,旧任务转为`legacy_split`并保留。预警页面不提供“历史待确认”页签,后端不暴露历史人工确认接口。
### 第10步:发送链兼容双读
发送链优先读取运营商级任务;未完成拆分的历史任务暂时使用受控旧资格。记录每次使用旧资格的可观测指标,为严格切换提供清零门禁。
### 第11步:严格运营商级发送门禁
只有当活动历史未拆分数、旧资格命中数和异常数据全部为零后,才切换为“签名 × 通道 × 运营商”严格校验。专项验证断连、超时、补发和通道切换,避免误阻断真实业务。
### 第12步:预警规则与通知配置
实现通用规则、企业应用特殊规则、通道特殊规则、规则版本和多Webhook配置。Webhook地址加密保存、脱敏显示,并限制安全目标。
### 第13步:每日检测、预警周期与快照
实现北京时间每日幂等任务、企业和通道两类检测、连续周期、恢复关闭、规则快照和近30日聚合。不得以Mock、静态数据或前端计算代替真实数据库结果。
调度固定拆为两个阶段:北京时间04:00只完成检测、周期推进和快照落库,同时冻结预警标题与正文;北京时间08:00再按快照创建站内消息、聚合Webhook并开始投递。服务晚启动时按所处时点顺序补偿,04:00前不检测、08:00前不发消息。由于自动调度、启动补偿和数据库幂等已覆盖日常及故障恢复,运营端不再保留手动检测按钮和对外管理接口。
### 第14步:抑制、已读和Webhook投递
实现临时/永久抑制、抑制管理、取消审计、未读状态、异步Webhook、幂等、失败重试和投递日志。抑制期间继续生成检测快照。
### 第15步:预警页面和签名质量检测改版
在安全控制增加“签名清退预警”,增加消息列表、今日企业/通道数量和顶部预警铃铛;原待审核铃铛更换为任务图标但保留全部功能。签名质量检测页先展示“签名通道发送质量”,其后增加企业、通道两类30日热力图;两张热力图按维度行各自独立分页、每页10行,日期列仍横向滚动。删除已确认不保留的四个统计模块。
预警页签命名为“预警消息”,后端按消息创建时间的北京时间日期区间查询历史消息,并在同一查询中关联检测维度、企业、企业应用、签名和通道完成筛选、计数及每页10条分页;默认区间为今日至今日。抑制和取消抑制复用平台`Modal`,临时抑制以截止日期换算为后端天数,永久抑制不传天数,两者均要求原因;取消也要求原因,不使用浏览器原生弹窗。
热力图日期列按`T-1``T-30`由新到旧排列;行首只常驻签名以及通道维度必要的通道/运营商标识,企业和企业应用通过签名悬停提示查看。企业、通道模块分别在前端对后端返回的真实维度按企业、企业应用、签名过滤并独立分页。每行增加30日上游受理业务短信合计并按合计降序排列。格子悬停明确展示提交条数和最终发送成功条数,不混淆上游接受。报备后的完整观察窗口只控制清退预警资格,观察期仍按日生成`observing`快照并展示真实发送量,不创建预警周期、站内消息或Webhook。
页面底部增加独立的未报备签名查询。后端按所选北京时间自然日,以`SmsMessageRecord`为业务短信事实,从正文开头提取规范`【签名】`;仅当消息没有关联签名且当前企业应用的有效签名库不存在同名记录时计入。该模块用于发现类似`【湘银物业】`的系统外签名,不再检查通道或运营商报备任务。结果按正文签名和消息实际企业应用聚合,后端完成关键字过滤、总数和分页,前端不使用静态数据或全量拉取伪分页。
### 第16步:完整验证与分阶段预生产发布
依次执行migration演练、真实PostgreSQL聚合、API专项、发送链、长短信、重试/补发、权限、Webhook安全、TypeScript、构建、结构契约和`git diff --check`。发布必须分别设置“兼容底座”“历史确认”“严格发送”“预警启用”门禁,不在一次发布中同时迁移、切换发送和启用预警。
## 8. 暂停与回滚门禁
- 第2步用户未确认:停止,不实施。
- 第4至10步可回滚到能够读取运营商集合和历史任务的兼容版本;出现双运营商通道后不得回滚到只识别旧单值的版本。
- 第11步严格门禁启用后,如出现真实发送资格异常,优先切回兼容双读版本,不回滚或覆盖已经产生的新业务数据。
- 任一阶段不得为验收发送、补发或重投真实短信,不修改真实通道账号、密码、启停状态、企业余额或客户连接,除非另获明确授权。
+238 -9
View File
@@ -2332,8 +2332,8 @@
3. 使用已保存草稿或 scheduled 任务触发送。
4. 查询历史发送详情。
- 预期结果:
- 新发送不可选择已删除模板。
- 草稿或 scheduled 到点时校验失败,不入队
- 新发送和尚未创建真实任务的草稿不可选择已删除模板。
- 删除前已创建的 scheduled 任务到点时使用持久化快照继续入队;企业、应用或签名当前不可用时仍阻断
- 历史发送详情仍展示原短信内容、模板名称或模板快照。
- 不影响历史计费和对账。
@@ -3448,7 +3448,7 @@ npm run verify:phase8
| TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 |
| TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 |
| TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;分别覆盖HTTP提交、CMPP短短信和多分片长短信,启动 API 定时扫描并模拟投递建单失败后重复扫描。 | 两类短信都转为 timeout、写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`并只退款一次;HTTP产生一个明确失败Webhook,CMPP对每个请求回执的原始分片产生失败状态报告且使用各自SubmitResp Msg_Id;建单未完成时`timeoutReceiptQueuedAt`保持空并由后续扫描补齐,成功建单后不重复;任务进度刷新。 |
| TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额整数不显示小数,非整数仅显示有效小数,余额仍显示四位精度;正数显示已入账,负数显示已冲正。 |
| TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额和余额最多显示四位小数并移除末尾无意义的0;正数显示已入账,负数显示已冲正。 |
| TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 |
### 17.5.1 报表对账细化
@@ -3456,7 +3456,7 @@ npm run verify:phase8
| 用例 | 细化执行点 | 必查断言 |
| --- | --- | --- |
| TC-REPORT-001 | 在同一发送日准备多个企业和应用的单条、长短信,覆盖 delivered、failed、unknown;次日执行报表刷新并按日期、企业、应用查询对账单。 | 只生成 T-1 及更早完整日期;发送和成功均按 `billingUnits` 汇总;成功只包含最终 delivered;企业与应用隔离正确;API 使用 PostgreSQL 报表表和服务端分页。 |
| TC-REPORT-002 | 准备短短信成功、三分片长短信仅两片成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 企业应用消费只包含 charged;退款不算收入;成本严格等于各次提交的通道成本单价快照乘以该次成功分片数,失败和未知分片成本为0;通道维度收入只归属最终提交且不重复;利润=消费-成本,利润率计算正确,收入为0时显示0%。 |
| TC-REPORT-002 | 准备不同客户单价的短短信成功、三分片长短信仅两片成功、最终失败退款、同通道成功和跨通道补发成功短信,分别按企业应用和通道查看利润报表。 | 收入逐条等于最终成功计费条数×该短信发送时的客户单价快照后汇总,不受 charged/refunded 状态切换影响;失败和未知短信不计收入;成本严格等于各次提交的通道成本单价快照乘以该次成功分片数,失败和未知分片成本为0;通道维度收入只归属最终提交且不重复;利润=收入-成本,利润率计算正确,收入为0时显示0%;页面、汇总与 CSV 均无返还字段。 |
| TC-REPORT-003 | 首次生成后,在 T-3 短信上补录 delivered 回执并将另一条 T-2 短信最终失败退款,再执行次日定时刷新。 | 每次刷新准确覆盖 T-4、T-3、T-2、T-1;对应日期旧行在事务内重建,成功数、消费、利润同步修正;T-5 及更早报表不被本次任务改写。 |
| TC-REPORT-004 | 先按成本价发送并 accepted,再修改通道单价,随后生成和重复刷新报表。 | `SmsSubmitRecord.costUnitPrice/costAmountCents` 保存提交时快照;历史成本不随通道当前单价变化;新提交使用新单价。 |
| TC-REPORT-005 | 打开运营端菜单和两张报表,切换日期、企业、应用及通道维度并翻页。 | “报表对账”位于“数据详单”之后且包含两个二级菜单;筛选和分页调用真实 `/admin/reports/*` API;页面展示生成时间及 T+1/T-4~T-1 口径,不使用 mock、静态数组或 localStorage 数据。 |
@@ -3627,6 +3627,7 @@ npm run verify:phase8
| --- | --- | --- |
| TC-GW-SUBMIT-EXCEPTION-001 | 制造一条超过 Gateway 最大处理次数的真实 `SubmitCommand`,打开运营端“网关异常”的“提交异常”Tab,按状态、应用、通道和关键字筛选并查看详情。 | 记录写入 PostgreSQL,页面汇总、分页和详情来自 NestJS API;手机号脱敏,命令中的密码、密钥和原始 payload 不返回浏览器,页面不使用“死信”作为业务名称。 |
| TC-GW-SUBMIT-EXCEPTION-002 | 对短信仍处于 pending/failed、通道 active 且 connected 的异常记录,输入 5~500 字原因,勾选“已确认上游未受理”并重新入队。 | 近期认证通过后服务端原子抢占记录、真实写入 Redis Stream;记录变为 requeued,人工次数、操作人、原因、Stream ID 和时间完整留痕,收到 SubmitResult 后变为 resolved。 |
| TC-GW-SUBMIT-EXCEPTION-004 | 对一条`pending`提交异常点击“已处理”,在确认弹窗中取消后再次确认。 | 取消不调用接口;确认后仅将记录原子更新为`resolved/manually_resolved`,保留原异常和命令证据并写操作日志,不写Redis Stream、不触发短信提交;非`pending`记录不展示按钮且后端拒绝并发变更。 |
| TC-GW-SUBMIT-EXCEPTION-003 | 不勾选确认、原因过短、重复点击同一记录,或分别把短信置为 accepted/submitted/delivered/unknown、把通道置为停用/断开、人工重试达到 3 次后尝试重新入队。 | API 拒绝危险或重复操作,不产生额外 Stream 命令;页面显示可读原因,操作日志不伪造成功。 |
| TC-GW-RATE-001 | 给通道 A 配置 10 TPS,连续投递 20 条;通道 B 同时配置 20 TPS 并投递,另让提交命令携带高于通道配置的数值。 | Gateway A 实际提交节奏不超过 10 TPS,B 独立按自身额度执行;消息值不能放大 A 的权威上限,同一通道跨通道组共享额度。 |
| TC-GW-RATE-002 | 超过通道 TPS 后观察 Redis Stream consumer group,并在存在等待消息时重启 Gateway。 | 超流速消息保留在 Stream pending,不直接失败;重启后通过 PEL/XAUTOCLAIM 恢复并继续按通道 TPS 排队提交,不丢失、不重复 ACK。 |
@@ -3650,7 +3651,7 @@ npm run verify:phase8
| TC-MONEY-001 | 运营端编辑企业应用,将客户单价填写为 `0.0325` 并保存,刷新列表后再次进入编辑页。 | 保存调用真实 NestJS API;数据库 `SmsApplication.customerUnitPrice=325`;列表和编辑页均展示 `0.0325`,不被舍入为 `0.03`。 |
| TC-MONEY-002 | 使用单价 `0.0325` 的应用发送 2 个计费条数。 | 预估、冻结及最终计费金额均为 `650` 金额单位,即 `0.0650 元`;返还时按相同精度原额冲回。 |
| TC-MONEY-003 | 分别设置余额、授信、充值、今日消费和今日返还为含 4 位小数的金额,查看运营端企业列表、详情、首页以及客户端首页和账单。 | 所有位置展示同一真实金额且固定为 4 位小数,不使用浮点累计或仅保留到分。 |
| TC-MONEY-004 | 打开短信详单、对账单、利润报表并导出 CSV。 | 消费金额、成本金额和利润均按 4 位小数显示;CSV 表头以“元”为单位,值固定 4 位小数,汇总结果与数据库整数金额单位一致。 |
| TC-MONEY-004 | 打开短信详单、对账单、利润报表并导出 CSV。 | 短信详单消费金额以及利润报表收入、成本和利润均按 4 位小数显示;CSV 表头以“元”为单位,值固定 4 位小数,汇总结果与数据库整数金额单位一致。 |
| TC-MONEY-005 | 在迁移前备份数据库并记录各金额列汇总,执行四位精度迁移后复核字段类型及汇总。 | 金额列升级为 `BIGINT`;迁移后整数汇总等于迁移前的 100 倍,按新除数换算后的人民币金额完全相等。 |
| TC-MONEY-006 | 在单价、授信和充值输入中分别填写超过 4 位小数、非法字符和超出 JavaScript 安全整数范围的值。 | 前后端拒绝无效值并返回可读错误;API 不静默舍入或输出已失真的金额。 |
| TC-IF-PARAM-001 | 运营端分别打开已开通 CMPP、HTTP 的企业应用参数弹窗并一键复制;模拟 Clipboard API 在 HTTP 页面被拒绝。 | CMPP 内容展示平台公网地址和端口而非上游通道地址;HTTP 内容包含应用、能力、QPS、白名单、投递模式和文档地址;降级复制成功且有明确提示。 |
@@ -3678,7 +3679,7 @@ npm run verify:phase8
| TC-DEFECT-004 | 企业应用列表保持相同条件连续点击查询/重置,打开包含长 ID 的 CMPP 连接详情;短信记录同样操作。 | 每次均有真实 API 请求且数据更新;弹窗无水平滚动,长 ID 自动换行。 |
| TC-DEFECT-005 | 通过/驳回一条待审短信并查看列表、更多信息及导航角标。 | 审核人/时间由当前会话写库,时间格式正确,列表不额外占列,角标不等待 30 秒轮询即更新。 |
| TC-DEFECT-006 | 打开真实短信详情,再新增后删除一条运营商区分规则。 | 详情分别展示 `clientSrcId` 与通道 `srcId + applicationExtension`;运营商显示中文,DELETE API 真实删库并刷新。 |
| TC-DEFECT-007 | 用登录运营用户修改企业应用及其他任一写操作,再查看系统日志;另构造失败请求。 | 成功写操作均有操作人、路径、资源和结果日志;失败请求不写伪成功日志日志不请求体、密码或密钥。 |
| TC-DEFECT-007 | 用登录运营用户修改企业应用及其他任一写操作,再查看系统日志;另构造失败请求。 | 成功写操作均有操作人、路径、资源和结果日志;失败请求不写伪成功日志。普通HTTP业务操作日志不保存请求体、密码或密钥;仅`cmpp_connection.connect_requested`按连接诊断要求保存客户实际提交的认证参数,且不得写入平台保存的密钥。 |
### 17.16 2026-07-20 缺陷回归
@@ -3764,7 +3765,7 @@ npm run verify:phase8
- `TC-UIUX-A5-DELETE-001`:活动通道组引用通道时打开删除确认层;真实预检返回引用数量、组名和优先级,`allowedActions`为空,前后端均禁止删除且通道状态不变。
- `TC-UIUX-A5-DELETE-002`:签名仍被未删除模板、引流信息或未结束报备任务引用;运营端和客户端均显示租户内依赖摘要并禁止删除,客户端不能读取其他企业对象。
- `TC-UIUX-A5-DELETE-003`:模板存在未结束发送或批量任务时阻断;无依赖模板填写原因后逻辑删除,返回操作单号PostgreSQL状态为`deleted`OperationLog包含原因、依赖、影响和幂等键。
- `TC-UIUX-A5-DELETE-003`:模板即使存在未结束发送或批量任务也允许逻辑删除,返回操作单号PostgreSQL模板状态为`deleted`,既有任务、消息和历史关联不变,OperationLog包含依赖快照、影响和幂等键。
- `TC-UIUX-A5-DELETE-004`:相同删除幂等键重试返回相同操作单号且不重复审计;旧版本并发提交返回409并要求重新预检;旧删除接口不能绕过治理规则。
- `TC-UIUX-A5-DELETE-005`:在1440×900、1366×768、768×1024、390×844和375×667打开依赖确认层;对象、依赖、影响和底部操作可滚动到达,无页面级横向溢出,控制台无error/warn。
@@ -3847,7 +3848,8 @@ npm run verify:phase8
- `TC-PROTOCOL-LOG-001`:向Gateway客户认证入口提交不存在的CMPP账号;真实接口返回业务4xx,通讯日志分别出现`received``failed`事件,账号可检索、耗时和安全错误可见,数据库无短信业务记录。
- `TC-PROTOCOL-LOG-002`:真实CMPP Submit获得供应商SubmitResp;通讯日志可按CMPP、通道到平台、SubmitResp及平台消息号筛选,展示上游消息号和结果码,不包含短信正文或通道密码;同一个SubmitResp只能落一条最终处理结果,不得同时出现`received``success`重复行。
- `TC-PROTOCOL-LOG-003`:供应商发送DELIVER状态报告;Gateway结构化日志出现收到事件,NestJS通讯日志以一条记录展示该报文及最终处理结果。构造解包失败或API拒绝时必须出现对应失败证据,不能静默返回。
- `TC-PROTOCOL-LOG-004`:通过公开HTTP API提交合法和非法请求;通讯日志展示客户到平台的受理或失败状态、请求号、脱敏手机号、业务码和耗时,鉴权头、密钥和正文不得入库。
- `TC-PROTOCOL-LOG-004`:通过公开HTTP API提交合法和非法请求;通讯日志展示客户到平台的受理或失败状态、请求号、完整手机号、业务码和耗时,可使用完整号码查询,鉴权头、密钥和正文不得入库。
- `TC-PROTOCOL-LOG-013`:分别产生CMPP Submit、供应商回执、上行和HTTP发送通讯日志;数据库新记录的`phoneNumber`、列表对象列、详情弹窗及完整号码关键字查询均显示/命中完整手机号,不写新的`phoneMasked`值,且短信正文、密码、密钥和鉴权头仍不入库。
- `TC-PROTOCOL-LOG-005`:平台向客户投递回执或上行Webhook并触发成功、网络失败和重试;通讯日志展示事件ID、HTTP状态或网络错误、耗时、尝试次数及最终状态,真实`HttpWebhookAttempt`状态一致。
- `TC-PROTOCOL-LOG-006`:连续运行CMPP心跳;`ProtocolInteractionLog`行数不随每个ACTIVE_TEST增长,连接状态中的最近心跳仍更新。超过配置保留期的数据被清理,业务表及操作审计不受影响。
- `TC-PROTOCOL-LOG-007`:运营端真实登录后打开系统日志,键盘切换“系统与操作日志/通讯交互日志”,筛选、分页、详情及固定操作列可用;桌面和平板/手机不产生页面级横向溢出,宽表允许容器内滚动,控制台无error/warn。
@@ -4369,7 +4371,8 @@ npm run verify:phase8
| --- | --- | --- |
| TC-DRAINAGE-DETECT-001 | 在引流识别规则页新增、编辑、停用规则并刷新 | 所有操作调用真实后端并持久化到 PostgreSQL;版本递增、状态生效并留下操作日志,刷新后不丢失 |
| TC-DRAINAGE-DETECT-002 | 分别测试协议 URL、裸域名、短链接、IP:端口/路径及中文标点相邻链接 | 均识别为含引流,命中类型为 URL,保存原始内容位置;检测不会改写短信原文 |
| TC-DRAINAGE-DETECT-003 | 输入被空格、中文句号拆开的域名以及普通邮箱地址 | 规避域名仍命中;完整或带空格规避的邮箱不被当作引流 URL |
| TC-DRAINAGE-DETECT-003 | 输入使用中文句号代替域名点号的URL以及普通邮箱地址 | 中文句号域名仍命中;完整或带空格规避的邮箱不被当作引流URL或手机号 |
| TC-DRAINAGE-DETECT-010 | 分别在协议URL、裸域名或路径后加入空格、制表符、换行、全角空格及后续字符,并输入空格拆分域名 | URL命中原文和位置均在首个空白前结束,空白后字符不属于前一个链接;空格拆分域名不被拼接恢复,短信原文不被改写 |
| TC-DRAINAGE-DETECT-004 | 测试`+86 138 0013 8000``138-0013-8000`及中文标点拆分手机号 | 均识别为手机号引流,原文片段可在短信记录中正确高亮 |
| TC-DRAINAGE-DETECT-005 | 测试`0108888-8888 转 123`等固话 | 区号括号、分隔符和分机号均可识别为固定电话引流 |
| TC-DRAINAGE-SEND-001 | 使用待审核、驳回或未报备的既有引流资料分别创建客户端批次和 CMPP 入站任务 | 不产生`DRAINAGE_NOT_APPROVED`,不因引流资料状态拒绝或转人工;其他发送校验仍正常执行,禁止用真实短信完成自动测试 |
@@ -4399,3 +4402,229 @@ npm run verify:phase8
| TC-RECEIPT-CONFLICT-001 | 整条级成功已经形成`delivered`后,同一提交尝试又收到明确失败回执,并重复输入同一矛盾事件 | 原始失败回执和分片证据保留;主记录仍为`delivered`,不补发、不退款、不向客户推送失败;`SmsReceiptAnomaly`按稳定键只有一条记录并累加发生次数 |
| TC-GATEWAY-EXCEPTION-UI-001 | 打开运营端“网关异常”,切换“提交异常”和“回执异常”Tab并刷新、筛选、翻页 | 菜单新名称和两个Tab正常展示且原路由可访问;两个Tab分别调用真实提交死信API和回执异常API,筛选、汇总、总数和分页与PostgreSQL一致,不使用mock、静态数据或localStorage |
| TC-GATEWAY-EXCEPTION-UI-002 | 阅读两个Tab标题说明并打开两类详情 | “提交异常”明确说明死信不等于供应商拒绝/送达失败及重入队风险;“回执异常”明确说明其为终态冲突摘要,并指向真实回执记录和通讯交互日志,详情不泄露通道密码或鉴权信息 |
## 2026-08-09 休眠唤醒与会话锁定恢复用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-AUTH-014 | 在运营端短信记录页长时间无操作或让桌面进入锁定/休眠,超过运营端空闲期限后唤醒浏览器并观察网络请求,再输入当前密码解锁 | 锁定后短信记录路由暂停,全局`pending-audits`轮询停止,唤醒产生的焦点事件不继续请求受保护接口;解锁成功后返回短信记录路由并重新请求真实短信记录、企业和应用选项,页面无连续401 |
| TC-AUTH-015 | 让旧会话活动时间超过空闲期限并进入完整登录页,在不刷新浏览器标签的情况下使用正确账号、密码和验证码重新登录 | 登录成功立即建立新的前端活动起点;新会话不会在1至2秒内调用`/auth/session/lock`,可正常打开短信记录并调用真实后端 |
| TC-AUTH-016 | 分别由前端空闲计时、服务端`SESSION_LOCKED`响应和另一标签页锁定事件触发运营端锁屏,再由当前或另一标签页解锁 | 三种入口均同步锁屏、暂停业务路由和角标轮询;解锁后统一恢复,重复锁定/解锁事件幂等,不产生额外业务写入 |
## 2026-08-09 签名删除预检与多通道报备汇总用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-DELETE-SIGNATURE-004 | 对仅存在`approved``abandoned`等已结束报备任务,且无模板、引流信息或其他活动依赖的签名执行删除预检 | 已结束报备任务不出现在“未结束报备任务”中,删除预检允许继续;历史任务和记录仍保留 |
| TC-DELETE-SIGNATURE-005 | 运营端对仍存在`pending``waiting_material``reporting``exporting`任务的签名执行删除预检 | 预检列出真实未结束任务ID和状态,出现“同时结束关联的报备任务”必选项;未勾选不能确认,勾选后可继续 |
| TC-DELETE-SIGNATURE-006 | 签名同时关联未删除模板、引流信息和未结束报备任务 | 分别出现三个级联勾选项;少勾选任意一项时确认按钮不可用且后端直接调用也拒绝,全部勾选后才可确认 |
| TC-DELETE-SIGNATURE-007 | 客户端删除存在未结束报备任务的签名 | 页面只展示统一的结束报备说明和勾选项;API及页面均不出现任务ID、状态、通道等内部详情;全部关联项勾选后允许确认 |
| TC-DELETE-SIGNATURE-008 | 全部勾选后删除同时关联模板、引流信息和过程态报备任务的签名 | 同一`Serializable`事务内模板、引流信息和签名均逻辑删除,任务置为`abandoned`;每条任务及子对象均有真实审计记录,历史消息、计费、审核和报备记录保留 |
| TC-DELETE-SIGNATURE-009 | 关联模板仍存在未结束发送或批量任务 | 即使勾选“同时删除关联的模板”仍由真实活动任务阻止删除,不中断或丢失正在处理的数据 |
| TC-DELETE-CHANNEL-010 | 通道仅关联未结束报备任务,没有活动组、直接路由或连接 | 出现“同时结束关联的报备任务”必选项;勾选后任务置为`abandoned`并写记录,通道逻辑删除,受影响有效签名按剩余有效通道重算汇总 |
| TC-DELETE-CHANNEL-011 | 通道仍存在活动通道组、直接路由或活动网关连接 | 报备任务勾选项不能绕过其他硬依赖,后端拒绝删除并返回真实阻断原因 |
| TC-DELETE-REASON-012 | 分别在运营端和客户端删除无硬依赖的通道、签名、模板,删除原因留空或填写内容 | 留空时允许删除;填写时原文进入审计详情,三类对象均不再要求至少4个字符 |
| TC-DELETE-TEMPLATE-013 | 模板关联任意终态或过程态的发送审核任务和批量任务 | 单独删除模板不查询也不依赖任务是否结束;模板逻辑删除成功,既有任务和历史关联不变 |
| TC-DELETE-TEMPLATE-014 | 使用已逻辑删除的模板创建新发送任务 | 后端拒绝创建,不产生批量任务、消息或计费记录 |
| TC-DELETE-TEMPLATE-015 | 已接受的定时任务到点前,其模板被逻辑删除,企业、应用和签名仍有效 | 任务按已持久化内容快照冻结费用并入队,不因模板当前`deleted`状态失败 |
| TC-DELETE-TEMPLATE-016 | 已接受的定时任务到点前,其签名变为未通过或删除 | 仍按签名安全规则阻断调度,不冻结费用、不入队,任务和消息记录真实标记失败原因 |
## 2026-08-09 通道组删除风险展示与历史保留用例
| 用例编号 | 场景 | 预期结果 |
| --- | --- | --- |
| TC-CHANNEL-GROUP-DELETE-001 | 打开同时关联正常应用、已删除应用、多个通道和`queued`提交记录的通道组删除弹窗 | 后端按不同应用ID去重并保留审计统计;弹窗只展示关联正常企业应用、组内通道、等待供应商提交结果三项真实数量,标题、数量单位、说明和按钮文案与需求一致 |
| TC-CHANNEL-GROUP-DELETE-002 | 同一正常企业应用存在多条通道组关联 | “关联正常企业应用”只计1个,不按关联规则条数重复累计 |
| TC-CHANNEL-GROUP-DELETE-003 | 关联记录指向状态为`deleted`或已不存在的企业应用 | 两类均不计入正常应用,删除弹窗不展示“关联已删除企业应用”;后台审计快照仍可保留其真实数量 |
| TC-CHANNEL-GROUP-DELETE-004 | 通道组存在正常/已删除应用关联、组内通道或等待供应商提交记录后确认删除 | 所有业务依赖只展示不阻止;后端将通道组状态置为`deleted`并写操作审计,不物理删除关联和历史记录 |
| TC-CHANNEL-GROUP-DELETE-005 | 删除通道组后查询通道组列表并发送新短信 | 默认列表不再显示该组,新短信选路不再选择该组 |
| TC-CHANNEL-GROUP-DELETE-006 | 删除通道组后查询历史发送/回执/审计,或按历史通道接入号处理上行 | 组内通道、应用关联、发送、回执和审计数据仍存在,历史链路可追溯 |
| TC-CHANNEL-GROUP-DELETE-007 | 删除影响数据仍在加载或加载失败 | 加载期间不允许盲目提交;失败时明确展示接口错误,不使用静态数量或本地伪数据 |
| TC-SIGNATURE-REPORT-AGG-001 | 同一签名两个目标通道分别为`approved``failed` | 签名整体及对应多目标汇总为“部分成功”,页面显示部分通道通过;失败通道及原因仍可查看,不显示整体报备失败 |
| TC-SIGNATURE-REPORT-AGG-002 | 同一签名两个目标通道分别为`pending``failed` | 签名整体保持“报备中”,不因一个通道失败提前结束;失败通道明细继续展示 |
| TC-SIGNATURE-REPORT-AGG-003 | 同一签名所有当前目标通道均为`failed/rejected` | 签名整体为“报备失败”;各通道失败事实和原因均保留 |
## 2026-08-09 发送质量矩阵与成功率色阶用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-ANALYTICS-SIGNATURE-004 | 打开签名通道发送质量明细并切换到“按引流切分”,检查多个通道及三网组合 | 每个通道固定依次展示“含引流、不含引流、未检测”三行,固定依次展示“移动、联通、电信”三列;缺少真实提交的组合显示`0`,其他组合与真实API数据一致 |
| TC-ANALYTICS-SIGNATURE-005 | 分别构造或选择成功率为`0、0.1、25、25.1、50、50.1、75、75.1、95.9、96`的签名统计结果,检查列表、明细总览、运营商概览和矩阵 | 数字颜色依次落入红、橙、橙、黄、黄、蓝、蓝、绿、绿、深绿;所有签名质量展示位置使用同一边界函数,统计值不被前端改写 |
| TC-CHANNEL-QUALITY-COLOR-001 | 在短信通道管理列表和通道报备详情检查上述成功率边界 | 送达成功率数字使用与签名质量相同的六档色阶;列表与详情对同一成功率显示一致 |
| TC-CHANNEL-QUALITY-COLOR-002 | 选择提交失败、回执未知或送达失败比例与数量均非零的通道,检查通道管理列表和报备详情 | 三类非成功指标的比例与数量均为黑灰色,不显示红色、橙色或成功率色阶;真实比例、数量及后端数据保持不变 |
## 2026-08-09 运营端菜单与查询控件细节用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-ADMIN-NAV-006 | 展开“审核中心”和“安全控制”,再打开风控规则页面 | “审核中心”不再显示风控规则,“安全控制”显示且可正常进入;页面面包屑为“安全控制 / 风控规则”,路由和真实规则数据不变 |
| TC-MONITOR-CARRIER-003 | 打开发送监控,检查移动、联通、电信、三网及未知运营商通道 | 已知运营商均显示中文;未知新增值保留后端原值,不显示空白,也不修改通道配置 |
| TC-REPORT-BATCH-008 | 打开待生成报备批次并切换两个页签 | 页签标题仅显示“待生成资料”和“已生成批次”,不含括号及数量;列表分页总数、筛选和真实API请求保持正常 |
| TC-SMS-RECORD-CHANNEL-003 | 打开短信记录通道下拉,输入通道名称或编码搜索并选择后查询、翻页和导出 | 下拉选项来自真实通道接口并支持搜索;查询和导出传递选中通道的精确`channelId`,结果、总数和CSV均只包含该通道记录;重置恢复全部通道 |
## 2026-08-09 CMPP客户连接请求诊断日志用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-CMPP-CONNECT-LOG-001 | 使用正确账号从允许IP发起CMPP 3.0连接,再查询系统与操作日志 | 认证成功;产生一条`cmpp_connection.connect_requested`,资源为`cmpp_downstream_connection`,日志`ipAddress`等于真实TCP来源IP,详情保存账号、AuthenticatorSource、时间戳、`cmpp30`、原始版本值、应用ID和authenticated结果 |
| TC-CMPP-CONNECT-LOG-002 | 从不同IP分别使用未知账号、错误AuthenticatorSource、未在白名单的IP或已停用应用发起连接 | 每次连接均按原认证规则拒绝,同时各自持久化失败日志;未知账号日志仍保存来源IP和请求账号,失败原因与真实拒绝原因一致,不因没有企业ID而丢失 |
| TC-CMPP-CONNECT-LOG-003 | 在系统与操作日志找到上述动作,核对IP列并点击“查看详情” | IP列有值;弹窗展示请求IP、Source_Addr、AuthenticatorSource、时间戳、协议版本、结果及失败原因。标准CMPP请求的密码字段明确显示“不传明文密码”,不得展示平台保存的密钥 |
| TC-CMPP-CONNECT-LOG-004 | 通过兼容Gateway调用显式携带`password`字段进行认证测试 | 日志详情原样保存并展示该请求字段;该兼容行为不改变标准CMPP只传AuthenticatorSource的协议事实,也不把平台配置密钥写入日志 |
## 2026-08-09 报表筛选结果全量汇总用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-REPORT-SUMMARY-001 | 在对账单准备超过一页的多日、多企业应用数据,分别按日期、企业和应用搜索并翻页 | 顶部提交、发送、未知、成功、失败合计等于PostgreSQL中全部筛选结果;翻页不改变汇总,改变筛选条件后同步刷新 |
| TC-REPORT-SUMMARY-002 | 在利润报表分别选择企业应用和通道维度,准备多行金额且至少一行收入为0 | 量类与金额类均按完整筛选结果求和;只展示收入、成本和利润金额合计,不展示返还合计;综合利润率=合计利润/合计收入,不是行利润率求和或平均,合计收入为0时为0% |
| TC-REPORT-SUMMARY-003 | 在发送质量报表的企业应用、通道、签名、引流信息四个Tab分别搜索和翻页 | 汇总条数来自真实后端全量聚合;综合成功率=合计成功/合计发送,不累加或平均各行成功率;汇总区不对平均到达时长求和 |
| TC-REPORT-SUMMARY-004 | 调用三个报表列表API,对比`items`当前页、`total``summary`和相同条件CSV | `summary`与CSV完整结果口径一致且不受`page/pageSize`影响;无匹配数据时所有合计和综合率均为0 |
## 2026-08-09 新建企业省市字典用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-ENTERPRISE-REGION-001 | 在`PhoneSegment`中准备多省多地市且含重复行,调用`GET /api/admin/dictionaries/administrative-regions` | API从真实PostgreSQL查询`province/city`,返回去重、去空值并按中文排序的省份—地市数组,不返回前端静态数据 |
| TC-ENTERPRISE-REGION-002 | 打开运营端新建企业,依次选择两个不同省份并查看地市下拉 | 省份选项来自真实字典API;地市只显示当前省的对应值,切换省份后旧地市立即清空;保存后省市真实写入企业档案 |
| TC-ENTERPRISE-REGION-003 | 编辑一个已存省市值暂未出现在当前号段字典的历史企业 | 页面将档案原值补入当前选项并正常显示,未主动修改时不会被清空 |
| TC-ENTERPRISE-REGION-004 | 断开字典API后打开新建企业 | 页面明确提示省市字典加载失败,不显示Mock、localStorage或旧的写死选项 |
## 2026-08-10 通道运营商多选验收用例(本地自动化与浏览器验收完成)
> 2026-08-09暂缓需求已重新纳入签名清退预警前置设计并完成本地兼容实现。以下仍是完整验收口径;自动化、真实本地数据库、构建和授权后的本地浏览器结果见本节末尾,未发送真实短信或向外部Webhook投递验收消息。
| 用例编号 | 操作 | 未来预期结果 |
| --- | --- | --- |
| TC-CHANNEL-CARRIER-MULTI-001 | 对包含`mobile/unicom/telecom/all`及已删除通道的生产数据副本执行兼容迁移 | 单运营商值分别迁移为单元素集合,`all`迁移为移动、联通、电信全选;记录数、通道ID、状态和历史关联不变,不根据名称或通道组使用情况推断并缩减能力 |
| TC-CHANNEL-CARRIER-MULTI-002 | 新建或编辑通道,分别勾选一个、两个、三个和零个运营商 | 一个、两个、三个非空组合均可真实保存并回填;零个被前后端拒绝;页面不再提供独立“三网”选项,三个全选等价于旧`all` |
| TC-CHANNEL-CARRIER-MULTI-003 | 将支持移动和联通但不支持电信的通道分别加入三类通道组并发送对应运营商短信 | 仅允许加入移动、联通通道组;电信组前后端均拒绝;发送链不会把电信短信选到该通道,通道组、路由规则和短信实际运营商仍为单值 |
| TC-CHANNEL-CARRIER-MULTI-004 | 取消通道已被活动通道组引用的运营商,再尝试保存 | 后端返回对应真实通道组及影响并阻止保存,不自动删除成员、路由、报备任务或历史数据;解除活动引用后才允许取消 |
| TC-CHANNEL-CARRIER-MULTI-005 | 多运营商通道参与移动、联通、电信发送及成本统计 | 三个运营商继续共用通道唯一单价,客户计费和平台成本不因多选被重复计算;本需求不产生分运营商价格 |
| TC-CHANNEL-CARRIER-MULTI-006 | 对旧单网、三网通道的签名任务执行自动转换,并检查报备记录和三网汇总 | 签名任务按“签名 × 通道 × 运营商”保存和汇总;旧单网任务生成一个运营商任务,旧三网任务生成三个;旧状态为已通过时全部适用运营商均为已通过且通过时间为migration执行时间,其他状态原样继承;旧任务保留并转为`legacy_split`,引流信息报备维度和页面不变 |
| TC-CHANNEL-CARRIER-MULTI-007 | 分阶段部署兼容底座后写入仅支持两个运营商的通道,再执行回滚演练 | 只能回滚到能够读取运营商集合的兼容版本;仅识别旧单值的代码不得重新上线并将双运营商数据误判为三网或单网 |
## 2026-08-10 运营商级签名报备验收用例(本地自动化与浏览器验收完成)
| 用例编号 | 操作 | 未来预期结果 |
| --- | --- | --- |
| TC-SIGNATURE-CARRIER-REPORT-001 | 为支持移动和联通的同一通道创建同一签名的报备任务 | 分别产生移动、联通两个独立任务;不产生电信任务;同一签名、通道、运营商不能重复创建当前任务 |
| TC-SIGNATURE-CARRIER-REPORT-002 | 分别修改三个运营商任务状态 | 只改变目标运营商状态并写对应任务记录;签名三网摘要按真实任务分别汇总,不由通道级状态复制 |
| TC-SIGNATURE-CARRIER-REPORT-003 | 任务首次通过、退出通过、再次通过 | `approvedAt`分别记录每次连续通过周期的开始时间;退出通过时旧时间不再作为当前监控起点,全部历史变化保留在记录表 |
| TC-SIGNATURE-CARRIER-REPORT-004 | 打开签名页、任务页和通道报备详情 | 三个入口展示并操作同一份运营商级任务;运营商、状态、当前通过时间、操作轨迹一致 |
| TC-SIGNATURE-CARRIER-REPORT-005 | 查看自动转换后的旧三网通道已通过任务 | 移动、联通、电信分别存在运营商级已通过任务,通过时间统一为migration执行时间;旧通道级任务保留为`legacy_split`且不再参与页面待办或发送兼容读取 |
| TC-SIGNATURE-CARRIER-REPORT-006 | 重复执行历史自动转换,且部分运营商级任务在转换前已经存在 | 重复执行不重复创建任务;已有运营商任务的状态、通过时间和操作轨迹均不覆盖,只补齐缺少的适用运营商,随后将旧任务转为`legacy_split` |
| TC-SIGNATURE-CARRIER-REPORT-006A | 打开签名清退预警页并检查前后端管理接口 | 页面不存在“历史待确认”页签、数量、表格和确认弹窗;后端不再暴露历史任务列表与确认接口,前端不再发起对应请求 |
| TC-SIGNATURE-CARRIER-REPORT-006B | 在企业签名页面将没有运营商级任务的目标保存为“已通过” | 创建真实运营商级任务,`approvedAt`取保存时刻;重复保存已通过状态保持当前连续通过时间,不要求额外填写历史时间 |
| TC-SIGNATURE-CARRIER-REPORT-007 | 兼容期发送一条目标运营商短信 | 优先使用运营商级通过任务;只有未拆分历史任务才走受控兼容资格并留下可统计命中记录 |
| TC-SIGNATURE-CARRIER-REPORT-008 | 历史未拆分数和兼容资格命中数不为0时尝试启用严格门禁 | 后端或发布门禁阻止切换;清零后才允许严格按“签名 × 通道 × 运营商”选路 |
| TC-SIGNATURE-CARRIER-REPORT-009 | 通道断连、提交超时、补发并切换通道 | 每次重新选路都校验目标运营商报备;不选择未报备通道,也不因其他运营商已通过而放行 |
| TC-SIGNATURE-CARRIER-REPORT-010 | 回归引流信息报备创建、状态修改、详情和历史 | 继续按“签名 × 引流信息 × 通道”工作,不出现运营商字段或新增运营商任务,历史数据不变 |
## 2026-08-10 签名清退预警验收用例(本地自动化与浏览器验收完成)
| 用例编号 | 操作 | 未来预期结果 |
| --- | --- | --- |
| TC-SIGNATURE-RETIREMENT-001 | 配置三网通用X/Y规则,并为一个企业应用或通道配置特殊规则 | 特殊规则优先于通用规则;保存规则版本,修改后的规则从下一检测日生效 |
| TC-SIGNATURE-RETIREMENT-002 | 同一签名只有移动运营商报备通过 | 只生成移动监控维度;联通、电信不进入名单,不要求三网全部通过 |
| TC-SIGNATURE-RETIREMENT-003 | 报备通过不足X个完整自然日后执行检测 | 不预警;达到X个北京时间完整自然日后才按`T-X``T-1`判断 |
| TC-SIGNATURE-RETIREMENT-004 | 同一业务短信在同一通道因断连或超时产生多次提交 | 提交尝试如实展示多次,通道清退活跃量按`messageRecordId + channelId`只计一次,企业活跃量也只计一次 |
| TC-SIGNATURE-RETIREMENT-005 | 同一业务短信从通道A补发到通道B | 企业活跃量只计一次;A、B各自通道活跃量分别计一次;最终成功仍按真实最终回执展示 |
| TC-SIGNATURE-RETIREMENT-006 | 同一检测维度连续多日低于阈值,随后恢复,再次低于阈值 | 连续低量属于同一预警周期并保存每日快照;恢复后关闭周期;再次低量创建新周期 |
| TC-SIGNATURE-RETIREMENT-007 | 重复执行同一检测日任务或两个实例并发执行 | 数据库唯一约束保证快照、周期、未读消息和Webhook均不重复 |
| TC-SIGNATURE-RETIREMENT-008 | 设置自定义临时抑制并跨越到期日 | 抑制期间继续生成检测快照但不产生未抑制提醒或Webhook;到期后的下一检测日自动恢复 |
| TC-SIGNATURE-RETIREMENT-009 | 设置永久抑制后从“抑制管理”取消 | 二次确认、原因和操作日志完整;下一检测日恢复,不补发被抑制期间的历史通知 |
| TC-SIGNATURE-RETIREMENT-010 | 检查右上角计数并将消息标记已读 | 数字只等于今日未读且未抑制数;已读、已抑制及历史日期消息不计入 |
| TC-SIGNATURE-RETIREMENT-011 | 配置多个企业微信和飞书Webhook并触发企业、通道预警 | 企业预警按企业汇总,通道预警按检测批次汇总;地址加密、脱敏,投递异步、幂等、有限重试并保留投递日志 |
| TC-SIGNATURE-RETIREMENT-012 | 配置非法协议、内网地址或不可达Webhook | 非安全目标被阻止;合法但不可达目标按上限重试并终结失败,不阻塞检测事务,也不伪造成功 |
| TC-SIGNATURE-RETIREMENT-013 | 切换页面日期并检查两类30日方格 | T随日期变化,展示`T-1``T-30`;企业按签名×运营商,通道按签名×通道×运营商,数据来自真实后端 |
| TC-SIGNATURE-RETIREMENT-014 | 查看尚未报备、报备前、零提交和非零成功率日期 | 尚未报备或报备前显示“不适用”,零提交显示灰色,其他数据按现有六档色阶,悬停数量和成功率与真实聚合一致 |
| TC-SIGNATURE-RETIREMENT-015 | 打开改版后的签名质量检测页面 | 企业应用排行、通道占比、当天发送量和当天成功率已删除,其余保留模块和真实查询不回归 |
| TC-SIGNATURE-RETIREMENT-016 | 检查顶部任务入口和预警入口 | 原待审核入口改为任务图标但计数、弹层和跳转完整;新增预警铃铛进入预警列表,两个计数互不混用 |
| TC-SIGNATURE-RETIREMENT-017 | 分别在北京时间04:00前后、08:00前后运行自动任务,并模拟服务跨过两个时点后重启 | 04:00只生成幂等检测快照且冻结规则版本和消息正文,不产生站内消息/Webhook;08:00才幂等创建站内消息并生成Webhook投递;晚启动按时点顺序补偿且不重复;页面和管理API均不存在手动检测入口 |
| TC-SIGNATURE-RETIREMENT-018 | 打开签名质量检测页,并分别翻动企业、通道热力图 | “签名通道发送质量”位于两张热力图之前;两张热力图各按10个维度分页,页码相互独立,翻页不改变另一张页码,30日列仍可横向滚动且数据与真实API一致 |
| TC-SIGNATURE-RETIREMENT-019 | 签名刚报备通过、尚未满足观察窗口,次日有真实发送数据并执行04:00检测 | 生成状态为“观察中”的单日检测快照,热力图展示真实受理条数,但不创建预警周期、站内消息或Webhook;满足观察窗口后才按窗口累计量判断预警 |
| TC-SIGNATURE-RETIREMENT-020 | 准备多个签名维度的T-1至T-30快照且合计不同,打开企业和通道热力图 | 每行展示30日受理短信合计,按合计降序排列;分页基于排序后的结果,单元格仍展示各自然日数据 |
| TC-UI-MONEY-001 | 遍历运营端和客户端包含余额、单价、消费、返还、充值、成本、收入和利润的页面 | 只读金额整数部分沿用主文字颜色,小数点及小数部分使用统一淡色;负号、币种符号和单位位置正确,输入框、复制值和CSV仍为完整纯文本数值 |
| TC-CLIENT-LOGIN-ANIMATION-001 | 打开客户端登录页并保持页面可见,再切换后台或启用减少动态效果 | Canvas动画在登录框背景平滑运行、不遮挡表单、不响应敏感输入;页面隐藏或组件卸载时停止帧循环,减少动态效果下显示静态背景 |
| TC-ENTERPRISE-SIGNATURE-STYLE-001 | 打开企业签名管理列表 | 企业名称和企业应用名称使用常规字重,签名名称及状态层级保持原样 |
| TC-SIGNATURE-CARRIER-REPORT-011 | 打开企业签名“报备状态”弹窗,并准备三网各有多个目标通道 | 移动、联通、电信按三列独立区域同时展示;通道不再铺成一条长列表;每个区域可独立滚动,状态保存仍提交真实“签名×通道×运营商”任务并共用修改原因 |
| TC-UI-CARRIER-TAG-001 | 检查通道、签名质量、清退预警和手机号段等运营商标签 | 移动为`#E8F1F7/#2F6F91/#C9DDE9`,联通为`#F6EAEA/#875758/#E8CECE`,电信为`#F0ECF7/#73538F/#DDD1EA`(背景/文字/边框);三者复用全局胶囊组件,业务状态标签不被误改 |
| TC-RECHARGE-RECEIPT-002 | 在1366×768桌面视口打开普通充值和冲正回执 | 本次金额区缩小,Logo、企业、明细、备注、说明和完成按钮无需滚动即可完整看到;异常长备注允许弹窗内容区滚动且真实文本不截断 |
| TC-CHANNEL-SORT-001 | 准备超过一页且今日提交量不同的通道并翻页 | 后端先按北京时间今日提交尝试数降序排列全部筛选结果,再分页;同量按通道名称、ID稳定排序,不出现仅当前页前端排序 |
| TC-CHANNEL-QUALITY-ZERO-001 | 查看今日提交数为0的通道 | 提交失败、送达成功、回执未知、送达失败四个比率均显示深灰色`-`且不带百分号;对应数量仍为0 |
| TC-CHANNEL-LAYOUT-001 | 查看短信通道列表 | 运营商低饱和胶囊位于通道信息列最底部横排;独立列标题为“成本费率”,费率固定4位小数且整数、小数同字号同色 |
| TC-UI-MONEY-002 | 检查运营端、客户端各金额页面及运营看板今日消费、企业应用单价 | 金额整数和小数同字号同色;末尾小数全为0时不显示,非零小数最多4位并移除末尾0;今日消费和应用单价恢复正常主数字深色样式;成本费率固定4位作为例外 |
| TC-SIGNATURE-RETIREMENT-019 | 检查两张热力图日期、行首和悬停信息 | 日期从左到右为`T-1``T-30`;行首不常驻企业和企业应用,悬停签名可看到企业、企业应用;通道维度仍能识别通道和运营商 |
| TC-SIGNATURE-RETIREMENT-020 | 分别在企业、通道热力图搜索企业、企业应用、签名并清空 | 每张热力图只过滤自身真实维度并回到第一页;三类关键字均可命中,清空恢复,另一张热力图的关键字和页码不变 |
| TC-SIGNATURE-RETIREMENT-021 | 悬停报备前、无快照、零量和非零量格子 | 报备前显示不适用;无快照说明当日无检测;真实快照明确显示提交条数、上游接受条数、发送成功条数和成功率,发送成功等于真实最终送达而非受理成功 |
| TC-SIGNATURE-RETIREMENT-022 | 所选日期构造正文以规范`【签名】`开头的短信:当前企业应用签名库存在同名记录、签名库不存在、仅其他企业应用存在同名记录、正文没有规范开头签名 | 只有当前企业应用签名库不存在的规范正文签名进入未报备模块;已有同名系统签名不受通道或运营商报备状态影响,其他应用同名签名不能替代当前应用记录,无规范签名正文不伪造成签名行 |
| TC-SIGNATURE-RETIREMENT-023 | 在未报备签名模块按企业、企业应用、签名搜索并翻页 | 后端搜索、总数、每页10行和分页结果一致;列表展示签名、企业、实际企业应用和未报备短信条数,修改主统计日期后按新的北京时间自然日重新查询 |
| TC-SIGNATURE-RETIREMENT-028 | 同一企业应用在所选北京时间自然日提交正文以`【湘银物业】`开头的短信,消息未关联`signatureId`且有效签名库无同名记录;另准备已有签名但缺少通道报备、其他应用同名签名、正文无规范开头签名三组对照数据 | 仅正文签名在当前企业应用签名库不存在的消息进入“未报备签名”,并按正文签名和实际企业应用聚合;已有系统签名但缺通道/运营商报备、其他应用的记录和无规范签名正文不得误判 |
| TC-SIGNATURE-RETIREMENT-029 | 生产量级数据中存在多个企业、应用和未登记正文签名,打开签名质量检测页并查询未报备签名 | PostgreSQL按企业、应用、正文签名完整分组,接口返回200;不发生`42803`分组错误,列表总数、分页和各组短信条数与独立SQL一致 |
| TC-SIGNATURE-RETIREMENT-024 | 首次打开预警页面,随后选择历史日期区间 | 页签和区块标题均为“预警消息”;默认开始、结束均为今日且只返回今日消息,历史区间返回对应历史消息,每页10条并显示真实总数 |
| TC-SIGNATURE-RETIREMENT-025 | 分别或组合选择企业、企业应用、签名关键字、通道及日期区间并翻页 | 后端同时应用全部条件,列表、总数和页码一致;条件变化查询后回到第1页,企业应用选项受企业筛选约束 |
| TC-SIGNATURE-RETIREMENT-026 | 点击消息“抑制”,分别选择临时截止日期和永久抑制并填写原因 | 只出现平台自研弹窗;临时模式要求未来截止日期,永久模式不显示日期,两种模式原因必填,保存调用真实抑制接口且刷新当前筛选页 |
| TC-SIGNATURE-RETIREMENT-027 | 在抑制管理点击“取消抑制”,填写或不填写原因 | 只出现平台自研弹窗;未填原因不能确认,填写后调用真实取消接口并刷新消息及抑制列表,不出现浏览器`prompt/confirm` |
| TC-REPORT-RECORD-LAYOUT-001 | 在报备记录页面查看长备注和短备注 | 备注列桌面宽度不小于320px,使用统一长文本换行样式;宽表允许内部横向滚动,备注不被其他固定列挤成窄竖列,详情仍展示全文 |
| TC-DEPLOY-HEALTH-001 | 发布重启后模拟API初始化超过3秒但在60秒内恢复,并分别模拟API或Gateway持续60秒不可用 | 前者由部署脚本逐秒重试并正常完成,不触发误回滚;后者在60秒后明确失败并保留发布前数据库、源码和环境恢复资产,不把端口尚未就绪当作构建或migration失败 |
### 2026-08-10 本地执行状态
- 已通过真实本地PostgreSQL迁移和数据约束检查、Prisma校验及85条迁移状态、API全量35个suite/448项测试、专项服务52项测试、API TypeScript构建、前端生产构建、4份Gateway队列结构契约和`git diff --check`;自动转换SQL已对真实本地数据库执行并重复执行验证幂等,不使用Mock、静态数据或localStorage。
- 通道能力集合、通道组兼容、运营商级报备、历史任务自动转换、发送资格兼容双读、每日检测幂等、规则版本、预警周期、抑制和Webhook安全边界已有自动化或数据库证据;严格运营商级发送门禁默认不启用,必须在兼容命中清零后另行切换。
- 未向外部Webhook投递验收消息,未发送、补发或重投真实短信。经用户授权使用本地专用平台管理员验收:预警页面只保留“预警消息、检测规则、Webhook、抑制管理”4个页签,不存在“历史待确认”页签和残留提示;切换检测规则页签成功,控制台error/warn为0。页面继续显示04:00自动检测、08:00发消息口径且不存在手动检测按钮。
## 2026-08-09 通道组按通道筛选用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-CHANNEL-GROUP-FILTER-001 | 打开通道组管理页并展开“通道”下拉 | 通道组和通道真实API并行加载;下拉显示全部真实通道的名称和编码,已删除通道标记“已删除”,未加入任何组的通道也不被隐藏 |
| TC-CHANNEL-GROUP-FILTER-002 | 在通道下拉中输入完整或部分通道名称、编码 | 下拉只显示标签包含关键字的真实通道选项;无匹配时显示“无匹配选项” |
| TC-CHANNEL-GROUP-FILTER-003 | 选择某通道 | 只展示`items.channelId`包含该通道的通道组,不展示仅运营商相同但未配置该通道的组;总数和分页与筛选结果一致 |
| TC-CHANNEL-GROUP-FILTER-004 | 同时输入通道组名称并选择通道 | 按名称包含与成员通道两个条件取交集,条件变更后回到第一页 |
| TC-CHANNEL-GROUP-FILTER-005 | 点击“重置” | 通道组名称和通道条件同时清空,恢复全部未删除通道组并回到第一页 |
# 下游投递后台重投任务专项用例(2026-08-12)
| 编号 | 场景 | 预期 |
|---|---|---|
| TC-DOWNSTREAM-REQUEUE-TASK-001 | 当前筛选条件预检 | 后端按企业、应用、类型、状态、日期、关键词和 `snapshotAt` 返回真实命中、可重投、跳过及状态分布;分页不影响数量。 |
| TC-DOWNSTREAM-REQUEUE-TASK-002 | 创建任务 | 原因少于 5 字拒绝;仅物化 `pending/failed/unconfirmed/rejected``delivered/awaiting_ack` 不进入执行。 |
| TC-DOWNSTREAM-REQUEUE-TASK-003 | 快照边界 | 创建任务后新增或筛选条件外记录不进入任务。 |
| TC-DOWNSTREAM-REQUEUE-TASK-004 | 并发与幂等 | `taskId+deliveryId` 唯一;重复扫描、API 重启及并发任务不会重复调用 Gateway。 |
| TC-DOWNSTREAM-REQUEUE-TASK-005 | ACK 闭环 | Gateway 写出后项目进入等待 ACK;`Result=0` 成功,拒绝/超时计失败并按阈值自动暂停。 |
| TC-DOWNSTREAM-REQUEUE-TASK-006 | 状态变化跳过 | 执行前状态变化、已确认或被其他操作认领时不调用 Gateway,记录明确跳过原因。 |
| TC-DOWNSTREAM-REQUEUE-TASK-007 | 任务控制 | 待执行/执行中任务可暂停、继续、终止;终止不撤回已写出消息。 |
| TC-DOWNSTREAM-REQUEUE-TASK-008 | 审计 | 创建、暂停、继续、终止和自动暂停记录操作人、筛选快照、原因和结果。 |
| TC-DOWNSTREAM-PAGE-SIZE-001 | 分页数量 | 可选 10/25/50;切换回第一页,后端返回对应条数,总数和筛选条件保持一致。 |
## 下游投递后台重投任务安全整改专项用例(2026-08-13)
| 编号 | 场景 | 预期 |
|---|---|---|
| TC-DOWNSTREAM-REQUEUE-TASK-009 | 单一状态严格预检 | 选择待投递后,命中数、状态分布和可重投数只基于 `pending`;企业、应用、类型、日期、关键词同时生效。 |
| TC-DOWNSTREAM-REQUEUE-TASK-010 | 预检签名绑定 | 篡改筛选、快照、操作人、签名或使用过期凭证均拒绝;合法凭证由后端按原快照物化任务项。 |
| TC-DOWNSTREAM-REQUEUE-TASK-011 | processing 租约恢复 | API 在认领后中断,租约过期后项目回到队列并重新复核;已成功项不重复调用 Gateway。 |
| TC-DOWNSTREAM-REQUEUE-TASK-012 | 每应用原子限速 | 多任务、多实例和重叠扫描同时执行时,每个应用每秒消耗不超过配置值;不同应用互不阻塞。 |
| TC-DOWNSTREAM-REQUEUE-TASK-013 | 离线等待与恢复 | 客户无 connected 连接时进入等待连接,不增加失败/跳过;连接恢复后回队列继续。 |
| TC-DOWNSTREAM-REQUEUE-TASK-014 | ACK 阈值与清零 | 写出进入等待 ACK 不清零;ACK 超时/拒绝按应用累加,达到阈值自动暂停并审计;有效 ACK 才清零。 |
| TC-DOWNSTREAM-REQUEUE-TASK-015 | 列表完整分页 | 任务列表支持状态和分页,第 11 条以后可访问,中文状态、创建人、原因、进度和各结果数准确。 |
| TC-DOWNSTREAM-REQUEUE-TASK-016 | 完整任务项查询 | 任务项支持分页、结果及关键词查询,等待连接/外部 ACK/本任务 ACK、跳过、失败和未处理均中文展示并保留原因。 |
| TC-DOWNSTREAM-REQUEUE-TASK-017 | 终止并发边界 | 终止后未认领和等待连接项置为未处理;处理中或已写出项不撤回;执行器不再认领新项。 |
# 2026-08-13 HTTP 与 Gateway 报文容量专项用例
| 用例编号 | 优先级 | 验证内容 | 预期结果 |
| --- | --- | --- | --- |
| TC-TRANSPORT-SIZE-001 | P0 | 模拟 NestJS 返回约128KiB的合法待投递回执JSON,由Gateway通用API传输方法读取并反序列化 | 响应完整解析,字段长度与服务端输出一致,不出现64KiB截断或JSON语法错误 |
| TC-TRANSPORT-SIZE-002 | P0 | 模拟NestJS返回超过4MiB的响应 | Gateway停止读取并返回`api response exceeds 4194304-byte limit`容量错误,不返回`unexpected end of JSON input`,不把不完整数据当作成功结果 |
| TC-HTTP-BODY-001 | P0 | 分别向普通JSON接口和`/api/client/send/imports/preview`提交约3MiB合法JSON | 普通接口在Controller前返回413;导入接口成功解析且保留rawBody,证明25MiB解析器只对导入路由生效 |
| TC-HTTP-BODY-002 | P1 | 分别测试普通JSON 2MiB边界、导入JSON 25MiB边界及业务层原始正文20MiB边界 | 边界内请求正常进入业务校验;超过解析器边界返回413;超过20MiB原始导入正文返回明确业务错误且不创建发送任务 |
| TC-NGINX-BODY-001 | P1 | 检查预生产有效Nginx配置及域名路由 | `sms.lisglo.com`请求体上限不低于30MiB并承载私有导入接口;`api.lisglo.com`不暴露私有导入路由且其现有上限不影响单条公网HTTP API |
# 2026-08-13 运营端信息密度与运营商标签统一专项用例
| 编号 | 场景 | 步骤 | 预期结果 |
| --- | --- | --- | --- |
| TC-BILLING-RECEIPT-OPERATOR-001 | 人工充值回执展示操作人员姓名 | 使用有`operatorId`的真实人工充值记录查询充值列表并打开回执 | API按用户表返回`operatorName`,回执显示姓名而非用户ID;无操作人的历史记录显示“系统”,关联用户确已不存在时显示“未知操作人员” |
| TC-DASHBOARD-SIGNATURE-REMOVE-001 | 删除看板签名统计模块并统一金额样式 | 打开运营看板,检查指标卡与后续模块 | 两个“今日签名发送统计”模块均不存在;今日消费金额与今日发送总量主数字字号、字重和颜色一致;今日活跃签名仍来自真实接口 |
| TC-ENTERPRISE-APP-WIDTH-001 | 企业应用关键列缩窄 | 在桌面大屏打开企业应用管理并读取表头列宽 | 状态、到达率、单价列宽均约为原宽度80%,字段与操作均未隐藏,横向滚动需求减少 |
| TC-UI-CARRIER-TAG-002 | 全局运营商数据展示复用通用标签 | 抽查通道/通道组、监控、报备、企业签名、短信审核、批次号码、发送记录和客户端发送详情 | 移动、联通、电信均使用全局低饱和胶囊及统一色值;有运营商集合的三网通道显示三个标签,历史通道级字段显示中性“三网”;筛选选项、图表图例与导出文本保持纯文本 |
+362 -2
View File
@@ -2,6 +2,26 @@
> 环境命名:当前 `8.160.169.106:12026`Web/API)和 `8.160.169.106:17890`(CMPP 入站)实例统一定义为“预发布环境”。历史记录中涉及该实例的验证、部署和业务页面均按预发布环境理解;`production-deploy.sh``NODE_ENV=production` 及正式生产安全/备份规范保留原有技术语义,不代表该实例为正式生产。
## 2026-08-12 企业签名弹窗、充值回执、通道列表与金额显示优化(已提交、已部署)
- 已确认企业签名报备状态采用“三列运营商分区”方案,运营商标签采用低饱和胶囊方案;本批同步调整充值回执密度、通道今日提交后端排序、零提交比率展示和成本费率布局。
- 撤回金额小数弱化规则:所有只读金额恢复同字号同色,最多展示4位并裁掉末尾0;通道成本费率固定4位。运营看板今日消费和企业应用单价恢复正常主数字深色样式。
- 不涉及数据库结构或生产数据迁移。通道服务专项 1 suite/46 项、API 全量 35 suites/453 项、前后端 TypeScript/构建、Gateway `go test ./...``go vet ./...`、4 份队列结构契约及 `git diff --check` 已通过;Vite 仅保留既有大分块 warning,测试中的 Redis unavailable 和预期异常日志均为既有受控分支,未伪造外部依赖。
- 应用内浏览器连接真实本地 NestJS、PostgreSQL、Redis 和 production preview 验收:1366×768 下三运营商报备状态弹窗横向三分区且页面无横向溢出;普通充值回执内容区 `scrollHeight=clientHeight=546`,无需滚动即可看到完成按钮;通道零提交四个比率均为深灰短杠,移动/联通/电信低饱和胶囊色互不相同;运营看板今日消费为 30px 深黑主数字,企业应用单价为深色常规字号。820px 复核弹窗改为单列且页面无横向溢出,浏览器 console error/warn 为 0。验收未保存报备状态、未充值、未启停或编辑通道、未发送短信。
- 功能提交 `f350bf5ef333c78756505e1d895768c46fe73c72` 已推送并部署预发布。发布包 `outputs/cmpp-f350bf5e-20260812-131846.tar.gz` 的本地/服务器 SHA-256 均为 `990a1e0d5fcf69662a73fa0d61a837460858c5dfe9d3ba44495c08b139eb32df`;运行目录 `.deployed-commit` 回读与功能提交完全一致。
- 有效发布前备份位于 `/opt/cmpp-platform-backups/releases/20260812-132030-before-f350bf5e`,PostgreSQL、旧运行源码和环境文件均非空并通过 `sha256sum -c`;旧运行目录保留为 `/opt/cmpp-platform.previous-20260812-132030`。第一次备份因 Prisma URL 的 `schema` 参数不被 `pg_dump` 接受而在切换运行目录前安全中止,其目录 `/opt/cmpp-platform-backups/releases/20260812-131846-before-f350bf5e` 保留为中止证据;第二次仅在传给 `pg_dump` 时移除该客户端专用参数,未修改线上环境文件。
- 预发布 85 条 migration 全部完成且无待执行;API、Gateway、Nginx、PostgreSQL 均 active`3000/8090/12026/17890/9000` 正常监听,运营端、客户端和公网 API 均返回 200。Redis Stream 为 `consumer=1/pending=0/lag=0`,发布后 API/Gateway error 日志为 0,前端实际产物为 `index-C2qqOO8d.js``index-D1lUWX4h.css`。发布过程未发送、补发或重投真实短信,未修改通道账号、密码、启停状态、企业余额或客户连接配置。
## 2026-08-12 签名热力图、登录动画、金额样式与利润口径(已提交、已部署)
- 功能提交 `0cd353450abb999bd9c192c6df482af5e07095b5` 已推送并部署预发布。签名热力图新增 T-1~T-30 合计并按发送量降序,观察期继续保存真实单日快照但不触发预警;企业签名企业/应用文字改为常规字重,客户端登录框新增可降级的 Canvas 动画,金额展示统一弱化小数部分。
- 同批发布此前未提交的利润报表口径调整:净消费改为收入,收入按成功条数乘企业应用单价计算,返还字段和返还合计从 API、类型与页面移除。
- 本地真实 PostgreSQL 验证检测幂等且不增加站内消息或 webhook 投递;定向 2 suites/18 项、API 全量 35 suites/451 项、前后端 TypeScript/构建、Gateway `go test ./...``go vet ./...`、队列结构契约和 `git diff --check` 均通过。Vite 仅保留既有大分块 warning。
- 本地浏览器确认登录页 Canvas 存在且无控制台错误,热力图显示 30 日合计并降序,金额小数部分使用弱化色,企业/应用文字字重为 400。生产浏览器新标签连接超时,未把该次生产可见验收记为通过;生产 HTML/API、构建产物和真实数据库回读均正常。
- 发布包 `outputs/cmpp-0cd35345-20260812-114757.tar.gz` 的本地/服务器 SHA-256 均为 `593b90045694a6e1bda2ff0b75e971b4fdbe9b5d99f1fa5960a31a439c0c0c8f`。有效发布前备份位于 `/opt/cmpp-platform-backups/releases/20260812-115100-before-0cd35345`,PostgreSQL、旧运行源码和环境文件均非空并通过 `sha256sum -c`;旧运行目录保留为 `/opt/cmpp-platform.previous-20260812-115100`。首次短连接备份在生成校验清单前中断,未切换线上目录,其不完整事故目录 `/opt/cmpp-platform-backups/releases/20260812-114757-before-0cd35345` 被保留而未冒充可恢复备份。
- 预发布 85 条 migration 全部完成且无待执行项;API、Gateway、Nginx、PostgreSQL、Redis 和 MinIO 端口/health 正常,Redis Stream 为 `consumer=1/pending=0/lag=0`,发布后 API/Gateway error 日志为 0。启动补偿生成 2026-08-12 的 234 条观察期快照;受控补齐 2026-08-11 快照 132 条后,预警周期、站内消息和 webhook 投递仍全部为 0,没有发送、补发或重投真实短信。
- 生产真实数据库确认 `【榆林市供热有限公司】` 的 2026-08-12 检测快照对应 2026-08-11 活动,企业维度移动 2494、联通 1029、电信 1489,合计 5012;热力图不再因报备观察期而吞掉该日发送数据。
## 2026-07-16 客户端签名与引流信息页面重做(已提交、已部署)
- 客户端“签名与引流信息”按运营端信息结构重做为签名父级、引流信息子级的可展开工作台,增加真实后端状态统计、关键字/应用/状态筛选、已交资料数、修改说明、新增/修改/删除确认;客户端文案不再出现通道和内部报备概念。
@@ -1994,7 +2014,7 @@ git diff --check
- 根因确认:企业应用编辑页原先按 `Math.round(元 × 100)` 保存,`0.0325 元`只能落为 3 分;PostgreSQL 的余额、流水、单价、计费和利润字段也均为 `Int` 分,无法表达万分之一元。现统一调整为 `1 元 = 10000 金额单位`,字段名中的 `Cents` 仅为兼容既有 API 保留。
- Prisma 金额列统一升级为 `BigInt`migration `20260716150000_expand_money_precision_to_four_decimals` 将历史整数分乘以 100。迁移前已备份本地真实 PostgreSQL;抽查 `AccountTransaction、SmsApplication、SmsMessageRecord、TenantAccount` 汇总,迁移后整数值均精确为迁移前 100 倍,按新除数换算后的人民币金额不变。53 条 migration 已全部应用,Prisma schema validate 和 migrate status 均通过。
- 企业应用客户价、通道成本价、授信和人工充值输入均允许最多 4 位小数并转换为整数金额单位;运营端与客户端的余额、授信、今日消费、今日返还、充值、短信详单、客户价、通道价和利润报表统一固定展示 4 位小数。利润 CSV 改为以“元”为表头并导出 4 位小数
- 企业应用客户价、通道成本价、授信和人工充值输入均允许最多 4 位小数并转换为整数金额单位;该阶段曾统一固定展示 4 位小数,现已由 2026-08-12 的统一金额样式需求调整为最多 4 位并裁剪末尾无意义的 `0`,通道成本费率除外。利润 CSV 以“元”为表头并保留业务所需精度
- NestJS 对客户价、通道价、充值、授信、计费规则和计费结果增加安全整数校验;Prisma `BigInt` 响应仅在 JavaScript 安全整数范围内序列化为 number,超限直接报错,避免静默精度损失。计费单测新增 `325 × 2 = 650` 金额单位,企业应用更新单测使用 `customerUnitPrice=325`
- 使用真实本地 NestJS API、PostgreSQL、Redis 和 Playwright/Chromium 编辑一条已配置通道组的企业应用:页面填写 `0.0325` 后保存,数据库核对 `SmsApplication.customerUnitPrice=325`,再次进入编辑页仍为 `0.0325`,控制台无 error;随后已恢复原单价并清理临时管理员、角色关联和操作日志。
- API 全量 20 个 Jest 测试套件通过,其中金额与企业应用目标套件 45 条用例通过;API TypeScript build、前端 TypeScript/Vite 生产 build、Gateway `go test ./...`、Prisma validate/status 和 `git diff --check` 均通过。
@@ -2377,7 +2397,7 @@ git diff --check
- 运营端充值记录每行新增“查看回执”操作,弹窗直接使用真实`RechargeOrder`、企业信息及订单关联的`balanceAfterCents`,据此计算入账前余额;历史记录缺少可追溯余额时显示`-`,不使用当前账户余额或前端假数据补齐。
- 回执左上只展示系统现有`/logo/logo1.png`真实Logo;展示入账状态、本次金额、企业名称和编码、订单号、入账时间、前后余额、入账方式与备注,适合客户截图留存。
- “本次充值金额”采用实际精度:整数不显示小数,存在小数时移除末尾无效零;前后余额继续显示平台统一四位精度。正数显示“已入账”,负数冲正显示“已冲正”。
- “本次充值金额”采用实际精度:整数不显示小数,存在小数时移除末尾无效零;该阶段前后余额曾固定显示四位,现已随 2026-08-12 统一金额样式调整为最多四位并裁剪末尾无意义的0。正数显示“已入账”,负数冲正显示“已冲正”。
- 金额边界验证结果:`10000.0000 → 10,000``10000.2500 → 10,000.25``10000.0001 → 10,000.0001`、负数冲正`-123.4500 → 123.45`,符合主金额按实际精度展示口径。
- 使用Node.js v24.14.0执行前端TypeScript和Vite生产构建通过,保留既有约1.93MB单chunk/579.51KB gzip警告;`git diff --check`通过。首次由系统旧Node执行时Vite不支持`??=`且错误返回0,已明确排除,未将其计为通过。
- 浏览器加载真实本地前端/API后进入运营登录页,页面标题正确、控制台0条error/warn;由于当前浏览器无有效会话且存在图形验证码,本轮未绕过验证码,登录后的“查看回执”点击与视觉验收仍需人工登录后补测。
@@ -3213,3 +3233,343 @@ git diff --check
- 合并后同步结构契约:R1登记`getPendingAudits/listReceiptAnomalies`及当前重入队实现,R2登记轻量审核和回执异常查询,R5/R10登记长短信回执口径及处理变化;R6清单的5个声明哈希与当前`main`既有Gateway源码重新对齐,Gateway业务源码没有工作区改动。全部结构门禁随后通过。
- 发布前验证通过:API全量32 suites / 413 testsAPI TypeScript构建;前端TypeScript与Vite生产构建;Prisma schema validate/generateGateway `go test ./... -count=1``go vet ./...`19个`.mjs`结构门禁、R6/R7 Go结构门禁、依赖缓解安全门禁和`git diff --check`。Vite仍只有既有大chunk警告,Jest仍使用`--forceExit`结束既有开放句柄。
- 提交范围排除`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`;这些缓存或临时产物继续保留在工作区,不删除、不提交。本轮未连接或修改生产/预生产服务、数据库、通道、余额或客户连接,未发送、补发或重投真实短信。
## 2026-08-06 `RealeseV2.3` 预生产发布记录
- 发布目标为annotated tag`RealeseV2.3`对应提交`8ad8e6179305f7be3046fc761826c6f1c1b9e4c2`。发布前预生产`.deployed-commit=530a65de809a1d2dee7000642bc99c1e483c15f6`,数据库81条migrationAPI、Gateway、Nginx、PostgreSQL、Redis、MinIO均active,关键端口全部监听,API/Gateway health与Redis PONG通过。5条active供应商通道均为`connected 1/1`,最近120秒客户下游连接为0,Redis Stream消费者1、`pending=0``lag=0`,近30分钟API/Gateway无error级日志。
- 精确Git归档`outputs/cmpp-RealeseV2.3-8ad8e617-20260806-1128.tar.gz`包含803个条目、2128574字节,本地与服务器SHA-256均为`3c6c8dd274b7feef783dcb941e83950bf3b24c1841587d1419f4084912ced167`,服务器tar完整性检查通过。
- 发布前备份目录为`/opt/cmpp-platform-backups/releases/20260806-112900-before-8ad8e617`。PostgreSQL备份`postgresql.sql.gz`为12118930字节、SHA-256=`25c9b47f7287a486b204669c0afc90799a2bb6abd441b7b0fa742440ba1386f7`;运行源码`runtime-source.tar.gz`为2142057字节、SHA-256=`d7cb9d03b40687cba5c9b56c48528d3c217fd448e4c221dafe435a616ee40793`;环境文件为895字节、SHA-256=`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60`。三项备份均为0600并通过gzip/tar及`sha256sum -c`,上一运行目录保留为`/opt/cmpp-platform.previous-20260806-112900`
- 标准`tools/deploy/production-deploy.sh`成功完成两套依赖安装、安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx配置校验及Gateway先于API重启。新增`20260806100000_add_receipt_anomalies`成功应用,预生产共82条migration且Prisma确认schema up to date`SmsReceiptAnomaly`表存在,发布时记录数为0。最终`.deployed-commit=8ad8e6179305f7be3046fc761826c6f1c1b9e4c2`
- 部署后API、Gateway、Nginx、PostgreSQL、Redis、MinIO均active,关键端口均监听;API/Gateway/MinIO内部健康、Redis PONG、公网页面、运营登录、客户端登录、API health和客户Swagger均通过,客户OpenAPI未认证POST返回401,API专用域名的根路径、管理页面和管理API仍返回404,公网CMPP 17890纯TCP连通。
- 5条active供应商通道均在本次重启后产生新状态并恢复`connected 1/1`Redis Stream仍为消费者1、`pending=0``lag=0`,13个通道TPS配置键存在,最近120秒客户下游连接为0。部署后API/Gateway error级journal为0,程序错误关键字无新增;Nginx仅记录正常优雅重启notice。
- npm审计仍报告根项目2项high及API 3项moderate、2项high;专用安全门禁确认PostCSS补丁、React Router RSC未使用和brace expansion边界有效,未执行可能破坏兼容性的自动升级。本次没有发送、补发或重投真实短信,没有修改真实通道账号、密码、启停状态、企业余额或客户连接。
## 2026-08-09 运营端休眠唤醒后连续401修复(本地未提交)
- 生产Nginx只读日志确认`pending-audits`连续401的响应体长度为86字节,对应`SESSION_LOCKED`2026-08-09 11:10:45运营端登录成功后,前端在11:10:47立即请求`/auth/session/lock`11:10:49才完成解锁,而11:10:57短信记录、企业和应用选项接口均返回200。根因是同一SPA重新登录未重置上一会话的内存活动时间,以及布局只读取首次挂载的`session.locked`快照,锁定后仍保留业务路由和角标轮询。
- 登录成功后立即调用`markUserActivity()`,保证新会话使用新的活动起点。`AppShell`在本地空闲、服务端`SESSION_LOCKED`和跨标签事件三条锁定入口中统一写入实时锁定状态、暂停业务路由,并将状态回传给运营布局;解锁时恢复路由、清除锁定请求标记并重新挂载当前页面。
- 运营端待审核角标现在跟随`AppShell`实时锁定状态启停,锁定后清理30秒定时器和窗口焦点监听;解锁后才重新拉取真实待审核数量。短信记录路由因锁定被卸载,解锁后重新请求真实短信记录及筛选项,不使用缓存或静态数据伪造恢复结果。
- 使用Node.js v24.14.0分别执行前端TypeScript `--noEmit`和Vite v8.1.5生产构建,2534个模块构建通过;仅保留既有约2.03MB单chunk和CSS插件耗时提示。`git diff --check`通过。
- 本地`http://127.0.0.1:4173/#/admin/login`浏览器检查通过页面身份、非空渲染、无框架错误覆盖和输入控件交互;本地预览未启动真实API,验证码请求按预期返回502,因此没有伪造登录态,也未在本地完成真实锁定/解锁交互。完整`TC-AUTH-014``TC-AUTH-016`仍需代码发布后在预生产使用真实会话复测。
- 本轮未提交、未推送、未部署,没有发送、补发或重投真实短信,没有修改数据库、企业余额、真实通道配置或客户连接。既有`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`继续保留,不删除、不提交、不归因。
## 2026-08-09 签名删除预检与多通道报备汇总修复(本地未提交)
- 删除预检现已将`approved``abandoned`等报备终态排除在“未结束报备任务”之外;预生产只读核对的三条任务`cmrxc3rgk004017nks1bktp9n``cmrxc3rgo004217nkw9enwk72``cmrxc3rgr004417nk887sg9ef`均为`abandoned`,修复后不会再仅因这三条历史任务阻止签名删除。模板、引流信息和真实过程态任务仍继续阻止删除。
- 新增统一报备汇总函数并复用于签名总状态、三网摘要和引流报备摘要:全部目标失败才汇总为`failed`;通过与失败并存汇总为`partial_success`;失败与待处理并存保持`reporting`。运营端签名卡片同步调整为只有所有适用目标均失败才显示红色整体失败,部分失败且仍待处理显示橙色,部分通过显示蓝色。
- 使用Node.js v24.14.0执行本次相关4个API suites,118个测试全部通过;排除受本地Redis影响的`send-chain.service.spec.ts`后,其余API全量32个suites、312个测试全部通过。API与前端TypeScript `--noEmit --incremental false`均通过,Vite v8.1.5生产构建通过(2534个模块,仅保留既有约2.03MB单chunk提示),`git diff --check`通过。
- API全量运行结果为33 suites中的32个通过、420个测试中的415个通过;未通过的5项全部位于本次未修改的`send-chain.service.spec.ts`,原因是本地Redis `127.0.0.1:6379`未运行导致BullMQ连接失败和5秒超时。该套件单独重跑同样被Redis重连拖至工具超时,因此不把API全量记为通过,也未为本任务修改发送链代码或启动外部依赖。
- 本轮没有提交、推送或部署,没有修改预生产数据库、发送真实短信、调整通道配置或客户连接。其他会话已有的登录/休眠恢复代码和文档增量继续原样保留;测试命令意外生成且本轮开始前不存在的根目录`pnpm-lock.yaml`已删除,既有`tsbuildinfo``outputs/`和空文件`=`仍受保护。
## 2026-08-09 发送质量矩阵与成功率色阶统一(本地未提交)
- 签名通道发送质量明细的“按引流切分”已改为每个通道固定三行“含引流、不含引流、未检测”,并固定三列“移动、联通、电信”;通道集合取整体和引流切分真实数据的并集,缺少真实提交的组合保留位置并显示单个`0`。整体统计页签及后端统计口径未改动。
- 新增共享成功率色阶函数,签名质量列表、明细总览、运营商概览、矩阵、短信通道管理列表和通道报备详情统一按`0`红、`>0且<=25`橙、`>25且<=50`黄、`>50且<=75`蓝、`>75且<96`绿、`>=96`深绿展示数字。通道列表和报备详情的提交失败、回执未知、送达失败比例及数量恢复为黑灰色。
- 使用Node.js v24.14.0执行成功率边界校验,`0、0.1、25、25.1、50、50.1、75、75.1、95.9、96`共10个边界值全部符合约定;前端TypeScript `--noEmit --incremental false`通过,Vite v8.1.5生产构建通过(2535个模块),仅保留既有约2.03MB单chunk提示;`git diff --check`通过。
- 本步骤只修改前端展示、共享色阶工具及需求/用例/进度文档,没有修改后端、数据库或真实统计接口,没有连接预生产、发送/补发/重投短信,也没有修改真实通道、企业余额或客户连接。代码按要求保持未提交、未推送、未部署;其他会话和前一步已有修改、`tsbuildinfo``outputs/`及空文件`=`继续保留并保护。
## 2026-08-09 运营端菜单与查询控件细节修正(本地未提交)
- 风控规则已从“审核中心”移动到“安全控制”,页面面包屑同步调整,既有`/admin/risk-rules`路由和真实后端规则接口未改变。发送监控对移动、联通、电信、三网和未识别运营商统一显示中文,未知新值仍原样展示。
- 待生成报备批次两个页签已移除括号及动态数量,标题固定为“待生成资料”和“已生成批次”;后端返回的`total`继续用于分页。短信记录通道条件已由自由文本改为通用可搜索`Select`,真实调用通道接口加载未删除通道,以名称和编码搜索,查询及导出改传精确`channelId`,重置恢复全部通道。
- 使用Node.js v24.14.0执行前端TypeScript `--noEmit --incremental false`通过,Vite v8.1.5生产构建通过(2535个模块),仅保留既有约2.03MB单chunk提示;`git diff --check`通过。本步骤不涉及后端业务逻辑,因此未增加或运行API单元测试。
- 本步骤没有连接预生产、修改数据库、发送/补发/重投短信,也没有修改真实通道配置、企业余额或客户连接。代码保持未提交、未推送、未部署;`AdminLayout.tsx`仅对菜单项位置做局部修改,其他会话已有的会话锁定和轻量轮询增量继续保留且未归因给本步骤。
## 2026-08-09 URL空白边界识别修正(本地未提交)
- 引流检测副本不再删除URL类别中的空白字符。协议链接、裸域名及路径遇普通空格、制表符、换行或全角空格时立即结束命中,空白后的字符不再归入前一个链接;空格拆分域名也不再被拼接成一个URL。短信真实原文、命中位置映射、中文句号域名兼容和数据库默认URL正则保持不变,因此本步骤不需要migration。
- 邮箱排除改用独立检测副本,继续允许仅为排除目的而规范化带空格邮箱,避免其中的数字本地部分被误判为手机号;手机号和固话类别原有空格、短横线及中文标点规避识别不受URL边界修正影响。
- 引流检测针对性测试13/13通过,覆盖四类空白边界、空格拆分域名不命中、中文句号域名、原文高亮位置、普通及带空格邮箱排除、手机号和固话识别;规则管理与通道相关2个suites、57个测试通过。API正式构建配置TypeScript检查和`git diff --check`通过。
- 一次诊断命令误用`api/tsconfig.json --noEmit`,该配置会包含全部`*.spec.ts`但不加载Jest全局类型,因而产生既有测试类型环境错误;随后使用项目正式`api/tsconfig.build.json`重新检查并通过,未修改TypeScript或Jest配置。
- 本步骤未连接预生产、未修改数据库规则、未发送/补发/重投真实短信,也未修改通道、余额或客户连接;代码保持未提交、未推送、未部署。
## 2026-08-09 CMPP客户连接请求诊断日志(本地未提交)
- Gateway入站CMPP CONNECT鉴权请求新增协议版本和原始版本值,并将真实TCP远端IP、Source_Addr账号、Base64 AuthenticatorSource、时间戳一并传给API。API在认证成功或失败时同步写`cmpp_connection.connect_requested`操作日志,客户入站资源固定为`cmpp_downstream_connection`;未知账号同样以请求账号为资源ID保存,便于定位恶意连接。
- 日志`ipAddress`直接保存Gateway报告的真实远端IP,结构化详情保存认证结果、应用ID、失败原因和全部客户请求参数。标准CMPP CONNECT报文不含明文密码,因此详情明确显示该协议事实;只有兼容调用真实携带`password`字段时才保存该字段,平台数据库内的应用密钥不会作为客户参数写入日志。
- 运营端系统与操作日志对`cmpp_connection.connect_requested`增加“查看详情”按钮,弹窗展示请求IP、账号、密码字段说明、AuthenticatorSource、时间戳、协议版本、结果、失败原因及应用ID;既有列表IP列继续读取真实`OperationLog.ipAddress`
- Gateway全量`go test ./... -count=1``go vet ./...`通过;Gateway入站包测试通过。API Gateway认证针对性3/3通过,覆盖成功、应用禁用失败和未知恶意账号;API正式构建TypeScript、前端TypeScript、Vite生产构建和`git diff --check`通过。Vite仅保留既有约2.03MB单chunk提示,Jest使用`--forceExit`结束既有开放句柄。
- 本步骤未实际建立、断开或修改预生产客户连接,未连接预生产数据库,未发送短信,也未修改真实账号、密码、IP白名单、连接数、通道或余额;代码保持未提交、未推送、未部署。
## 2026-08-09 通讯交互日志完整手机号(本地未提交)
- `ProtocolInteractionLog`新增可空`phoneNumber`字段及migration`20260809130000_add_protocol_log_plain_phone`。新产生的CMPP/HTTP通讯日志将Gateway或API上报的完整号码写入该字段,不再为新记录生成`phoneMasked`;既有脱敏列暂不删除,仅作为旧行显示兜底,不执行历史号码恢复或回填。
- 通讯日志关键词查询已从`phoneMasked`切换到`phoneNumber`,运营端列表对象列和详情读取完整号码,筛选提示及页面说明同步明确“完整手机号”。短信正文、密码、密钥、Token、鉴权头和完整请求体仍继续由通讯日志详情清洗逻辑排除。
- Prisma schema validate和client generate通过;通讯日志服务测试3/3通过,覆盖新日志完整号码持久化、敏感详情排除和完整号码查询。API正式构建TypeScript、前端TypeScript、Vite生产构建和`git diff --check`通过,Vite仅保留既有约2.03MB单chunk提示。
- 新migration尚未应用到本地或预生产数据库;本步骤未查询或修改历史手机号,未连接预生产、发送短信、修改通道、余额、客户连接或权限配置。代码保持未提交、未推送、未部署。
## 2026-08-09 三类报表全量筛选汇总(本地未提交)
- `GET /api/admin/reports/reconciliation``profit``quality`在原有分页响应中新增`summary`;后端使用与明细、总数完全相同的`where`对PostgreSQL报表表执行聚合,不从当前页`items`二次求和。
- 对账、利润、质量页在筛选区后展示“筛选结果汇总”,明确说明不受当前分页影响。三页均展示提交/发送/未知/成功/失败合计;利润页另展示全部金额合计和重算综合利润率,质量页展示重算综合成功率。
- 成功率按合计成功/合计发送、利润率按合计利润/合计净消费计算,避免求和或平均分组百分比导致失真;平均到达时长不可直接加总,未放入汇总区。
- 使用Node.js v24.14.0运行`reports.service.spec.ts` 7/7通过,增加无匹配行时合计及综合率全部归零覆盖;API正式构建TypeScript和前端TypeScript均通过。首次测试命令命中系统旧Node导致缺少`node:util/types`,改用工作区Node后专项测试正常;一次在仓库根目录直接运行API Jest未加载`api/jest.config.cjs`,随后在`api`目录按项目配置重跑通过。
- 本步骤未连接预生产、未修改数据库、未发送/补发/重投短信,也未修改真实通道、余额或客户连接。代码保持未提交、未推送、未部署。
## 2026-08-09 运营端新建企业省市字典修正(本地未提交)
- 确认根因为`AdminCustomerFormPage`前端写死仅12个省级地区,且每省只列出1至3个地市,与平台真实数据库不一致。该静态省市数组已移除。
- 新增真实字典接口`GET /api/admin/dictionaries/administrative-regions`,从PostgreSQL `PhoneSegment.province/city`执行去重查询,服务层过滤空白值、合并重复地市并按中文排序。本轮不新建静态地区表、不使用Mock或localStorage。
- 新建/编辑企业页加载上述接口并做真实省—地市级联,切换省份时清空原地市;字典加载失败显式报错。编辑历史档案时会将当前原值补入选项,避免因号段库格式差异静默丢值。
- 字典服务专项测试16/16通过,覆盖真实查询参数、去重、空值过滤和中文排序;API正式构建TypeScript与前端TypeScript通过。
- 本步骤未新增migration,未修改`PhoneSegment`数据或任何企业档案,未连接预生产、发送短信、修改通道、余额或客户连接。代码保持未提交、未推送、未部署。
- 本轮最终组合复核:报表与字典专项共2 suites / 23 tests通过,API正式构建TypeScript、前端TypeScript和Vite v8.1.5生产构建通过(2535个模块);仅保留既有约2.03MB单chunk告警。
## 2026-08-09 通道组逻辑删除与真实风险统计(本地验证完成)
- 新增`GET /api/admin/channel-groups/:id/deletion-impact`,从真实数据库按不同企业应用统计正常/已删除关联,并返回组内通道数及`submitStatus = queued`的等待供应商提交记录数;不使用静态数据、Mock或localStorage。
- 删除弹窗改为“删除通道组:{名称}”,依次展示“关联正常企业应用、关联已删除企业应用、组内通道、等待供应商提交结果”,并使用约定的历史保留说明;不再要求输入名称或删除原因,业务关联数量不禁用确认删除。
- 删除接口不再因企业应用关联阻止,也不再物理删除通道组或组内通道;仅将通道组状态置为`deleted`并记录删除前快照、实时影响统计和逻辑删除方式。默认列表排除已删除组,新短信继续只选择活动通道组,历史配置、发送、回执、上行匹配和审计链路保留。
- 通道专项`channels.service.spec.ts` 1 suite / 43 tests通过;API与前端TypeScript `--noEmit --incremental false`通过;Vite v8.1.5生产构建通过(2535 modules,仅保留既有约2.03MB单chunk提示);Gateway全量`go test ./... -count=1``go vet ./...`通过;Prisma schema validate及client generate通过;全部结构契约门禁通过。
- API全量运行共33 suites / 429 tests,其中32 suites / 424 tests通过;仅`send-chain.service.spec.ts`的5项因本机Redis `127.0.0.1:6379`未运行产生连接拒绝并超时,与此前环境阻塞一致,不是业务断言失败。本轮未为通过测试而伪造Redis或修改发送链逻辑。
- 本地验证未发送、补发或重投真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接。预生产发布结果将在安全备份、migration和部署后只读检查完成后补记。
## 2026-08-09 工作区合并与预生产发布记录(`4724b9db`
- 工作区65个有效源码、migration、测试、契约和文档文件统一提交为`4724b9db6a99bec15350e37978d3fb165ee74bb9`并推送`origin/main`;本地与远端提交一致。构建缓存`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`未提交、未删除。
- 精确Git归档`outputs/cmpp-4724b9db-20260809-150404.tar.gz`包含808个条目、2148867字节,本地与服务器SHA-256均为`e5b413f10fa54a5bd7b9cc368ae2908d06e53122d5d3e65cb205ce5c74955985`,服务器tar完整性检查通过。
- 发布前备份目录为`/opt/cmpp-platform-backups/releases/20260809-150404-before-4724b9db`。PostgreSQL备份`postgresql.sql.gz`为16606417字节、SHA-256=`0e2dbb91345f266586a349006357477214bbc2daa7125996abe99df44f869aa6`;运行源码`runtime-source.tar.gz`为2096964字节、SHA-256=`a65591656e4c8e102aaacdf1afa061c4645597d22dc80575a19fb25b64781f2f`;环境文件为895字节、SHA-256=`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60`。三项备份均为0600并通过gzip/tar及`sha256sum -c`,上一运行目录保留为`/opt/cmpp-platform.previous-20260809-150404`
- 标准`tools/deploy/production-deploy.sh`成功完成依赖安装、安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx配置校验及Gateway先于API重启。新增`20260809130000_add_protocol_log_plain_phone`成功应用,预生产共83条已完成migration`ProtocolInteractionLog.phoneNumber`字段存在;最终`.deployed-commit=4724b9db6a99bec15350e37978d3fb165ee74bb9`
- 部署后API、Gateway、Nginx、PostgreSQL、MinIO均activeRedis PONG,内部API/Gateway/MinIO健康通过,无failed systemd unit。公网运营登录、客户端登录、API health和客户Swagger`/api/client-docs`均为200;API专用域名根路径、管理页面和管理API均为404,公网CMPP 17890纯TCP连通。Redis Stream消费者1、`pending=0``lag=0`17个通道TPS配置键存在。
- 新通道组删除影响接口已出现在部署后Swagger路径中,未认证访问返回401;前端生产包包含“等待供应商提交结果”和历史数据保留完整文案。对真实PostgreSQL最近三个活动通道组只读执行同口径统计,均得到正常应用2、已删除应用0、组内通道3、等待供应商提交0;没有点击或调用确认删除。现有浏览器无登录会话,未绕过验证码或伪造登录态,登录后UI弹窗交互仍可作为后续人工验收项。
- 发布前数据库状态为9条active通道、连接状态9条connected;重启后6条active通道恢复`connected 1/1`3条富泷通道返回供应商`authentication / connect response status: auth failed`。本轮未修改这些通道的账号、密码或启停状态,仅保留真实失败状态并报告。部署后API/Gateway error级journal均为0。
- npm审计报告根项目3项high、API项目3项moderate和4项high;专用安全门禁确认PostCSS补丁、React Router RSC未使用和brace expansion边界有效,未执行可能破坏兼容性的自动升级。本次没有发送、补发或重投真实短信,没有修改企业余额、客户连接或任何真实通道配置。
## 2026-08-09 通道、签名与模板级联删除确认(已提交、已部署)
- 通道组删除弹窗不再展示“关联已删除企业应用”,继续展示关联正常企业应用、组内通道和等待供应商提交结果;后端已有真实影响统计及删除审计快照保持不变。
- 通道、签名和模板删除原因统一改为选填。签名存在关联模板、引流信息或未结束报备任务时分别出现“同时删除关联的模板”“同时删除引流信息”“同时结束关联的报备任务”;通道存在未结束报备任务时出现结束报备勾选项。发现的级联项必须全部勾选后页面才允许确认,后端也独立复核所有布尔选项,不能绕过前端直接删除。
- 客户端允许同步结束签名关联的未结束报备任务,但客户端专用预检不返回任务ID、状态或通道详情,页面只展示统一处理说明。运营端仍可查看真实任务ID和状态。
- 所有级联动作与主对象逻辑删除在同一个`Serializable`事务完成;关联模板和引流信息逻辑删除,过程态报备任务置为`abandoned`并逐条写`ChannelSignatureReportRecord`,子对象另写操作日志。通道删除后,同事务按剩余未删除通道重算受影响签名报备汇总;活动通道组、直接路由、活动连接及关联模板的活动发送任务仍保持硬阻断。
- 修正模板活动任务终态口径:`SmsSendTask``approved/rejected``SmsBatchTask``finished/canceled/rejected/failed/completed/cancelled`不再被误判为未结束任务;真实过程态任务继续阻止模板或签名级联删除。无需新增数据库字段或migration。
- 使用Node.js v24运行删除治理定向1 suite / 11 tests全部通过;排除此前已确认依赖本机Redis的`send-chain.service.spec.ts`后,API其余32 suites / 324 tests全部通过。API TypeScript build、前端TypeScript`--noEmit --incremental false`、Vite v8.1.5生产构建(2535 modules,仅既有约2.04MB单chunk提示)和`git diff --check`均通过。
- 开发与本地验证阶段未连接或修改预生产数据库,未执行任何真实通道、签名、模板、引流信息或报备任务删除,未发送、补发或重投真实短信,未修改真实通道、企业余额或客户连接;最终提交、推送和预生产发布证据见下方发布记录。既有`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`继续保护,不归因、不删除、不提交。
## 2026-08-09 级联删除治理预生产发布记录(`7804f64c`
- 级联删除治理9个有效源码、测试和文档文件提交为`7804f64ced19435b15b981d2fcb7f8c7e934e33f`并推送`origin/main`;首次推送因远端认证失败,使用既有Git凭据助手安全重试后成功。构建缓存`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`未提交、未删除。
- 精确Git归档`outputs/cmpp-7804f64c-20260809-191015.tar.gz`包含808个条目、2157663字节,本地与服务器SHA-256均为`97faa7900db7b06199bc99c576d2eec99c4a75d474c6d488db0681f07f63d2a0`,服务器tar完整性检查通过。
- 部署前恢复资产位于`/opt/cmpp-platform-backups/releases/20260809-191015-before-7804f64c`。PostgreSQL备份`postgresql.sql.gz`为16714530字节、SHA-256=`6558ce693de1f4ae88d33df9a3a7a0e1bc51d460c2f3fe634c8f352841355328`;运行源码`runtime-source.tar.gz`为2176250字节、SHA-256=`c580c731bc90778182bb915f96c1ca0ab829f707dc044aefb428fee3df9f85af`;环境文件为895字节、SHA-256=`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60`。备份目录为0700,备份文件为0600gzip、tar和SHA-256校验均通过;上一运行目录保留为`/opt/cmpp-platform.previous-20260809-191015`
- 标准`tools/deploy/production-deploy.sh`成功完成依赖安装、安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx配置校验以及Gateway先于API重启。预生产仍为83条已完成migration且无待执行项,最终`.deployed-commit=7804f64ced19435b15b981d2fcb7f8c7e934e33f`
- 部署后API、Gateway、Nginx、PostgreSQL、MinIO均activeRedis PONG,内部API/Gateway/MinIO健康、PostgreSQL readiness均通过,无failed systemd unit。Redis Stream消费者1、`pending=0``lag=0`API和Gateway发布后10分钟error级journal均为0,运行源码包含级联选项、客户端详情隔离、报备任务`deletion_governance`轨迹和删除原因选填实现。
- 从预生产服务器公网复核:运营登录、客户端登录、API health和客户Swagger均为HTTP 200;API独立域名根路径及管理删除预检均为404;主站未认证运营端和客户端删除预检均为401。公网CMPP 17890纯TCP连接成功。本机执行公网检查时因本机DNS无法解析两个域名返回000,已由服务器侧公网检查闭环,不将本机DNS故障误记为平台故障。
- 9条active供应商通道发布重启后6条为`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”3条仍为`authentication / connect response status: auth failed`。本轮未修改通道账号、密码、启停状态或连接参数,只保留并报告供应商真实返回。
- 本次只部署代码和文档,未执行任何真实通道、签名、模板、引流信息或报备任务删除,未发送、补发或重投真实短信,未修改企业余额、客户连接或通道配置。部署依赖审计仍报告根项目3项high、API项目3项moderate和4项high,专用安全缓解门禁通过,未执行可能破坏兼容性的自动升级。
## 2026-08-09 模板删除与既有短信任务解耦(本地未提交)
- 纠正“模板删除必须等待关联短信任务结束”的错误耦合。单独删除模板时,预检和事务内复核都不再查询或阻断`SmsSendTask`/`SmsBatchTask`;模板仅逻辑删除,已创建任务、消息、计费、审核快照及历史`templateId`关联保持不变。
- 已删除模板仍不能用于新建发送任务。已接受的定时任务到点时改为使用持久化内容快照继续处理,不因模板当前`deleted`状态失败;仍重新校验企业、应用、模板归属和签名当前状态。签名删除的级联安全阻断本轮未改。
- 删除治理定向1 suite / 11 tests通过;发送链3个新增边界用例通过,覆盖已删模板禁止新任务、已有定时任务按快照继续、签名失效仍阻断。排除依赖本机Redis的`send-chain.service.spec.ts`后,API其余32 suites / 324 tests全部通过;API正式构建TypeScript检查和`git diff --check`通过。
- 完整发送链套件仍因本机Redis `127.0.0.1:6379` 未运行出现5个既有连接拒绝超时,与上次记录的环境阻塞一致;本轮新增3个发送链用例已单独精确运行并通过,未为通过测试伪造Redis或改动队列配置。
- 本轮未连接预生产、未修改数据库,未执行任何真实模板/签名/任务操作,未发送、补发或重投短信,也未修改通道、余额或客户连接。代码和文档保持未提交、未推送、未部署。
## 2026-08-09 通道支持运营商多选需求评估(暂缓,未实施)
- 新需求拟将通道本体从“移动、联通、电信、三网”单选改为“移动、联通、电信”三个运营商复选,三个全选等价于现行三网,并允许两个运营商组合。用户已明确本需求暂时不实施,本步骤只同步需求和规划用例,没有修改代码、Prisma schema、migration、API、页面或生产数据。
- 已确认多运营商通道继续共用同一个通道单价,不设计分运营商价格。完整报备的理想模型为“签名 × 通道 × 运营商”,但本期暂不考虑该扩展;未来重新启动需求时必须先重新确认报备粒度,不能把当前“签名 × 通道”状态无依据复制到各运营商。
- 2026-08-09预生产只读盘点共18条通道:8条`all/active`、1条`all/disabled`、1条`mobile/active`,另有6条`mobile/deleted`、1条`unicom/deleted`和1条`telecom/deleted`,没有NULL或非法旧值;另有28条通道组成员、54条活动路由规则、69条签名报备任务、148条报备历史记录和6907条提交记录需要在未来迁移与回归时保护。
- 未来数据迁移固定按旧值语义保守映射:单运营商转单元素集合,`all`转移动/联通/电信全选,已删除通道同样迁移;不得从通道名称、当前通道组关联或近期发送量自动推断并缩减能力。取消仍被对应运营商活动通道组引用的能力时必须由真实后端阻止,禁止自动删除关联或历史。
- 未来实施属于跨数据库、通道管理、通道组校验、发送选路、签名/引流报备、批次生成、筛选、复制、审计和文档的高风险改造,必须采用“兼容字段与回填底座→开放多选写入”的分阶段发布。生产出现双运营商组合后,回滚下限必须是已支持新集合的兼容版本,不能回滚到只识别旧`carrier`单值的版本。
- 规划验收用例已记录为`TC-CHANNEL-CARRIER-MULTI-001``007`,当前均为“暂缓、未执行”,不计入现版本通过率,也不得作为现有系统Bug;本步骤未连接或修改预生产数据库,未修改通道账号、密码、启停状态、企业余额或客户连接,未发送、补发或重投真实短信。
## 2026-08-09 通道组按通道筛选(本地未提交)
- 运营端“短信通道组管理”新增“通道”可搜索下拉筛选。页面首次加载并行请求真实`GET /api/admin/channel-groups``GET /api/admin/channels`,下拉显示全部真实通道的名称和编码;已删除通道显式标记,未加入任何组的通道也保留可选,未新增静态列表、Mock或localStorage。
- 选定通道后按成员的精确`channelId`筛选通道组,与通道组名称条件取交集;筛选结果的总数和分页同步重算,条件变更后回到第一页。“重置”同时清空名称和通道条件。
- 按React性能口径将通道选项和筛选结果都作为`groups`与查询状态的派生值计算,不使用effect复制派生状态,避免额外请求、重复渲染和状态偏移。
- 前端TypeScript `--noEmit --incremental false`通过;Vite v8.1.5生产构建通过(2535 modules),仅保留既有约2.04MB单chunk告警;`git diff --check`通过。本地预览能正常加载运营端应用和登录页,但本地API未运行,请求返回502且无已登录会话,因此未伪造登录或Mock通道数据进行页面交互验收。
- 本轮未连接预生产、未修改数据库或真实通道/通道组,未发送、补发或重投短信,也未修改余额或客户连接。代码和文档保持未提交、未推送、未部署。
## 2026-08-09 模板任务解耦与通道组筛选预生产发布记录(`78b839f4`
- 模板删除与既有任务解耦、定时任务快照继续处理、通道组按通道可搜索筛选,以及已完成但暂缓实施的“通道运营商多选评估”文档记录,共11个有效源码、测试和文档文件提交为`78b839f468f053ea3a1299e56418077b0dc11c99`并推送`origin/main`。首次推送复现HTTP远端认证失败,未改动或输出凭据,使用既有凭据助手直接安全重试后成功。`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`未提交、未删除。
- 发布前删除治理1 suite / 11 tests、发送链新增3个边界用例、排除本机Redis环境阻塞的API其余32 suites / 324 tests全部通过;API与前端TypeScript、Vite v8.1.5生产构建(2535 modules)和`git diff --check`通过,仅保留既有约2.04MB单chunk告警。
- 精确Git归档`outputs/cmpp-78b839f4-20260809-205832.tar.gz`包含808个条目、2164322字节,本地与服务器SHA-256均为`4024ed65beb6f950a609a90bee4d7bbf4c47ad7850b8e32fc1631fc34cbf1fd5`,服务器tar完整性通过。
- 部署前恢复资产位于`/opt/cmpp-platform-backups/releases/20260809-205832-before-78b839f4`。PostgreSQL备份`postgresql.sql.gz`为16756199字节、SHA-256=`389f98e7901e9d03e72908000182da40defbdf3cfc4676ad038ad6c30f6f4c6e`;运行源码`runtime-source.tar.gz`为2183000字节、SHA-256=`216b0db65de6a46135272af87cdebd8a5613e3e886ba9046d6a5e2fd56027395`;环境文件为895字节、SHA-256=`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60`。目录为0700,备份和校验单为0600,gzip、tar与`sha256sum -c`全部通过。首次`pg_dump`因Prisma连接串含`schema=public`专用参数而在运行目录切换前停止;移除该Prisma参数后在同一精确目录覆盖生成并完整校验,期间未发生迁移或服务重启。
- 使用标准`tools/deploy/production-deploy.sh`成功完成依赖安装、安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx校验及Gateway先于API重启。预生产仍为83条已完成migration且schema up to date,最终`.deployed-commit=78b839f468f053ea3a1299e56418077b0dc11c99`;上一完整运行目录保留为`/opt/cmpp-platform.previous-20260809-205832`,切换命令配置了部署失败自动恢复保护。
- 部署后API、Gateway、Nginx、PostgreSQL、MinIO均为activeRedis PONG,内部API/Gateway/MinIO健康通过,无failed systemd unitRedis Stream消费者1、`pending=0``lag=0`。API和Gateway发布后均无error级journal。
- 从预生产服务器公网复核:运营登录、客户登录、API health和客户Swagger均为HTTP 200;API独立域名根路径、管理页面和管理通道组接口均为404,主站未认证通道组接口为401。生产前端包已包含“输入通道名称或编码搜索”,运行API源码已包含模板任务快照继续处理逻辑。
- 9条active供应商通道发布重启后6条为`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”3条仍返回`connect response status: auth failed`。本轮未修改其账号、密码、启停状态或连接参数,只保留并报告供应商真实返回。
- 发布依赖审计仍报告根项目3项high、API项目3项moderate和4项high;专用安全缓解门禁通过,未执行可能破坏兼容性的自动升级。本次没有执行任何真实模板、签名、通道或通道组删除,未发送、补发或重投短信,未修改企业余额、客户连接或供应商通道配置。
## 2026-08-09 签名发送质量多周期趋势完整回滚
- 按用户明确要求撤回`35de17a2d44702d3ec3e839362a37d6f2bf26c89`及其部署记录`e0c8f82bcfc21d4707c0ec168d19808959fcfd46`。预生产运行目录已精确切回上一版本`78b839f468f053ea3a1299e56418077b0dc11c99`;被撤回版本完整保留于`/opt/cmpp-platform.rolled-back-35de17a2-20260809-rollback`,没有删除恢复资产。
- 本次发布未新增migration,预生产仍为83条已完成migration,因此没有恢复数据库备份,避免覆盖发布后正常产生的短信、回执和业务数据。回滚前Redis Stream消费者1、`pending=0``lag=0`;运行目录切换仅停止并按Gateway、API、Nginx顺序重启服务。
- 回滚后API、Gateway、Nginx、PostgreSQL和MinIO均为activeRedis PONG,内部API/Gateway健康通过;公网运营登录、客户端登录及API health均为HTTP 200,发布后API/Gateway无error级journalRedis Stream继续为`pending=0``lag=0`
- 9条active供应商通道回滚重启后6条为connected;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”3条恢复为此前已记录的供应商`connect response status: auth failed`状态。本次未修改其连接配置,仅记录真实返回。
- Git使用`git revert --no-commit`反向撤销上述两个提交,不使用`reset``checkout`覆盖工作区。恢复后的业务源码、需求文档、测试用例和结构契约与`608662a`(即`78b839f4`功能版本加其部署记录)一致,仅追加本回滚记录。
- 回滚后运营统计专项1 suite / 28 tests通过,API正式TypeScript构建、前端TypeScript和Vite v8.1.5生产构建通过(2535 modules,仅既有约2.04MB单chunk提示),依赖安全缓解门禁及`git diff --check`通过。`operations-r2`字节哈希门禁在完全恢复上一版本内容后仍受该文件历史混合CRLF/LF行尾影响而误报,Git归一化内容与`608662a`无差异;未为通过门禁改写上一版结构契约哈希。
- 回滚没有发送、补发或重投真实短信,没有修改数据库记录、企业余额、通道账号、密码、启停状态或客户连接;受保护的构建缓存、`outputs/`和空文件`=`继续不提交、不删除。
## 2026-08-10 签名清退预警与运营商级报备设计(待评审、未实施)
- 已完整阅读用户提供的`C:\Users\hectorzhao\Downloads\签名清退预警.md`,并结合当前真实代码模型和预生产只读聚合重新评估。当前`ChannelSignatureReportTask`事实粒度为“签名 × 通道”,任务和记录均没有运营商字段;现有69条报备任务涉及28个签名、13个通道,38条当前通过任务都能找到通过轨迹,但不能据此自动拆成三网分别通过。
- 新增评审稿`docs/signature-retirement-alert-design.md`,将完整实施拆为16步,固定先完成设计、需求和规划用例,再经用户评审后进入兼容数据底座。后续必须依次经过通道能力回填、运营商级签名任务、历史人工确认、发送链兼容双读、严格门禁、预警规则、检测快照、抑制/Webhook、页面改版和分阶段发布;不得在一次发布中同时迁移、切换发送和启用预警。
- 原“通道运营商多选”暂缓需求重新纳入清退预警前置设计,但当前仍为“方案评审中、未实施”。通道目标能力为移动、联通、电信多选且共用一个单价;签名报备计划升级为“签名 × 通道 × 运营商”,继续复用`ChannelSignatureReportTask/Record`,不另建重复事实表。本需求明确不改造引流信息报备,`reportType/drainageItemId`不属于本需求业务维度。
- 历史`all`通道只迁移为三网能力集合,旧报备任务保留为`legacy_channel`范围并显示“历史通道级通过(运营商未拆分)”;必须由运营人员依据供应商真实结果人工拆分确认,系统不得自动复制为三条运营商通过。严格运营商级发送门禁只能在活动历史未拆分数和兼容资格命中数清零后启用。
- 已确认清退活跃量口径:企业按上游至少接受一次的业务短信去重,通道按`messageRecordId + channelId`去重;同一通道断连、超时或重试只计一个活跃量,切换到其他通道后各通道分别计一次。提交尝试、上游接受和最终送达分开展示,不把`SubmitResp status=0`称为最终送达成功。
- 已确认规则下一检测日生效,恢复后再次低量形成新预警周期;临时抑制天数可配置,永久抑制从“抑制管理”取消且不补发历史通知;右上角只展示今日未读且未抑制数;颜色复用现有六档色阶;签名质量检测页面删除企业应用排行、通道占比、当天发送量和当天成功率。
- `docs/system-functional-test-cases.md`已将原7条运营商多选用例调整为“规划、未执行”,并新增`TC-SIGNATURE-CARRIER-REPORT-001``010``TC-SIGNATURE-RETIREMENT-001``016`。这些用例当前不计入现版本通过率,也不得被解释为代码已完成或当前系统Bug。
- 本步骤只修改设计、需求、规划测试用例和测试进度文档;未修改源码、Prisma schema或migration,未连接或修改预生产数据,未发送、补发或重投真实短信,未修改通道账号、密码、启停状态、企业余额或客户连接。文件保持未提交、未推送、未部署,等待用户先行评审。
## 2026-08-10 签名清退预警与运营商级报备本地实现(未提交、未发布)
- 用户确认设计后已按16步顺序完成本地最小充分实现:通道运营商多选、通道组及选路能力校验、运营商级签名报备、历史通道级任务人工拆分确认、兼容双读发送资格、清退规则/周期/检测快照、临时与永久抑制、已读消息、Webhook安全投递、顶部独立预警入口和两类30日热力图。引流信息报备保持原维度,未纳入本需求。
- 修改继续复用`ChannelSignatureReportTask/Record`作为报备事实与轨迹;历史任务保持`legacy_channel`,不得自动伪造三网通过。严格运营商级门禁由`SIGNATURE_REPORT_STRICT_CARRIER=true`显式启用,当前默认关闭,后续必须等活动历史未拆分数和兼容资格命中数清零再分阶段切换。
- 本地PostgreSQL迁移前已备份到`C:\cmpp-platform-local\backups\cmpp-platform-before-signature-retirement-20260810-165721.dump`(435753字节)。本地已完成84条migration;5条通道中3条历史空运营商按旧系统实际兼容口径回填为`mobile`,迁移后空集合0条、非法集合0条,并增加非空且只允许移动/联通/电信的数据库约束。历史报备任务1条,运营商级任务0条,未自动拆分;检测和开放周期唯一索引已核对。
- 本地真实PostgreSQL上的清退统计SQL执行成功,返回提交尝试0、上游接受业务短信0、最终送达业务短信0,证明查询可由真实表执行;最终送达按真实分片审计和回执表归属目标通道,不把其他通道补发成功记到原通道,也不把`SubmitResp status=0`当作最终送达。
- API全量测试35个suite/444项通过;真实Redis启动后发送链112/112通过;通道与清退专项、报备/发送配置/删除治理及发送链相关专项均通过。Prisma schema校验及84条迁移状态、API TypeScript构建、前端Vite生产构建、4份Gateway队列结构契约、Gateway全量Go测试和`git diff --check`通过;前端仅保留既有约2.05MB单chunk提示,差异检查仅输出既有LF/CRLF提示。
- 本地PostgreSQL 5432、Redis 6379、API 3000和前端4173已启动供验收。API启动时尝试恢复本地活动通道,因未启动Gateway而记录连接失败;未修改任何生产配置,也未发送、补发或重投真实短信。经用户授权重置既有本地专用`codex_local_admin`临时密码、读取真实算术验证码并登录,未新建重复账号。
- 全部代码、migration和文档均保持未提交、未推送、未部署;受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`不删除、不提交、不归因于本需求。
- 登录后浏览器验收发现并修复历史三网任务弹窗默认把移动、联通、电信全部设为“已通过”的问题。修复后所有运营商默认“请选择”,必须逐项确认,且“已通过”必须填写真实有效通过时间;前端禁用不完整提交,后端不再回退使用历史通道级时间或当前时间伪造运营商通过事实。同步新增`TC-SIGNATURE-CARRIER-REPORT-006A`
- 修复后重新完成API专项3项、API TypeScript和前端生产构建,并精确重启本轮API/前端进程。浏览器复验历史拆分三项均为“请选择”且确认按钮禁用;通道编辑展示移动/联通/电信三个复选框,三项清空后显示“至少选择一个运营商”且未写入;报备明细显示“历史通道级(未拆分)”;清退预警5个页签、规则/Webhook弹窗、今日检测、顶部独立0条计数和两类30日热力图均正常,已确认删除的四个统计模块未出现,控制台日志为0。没有保存规则、Webhook或历史拆分,没有发送短信。
## 2026-08-10 签名清退自动调度与本地验收数据(未提交、未发布)
- 用户确认改为每天北京时间04:00自动检测、08:00发消息。检测阶段现只推进周期、写幂等快照并冻结规则版本、预警标题和正文;08:00通知阶段才创建站内消息、聚合Webhook并立即触发投递。服务晚启动时按04:00、08:00两个时点顺序补偿,两个阶段均依赖数据库唯一键防重。运营页面“执行今日检测”按钮和`POST /api/admin/signature-retirement/detect`管理接口已删除,内部`runDetection`仅供调度和测试。
- 本地真实造数新增2家验收企业、2个应用、3条停用验收通道(华东三网、移动联通、电信专线)、4个审核通过签名、6条运营商级报备通过任务和42条历史消息记录,覆盖稳定活跃、低量、零量及跨通道失败后补发四类场景。数据脚本为`tools/local/seed-signature-retirement.mjs`,使用固定`qa-retirement-*`标识,重跑前只清理自身数据,不进入发送队列、不连接真实通道。
- 真实造数首次暴露旧`ChannelSignatureReportTask_target_key`仍按“签名×通道”唯一、会阻止同通道多运营商事实。migration现明确删除旧索引,并分别建立运营商级签名、历史通道级签名和引流任务三个条件唯一索引;本地数据库已同步调整,成功保存同一签名/通道的移动和联通两条任务。
- 使用正式`SignatureRetirementService`按时间顺序回放`T-30`至T共31个检测日,生成341条真实检测快照;今天11个维度中9个预警、2个正常,08:00通知阶段幂等生成9条未读站内消息。浏览器真实API显示右上角9条、今日预警9条,列表包含4/8/0等活动量;企业和通道热力图均展示07-11至08-09共30列的真实渐进数据,页面文案明确“04:00自动检测,08:00生成站内消息并发送Webhook”,手动按钮已消失。
- 分阶段真实数据库复核先删除今天9条验收消息,再重复运行04:00检测,消息数保持0;随后运行08:00通知阶段才恢复9条。最终API全量35个suite/444项、Prisma validate及84条migration状态、前后端生产构建和`git diff --check`通过;三个新条件唯一索引均存在、旧`ChannelSignatureReportTask_target_key`已不存在,同一签名/通道的移动与联通任务可同时保存。造数后浏览器控制台日志为0。
## 2026-08-10 签名质量检测模块顺序与热力图分页(未提交、未发布)
- 按验收反馈将“签名通道发送质量”调整到页面最上方,企业、通道两张30日热力图依次下移;统计接口和真实数据口径不变。
- 两张热力图分别增加独立的维度行分页,每页10行;各自页码互不影响,30日日期列继续保留表格内横向滚动。同步新增`TC-SIGNATURE-RETIREMENT-018`
- 使用Node.js 24.14.0完成前端TypeScript与Vite 8.1.5生产构建(2538 modules,仅既有大chunk提示),`git diff --check`通过且只有既有行尾提示。精确重启本轮本地Vite预览后,以已登录运营账号和真实本地API验收:页面模块顺序为发送质量、企业热力图、通道热力图;两张热力图各自显示一套上一页/下一页和页码输入控件,当前真实造数分别为5、6个维度,均为第1/1页,控制台日志为0。
## 2026-08-10 热力图交互优化与未报备签名(未提交、未发布)
- 企业、通道热力图日期列已调整为从左到右`T-1``T-30`;行首只常驻签名、运营商及通道维度必要的通道名称,企业和企业应用改为签名悬停文案。两张热力图分别增加企业、企业应用、签名即时搜索,筛选后各自回到第一页且互不影响;格子悬停明确展示提交、上游接受、发送成功、成功率和阈值。
- 新增真实后端`GET /api/admin/signature-retirement/unreported-signatures`,按所选北京时间自然日和`SmsMessageRecord`统计。短信实际运营商在任一未删除通道存在当前运营商级通过事实,或仍存在历史通道级通过事实时不计入;其余按签名×消息实际企业应用聚合,后端完成关键字、总数和分页。
- 本地自清理造数扩展为2家企业、2个应用、3条停用通道、6个签名、7条运营商级报备通过任务和52条消息,新增“完全未报备”和“仅移动报备但提交电信”两类场景;未进入发送队列且未连接真实通道。正式服务回放31个检测日后生成403条检测快照,今天13个维度中11个预警、2个正常,重复通知阶段新增0条,幂等保持。
- 真实PostgreSQL聚合返回“完全未报备”6条、“仅移动已报备但提交电信”4条;浏览器按企业B搜索后只显示后者4条。企业热力图按企业B搜索只保留3个相关维度,通道热力图仍保留全部7个维度;签名悬停属性显示真实企业和应用,格子悬停属性显示五项明确口径,日期首列为08-09、末列为07-11,控制台error/warn为0。
- 清退专项7/7、API全量35个suite/446项通过,API与前端TypeScript、API正式构建、Vite 8.1.5生产构建通过(2538 modules,仅既有大chunk提示)。API全量用例本身12.573秒完成,但既有异步句柄使Jest不自行退出,本次使用`--forceExit`收尾并保留该提示;两次外层超时遗留的本轮Jest进程已按精确命令行确认后停止,未影响API、前端、PostgreSQL或Redis。
## 2026-08-12 未报备签名判定修正(已发布)
- 生产只读核查确认【彩生活物业】2026-08-12北京时间自然日有4个号码级`SmsMessageRecord`,4条均为2个计费分片;2026-08-11另有2个号码级消息。因此页面当日显示4条正确,用户所见6条为相邻两日累计,不修改签名质量统计代码,也不增加额外列表说明。
- “未报备签名”按确认口径改为系统签名库缺失:从`signatureId IS NULL`消息正文开头提取规范`【签名】`,仅在同企业应用不存在未删除同名`SmsSignature`时计入;不再用通道、运营商或报备任务通过状态判定。结果仍按正文签名和实际企业应用聚合、搜索和分页。
- 使用生产数据只读回放新聚合SQL,【湘银物业】在2026-08-12正确返回266条,企业为“王斯评与聆界中转企业”、应用为“王斯评平台To百信互动物业”;全过程未写生产数据库、未发送或重投短信。
- 签名清退专项9/9、API与前端TypeScript、API正式构建、Vite 8.1.5生产构建及`git diff --check`通过;Vite仅保留既有约2.08MB单chunk提示。该修正后续已随提交`16135e5a`发布。
## 2026-08-10 预警消息检索分页、抑制弹窗与备注列宽(未提交、未发布)
- “今日预警”已调整为“预警消息”,后端按预警日期、企业、企业应用、签名和通道执行真实PostgreSQL筛选及分页;页面默认选中北京时间今日,仅查询今日,支持历史日期区间并固定每页10条。本地回放最近5个检测日后,今日共11条:浏览器验收第1页10条、第2页1条;选择近7天并按“跨通道”签名查询返回15条、2页,可见`2026/8/9 08:00:00`历史消息及真实企业应用名称。
- 抑制操作已改为自研弹窗,在同一弹窗内选择临时抑制截止日期或永久抑制并填写必填原因;切换永久抑制后截止日期隐藏。取消抑制也使用自研弹窗并要求填写取消原因。浏览器只验证弹窗打开、模式切换和未填原因时确认按钮禁用,没有确认保存或取消任何抑制。
- 报备记录“备注”列统一使用长文本列规范,桌面端设置为320px并允许表格内部横向滚动;`docs/ui-design-guidelines.md`新增全局约束:长文本列最小240px、建议280–360px并使用`.ui-table__long-text`。浏览器读取“备注”表头计算宽度及最小宽度均为320px。
- 清退专项9/9、API全量35个suite/448项通过;API与前端TypeScript、API正式构建、Vite 8.1.5生产构建通过(2538 modules,仅既有约2.06MB单chunk提示),`git diff --check`通过且仅输出既有LF/CRLF提示。真实查询回放脚本重复执行新增0条,证明QA消息生成幂等;未发送短信、Webhook,未保存抑制,未修改生产或预生产数据。
- 本轮代码、测试和文档继续保持未提交、未推送、未部署;受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`不删除、不提交、不归因于本需求。
## 2026-08-10 签名清退预警预生产发布与慢启动兼容(已发布)
- 签名清退功能提交`55aa054005d07eef04891ce6ee0700ae629aee3f`已推送后,首次预生产发布成功完成备份、依赖门禁、Prisma generate、84条migration应用和前后端/Gateway构建;服务重启后的API单次健康检查在固定3秒窗口内尚未监听3000端口,发布包装器按设计恢复上一运行目录。恢复后API、Gateway、Nginx、PostgreSQL、Redis和MinIO均为activeAPI health为200API journal没有启动异常,确认属于健康检查与正常慢启动竞态,不是代码构建或migration失败。
- 权威`tools/deploy/production-deploy.sh`将API和Gateway检查改为最多60秒逐秒重试。为什么:Nest初始化和活动通道恢复耗时随生产数据量波动,固定等待会把正常慢启动误判为发布失败;超过60秒仍不可用才应终止并进入日志诊断和恢复流程。
- 首次发布前恢复资产完整保留在`/opt/cmpp-platform-backups/releases/20260810-205625-before-55aa0540`;PostgreSQL、运行源码和环境文件均已通过格式、非空和SHA-256检查。`20260810143000_add_signature_retirement_alerts`已成功且仅应用一次,数据库当前84条已完成migration;重新发布依赖Prisma幂等状态,不重复伪造或手工标记migration。
- 同步更新部署手册和`TC-DEPLOY-HEALTH-001`。本步骤没有发送、补发或重投短信,没有创建外部Webhook,没有修改通道账号、密码、启停状态、企业余额或客户连接。
- 慢启动修复提交`0eb27e4ac0473732a243b9461b704cb8874d3a27`已推送并作为最终运行版本。精确Git归档`outputs/cmpp-0eb27e4a-20260810-210142.tar.gz`包含824项、2217777字节,本地和服务器SHA-256均为`c33c783e4a1ace47c4c80d121277becc19b062d9a308810bb5102b2b668ccfcc`。第二次标准部署显示无待执行migration,构建完成后API和Gateway在60秒窗口内通过健康检查并输出`DEPLOY_OK`;运行目录`.deployed-commit`已核对为`0eb27e4a`
- 两套恢复资产均保留且权限收紧为目录0700、文件0600。迁移前备份`/opt/cmpp-platform-backups/releases/20260810-205625-before-55aa0540`中PostgreSQL为17548853字节、SHA-256=`e2022d20946929639e5a6ba3de56883c3431d22d4c18db10a2a7a25d122ae450`,运行源码为2188655字节、SHA-256=`3c3f66fa16cefa1053fab4f27e8ae2a705ddd092cd19f9cbb2e4704e46cbb321`;迁移后切换前备份`/opt/cmpp-platform-backups/releases/20260810-210142-before-0eb27e4a`中PostgreSQL为17554028字节、SHA-256=`53221b396cff34d841d17ff6df39e533010504b533374df50f91a4c9423d563b`。两套环境文件SHA-256均为`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60`;gzip、tar和校验单全部通过。上一运行目录保留为`/opt/cmpp-platform.previous-20260810-210142`,首次失败的新版本目录保留为`/opt/cmpp-platform.failed-20260810-205625`,未擅自删除。
- 发布后源代码与数据库均为84条migration。18条通道回填结果为8条活动三网、1条停用三网、1条活动移动及8条已删除单网,运营商集合空值0、非法值0;69条既有报备任务全部保留为`legacy_channel`,没有自动复制为运营商通过。严格运营商门禁保持关闭,清退规则、Webhook、检测和消息均为0,因此启动补偿没有生成或外发预警。
- API、Gateway、Nginx、PostgreSQL、Redis、MinIO均active,无failed systemd unit12026、17890、8090、3000、6379、5432和9000端口均监听。内部API/Gateway/MinIO健康通过,Redis PONG`gateway.submit.commands`消费者1、`pending=0``lag=0`;公网运营登录、客户端登录、API health和客户Swagger为200,API专用域名根路径、管理页面和管理接口为404,主站未认证清退接口为401,公网CMPP 17890 TCP连通,发布后API/Gateway error级journal为0。
- 9条活动通道恢复为6条`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”继续返回既有供应商`authentication / connect response status: auth failed`,本轮未修改账号、密码、启停状态或连接参数。依赖审计仍报告根项目3项high、API项目3项moderate和4项high,专用安全缓解门禁通过,未执行破坏性自动升级。
- 最终发布没有发送、补发或重投真实短信,没有配置或投递Webhook,没有修改企业余额或客户连接。功能提交、慢启动修复和本发布记录均只包含有效源码、migration、测试和文档;`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不提交、不删除。
## 2026-08-10 删除历史待确认并自动转换历史签名任务(已发布)
- 按最终业务口径删除签名清退预警页的“历史待确认”页签、数量、表格、人工确认弹窗、前端请求与类型,以及后端历史任务列表/确认DTO、Controller路由和Service逻辑;企业签名报备详情和状态弹窗同步移除“历史待确认”残留提示。
- 新增第85条幂等migration `20260810214500_auto_split_legacy_signature_reports`:旧`legacy_channel`签名任务按通道实际运营商集合补齐缺失的`carrier_specific`任务;旧状态为`approved`时三网通道的移动、联通、电信均记为已通过,`approvedAt`取migration执行时刻,其他状态原样转换且通过时间为空。已有运营商级事实不覆盖;全部适用运营商齐全后旧任务改为`legacy_split`并保留记录。
- 企业签名页面新建运营商级“已通过”任务的现有逻辑保持不变:`approvedAt`取保存时刻。新增自动化用例明确验证新建任务写入`Date`,避免未来回归成空值或历史任务时间。
- migration前已备份本地真实PostgreSQL到`C:\cmpp-platform-local\backups\cmpp-platform-before-legacy-auto-split-20260810-2145.dump`514656字节,SHA-256=`f776feb92243afb117b648256f630ad826039985030c4fae21ea631f111a20ea`。本地执行后`legacy_channel=0``legacy_split=1``carrier_specific=10`;新建3条运营商任务和1条旧任务完成记录,已通过运营商任务`approvedAt`空值为0。原SQL再次执行新增任务0、记录0,数量不变,证明幂等。
- 清退与通道专项2个suite/52项通过;API全量分组35/35个suite、448/448项通过。API TypeScript构建、前端TypeScript与Vite 8.1.5生产构建、Prisma validate及85条migration状态、4份Gateway队列结构契约和`git diff --check`均通过;前端仅有既有大chunk提示。整体Jest命令受既有未关闭句柄影响未自行退出,按完整suite清单分两组在全部断言通过后`--forceExit`取得明确退出码0。
- 本地浏览器使用真实API和PostgreSQL验收:页面只显示“预警消息、检测规则、Webhook、抑制管理”4个页签,“历史待确认”不可见;切换检测规则成功,页面无框架错误覆盖,控制台error/warn为0。仅重置本地专用`codex_local_admin`临时密码以解锁既有本地会话,未新建账号。
- 功能提交`2ecb24cf8d09dd428dfab0c682b33581959618ea`已推送并成功发布。精确Git归档`outputs/cmpp-2ecb24cf-20260810-225340.tar.gz`为2218419字节,服务器共826个归档条目,本地和服务器SHA-256均为`ddcae8619f987522b1a9d487b13cdcfd9a82ea2a2cc7993caa01a7a1253bcedc`
- 发布前恢复资产位于`/opt/cmpp-platform-backups/releases/20260810-225550-before-2ecb24cf`,目录0700、文件0600。PostgreSQL备份17570927字节、SHA-256=`25e2ea99936f39210684f88325589458e2dd4666f32598a3730f6e0a0d689166`;运行源码备份2245714字节、SHA-256=`834b658dd6050ab1f894ea3c267b95fab299fcd7834f9625757091f476f10548`;环境文件895字节、SHA-256=`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60`。gzip、tar和`sha256sum -c`全部通过,上一运行目录保留为`/opt/cmpp-platform.previous-20260810-225550`;发布包装流程配置了失败时数据库和运行目录恢复,本次未触发回滚。
- 标准`tools/deploy/production-deploy.sh`成功完成依赖安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx校验以及Gateway先于API重启。第85条`20260810214500_auto_split_legacy_signature_reports`仅应用一次,最终`.deployed-commit=2ecb24cf8d09dd428dfab0c682b33581959618ea`
- 发布前有61条`legacy_channel`任务和64个缺失运营商目标;发布后`legacy_channel=0`,新增`legacy_carrier_auto_split=64``legacy_scope_auto_split=61`条migration轨迹。16条由历史已通过任务新建的运营商任务`approvedAt`统一为北京时间`2026-08-10 22:56:14.239`且空值为0;原有运营商级任务未覆盖,历史任务全部保留为`legacy_split`
- API、Gateway、Nginx、PostgreSQL和MinIO均activeAPI/Gateway/MinIO健康、Redis PONG、Stream消费者1、`pending=0``lag=0`,运营端、客户端和公网API health均HTTP 200;部署后API/Gateway error和warning级日志为0,运行源码和前端产物均不存在`legacy-report-tasks`或“历史待确认”标记。
- 9条活动通道重启恢复后6条`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”继续为发布前已知的供应商`authentication`失败。本轮未修改通道账号、密码、启停状态、企业余额或客户连接,没有手工发送、补发或重投短信,也没有修改Webhook。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于业务源码提交。
## 2026-08-12 利润报表收入口径调整(历史开发记录,已随 `0cd3534` 发布)
- 利润报表“净消费”统一更名为“收入”。收入按每条最终成功短信的`billingUnits × unitPrice`发送时快照逐条计算后汇总,失败和未知短信不计收入;不再以`SmsBillingRecord.billingStatus=charged/refunded`决定利润报表收入。不同历史单价必须分别计算,不能使用当前应用单价倒算。
- 通道维度继续仅将收入归属到短信最终提交所在通道,避免补发链路在多个通道重复计收;成本仍按各次提交的通道成本单价快照乘以成功分片数,利润=收入-成本,综合利润率=合计利润/合计收入。
- 页面明细、筛选结果汇总、前端类型、列表API和CSV均移除返还数据;CSV表头改为“收入金额(元)”。数据库`DailyProfitReport.refundCents`暂时保留作既有数据和回滚兼容,新重算快照统一写0,不执行破坏性migration。
- 本地PostgreSQL和Redis恢复后,使用正式`ReportsService`成功重算2026-08-08至2026-08-11。独立SQL按应用逐条复核`成功计费条数 × 客户单价快照`,与报表收入差异0条,重算行`refundCents`非0差异0条;本地现有成功样本单价均为0,非零及混合单价场景由专项自动化断言覆盖,未伪造数据库样本。
- 报表专项9/9、API全量35个suite/450项通过;前端TypeScript、API TypeScript正式构建及Vite 8.1.5生产构建通过,Vite仅保留既有大chunk提示。依赖包装器因既有`msgpackr-extract`构建脚本未审批而未用于验证,改为直接调用已安装的本地Jest、TypeScript和Vite入口,未修改依赖审批或供应链配置。
- 本轮未提交、未推送、未部署,未连接或修改预生产数据,未发送、补发或重投短信。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于本需求;依赖包装器临时生成的`pnpm-lock.yaml`已精确移除。
## 2026-08-12 热力图观察期、登录动画与金额样式(历史开发记录,已随 `0cd3534` 发布)
- 修复签名报备通过后完整观察窗口内不生成快照的问题:04:00检测现在按T-1自然日保存单日提交、受理和成功量,观察期状态为`observing`,不创建预警周期、站内消息或Webhook;观察期结束后仍使用配置的15/30天窗口累计量判断预警。热力图将检测日映射到T-1活动日,每行增加30日受理短信合计并按合计降序排序。
- 企业签名管理列表中的企业、企业应用名称改为常规400字重;签名名称和状态层级不变。客户端登录页增加纯展示Canvas粒子连线动画,Canvas不接收点击、不读取输入,组件卸载时取消动画帧,系统减少动态效果或页面隐藏时停止位移。
- 新增统一`MoneyText`只读金额组件,运营端和客户端现有余额、授信、单价、消费、返还、充值、收入、成本、利润及短信计费等金额,小数点和小数部分使用统一次级文字色;输入框、CSV、复制文本和底层金额值不拆分、不改变。
- 本地正式`SignatureRetirementService`在真实PostgreSQL执行2026-08-12检测,生成11条alert、2条healthy、6条observing快照;执行前后站内消息均55条、Webhook投递均0条,证明检测阶段不外发。浏览器真实API验收热力图首列为08-11、显示30日合计且合计491/134/65/0按降序,6个观察期格子可见;企业与应用字重为400;金额小数色为`rgb(107, 114, 128)`;客户端登录Canvas为2560×1440并正常绘制,页面控制台error/warn为0。
- API全量35个suite/451项、签名清退与利润专项18/18项、前后端TypeScript、API正式构建、Vite 8.1.5生产构建、4份Gateway队列契约、Gateway `go test ./...``go vet ./...`通过;Vite仅保留既有约2.06MB单chunk提示,`git diff --check`仅有既有LF/CRLF提示。
# 2026-08-12 下游投递后台重投任务与分页数量(已发布)
- 已确认第一版设计:按当前真实筛选条件和创建时快照建立后台任务,只允许 `pending/failed/unconfirmed/rejected`,不批量重投 `delivered`,等待连接/ACK 与真正跳过严格分开。
- 已增加任务/任务项真实 PostgreSQL 模型、预检、创建、分批原子认领、ACK 闭环、暂停/继续/终止、操作审计,以及运营端任务列表和详情;下游投递列表同步增加每页 `10/25/50` 条选择。
- 本地PostgreSQL已真实应用`20260812153000_add_downstream_requeue_tasks`,当前共86条migrationPrisma validate、专项4项、API全量36个suite/458项、API/前端TypeScript、API/Vite生产构建、4份Gateway队列契约、R10结构契约和`git diff --check`通过。R10稳定门面方法数同步为104。
- 全量Jest的458项断言均通过;仓库仍有既有异步句柄导致不自行退出,使用`--forceExit`取得退出码0。新增后台任务扫描器已在相关启动定时器用例中显式关闭,复跑时不再产生缺少测试Prisma delegate的循环错误日志。
# 2026-08-12 网关提交异常人工标记已处理(已发布)
- “网关异常 / 提交异常”对`pending`记录增加“已处理”按钮和自研确认弹窗;确认后真实调用后端,将记录原子更新为`resolved`、写`resolvedAt``manually_resolved`,保留原始异常证据,不重新入队、不发送短信。
- 后端记录操作人和`gateway.submit_dead_letter_resolved`审计日志;非`pending`状态拒绝并发标记,重复读取已处理记录保持幂等。
- 功能纳入API全量36个suite/458项验证;前端TypeScript、API正式TypeScript构建、Vite生产构建和`git diff --check`通过,Vite仅有既有大chunk提示。
# 2026-08-12 全局运营商标签色值调整(已发布)
- 移动、联通、电信继续复用`CarrierTag`全局低饱和胶囊组件,仅按确认方案调整背景、文字和边框色值,不改变标签尺寸、字重或业务状态标签。
- 前端TypeScript、Vite 8.1.5生产构建和`git diff --check`通过。使用真实本地API和PostgreSQL登录短信通道管理页,计算样式逐项核对为移动`#E8F1F7/#2F6F91/#C9DDE9`、联通`#F6EAEA/#875758/#E8CECE`、电信`#F0ECF7/#73538F/#DDD1EA`,页面已保留供用户预览。
- 用户确认页面预览后先授权提交、推送,后续已明确授权发布;仅重置既有本地专用`codex_local_admin`临时密码以恢复本地验收会话,未新建账号。受保护缓存、`outputs/`和空文件`=`不归因、不处理。
# 2026-08-12 下游重投、异常处置、未报备签名及运营商标签预生产发布(已发布)
- 最新功能提交`16135e5a3ef69e22011e483e5a74dd8a36699189`已成功发布;精确Git归档`outputs/cmpp-16135e5a-20260812-173616.tar.gz`共834项、2246057字节,本地与服务器SHA-256均为`f4cb42a2ffed9bf4fba1e43656433a6e1b2de5ba2af52bcb0193c9f77ce56c21`。运行目录`.deployed-commit`已核对为该提交。
- 发布前恢复资产位于`/opt/cmpp-platform-backups/releases/20260812-174041-before-16135e5a`,PostgreSQL、运行源码和环境文件均通过`sha256sum -c`;上一运行目录保留为`/opt/cmpp-platform.previous-20260812-174041`。部署流程完成依赖安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx校验、服务重启和健康检查,未触发回滚。
- 第86条migration `20260812153000_add_downstream_requeue_tasks`仅应用一次,数据库当前86条已完成migration`DownstreamRequeueTask``DownstreamRequeueTaskItem`两张真实任务表均已核对存在。
- API、Gateway、Nginx、PostgreSQL、Redis与MinIO均为active12026、17890、8090、3000、6379、5432和9000端口均监听。内部API/Gateway/MinIO健康通过、Redis PONG`gateway.submit.commands`消费者1、`pending=0``lag=0`。公网运营端登录、客户端登录和API health均HTTP 200,公网CMPP 17890 TCP连通,发布后API/Gateway warning级journal为0。
- 依赖审计仍报告根项目3项high、API项目3项moderate和4项high,专用安全缓解门禁通过;本次未执行`npm audit fix`或破坏性依赖升级。发布过程没有发送、补发或重投真实短信,没有修改通道账号、密码、启停状态、企业余额或客户连接。
- 受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续不删除、不提交、不归因于本次发布记录。
# 2026-08-12 签名质量检测未报备签名聚合热修复
- 发布后生产日志确认签名质量检测页底部`GET /api/admin/signature-retirement/unreported-signatures`返回500;根因是结果标识使用`extracted.tenant_id/application_id`,但PostgreSQL聚合只分组了关联表主键和签名,生产数据执行时严格报`42803`。该故障仅影响只读未报备签名统计,不涉及签名通道发送质量、短信发送、计费或回执数据写入。
- 最小修复为将原始企业、应用字段加入`GROUP BY`,保持“正文签名 × 实际企业应用”业务口径、搜索、计数和分页不变;同步新增`TC-SIGNATURE-RETIREMENT-029`,防止测试桩只验证返回映射而遗漏真实PostgreSQL语法约束。
- 签名清退专项9/9、API TypeScript正式构建和`git diff --check`通过。热修复提交`4c70978da4e3bd22ea159313c95b36ca18d150d6`已推送,并采用API最小热发布:备份位于`/opt/cmpp-platform-backups/releases/20260812-175436-before-hotfix-4c70978d`,只替换本次API源码/编译产物并重启`cmpp-api`,未重启Gateway、Nginx或短信通道。
- 发布后运行标识已核对为`4c70978d`,API和Gateway健康通过。使用生产真实PostgreSQL执行与接口相同的完整聚合SQL成功返回1组、266条短信,未再出现`42803`;热发布后的API日志没有新增`ExceptionsHandler`或Prisma聚合异常。现有浏览器没有可接管的登录页,因此未伪造账号会话;页面可由用户直接刷新验收。
# 2026-08-13 Gateway响应截断与NestJS请求体容量修复(本地未提交、未发布)
- 只读复核确认Gateway通用API传输方法使用`io.LimitReader(resp.Body, 64*1024)`静默截断响应;待投递回执恢复查询单批100条时,生产真实JSON已可超过该边界并形成`unexpected end of JSON input`。本轮将完整响应上限调整为4MiB,并额外读取1字节识别超限:超过边界返回明确容量错误,不再把传输截断伪装成JSON语法错误。
- NestJS关闭框架自动注册的默认100KiB body parser,显式注册普通JSON/URL-encoded 2MiB解析器;仅`/api/client/send/imports/*`先注册25MiB JSON解析器。两类JSON解析器继续保存`rawBody`,公网HTTP API验签和幂等正文哈希语义不变;导入业务层原始正文20MiB限制保持不变。
- 域名链路重新核对:客户导入使用`sms.lisglo.com`私有API,其标准Nginx上限50MiB已覆盖25MiB解析需求;`api.lisglo.com`只开放单条HTTP API、客户Swagger和健康检查,不承载文件导入,因此本轮不错误扩大API专用域名或开放私有路由。
- 新增专项自动化:Gateway可完整解析约128KiB合法响应,超过4MiB返回`api response exceeds 4194304-byte limit`且不出现截断JSON错误;同一份约3MiB JSON在导入路由返回200、保留完整rawBody,普通路由返回413。API容量专项2/2、API全量37套/460项、API TypeScript正式构建、Gateway inbound专项、Gateway全量`go test ./... -count=1``go vet ./...`、4份Gateway队列契约和`git diff --check`均通过。
- 19份既有结构门禁中10份通过、9份失败;失败均来自当前`HEAD`在本轮开始前已经存在的契约漂移(下游重投/异常处置API、通道排序、签名弹窗、签名质量查询、报备导出、运营商集合、定时调度、长文本样式和签名查询),与本轮新增/修改文件无交集。本轮不为通过门禁而顺手刷新或改写其他会话业务契约,保留为既有基线问题单独治理。
- 本轮没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额、客户连接或生产数据;代码保持未提交、未推送、未部署。受保护的`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`不删除、不提交、不归因。
# 2026-08-13 充值回执操作人员、看板精简与运营商标签统一(待提交、未发布)
- 充值记录列表接口使用充值单既有`operatorId`批量查询真实用户,返回`displayName`(缺失时回退`username`)作为`operatorName`;充值回执新增“操作人员”,历史无操作人的系统记录展示“系统”。未新增字段或migration,避免复制姓名造成历史数据与用户资料不一致。
- 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个模块及其分页状态;今日活跃签名继续使用真实发送质量接口聚合。指标文案调整为“今日消费金额”,主数字直接复用与今日发送总量相同的`metric-card strong`样式。
- 企业应用管理的状态、到达率、单价列宽均从130px缩至104px,字段和操作未隐藏。全局审计运营商数据展示后,监控、通道/通道组、应用路由、签名和报备、清退、短信审核、批次号码、发送记录及客户端发送详情统一复用`CarrierTag`;筛选选项、图表图例、导出和说明文字保留纯文本,三网通道展开为三个标签。
- API全量37套/460项、充值和HTTP容量专项14/14、前后端TypeScript、Vite 8.1.5生产构建、Gateway全量测试与`go vet`、4份Gateway队列契约、企业应用R11、短信记录R11、企业签名R4结构契约和`git diff --check`均通过;Vite仅保留既有约2.07MiB单chunk提示。企业签名R4哈希只按本次经验证的运营商标签JSX同步更新,未放宽模块边界。浏览器连接本地页面时既有登录会话已失效,未输入账号密码或验证码,因此页面级视觉验收未完成。
- 本轮与此前未提交的HTTP/Gateway容量修复一并进入待提交范围,未发送、补发或重投短信,未修改通道配置、余额、客户连接或生产数据;受保护缓存、`outputs/`和空文件`=`不删除、不提交、不归因。
# 2026-08-13 下游投递后台重投任务安全整改与页面优化(已发布)
- 已将`docs/downstream-requeue-task-design-20260812.md`确认的完整设计口径合并进平台需求和系统测试文档;独立设计稿继续作为审计输入保留,不以现有实现反向改写设计结论。
- 预检严格使用企业、应用、类型、状态、北京时间日期和关键词筛选,不再把单一状态扩为全部状态;预检生成绑定操作人、筛选和`snapshotAt`的15分钟服务端签名凭证,创建接口不再接受前端重传的范围,仅按签名快照在真实PostgreSQL重新物化任务项。
- 新增任务扫描数据库租约、`processing`两分钟认领租约恢复和每应用秒级原子限速窗口;多实例/重叠扫描不能突破每应用配置速率。客户无connected连接时进入`waiting_connection`,连接恢复后回队列;写出进入`waiting_ack`不清零,只有有效ACK清零,ACK超时/拒绝按应用累计并达到阈值后自动暂停和审计。
- 新增完整任务项分页接口,支持状态、消息ID、错误和跳过原因查询;终止将尚未开始和等待连接项置为`unprocessed`,不撤回处理中或已写出项。任务列表增加状态筛选和分页,页面补齐企业—应用联动、中文状态、创建人、原因、进度及结果数;创建弹窗重做命中/可重投/跳过/应用信息层级和安全边界,详情可查询全部历史项而非最近50条。
- 新增向前兼容migration`20260813150000_harden_downstream_requeue_tasks`,只增加任务应用级失败计数、扫描租约和速率窗口表,不删除或改写历史投递/ACK。已在明确指向`localhost:5432/cmpp_platform`的本地真实PostgreSQL应用,当前本地87条migration且schema up to date;未连接或修改预生产数据库。
- 后台任务专项7/7通过,覆盖严格预检、签名凭证、服务端物化、完整分页、processing恢复、离线等待和ACK自动暂停;API全量37套/463项、Prisma format/validate、API及前端TypeScript正式构建、Vite 8.1.5生产构建(2541 modules)均通过,仅保留既有约2.08MB单chunk提示。新增任务明细分页使`SendChainService`稳定门面方法由104增至105,对应R10结构契约已按真实新增接口精确同步并通过;`git diff --check`通过。
- 浏览器优先接管现有会话后确认本地真实API与PostgreSQL服务可访问,但现有浏览器没有已登录页面,访问下游投递页被正常引导到运营端登录;本轮未读取、猜测或重置凭据,也未绕过登录,因此创建弹窗、任务列表和详情的登录后视觉验收留待具备既有会话时补充。
- 功能提交`433b2ee56f6016ad8afff1bac73f510b8fd53083`已推送并成功发布。精确Git归档`outputs/cmpp-433b2ee5-20260813-120852.tar.gz`共840项、2268178字节,本地与服务器SHA-256均为`456cf284a8e38ff532c646de93ec5dfcc378b9acecb1cec58497782cdb155755`;运行目录`.deployed-commit`已核对为该提交。
- 发布前恢复资产位于`/opt/cmpp-platform-backups/releases/20260813-121038-before-433b2ee5`,目录权限0700、文件0600PostgreSQL备份41149565字节、SHA-256=`818442b23c0450786543b445ac444be65ac2d03f9a84039fa02dee90bacdeefa`,运行源码备份2275942字节、SHA-256=`1222f7718ebb2ae0da58434ca2836408154215e9c121e5e1c9423a7b40538e52`,环境文件895字节、SHA-256=`7a26d83b062f9d7e9503e2a987f39a899510f8981be63be997bccfc988fbbc60``sha256sum -c`、gzip和tar校验均通过,上一运行目录保留为`/opt/cmpp-platform.previous-20260813-121038`
- 标准发布完成依赖安全缓解门禁、Prisma generate/migrate、前端/API/Gateway构建、Nginx校验以及Gateway先于API重启。第87条migration`20260813150000_harden_downstream_requeue_tasks`仅应用一次,迁移记录完成且无回滚/错误日志;新增`DownstreamRequeueRateWindow`表、3个索引以及任务租约/应用失败字段均真实存在。
- API、Gateway、Nginx、PostgreSQL、Redis与MinIO均active,内部API/Gateway/MinIO健康、Redis PONG`gateway.submit.commands`消费者1、`pending=0``lag=0`。公网运营端、客户端和API health均HTTP 200,公网CMPP 17890 TCP连通;发布时间窗API/Gateway warning级日志为0。
- 首次前台SSH执行因本地等待窗口关闭而收到终止信号,包装流程按设计恢复PostgreSQL和原运行目录,运行标识回到`4c70978d`、migration回到86条、服务全部active;确认恢复资产完整后改为服务器后台日志方式重新执行并成功,未并发重复部署。
- 本轮没有创建真实重投任务、调用真实Gateway重投、发送短信、修改通道配置、余额或客户连接。既有`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`和空文件`=`继续保护,不归入业务提交。
+1
View File
@@ -63,6 +63,7 @@
- 表头使用浅灰背景,字号 13-14px,字体 600。
- 行高常规 64-86px,复杂两行信息可增加,但避免超过 110px。
- 操作按钮采用文字或图标加文字,危险操作使用红色。
- 备注、原因、说明、失败信息等不可预测长度的业务文本列必须显式设置列宽:桌面端最小240px,常规建议280-360px;不得省略`TableColumn.width`后任由其被固定信息列挤窄。长文本使用全局`.ui-table__long-text`样式正常换行并允许在任意长单词处断行,完整内容仍应可通过详情查看。宽表因此超过容器时使用表格内部横向滚动,不压缩长文本列到不可读宽度。
## 表单和弹窗
+14 -10
View File
@@ -13,10 +13,12 @@ import (
)
type authRequest struct {
Account string `json:"account"`
AuthSource string `json:"authSource"`
Timestamp uint32 `json:"timestamp"`
RemoteIP string `json:"remoteIp,omitempty"`
Account string `json:"account"`
AuthSource string `json:"authSource"`
Timestamp uint32 `json:"timestamp"`
RemoteIP string `json:"remoteIp,omitempty"`
Version string `json:"version"`
RequestedVersion uint8 `json:"requestedVersion"`
}
type authResponse struct {
@@ -42,7 +44,7 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30)
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh]
}
auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp)
auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp, req.Version)
if err != nil {
logger.Printf("cmpp inbound auth failed account=%s remote=%s err=%v", account, packet.Conn.Conn.RemoteAddr(), err)
setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version)
@@ -114,12 +116,14 @@ func cmppVersionName(version cmpp.Type) string {
}
}
func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) {
func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32, version cmpp.Type) (authResponse, error) {
payload := authRequest{
Account: account,
AuthSource: base64.StdEncoding.EncodeToString([]byte(authSource)),
Timestamp: timestamp,
RemoteIP: remoteIP(remote),
Account: account,
AuthSource: base64.StdEncoding.EncodeToString([]byte(authSource)),
Timestamp: timestamp,
RemoteIP: remoteIP(remote),
Version: cmppVersionName(version),
RequestedVersion: uint8(version),
}
var result authResponse
err := s.post(context.Background(), "/gateway/events/inbound/authenticate", payload, &result)
+1 -1
View File
@@ -228,7 +228,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
case <-time.After(2 * time.Second):
t.Fatal("expected downstream acknowledgement callback")
}
if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" {
if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" || gotAuth.Version != "cmpp30" || gotAuth.RequestedVersion != uint8(cmpp.V30) {
t.Fatalf("unexpected auth payload: %+v", gotAuth)
}
if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试入站" ||
+10 -1
View File
@@ -12,6 +12,8 @@ import (
"time"
)
const maxAPIResponseBodyBytes int64 = 4 * 1024 * 1024
func (s Server) post(ctx context.Context, path string, payload any, result any) error {
client := s.HTTPClient
if client == nil {
@@ -31,10 +33,17 @@ func (s Server) post(ctx context.Context, path string, payload any, result any)
return err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
// Read one byte beyond the supported boundary so an oversized upstream
// response is reported explicitly. Silently cutting JSON at the boundary
// turns a transport-capacity problem into a misleading syntax error and can
// leave recoverable downstream receipts stuck indefinitely.
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBodyBytes+1))
if err != nil {
return fmt.Errorf("read api response: %w", err)
}
if int64(len(responseBody)) > maxAPIResponseBodyBytes {
return fmt.Errorf("api response exceeds %d-byte limit", maxAPIResponseBodyBytes)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detail := strings.TrimSpace(string(responseBody))
if detail == "" {
@@ -0,0 +1,46 @@
package inbound
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPostAcceptsAPIResponseLargerThanLegacy64KiB(t *testing.T) {
payload := strings.Repeat("x", 128*1024)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"payload": payload})
}))
defer server.Close()
var result struct {
Payload string `json:"payload"`
}
if err := (Server{APIBaseURL: server.URL}).post(context.Background(), "/large", map[string]string{"request": "ok"}, &result); err != nil {
t.Fatalf("post response larger than 64KiB: %v", err)
}
if result.Payload != payload {
t.Fatalf("payload length = %d, want %d", len(result.Payload), len(payload))
}
}
func TestPostRejectsAPIResponseBeyondFourMiBWithoutTruncatedJSONError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"payload":"` + strings.Repeat("x", int(maxAPIResponseBodyBytes)) + `"}`))
}))
defer server.Close()
var result map[string]any
err := (Server{APIBaseURL: server.URL}).post(context.Background(), "/too-large", map[string]string{"request": "ok"}, &result)
if err == nil || !strings.Contains(err.Error(), "api response exceeds 4194304-byte limit") {
t.Fatalf("error = %v, want explicit response-size error", err)
}
if strings.Contains(err.Error(), "unexpected end of JSON input") {
t.Fatalf("oversized response must not surface as truncated JSON: %v", err)
}
}
+5 -3
View File
@@ -1,5 +1,5 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types';
import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelGroupDeletionImpact, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types';
import { assertUploadFileSize } from '@/utils/fileUpload';
// Report generation consumes channel report fields, so these endpoints keep one
@@ -36,6 +36,8 @@ export const adminChannelsReportsApi = {
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
getChannelGroupDeletionImpact: (id: string) =>
request<ChannelGroupDeletionImpact>(`/admin/channel-groups/${id}/deletion-impact`),
deleteChannelGroup: (id: string) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
addChannelGroupItem: (body: Record<string, unknown>) =>
@@ -79,9 +81,9 @@ export const adminChannelsReportsApi = {
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
+2 -1
View File
@@ -1,9 +1,10 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
import type { AdministrativeRegion, AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
// Review, risk and billing mutations keep their original URLs, payloads and
// response types behind one governance boundary.
export const adminGovernanceApi = {
listAdministrativeRegions: () => request<AdministrativeRegion[]>('/admin/dictionaries/administrative-regions'),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
+17 -4
View File
@@ -1,5 +1,5 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProtocolInteractionLogResponse, ReceiptAnomalyResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, DownstreamRequeueFilter, DownstreamRequeuePreview, DownstreamRequeueTask, DownstreamRequeueTaskItem, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
// Read-heavy operations endpoints are isolated from configuration mutations.
export const adminOperationsApi = {
@@ -15,15 +15,15 @@ export const adminOperationsApi = {
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(withQuery('/admin/reports/reconciliation', query)),
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)),
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }>(withQuery('/admin/reports/profit', query)),
exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/profit/export', query)),
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType']; summary: QualityReportSummary }>(withQuery('/admin/reports/quality', query)),
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
requestBlob(withQuery('/admin/reports/quality/export', query)),
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
@@ -53,6 +53,8 @@ export const adminOperationsApi = {
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
resolveGatewaySubmitException: (id: string) =>
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, { method: 'POST', body: JSON.stringify({}) }),
listReceiptAnomalies: (query: { tenantId?: string; applicationId?: string; channelId?: string; anomalyType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', query)),
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
@@ -70,4 +72,15 @@ export const adminOperationsApi = {
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
batchRequeueDownstreamDeliveries: (ids: string[]) =>
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }),
previewDownstreamRequeueTask: (filter: DownstreamRequeueFilter) =>
request<DownstreamRequeuePreview>('/admin/operations/downstream-requeue-tasks/preview', { method: 'POST', body: JSON.stringify({ filter }) }),
createDownstreamRequeueTask: (body: { previewToken: string; reason: string; ratePerSecond: number; consecutiveFailureLimit: number }) =>
request<DownstreamRequeueTask>('/admin/operations/downstream-requeue-tasks', { method: 'POST', body: JSON.stringify(body) }),
listDownstreamRequeueTasks: (query: { status?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DownstreamRequeueTask>>(withQuery('/admin/operations/downstream-requeue-tasks', query)),
getDownstreamRequeueTask: (id: string) => request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}`),
listDownstreamRequeueTaskItems: (id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DownstreamRequeueTaskItem>>(withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query)),
changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') =>
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { method: 'POST', body: JSON.stringify({}) }),
};
+37
View File
@@ -0,0 +1,37 @@
import { request, withQuery } from '../core/httpClient';
import type {
PagedResult,
SignatureRetirementHeatmapItem,
SignatureRetirementHeatmapDimension,
SignatureRetirementMessage,
SignatureRetirementRule,
SignatureRetirementRuleType,
SignatureRetirementSuppression,
SignatureRetirementWebhook,
UnreportedSignatureItem,
} from '../types';
export const adminSignatureRetirementApi = {
getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'),
saveSignatureRetirementRule: (body: {
ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean;
mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number;
telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string;
}) => request<SignatureRetirementRule>('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }),
createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) =>
request<SignatureRetirementWebhook>('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }),
deleteSignatureRetirementWebhook: (id: string) => request<SignatureRetirementWebhook>(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }),
listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SignatureRetirementMessage>>(withQuery('/admin/signature-retirement/messages', query)),
getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'),
readSignatureRetirementMessage: (id: string) => request<SignatureRetirementMessage>(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }),
readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }),
suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }),
listSignatureRetirementSuppressions: () => request<SignatureRetirementSuppression[]>('/admin/signature-retirement/suppressions'),
cancelSignatureRetirementSuppression: (id: string, reason: string) =>
request<SignatureRetirementSuppression>(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }),
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
};
+2
View File
@@ -9,6 +9,7 @@ import { adminChannelsReportsApi } from './admin/channels-reports.api';
import { adminOperationsApi } from './admin/operations.api';
import { adminGovernanceApi } from './admin/governance.api';
import { adminFilesApi } from './admin/files.api';
import { adminSignatureRetirementApi } from './admin/signature-retirement.api';
export const adminApi = {
...adminIdentityApi,
@@ -16,4 +17,5 @@ export const adminApi = {
...adminOperationsApi,
...adminGovernanceApi,
...adminFilesApi,
...adminSignatureRetirementApi,
};
+14 -1
View File
@@ -7,6 +7,7 @@ export type AdminChannel = {
code: string;
name: string;
carrier?: string | null;
carriers?: Array<'mobile' | 'unicom' | 'telecom'>;
sendRegion?: string | null;
gatewayHost: string;
gatewayPort: number;
@@ -66,6 +67,15 @@ export type ChannelGroup = DictionaryItem & {
items?: ChannelGroupItem[];
};
export type ChannelGroupDeletionImpact = {
groupId: string;
groupName: string;
normalApplicationCount: number;
deletedApplicationCount: number;
channelCount: number;
pendingSupplierSubmitCount: number;
};
export type ChannelGroupItem = DictionaryItem & {
groupId: string;
channelId: string;
@@ -252,6 +262,9 @@ export type ReportTask = DictionaryItem & {
tenantId: string;
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom' | null;
approvedAt?: string | null;
approvalScope?: 'carrier_specific' | 'legacy_channel';
reportType?: 'signature' | 'drainage';
drainageItemId?: string | null;
status: string;
@@ -264,7 +277,7 @@ export type ReportTask = DictionaryItem & {
application?: { id: string; name: string } | null;
};
drainageInfo?: SmsDrainageInfo | null;
channel?: { id: string; name: string; code: string };
channel?: { id: string; name: string; code: string; carrier?: string | null; carriers?: Array<'mobile' | 'unicom' | 'telecom'> };
reason?: string | null;
createdAt?: string;
updatedAt?: string;
+20 -2
View File
@@ -6,7 +6,17 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[] };
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
export type DeletionRequiredSelection = {
action: DeletionResolutionAction;
dependencyKind: string;
label: string;
description: string;
count: number;
};
export type DeletionPreflight = {
type: DeletionTargetType;
@@ -14,13 +24,21 @@ export type DeletionPreflight = {
expectedUpdatedAt: string;
identity: Record<string, string>;
dependencies: DeletionDependency[];
requiredSelections: DeletionRequiredSelection[];
impacts: string[];
blockedReasons: string[];
allowedActions: Array<'delete'>;
recoverability: { mode: 'soft_delete'; description: string };
};
export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string };
export type DeleteTargetRequest = {
expectedUpdatedAt: string;
idempotencyKey: string;
reason?: string;
deleteAssociatedTemplates?: boolean;
deleteAssociatedDrainage?: boolean;
abandonAssociatedReportTasks?: boolean;
};
export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean };
+5
View File
@@ -95,6 +95,11 @@ export type RiskTaskMessagePage = {
export type BatchTaskMessagePage = RiskTaskMessagePage;
export type AdministrativeRegion = {
province: string;
cities: string[];
};
export type DrainageDetectionRule = {
id: string;
code: string;
+2 -1
View File
@@ -178,6 +178,7 @@ export type RechargeOrder = {
payMethod?: string | null;
paidAt?: string | null;
operatorId?: string | null;
operatorName?: string | null;
remark?: string | null;
balanceAfterCents?: number | null;
createdAt: string;
@@ -238,7 +239,7 @@ export type ClientSmsSignature = {
application?: ClientSmsApplication | null;
reportStatus?: string;
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; carrier: 'mobile' | 'unicom' | 'telecom'; status: string; taskId?: string; approvedAt?: string | null; approvalScope?: 'carrier_specific' | 'legacy_channel' }>;
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
drainageReportTargets?: Record<string, Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>>;
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
+1
View File
@@ -3,3 +3,4 @@ export * from './identity-config';
export * from './channels-reports';
export * from './operations';
export * from './governance';
export * from './signature-retirement';
+80 -1
View File
@@ -306,6 +306,7 @@ export type ProtocolInteractionLogItem = {
traceId?: string | null;
requestId?: string | null;
phoneMasked?: string | null;
phoneNumber?: string | null;
resultCode?: string | null;
durationMs?: number | null;
payloadBytes?: number | null;
@@ -348,6 +349,27 @@ export type DailyReconciliationReport = {
updatedAt: string;
};
export type ReportVolumeSummary = {
submittedUnits: number;
sentUnits: number;
unknownUnits: number;
successUnits: number;
failedUnits: number;
};
export type ReconciliationReportSummary = ReportVolumeSummary;
export type ProfitReportSummary = ReportVolumeSummary & {
revenueCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
};
export type QualityReportSummary = ReportVolumeSummary & {
successRateBps: number;
};
export type DailyProfitReport = {
id: string;
reportDate: string;
@@ -364,7 +386,6 @@ export type DailyProfitReport = {
successUnits: number;
failedUnits: number;
revenueCents: number;
refundCents: number;
costCents: number;
profitCents: number;
profitRateBps: number;
@@ -462,6 +483,64 @@ export type BatchRequeueResponse = {
results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }>;
};
export type DownstreamRequeueFilter = {
tenantId?: string;
applicationId?: string;
deliveryType?: string;
status?: string;
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
};
export type DownstreamRequeuePreview = {
snapshotAt: string;
previewToken: string;
matchedCount: number;
replayableCount: number;
skippedCount: number;
applicationCount: number;
oldestCreatedAt?: string | null;
statusCounts: Record<string, number>;
filter: DownstreamRequeueFilter;
};
export type DownstreamRequeueTask = {
id: string;
taskNo: string;
status: string;
filterSnapshot: DownstreamRequeueFilter;
snapshotAt: string;
reason: string;
ratePerSecond: number;
totalCount: number;
successCount: number;
failedCount: number;
skippedCount: number;
waitingCount: number;
lastError?: string | null;
createdAt: string;
startedAt?: string | null;
finishedAt?: string | null;
pausedAt?: string | null;
tenant?: TenantOption | null;
application?: EnterpriseApplication | null;
createdBy?: { id: string; displayName: string; username: string } | null;
itemCounts?: Record<string, number>;
};
export type DownstreamRequeueTaskItem = {
id: string;
status: string;
previousStatus: string;
skipReason?: string | null;
errorMessage?: string | null;
claimedAt?: string | null;
completedAt?: string | null;
updatedAt: string;
delivery: { messageId?: string | null; deliveryType: string; status: string; lastError?: string | null };
};
export type DownstreamDeliveryDashboard = {
summary: {
total: number;
+105
View File
@@ -0,0 +1,105 @@
export type SignatureRetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel';
export type SignatureRetirementCarrier = 'mobile' | 'unicom' | 'telecom';
export type SignatureRetirementRule = {
id: string;
ruleType: SignatureRetirementRuleType;
targetId?: string | null;
targetKey: string;
enabled: boolean;
mobileWindowDays: number;
mobileThreshold: number;
unicomWindowDays: number;
unicomThreshold: number;
telecomWindowDays: number;
telecomThreshold: number;
messageTemplate?: string | null;
version: number;
updatedAt: string;
};
export type SignatureRetirementWebhook = {
id: string;
name: string;
platform: 'wecom' | 'feishu';
urlMasked: string;
status: string;
createdAt: string;
};
export type SignatureRetirementDetection = {
id: string;
detectionDate: string;
dimensionType: 'enterprise' | 'channel';
tenantId: string;
applicationId?: string | null;
signatureId: string;
channelId?: string | null;
carrier: SignatureRetirementCarrier;
windowDays: number;
threshold: number;
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
approvedAt: string;
status: 'alert' | 'healthy' | 'observing';
suppressed: boolean;
};
export type SignatureRetirementMessage = {
id: string;
detectionId: string;
cycleId: string;
tenantId: string;
title: string;
content: string;
isRead: boolean;
suppressed: boolean;
createdAt: string;
detection?: SignatureRetirementDetection;
signatureName?: string;
channelName?: string | null;
tenantName?: string;
applicationName?: string;
};
export type SignatureRetirementSuppression = {
id: string;
dimensionType: 'enterprise' | 'channel';
signatureId: string;
channelId?: string | null;
carrier: SignatureRetirementCarrier;
mode: 'temporary' | 'permanent';
muteUntil?: string | null;
reason?: string | null;
updatedAt: string;
};
export type SignatureRetirementHeatmapItem = SignatureRetirementDetection & {
activityDate: string;
signatureName?: string;
channelName?: string | null;
tenantName?: string;
};
export type SignatureRetirementHeatmapDimension = {
dimensionType: 'enterprise' | 'channel';
signatureId: string;
channelId: string | null;
carrier: SignatureRetirementCarrier;
approvedAt: string;
signatureName: string;
channelName: string | null;
tenantName: string;
applicationName?: string | null;
};
export type UnreportedSignatureItem = {
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationId?: string | null;
applicationName?: string | null;
messageCount: number;
};
+7 -2
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
import { consumeSessionRecovery, readSessionRecovery, writeSession, type Portal } from '@/api/session';
import { Button, Input, Modal } from '@/components/ui';
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
type LoginPageProps = {
portal: Portal;
@@ -57,6 +57,10 @@ export function LoginPage({ portal }: LoginPageProps) {
captchaText,
});
writeSession(session);
// A login can happen without a full page reload after the previous session
// expired. Reset the in-memory activity clock so the new session is not
// immediately locked using the previous session's stale idle duration.
markUserActivity();
const target = consumeSessionRecovery(portal)?.returnUrl;
navigate(target ?? (isAdmin ? '/admin' : '/client'), { replace: true });
} catch (err) {
@@ -71,6 +75,7 @@ export function LoginPage({ portal }: LoginPageProps) {
return (
<main className="login-page">
{!isAdmin ? <ClientLoginCanvas /> : null}
<section className="login-panel">
<div className="login-brand">
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
+287 -117
View File
@@ -1,17 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { useDeferredValue, useEffect, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
type SignatureChannelCarrierDrainageQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
type SignatureRetirementHeatmapItem,
type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { createBarOption, createPieOption } from '@/theme/chartOptions';
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { successRateClassName, successRateTone } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
const majorCarrierOrder = ['mobile', 'unicom', 'telecom'] as const;
const drainageStates = [
{ value: 'with', label: '含引流' },
{ value: 'without', label: '不含引流' },
{ value: 'unknown', label: '未检测' },
] as const;
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
@@ -21,10 +29,14 @@ const carrierLabels: Record<string, string> = {
export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult<UnreportedSignatureItem> & { date: string }) | null>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [unreportedKeyword, setUnreportedKeyword] = useState('');
const [appliedUnreportedKeyword, setAppliedUnreportedKeyword] = useState('');
const [selectedSignature, setSelectedSignature] = useState<SignatureChannelQualityItem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -32,17 +44,20 @@ export function AdminAnalyticsPage() {
async function loadData(page = 1, keyword = appliedKeyword) {
setLoading(true);
try {
const [qualityData, signatureData] = await Promise.all([
adminApi.getSendQuality(statisticsDate),
const [signatureData, heatmapData, unreportedData] = await Promise.all([
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
page,
pageSize: 10,
}),
adminApi.getSignatureRetirementHeatmap(statisticsDate),
adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }),
]);
setQuality(qualityData);
setSignatureQuality(signatureData);
setRetirementHeatmap(heatmapData.items);
setRetirementDimensions(heatmapData.dimensions);
setUnreportedSignatures(unreportedData);
setAppliedKeyword(keyword);
setSelectedSignature((current) => current
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
@@ -73,15 +88,7 @@ export function AdminAnalyticsPage() {
};
}, [selectedSignature]);
const applicationOption = useMemo(() => createBarOption({
labels: quality?.applications.map((item) => item.applicationName) ?? [],
series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }],
}), [quality]);
const channelOption = useMemo(() => createPieOption({
data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [],
}), [quality]);
const effectiveDate = quality?.date ?? statisticsDate;
const effectiveDate = signatureQuality?.date ?? statisticsDate;
const signatureColumns: Array<TableColumn<SignatureChannelQualityItem>> = [
{
@@ -162,14 +169,34 @@ export function AdminAnalyticsPage() {
}
function changeSignaturePage(page: number) {
void loadData(page, appliedKeyword);
setLoading(true);
void adminApi.getSignatureQuality({ date: statisticsDate, keyword: appliedKeyword || undefined, page, pageSize: 10 })
.then((data) => {
setSignatureQuality(data);
setError('');
})
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '签名发送质量加载失败'))
.finally(() => setLoading(false));
}
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
setLoading(true);
void adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10 })
.then((data) => {
setUnreportedSignatures(data);
setAppliedUnreportedKeyword(keyword);
setError('');
})
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '未报备签名加载失败'))
.finally(() => setLoading(false));
}
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['数据统计']} />
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
</div>
<div className="page-actions">
<Input
@@ -186,42 +213,6 @@ export function AdminAnalyticsPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface metric-card">
<span>{effectiveDate} </span>
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span>{effectiveDate} </span>
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
<small>{quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} </small>
</div>
</div>
<div className="chart-grid">
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="info"></Tag>
</div>
<Chart height={320} option={applicationOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="accent"></Tag>
</div>
<Chart height={320} option={channelOption} />
</div>
</div>
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
@@ -271,6 +262,18 @@ export function AdminAnalyticsPage() {
) : null}
</div>
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" />
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" />
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
{selectedSignature ? (
<SignatureQualityDrawer
date={signatureQuality?.date ?? effectiveDate}
@@ -282,6 +285,165 @@ export function AdminAnalyticsPage() {
);
}
function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { date: string; dimensionType: 'enterprise' | 'channel'; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[]; title: string }) {
const pageSize = 10;
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
signatureName: item.signatureName,
channelName: item.channelName,
tenantName: item.tenantName,
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [date, deferredKeyword, dimensionType, dimensions.length]);
return (
<div className="surface signature-retirement-heatmap">
<div className="section-heading signature-retirement-heatmap__heading">
<div>
<h2>{title}</h2>
<p className="muted"></p>
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、企业应用或签名"
value={keyword}
/>
<Tag tone="info">T-1 T-30</Tag>
</div>
</div>
{rows.length ? (
<>
<div className="signature-retirement-heatmap__scroll">
<table>
<thead>
<tr><th></th><th>30</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
</thead>
<tbody>
{pagedRows.map((row) => (
<tr key={row.key}>
<th>
<span className="signature-retirement-heatmap__identity">
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong>
{row.channelName ? <small>{row.channelName}</small> : null}
</span>
<CarrierTag carrier={row.carrier} />
</th>
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt;
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0;
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}` : '当日无检测快照';
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={rows.length}
totalPages={totalPages}
/>
</>
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>}
</div>
);
}
function UnreportedSignaturesCard({
data,
keyword,
loading,
onKeywordChange,
onPageChange,
onSearch,
}: {
data: (PagedResult<UnreportedSignatureItem> & { date: string }) | null;
keyword: string;
loading: boolean;
onKeywordChange: (value: string) => void;
onPageChange: (page: number) => void;
onSearch: () => void;
}) {
const columns: Array<TableColumn<UnreportedSignatureItem>> = [
{ key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> },
{ key: 'tenantName', title: '企业名称', render: (record) => record.tenantName },
{ key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' },
{ key: 'messageCount', title: '未报备短信', align: 'right', width: '150px', render: (record) => `${record.messageCount.toLocaleString('zh-CN')}` },
];
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10)));
return (
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title"><h2></h2><Tag tone="warning"></Tag></div>
<p className="muted">{data?.date ?? '所选日期'} </p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索未报备签名、企业或企业应用"
onChange={(event) => onKeywordChange(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }}
placeholder="搜索签名、企业或企业应用"
value={keyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary"></Button>
</div>
</div>
<div className="signature-quality-card__note"><strong></strong></div>
<Table
columns={columns}
data={data?.items ?? []}
emptyText={loading ? '正在加载未报备签名…' : '所选日期没有未报备签名短信'}
pagination={false}
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
/>
{(data?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(data?.page ?? 1) >= totalPages}
onNext={() => onPageChange((data?.page ?? 1) + 1)}
onPageChange={onPageChange}
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
page={data?.page ?? 1}
previousDisabled={(data?.page ?? 1) <= 1}
total={data?.total ?? 0}
totalPages={totalPages}
/>
) : null}
</div>
);
}
function SignatureQualityDrawer({
date,
item,
@@ -308,7 +470,8 @@ function SignatureQualityDrawer({
return (leftRank < 0 ? carrierOrder.length : leftRank)
- (rightRank < 0 ? carrierOrder.length : rightRank);
});
const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()]
const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns]
.map((entry) => [entry.channelId, entry.channelName])).entries()]
.map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
@@ -330,7 +493,7 @@ function SignatureQualityDrawer({
<div className="signature-quality-overview">
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
<QualityMetric label="最终成功率" tone={rateTone(item.successRate)} value={`${item.successRate.toFixed(1)}%`} />
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
</div>
@@ -345,11 +508,11 @@ function SignatureQualityDrawer({
{carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
<div>
<Tag tone={carrierTagTone(carrier.carrier)}>{carrierLabel(carrier.carrier)}</Tag>
<CarrierTag carrier={carrier.carrier} />
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong>
</div>
<dl>
<div><dt></dt><dd>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd className={successRateClassName(carrier.finalSuccessRate)}>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
<div><dt></dt><dd>{carrier.channelCount} </dd></div>
</dl>
@@ -362,39 +525,59 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading">
<div>
<h3> × </h3>
<p>{matrixMode === 'overall' ? '整体口径展示该组合全部真实提交。' : '引流切分口径分别展示含引流、不含引流和历史未检测数据。'}</p>
<p>{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
</div>
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}></Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}></Button></div>
</div>
<div className="signature-quality-matrix">
<table>
<thead>
<tr>
<th></th>
{visibleCarriers.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
</tr>
{matrixMode === 'overall' ? (
<tr>
<th></th>
{visibleCarriers.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
</tr>
) : (
<tr>
<th></th>
<th></th>
{majorCarrierOrder.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
</tr>
)}
</thead>
<tbody>
{channels.map((channel) => (
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
const drainageMetrics = item.drainageBreakdowns.filter((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{matrixMode === 'overall'
? metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>
: drainageMetrics.length ? <DrainageMatrixMetrics metrics={drainageMetrics} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))}
{matrixMode === 'overall'
? channels.map((channel) => (
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))
: channels.flatMap((channel) => drainageStates.map((state, stateIndex) => (
<tr key={`${channel.channelId}-${state.value}`}>
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
{majorCarrierOrder.map((carrier) => {
const metric = item.drainageBreakdowns.find((entry) => (
entry.channelId === channel.channelId
&& normalizeCarrier(entry.carrier) === carrier
&& entry.drainageState === state.value
));
return <td key={carrier}><MatrixMetric metric={metric} zeroWhenEmpty /></td>;
})}
</tr>
)))}
</tbody>
</table>
</div>
@@ -409,41 +592,37 @@ function SignatureQualityDrawer({
);
}
function QualityMetric({ label, value, tone = 'default' }: { label: string; value: string; tone?: string }) {
function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
return (
<div className={`signature-quality-metric signature-quality-metric--${tone}`}>
<div className="signature-quality-metric">
<span>{label}</span>
<strong>{value}</strong>
<strong className={valueClassName}>{value}</strong>
</div>
);
}
function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }) {
function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) {
const total = metric?.total ?? 0;
const successRate = metric?.successRate ?? 0;
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
return (
<div className="signature-quality-matrix__metric">
<strong>{metric.total.toLocaleString('zh-CN')} </strong>
<span className={`signature-quality-matrix__rate signature-quality-matrix__rate--${rateTone(metric.successRate)}`}>
{metric.successRate.toFixed(1)}%
<strong>{total.toLocaleString('zh-CN')} </strong>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
<small>{formatDuration(metric.averageArrivalMs)}</small>
{metric.submitFailureCount > 0 ? <em> {metric.submitFailureCount}</em> : null}
<small>{formatDuration(metric?.averageArrivalMs)}</small>
{(metric?.submitFailureCount ?? 0) > 0 ? <em> {metric?.submitFailureCount}</em> : null}
</div>
);
}
function DrainageMatrixMetrics({ metrics }: { metrics: SignatureChannelCarrierDrainageQualityStat[] }) {
const labels = { with: '含引流', without: '不含引流', unknown: '未检测' };
return <div className="signature-quality-matrix__drainage">{(['with', 'without', 'unknown'] as const).map((state) => {
const metric = metrics.find((item) => item.drainageState === state);
return metric ? <div key={state}><b>{labels[state]}</b><MatrixMetric metric={metric} /></div> : null;
})}</div>;
}
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div>
<strong className={`signature-quality-rate--${rateTone(value)}`}>{value.toFixed(1)}%</strong>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
}
@@ -460,20 +639,6 @@ function carrierLabel(value: string) {
return carrierLabels[normalizeCarrier(value)] ?? '未知';
}
function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral' {
const carrier = normalizeCarrier(value);
if (carrier === 'mobile') return 'info';
if (carrier === 'unicom') return 'accent';
if (carrier === 'telecom') return 'warning';
return 'neutral';
}
function rateTone(value: number) {
if (value >= 98) return 'success';
if (value >= 95) return 'warning';
return 'danger';
}
function formatDuration(value?: number | null) {
if (value == null) return '—';
if (value < 1000) return `${Math.round(value)} 毫秒`;
@@ -490,3 +655,8 @@ function shanghaiDateKey(value = new Date()) {
const byType = new Map(parts.map((part) => [part.type, part.value]));
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
}
function previousDateKeys(endKey: string, days: number) {
const end = new Date(`${endKey}T12:00:00+08:00`);
return Array.from({ length: days }, (_, index) => shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)));
}
+5 -5
View File
@@ -53,8 +53,8 @@ function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
function isCarrierCompatible(channelCarrier: string | null | undefined, carrier: Carrier) {
return !channelCarrier || channelCarrier === 'all' || channelCarrier === carrier;
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
return channel.carriers?.length ? channel.carriers.includes(carrier) : !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
}
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
@@ -95,14 +95,14 @@ function RouteConfigModal({
const provinceOptions = [
{ label: '请选择省份', value: '' },
...Array.from(new Set(channels
.filter((channel) => isCarrierCompatible(channel.carrier, carrier))
.filter((channel) => isCarrierCompatible(channel, carrier))
.map((channel) => channel.sendRegion)
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')),
)).sort().map((region) => ({ label: region, value: region })),
];
const selectableChannels = channels.filter((channel) => {
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
if (!isCarrierCompatible(channel, carrier)) return false;
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false;
if (modal.type === 'province' && province) {
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
@@ -112,7 +112,7 @@ function RouteConfigModal({
const channelOptions = [
{ label: '请选择', value: '' },
...selectableChannels.map((channel) => ({
label: `${channel.name}${channel.code} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
label: `${channel.name}${channel.code} / ${(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).join('、')} / ${channel.sendRegion ?? '全国'}`,
value: channel.id,
})),
];
+83 -27
View File
@@ -1,16 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
const carrierLabels: Record<GroupCarrier, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
};
import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { adminApi, type AdminChannel, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi';
function formatRetryLimit(group: ChannelGroup) {
if (group.retryEnabled === false) return '已关闭';
@@ -33,42 +25,83 @@ function getGroupSummary(group: ChannelGroup) {
export function AdminChannelGroupsPage() {
const navigate = useNavigate();
const [groupName, setGroupName] = useState('');
const [channelId, setChannelId] = useState('');
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
const [deletionImpact, setDeletionImpact] = useState<ChannelGroupDeletionImpact | null>(null);
const [deletionImpactLoading, setDeletionImpactLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [page, setPage] = useState(1);
const [error, setError] = useState('');
const pageSize = 10;
function loadData() {
adminApi.listChannelGroups()
.then((items) => {
setGroups(items);
Promise.all([adminApi.listChannelGroups(), adminApi.listChannels()])
.then(([groupItems, channelItems]) => {
setGroups(groupItems);
setChannels(channelItems);
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道组加载失败'));
.catch((failure: Error) => setError(failure.message || '通道组数据加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
const channelOptions = useMemo(() => [
{ label: '全部通道', value: '' },
...channels
.map((channel) => {
const identity = `${channel.name}${channel.code}`;
return { label: channel.status === 'deleted' ? `${identity}(已删除)` : identity, value: channel.id };
})
.sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
], [channels]);
const filteredGroups = useMemo(() => {
const keyword = groupName.trim();
return groups.filter((group) => (
(!keyword || group.name.includes(keyword))
&& (!channelId || (group.items ?? []).some((item) => item.channelId === channelId))
));
}, [channelId, groupName, groups]);
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleGroups = filteredGroups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [groupName, groups.length]);
}, [channelId, groupName, groups.length]);
function closeDeleteModal() {
if (deleting) return;
setDeleteTarget(null);
setDeletionImpact(null);
}
function openDeleteModal(group: ChannelGroup) {
setDeleteTarget(group);
setDeletionImpact(null);
setDeletionImpactLoading(true);
setError('');
adminApi.getChannelGroupDeletionImpact(group.id)
.then(setDeletionImpact)
.catch((failure: Error) => setError(failure.message || '删除影响数据加载失败'))
.finally(() => setDeletionImpactLoading(false));
}
function deleteGroup() {
if (!deleteTarget) return;
if (!deleteTarget || !deletionImpact || deleting) return;
setDeleting(true);
adminApi.deleteChannelGroup(deleteTarget.id)
.then(() => {
setDeleteTarget(null);
setDeletionImpact(null);
loadData();
})
.catch((failure: Error) => setError(failure.message || '通道组删除失败'));
.catch((failure: Error) => setError(failure.message || '通道组删除失败'))
.finally(() => setDeleting(false));
}
return (
@@ -85,9 +118,20 @@ export function AdminChannelGroupsPage() {
<section className="surface channel-group-filter">
<Input label="通道组名称" onChange={(event) => setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} />
<Select
label="通道"
onChange={(event) => setChannelId(event.target.value)}
options={channelOptions}
searchable
searchPlaceholder="输入通道名称或编码搜索"
value={channelId}
/>
<div className="channel-group-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setGroupName('')} variant="ghost"></Button>
<Button onClick={() => {
setGroupName('');
setChannelId('');
}} variant="ghost"></Button>
</div>
</section>
@@ -102,7 +146,7 @@ export function AdminChannelGroupsPage() {
<span className="channel-group-config-item__icon"><Layers3 size={18} /></span>
<div>
<strong>{group.name}</strong>
<span>{carrierLabels[group.carrier] ?? group.carrier}</span>
<span><CarrierTag carrier={group.carrier} /> </span>
</div>
</div>
@@ -144,7 +188,7 @@ export function AdminChannelGroupsPage() {
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
<Pencil size={15} />
</button>
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
<button className="is-danger" onClick={() => openDeleteModal(group)} type="button">
<Trash2 size={15} />
</button>
</div>
@@ -167,17 +211,29 @@ export function AdminChannelGroupsPage() {
<Modal
footer={(
<>
<Button onClick={() => setDeleteTarget(null)} variant="ghost"></Button>
<Button onClick={deleteGroup} variant="danger"></Button>
<Button disabled={deleting} onClick={closeDeleteModal} variant="ghost"></Button>
<Button disabled={deletionImpactLoading || !deletionImpact || deleting} onClick={deleteGroup} variant="danger">
{deleting ? '删除中...' : '确认删除'}
</Button>
</>
)}
onClose={() => setDeleteTarget(null)}
onClose={closeDeleteModal}
open={Boolean(deleteTarget)}
title="删除通道组"
title={`删除通道组:${deleteTarget?.name ?? ''}`}
>
<div className="channel-confirm">
<strong>{deleteTarget?.name}</strong>
<p>使</p>
{deletionImpactLoading ? <span>...</span> : null}
{deletionImpact ? (
<>
<span>{deletionImpact.normalApplicationCount} </span>
<span>{deletionImpact.channelCount} </span>
<span>{deletionImpact.pendingSupplierSubmitCount} </span>
<p>
<br />
</p>
</>
) : null}
</div>
</Modal>
</div>
+9 -8
View File
@@ -2,8 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { successRateClassName } from '@/utils/successRate';
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
type ReportType = 'signature' | 'drainage';
@@ -58,10 +59,10 @@ function DeliveryStats({ task }: { task: ReportTask }) {
failureRate: 0,
};
return <div className="channel-report-stats">
<span><strong className="is-success">{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-warning">{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-danger">{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-danger">{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
<span><strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
</div>;
}
@@ -146,7 +147,7 @@ export function AdminChannelReportPage() {
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, carrier: statusTask.carrier ?? undefined, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
@@ -181,10 +182,10 @@ export function AdminChannelReportPage() {
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const reportedAt = approvedRecord(task.id)?.createdAt;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div>
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
+3 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, FileText, MessageSquare, Server } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type ClientSmsTemplate, type EnterpriseApplication, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
export function AdminCustomerDetailPage() {
@@ -68,8 +68,8 @@ export function AdminCustomerDetailPage() {
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card"><Server size={22} /><div><span></span><strong>{tenant?.code ?? '-'}</strong><small>{tenant?.status ?? '-'}</small></div></div>
<div className="surface mini-status-card"><MessageSquare size={22} /><div><span></span><strong></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.balanceCents)}</strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.creditCents)}</strong><small> 0</small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong><MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong><MoneyText>¥{formatCents(account?.creditCents)}</MoneyText></strong><small> 0</small></div></div>
</div>
<div className="surface section-stack">
+30 -26
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ImagePlus } from 'lucide-react';
import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
import { adminApi, type AdministrativeRegion, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
@@ -23,26 +23,6 @@ type EnterpriseForm = {
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
const provinceOptions = [
{ label: '请选择省/直辖市', value: '' },
...'北京,上海,广东,山东,河南,江苏,浙江,四川,重庆,湖北,湖南,陕西'.split(',').map((item) => ({ label: item, value: item })),
];
const cityOptionsByProvince: Record<string, Array<{ label: string; value: string }>> = {
: [{ label: '北京市', value: '北京市' }],
: [{ label: '上海市', value: '上海市' }],
广: ['广州市', '深圳市', '东莞市'].map((item) => ({ label: item, value: item })),
: ['济南市', '青岛市', '烟台市'].map((item) => ({ label: item, value: item })),
: ['郑州市', '洛阳市', '开封市'].map((item) => ({ label: item, value: item })),
: ['南京市', '苏州市', '无锡市'].map((item) => ({ label: item, value: item })),
: ['杭州市', '宁波市', '温州市'].map((item) => ({ label: item, value: item })),
: ['成都市', '绵阳市', '德阳市'].map((item) => ({ label: item, value: item })),
: [{ label: '重庆市', value: '重庆市' }],
: ['武汉市', '宜昌市', '襄阳市'].map((item) => ({ label: item, value: item })),
: ['长沙市', '株洲市', '湘潭市'].map((item) => ({ label: item, value: item })),
西: ['西安市', '咸阳市', '宝鸡市'].map((item) => ({ label: item, value: item })),
};
const emptyForm: EnterpriseForm = {
name: '',
creditCode: '',
@@ -87,6 +67,16 @@ export function AdminCustomerFormPage() {
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [regions, setRegions] = useState<AdministrativeRegion[]>([]);
const [regionsLoading, setRegionsLoading] = useState(true);
const [regionError, setRegionError] = useState('');
useEffect(() => {
adminApi.listAdministrativeRegions()
.then((items) => { setRegions(items); setRegionError(''); })
.catch((failure: Error) => { setRegions([]); setRegionError(failure.message || '省市字典加载失败'); })
.finally(() => setRegionsLoading(false));
}, []);
useEffect(() => {
if (!enterpriseId) {
@@ -101,10 +91,23 @@ export function AdminCustomerFormPage() {
.catch((failure: Error) => setError(failure.message || '企业信息加载失败'));
}, [enterpriseId]);
const cityOptions = useMemo(() => [
{ label: '请选择市/区', value: '' },
...(cityOptionsByProvince[form.province] ?? []),
], [form.province]);
const provinceOptions = useMemo(() => {
const values = regions.map((item) => item.province);
if (form.province && !values.includes(form.province)) values.push(form.province);
return [
{ label: regionsLoading ? '正在加载省市字典...' : '请选择省/直辖市', value: '' },
...values.map((item) => ({ label: item, value: item })),
];
}, [form.province, regions, regionsLoading]);
const cityOptions = useMemo(() => {
const values = [...(regions.find((item) => item.province === form.province)?.cities ?? [])];
if (form.city && !values.includes(form.city)) values.push(form.city);
return [
{ label: form.province ? '请选择地市' : '请先选择省份', value: '' },
...values.map((item) => ({ label: item, value: item })),
];
}, [form.city, form.province, regions]);
function updateForm<K extends keyof EnterpriseForm>(key: K, value: EnterpriseForm[K]) {
setForm((current) => ({
@@ -178,6 +181,7 @@ export function AdminCustomerFormPage() {
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
{regionError ? <p className="form-error">{regionError}</p> : null}
<div className="surface enterprise-form-card">
<section className="ui-detail-section">
@@ -233,7 +237,7 @@ export function AdminCustomerFormPage() {
<div className="form-grid form-grid--two">
<Select label="省/直辖市" onChange={(event) => updateForm('province', event.target.value)} options={provinceOptions} value={form.province} />
<Select label="市/区" onChange={(event) => updateForm('city', event.target.value)} options={cityOptions} value={form.city} />
<Select disabled={!form.province || regionsLoading} label="市" onChange={(event) => updateForm('city', event.target.value)} options={cityOptions} value={form.city} />
</div>
<Textarea
+6 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, MoneyText, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
type AdminCustomersPageProps = {
@@ -85,15 +85,15 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const balance = record.account?.balanceCents ?? 0;
return (
<span className={balance < 0 ? 'status-danger' : ''}>
¥{formatCents(balance)}
<MoneyText>¥{formatCents(balance)}</MoneyText>
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag"></Tag> : null}
</span>
);
},
},
{ key: 'creditLimit', title: '授信额度', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.account?.creditCents ?? 0)}` },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todaySpendCents)}` },
{ key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todayRefundCents)}` },
{ key: 'creditLimit', title: '授信额度', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.account?.creditCents ?? 0)}</MoneyText> },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.todaySpendCents)}</MoneyText> },
{ key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => <MoneyText>¥{formatCents(record.todayRefundCents)}</MoneyText> },
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{
key: 'actions',
@@ -160,7 +160,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="surface mini-status-card"><Building2 size={22} /><div><span></span><strong>{records.length}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span></span><strong>{activeCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span></span><strong>{disabledCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{formatCents(totalBalance)}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong><MoneyText>¥{formatCents(totalBalance)}</MoneyText></strong><small></small></div></div>
</div>
<div className="surface ui-query-panel">
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type DownstreamRequeueTaskItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -42,6 +42,24 @@ const attemptStatusLabel: Record<string, string> = {
failed: '投递失败',
};
const requeueTaskStatusLabel: Record<string, string> = {
queued: '排队中', running: '执行中', paused: '已暂停', completed: '已完成',
partial_completed: '部分完成', terminated: '已终止',
};
const requeueItemStatusLabel: Record<string, string> = {
queued: '排队中', processing: '处理中', waiting_connection: '等待连接',
waiting_external_ack: '等待其他链路确认', waiting_ack: '等待客户确认', success: '成功',
failed: '失败', skipped: '跳过', unprocessed: '未处理',
};
function requeueTone(status: string) {
if (status === 'completed' || status === 'success') return 'success' as const;
if (status === 'partial_completed' || status === 'failed') return 'danger' as const;
if (status === 'paused' || status === 'skipped' || status === 'unprocessed') return 'warning' as const;
return 'info' as const;
}
type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
@@ -148,12 +166,14 @@ export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
const [dashboard, setDashboard] = useState<DownstreamDeliveryDashboard | null>(null);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [deliveryType, setDeliveryType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [tenantId, setTenantId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -164,12 +184,45 @@ export function AdminDownstreamDeliveriesPage() {
const [requeueBusy, setRequeueBusy] = useState(false);
const [requeueResult, setRequeueResult] = useState<RequeueResult | null>(null);
const requeueInFlightRef = useRef(false);
const [taskPreview, setTaskPreview] = useState<DownstreamRequeuePreview | null>(null);
const [taskPreviewBusy, setTaskPreviewBusy] = useState(false);
const [taskReason, setTaskReason] = useState('');
const [taskRate, setTaskRate] = useState(10);
const [taskCreateBusy, setTaskCreateBusy] = useState(false);
const [requeueTasks, setRequeueTasks] = useState<DownstreamRequeueTask[]>([]);
const [requeueTaskStatus, setRequeueTaskStatus] = useState('all');
const [requeueTaskPage, setRequeueTaskPage] = useState(1);
const [requeueTaskTotal, setRequeueTaskTotal] = useState(0);
const [selectedTask, setSelectedTask] = useState<DownstreamRequeueTask | null>(null);
const [taskItems, setTaskItems] = useState<DownstreamRequeueTaskItem[]>([]);
const [taskItemStatus, setTaskItemStatus] = useState('all');
const [taskItemKeyword, setTaskItemKeyword] = useState('');
const [taskItemAppliedKeyword, setTaskItemAppliedKeyword] = useState('');
const [taskItemPage, setTaskItemPage] = useState(1);
const [taskItemTotal, setTaskItemTotal] = useState(0);
const currentTaskFilter = useCallback(() => ({
keyword: keyword || undefined,
status,
deliveryType,
tenantId,
applicationId,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status, tenantId]);
const loadRequeueTasks = useCallback(() => {
adminApi.listDownstreamRequeueTasks({ status: requeueTaskStatus, page: requeueTaskPage, pageSize: 10 })
.then((response) => { setRequeueTasks(response.items); setRequeueTaskTotal(response.total); })
.catch((failure: Error) => setError(failure.message || '后台重投任务加载失败'));
}, [requeueTaskPage, requeueTaskStatus]);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.getDownstreamDeliveryDashboard({
applicationId,
tenantId,
deliveryType,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
@@ -179,29 +232,87 @@ export function AdminDownstreamDeliveriesPage() {
status,
deliveryType,
applicationId,
tenantId,
page,
pageSize,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}),
adminApi.listEnterpriseApplications(),
adminApi.listTenants(),
])
.then(([dashboardResponse, response, apps]) => {
.then(([dashboardResponse, response, apps, tenantOptions]) => {
setDashboard(dashboardResponse);
setRecords(response.items);
setTotal(response.total);
setApplications(apps);
setTenants(tenantOptions);
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
setError('');
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status]);
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status, tenantId]);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
loadRequeueTasks();
const timer = window.setInterval(loadRequeueTasks, 3000);
return () => window.clearInterval(timer);
}, [loadRequeueTasks]);
const openTaskPreview = async () => {
setTaskPreviewBusy(true);
setError('');
try {
setTaskPreview(await adminApi.previewDownstreamRequeueTask(currentTaskFilter()));
setTaskReason('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '后台重投范围预检失败');
} finally {
setTaskPreviewBusy(false);
}
};
const createRequeueTask = async () => {
if (!taskPreview || taskReason.trim().length < 5) return;
setTaskCreateBusy(true);
try {
await adminApi.createDownstreamRequeueTask({ previewToken: taskPreview.previewToken, reason: taskReason.trim(), ratePerSecond: taskRate, consecutiveFailureLimit: 10 });
setTaskPreview(null);
loadRequeueTasks();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '后台重投任务创建失败');
} finally {
setTaskCreateBusy(false);
}
};
const filteredApplications = useMemo(
() => applications.filter((item) => tenantId === 'all' || item.tenantId === tenantId),
[applications, tenantId],
);
const openTaskDetail = async (id: string) => {
try {
const task = await adminApi.getDownstreamRequeueTask(id);
setSelectedTask(task);
setTaskItemStatus('all'); setTaskItemKeyword(''); setTaskItemAppliedKeyword(''); setTaskItemPage(1);
} catch (failure) { setError(failure instanceof Error ? failure.message : '任务详情加载失败'); }
};
const loadTaskItems = useCallback(() => {
if (!selectedTask) return;
adminApi.listDownstreamRequeueTaskItems(selectedTask.id, { status: taskItemStatus, keyword: taskItemAppliedKeyword || undefined, page: taskItemPage, pageSize: 20 })
.then((response) => { setTaskItems(response.items); setTaskItemTotal(response.total); })
.catch((failure: Error) => setError(failure.message || '任务明细加载失败'));
}, [selectedTask, taskItemAppliedKeyword, taskItemPage, taskItemStatus]);
useEffect(() => { loadTaskItems(); }, [loadTaskItems]);
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
const selectableIds = useMemo(
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
@@ -320,6 +431,12 @@ export function AdminDownstreamDeliveriesPage() {
<div className="surface ui-filter-row">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select
label="企业"
options={[{ label: '全部企业', value: 'all' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={tenantId}
onChange={(event) => { setTenantId(event.target.value); setApplicationId('all'); setPage(1); }}
/>
<Select
label="状态"
options={[
@@ -354,7 +471,7 @@ export function AdminDownstreamDeliveriesPage() {
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
...filteredApplications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
@@ -369,6 +486,7 @@ export function AdminDownstreamDeliveriesPage() {
setKeyword('');
setStatus('all');
setDeliveryType('all');
setTenantId('all');
setApplicationId('all');
setDateRange(recentSevenDays());
setPage(1);
@@ -455,6 +573,17 @@ export function AdminDownstreamDeliveriesPage() {
<strong>{selectedIds.length}</strong>
</p>
<div>
<label className="downstream-page-size">
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); setSelectedIds([]); }}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
</label>
<Button disabled={taskPreviewBusy} onClick={() => void openTaskPreview()} variant="secondary">
{taskPreviewBusy ? '范围预检中…' : '按筛选条件重投'}
</Button>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
@@ -548,7 +677,115 @@ export function AdminDownstreamDeliveriesPage() {
/>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading downstream-requeue-task-heading">
<div><h2></h2><p className="muted"></p></div>
<div className="downstream-requeue-task-heading__actions">
<Select
aria-label="任务状态"
options={[
{ label: '全部状态', value: 'all' }, { label: '排队中', value: 'queued' }, { label: '执行中', value: 'running' },
{ label: '已暂停', value: 'paused' }, { label: '已完成', value: 'completed' }, { label: '部分完成', value: 'partial_completed' }, { label: '已终止', value: 'terminated' },
]}
value={requeueTaskStatus}
onChange={(event) => { setRequeueTaskStatus(event.target.value); setRequeueTaskPage(1); }}
/>
<Button icon={<RefreshCw size={15} />} onClick={loadRequeueTasks} variant="ghost"></Button>
</div>
</div>
<div className="downstream-requeue-task-list">
{requeueTasks.map((task) => (
<article key={task.id}>
<div className="downstream-requeue-task-list__identity"><strong>{task.taskNo}</strong><span>{formatDateTime(task.createdAt)}</span><small>{task.createdBy?.displayName || task.createdBy?.username || '系统创建'}</small></div>
<div className="downstream-requeue-task-list__scope"><span>{task.tenant?.name ?? '全部企业'} · {task.application?.name ?? '多个应用'}</span><strong>{task.reason}</strong></div>
<div className="downstream-requeue-task-list__progress">
<div><Tag tone={requeueTone(task.status)}>{requeueTaskStatusLabel[task.status] ?? task.status}</Tag><strong>{task.successCount + task.failedCount + task.skippedCount}/{task.totalCount}</strong></div>
<div className="downstream-requeue-task-list__meter"><span style={{ width: `${task.totalCount ? Math.min(100, ((task.successCount + task.failedCount + task.skippedCount) / task.totalCount) * 100) : 0}%` }} /></div>
<small> {task.successCount} · {task.failedCount} · {task.skippedCount} · {task.waitingCount}</small>
</div>
<div className="downstream-requeue-task-list__actions">
<Button icon={<Eye size={14} />} onClick={() => void openTaskDetail(task.id)} size="sm" variant="ghost"></Button>
{task.status === 'running' || task.status === 'queued' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'pause').then(loadRequeueTasks)} size="sm" variant="secondary"></Button> : null}
{task.status === 'paused' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'resume').then(loadRequeueTasks)} size="sm" variant="secondary"></Button> : null}
{['queued', 'running', 'paused'].includes(task.status) ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'terminate').then(loadRequeueTasks)} size="sm" variant="warning"></Button> : null}
</div>
</article>
))}
{requeueTasks.length === 0 ? <p className="muted"></p> : null}
</div>
<Pagination
total={requeueTaskTotal}
page={requeueTaskPage}
totalPages={Math.max(1, Math.ceil(requeueTaskTotal / 10))}
onPageChange={setRequeueTaskPage}
previousDisabled={requeueTaskPage <= 1}
nextDisabled={requeueTaskPage >= Math.max(1, Math.ceil(requeueTaskTotal / 10))}
onPrevious={() => setRequeueTaskPage((current) => Math.max(1, current - 1))}
onNext={() => setRequeueTaskPage((current) => Math.min(Math.max(1, Math.ceil(requeueTaskTotal / 10)), current + 1))}
/>
</div>
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
{taskPreview ? (
<Modal open onClose={() => !taskCreateBusy && setTaskPreview(null)} title={<div className="template-modal-title"><h2></h2><p></p></div>} size="xl" footer={<><Button disabled={taskCreateBusy} onClick={() => setTaskPreview(null)} variant="ghost"></Button><Button disabled={taskCreateBusy || taskPreview.replayableCount === 0 || taskReason.trim().length < 5} onClick={() => void createRequeueTask()} variant="warning">{taskCreateBusy ? '创建中…' : `创建任务 · ${taskPreview.replayableCount}`}</Button></>}>
<div className="downstream-requeue-preview">
<div className="downstream-requeue-preview__summary">
<div><span></span><strong>{taskPreview.matchedCount}</strong><small></small></div>
<div className="is-primary"><span></span><strong>{taskPreview.replayableCount}</strong><small></small></div>
<div><span></span><strong>{taskPreview.skippedCount}</strong><small> Gateway</small></div>
<div><span></span><strong>{taskPreview.applicationCount}</strong><small></small></div>
</div>
<div className="detail-grid downstream-requeue-preview__meta">
<div><span></span><strong>{taskPreview.oldestCreatedAt ? formatDateTime(taskPreview.oldestCreatedAt) : '-'}</strong></div>
<div><span></span><strong>{formatDateTime(taskPreview.snapshotAt)}</strong></div>
</div>
<div className="downstream-requeue-preview__distribution"><span></span><div>{Object.entries(taskPreview.statusCounts).map(([key, value]) => <Tag key={key} tone={statusTone[key] ?? 'neutral'}>{statusLabel[key] ?? key} {value}</Tag>)}</div></div>
<Select label="执行速度" value={String(taskRate)} onChange={(event) => setTaskRate(Number(event.target.value))} options={[{ label: '平稳(每应用10条/秒)', value: '10' }, { label: '快速(每应用20条/秒)', value: '20' }, { label: '低速(每应用5条/秒)', value: '5' }]} />
<label className="field"><span> *</span><textarea value={taskReason} onChange={(event) => setTaskReason(event.target.value)} placeholder="请填写事故原因、工单号或处理说明(至少5个字)" rows={3} /></label>
<div className="downstream-requeue-warning"><AlertTriangle size={20} /><div><strong></strong><p> ACK 线</p></div></div>
</div>
</Modal>
) : null}
{selectedTask ? (
<Modal open onClose={() => setSelectedTask(null)} title={<div className="template-modal-title"><h2></h2><p>{selectedTask.taskNo}</p></div>} size="xl" footer={<Button onClick={() => setSelectedTask(null)}></Button>}>
<div className="report-record-detail downstream-requeue-task-detail">
<div className="detail-grid">
<div><span></span><strong><Tag tone={requeueTone(selectedTask.status)}>{requeueTaskStatusLabel[selectedTask.status] ?? selectedTask.status}</Tag></strong></div><div className="detail-grid__wide"><span></span><strong>{selectedTask.reason}</strong></div>
<div><span> / </span><strong>{selectedTask.tenant?.name ?? '全部企业'} / {selectedTask.application?.name ?? '多个应用'}</strong></div>
<div><span></span><strong>{selectedTask.createdBy?.displayName || selectedTask.createdBy?.username || '系统创建'}</strong></div>
<div><span></span><strong>{formatDateTime(selectedTask.snapshotAt)}</strong></div><div><span></span><strong> {selectedTask.ratePerSecond} /</strong></div>
<div><span></span><strong>{selectedTask.totalCount}</strong></div><div><span></span><strong>{selectedTask.successCount}</strong></div>
<div><span></span><strong>{selectedTask.failedCount}</strong></div><div><span></span><strong>{selectedTask.skippedCount}</strong></div>
<div><span></span><strong>{selectedTask.waitingCount}</strong></div><div><span></span><strong>{selectedTask.itemCounts?.unprocessed ?? 0}</strong></div>
</div>
{selectedTask.lastError ? <div className="downstream-requeue-detail-alert"><AlertTriangle size={17} /><span>{selectedTask.lastError}</span></div> : null}
<section className="downstream-requeue-detail-section">
<div className="section-heading"><div><h3></h3><p className="muted"> {taskItemTotal} </p></div></div>
<div className="downstream-requeue-detail-filter">
<Select options={[{ label: '全部结果', value: 'all' }, ...Object.entries(requeueItemStatusLabel).map(([value, label]) => ({ value, label }))]} value={taskItemStatus} onChange={(event) => { setTaskItemStatus(event.target.value); setTaskItemPage(1); }} />
<Input placeholder="消息 ID / 错误 / 跳过原因" value={taskItemKeyword} onChange={(event) => setTaskItemKeyword(event.target.value)} />
<Button icon={<Search size={14} />} onClick={() => {
const nextKeyword = taskItemKeyword.trim();
if (taskItemPage !== 1) setTaskItemPage(1);
if (nextKeyword !== taskItemAppliedKeyword) setTaskItemAppliedKeyword(nextKeyword);
else if (taskItemPage === 1) loadTaskItems();
}} variant="secondary"></Button>
</div>
<div className="downstream-requeue-detail-items">
<div className="downstream-requeue-detail-items__head"><span> ID</span><span></span><span></span><span></span><span></span></div>
{taskItems.map((item) => <div key={item.id}><strong>{item.delivery.messageId ?? '-'}</strong><Tag tone={requeueTone(item.status)}>{requeueItemStatusLabel[item.status] ?? item.status}</Tag><span>{statusLabel[item.previousStatus] ?? item.previousStatus}</span><span>{item.skipReason ?? item.errorMessage ?? '—'}</span><time>{formatDateTime(item.updatedAt)}</time></div>)}
{taskItems.length === 0 ? <p className="muted"></p> : null}
</div>
<Pagination
total={taskItemTotal} page={taskItemPage} totalPages={Math.max(1, Math.ceil(taskItemTotal / 20))} onPageChange={setTaskItemPage}
previousDisabled={taskItemPage <= 1} nextDisabled={taskItemPage >= Math.max(1, Math.ceil(taskItemTotal / 20))}
onPrevious={() => setTaskItemPage((current) => Math.max(1, current - 1))}
onNext={() => setTaskItemPage((current) => Math.min(Math.max(1, Math.ceil(taskItemTotal / 20)), current + 1))}
/>
</section>
</div>
</Modal>
) : null}
{requeueTarget ? (
<Modal
footer={requeueResult ? (
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Check, FileSearch, Search, X } from 'lucide-react';
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, MoneyText, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
@@ -194,7 +194,7 @@ export function AdminEnterpriseAuditPage() {
<div><span></span><strong>{detailRecord.bankAccountName}</strong></div>
<div><span></span><strong>{detailRecord.bankName}</strong></div>
<div><span></span><strong>{detailRecord.bankAccountNo}</strong></div>
<div><span></span><strong>{detailRecord.verificationAmount}</strong></div>
<div><span></span><strong><MoneyText>{detailRecord.verificationAmount}</MoneyText></strong></div>
</section>
<section>
<h3></h3>
@@ -18,6 +18,13 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
resolved: 'success',
};
const resolvedStatusLabel: Record<string, string> = {
manually_resolved: '人工标记已处理',
accepted: '上游已受理',
rejected: '上游已拒绝',
timeout: '上游提交超时',
};
function formatTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
@@ -34,10 +41,11 @@ function commandValue(record: GatewaySubmitException, key: string) {
return value == null ? '' : String(value);
}
function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
function ExceptionDetailModal({ record, onClose, onRequestRequeue, onRequestResolve }: {
record: GatewaySubmitException;
onClose: () => void;
onRequestRequeue: () => void;
onRequestResolve: () => void;
}) {
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
const content = record.messageState?.content ?? commandValue(record, 'content');
@@ -50,6 +58,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
footer={(
<div className="modal-footer-actions">
<Button onClick={onClose} variant="ghost"></Button>
{record.status === 'pending' ? <Button icon={<CheckCircle2 size={15} />} onClick={onRequestResolve} variant="secondary"></Button> : null}
{record.status === 'pending' ? <Button icon={<RotateCcw size={15} />} onClick={onRequestRequeue}></Button> : null}
</div>
)}
@@ -76,7 +85,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
<div><span></span><strong>{formatTime(record.createdAt)}</strong></div>
<div><span></span><strong>{formatTime(record.lastRetriedAt)}</strong></div>
<div><span></span><strong>{formatTime(record.resolvedAt)}</strong></div>
<div><span></span><strong>{record.resolvedStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.resolvedStatus ? (resolvedStatusLabel[record.resolvedStatus] ?? record.resolvedStatus) : '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{content || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureCode}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureMessage}</strong></div>
@@ -90,6 +99,39 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
);
}
function ResolveModal({ record, submitting, onClose, onSubmit }: {
record: GatewaySubmitException;
submitting: boolean;
onClose: () => void;
onSubmit: () => void;
}) {
return (
<Modal
open
onClose={onClose}
size="md"
title="确认标记为已处理"
footer={(
<div className="modal-footer-actions">
<Button disabled={submitting} onClick={onClose} variant="ghost"></Button>
<Button disabled={submitting} icon={<CheckCircle2 size={15} />} onClick={onSubmit}>
{submitting ? '处理中...' : '确认已处理'}
</Button>
</div>
)}
>
<div className="downstream-requeue-confirm">
<CheckCircle2 aria-hidden="true" size={24} />
<div>
<p></p>
<small>{record.messageId ?? record.streamMessageId}</small>
<strong></strong>
</div>
</div>
</Modal>
);
}
function RequeueModal({ record, submitting, onClose, onSubmit }: {
record: GatewaySubmitException;
submitting: boolean;
@@ -156,6 +198,8 @@ function GatewaySubmitExceptionPanel() {
const [error, setError] = useState('');
const [detail, setDetail] = useState<GatewaySubmitException | null>(null);
const [requeueRecord, setRequeueRecord] = useState<GatewaySubmitException | null>(null);
const [resolveRecord, setResolveRecord] = useState<GatewaySubmitException | null>(null);
const [resolving, setResolving] = useState(false);
const pageSize = 10;
const loadData = useCallback(() => {
@@ -192,7 +236,7 @@ function GatewaySubmitExceptionPanel() {
{ key: 'failure', title: '异常原因', render: (record) => <div><strong>{record.failureCode}</strong><small className="table-cell-note">{record.failureMessage}</small></div> },
{ key: 'attempts', title: '尝试', width: '80px', align: 'center', render: (record) => `${record.attempts}/${record.maxAttempts}` },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '105px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
{ key: 'actions', title: '操作', width: '190px', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>{record.status === 'pending' ? <Button icon={<CheckCircle2 size={14} />} onClick={() => setResolveRecord(record)} size="sm" variant="secondary"></Button> : null}</div> },
], []);
async function submitRequeue(reason: string) {
@@ -214,6 +258,22 @@ function GatewaySubmitExceptionPanel() {
}
}
async function submitResolve() {
if (!resolveRecord) return;
setResolving(true);
try {
await adminApi.resolveGatewaySubmitException(resolveRecord.id);
setResolveRecord(null);
setDetail(null);
setError('');
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '标记已处理失败');
} finally {
setResolving(false);
}
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
@@ -243,8 +303,9 @@ function GatewaySubmitExceptionPanel() {
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无提交异常'} pagination={false} rowKey="id" />
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
</div>
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} onRequestResolve={() => setResolveRecord(detail)} /> : null}
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
{resolveRecord ? <ResolveModal record={resolveRecord} submitting={resolving} onClose={() => setResolveRecord(null)} onSubmit={() => void submitResolve()} /> : null}
</div>
);
}
+5 -86
View File
@@ -11,12 +11,12 @@ import {
Button,
Chart,
Modal,
Pagination,
MoneyText,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
@@ -28,12 +28,6 @@ type EnterpriseSpendRank = {
availableBalance: number;
};
type RankedSignatureQualityStat = SignatureQualityStat & {
rank: number;
};
const SIGNATURE_PAGE_SIZE = 10;
const balanceTone = {
: 'success',
: 'warning',
@@ -54,8 +48,6 @@ export function AdminHome() {
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [error, setError] = useState('');
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const [plainSignaturePage, setPlainSignaturePage] = useState(1);
const [drainageSignaturePage, setDrainageSignaturePage] = useState(1);
useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
@@ -130,7 +122,7 @@ export function AdminHome() {
</div>
),
},
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText> },
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
{
@@ -145,36 +137,6 @@ export function AdminHome() {
},
];
const signatureColumns: Array<TableColumn<RankedSignatureQualityStat>> = [
{ key: 'rank', title: '排名', width: '72px', render: (record) => record.rank },
{ key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
{ key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) },
{ key: 'submitFailureCount', title: '提交失败', align: 'right', render: (record) => formatCount(record.submitFailureCount) },
{ key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) },
{ key: 'unknownCount', title: '未知', align: 'right', render: (record) => formatCount(record.unknownCount) },
{ key: 'failureCount', title: '失败', align: 'right', render: (record) => formatCount(record.failureCount) },
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` },
{ key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}` },
];
const plainSignatureQuality = quality?.signatures ?? [];
const drainageSignatureQuality = quality?.drainageSignatures ?? [];
const plainSignatureTotalPages = Math.max(1, Math.ceil(plainSignatureQuality.length / SIGNATURE_PAGE_SIZE));
const drainageSignatureTotalPages = Math.max(1, Math.ceil(drainageSignatureQuality.length / SIGNATURE_PAGE_SIZE));
const pagedPlainSignatureQuality = plainSignatureQuality
.slice((plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE, plainSignaturePage * SIGNATURE_PAGE_SIZE)
.map((item, index) => ({ ...item, rank: (plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
const pagedDrainageSignatureQuality = drainageSignatureQuality
.slice((drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE, drainageSignaturePage * SIGNATURE_PAGE_SIZE)
.map((item, index) => ({ ...item, rank: (drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
useEffect(() => {
setPlainSignaturePage((page) => Math.min(page, plainSignatureTotalPages));
}, [plainSignatureTotalPages]);
useEffect(() => {
setDrainageSignaturePage((page) => Math.min(page, drainageSignatureTotalPages));
}, [drainageSignatureTotalPages]);
return (
<section className="page-stack admin-dashboard">
<div className="overview-hero admin-dashboard-hero">
@@ -204,7 +166,7 @@ export function AdminHome() {
<small>delivered / </small>
</div>
<div className="surface metric-card">
<span></span>
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
</div>
@@ -229,49 +191,6 @@ export function AdminHome() {
</div>
</div>
<div className="overview-grid admin-signature-rank-grid">
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Tag tone="info">{plainSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无签名发送记录" rowKey="id" />
<Pagination
total={plainSignatureQuality.length}
page={plainSignaturePage}
totalPages={plainSignatureTotalPages}
previousDisabled={plainSignaturePage <= 1}
nextDisabled={plainSignaturePage >= plainSignatureTotalPages}
onPrevious={() => setPlainSignaturePage((page) => Math.max(1, page - 1))}
onNext={() => setPlainSignaturePage((page) => Math.min(plainSignatureTotalPages, page + 1))}
onPageChange={setPlainSignaturePage}
/>
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
</div>
<Tag tone="accent">{drainageSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={pagedDrainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
<Pagination
total={drainageSignatureQuality.length}
page={drainageSignaturePage}
totalPages={drainageSignatureTotalPages}
previousDisabled={drainageSignaturePage <= 1}
nextDisabled={drainageSignaturePage >= drainageSignatureTotalPages}
onPrevious={() => setDrainageSignaturePage((page) => Math.max(1, page - 1))}
onNext={() => setDrainageSignaturePage((page) => Math.min(drainageSignatureTotalPages, page + 1))}
onPageChange={setDrainageSignaturePage}
/>
</div>
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
@@ -372,7 +291,7 @@ export function AdminHome() {
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>¥{formatCurrency(selectedEnterprise.todaySpend)}</strong>
<strong><MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText></strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>

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