This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
CREATE TABLE "ReportReadinessState" (
|
||||
"objectKey" TEXT PRIMARY KEY, "mask" INTEGER NOT NULL, "armed" BOOLEAN NOT NULL,
|
||||
"cycle" INTEGER NOT NULL DEFAULT 0, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
);
|
||||
CREATE TABLE "ReportNotificationHour" (
|
||||
"id" TEXT PRIMARY KEY, "tenantId" TEXT NOT NULL, "tenantName" TEXT NOT NULL,
|
||||
"hour" TIMESTAMP(3) NOT NULL, "revision" INTEGER NOT NULL DEFAULT 1,
|
||||
"signatureCount" INTEGER NOT NULL DEFAULT 0, "drainageCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
UNIQUE ("tenantId", "hour")
|
||||
);
|
||||
CREATE INDEX "ReportNotificationHour_hour_idx" ON "ReportNotificationHour" ("hour" DESC);
|
||||
CREATE TABLE "ReportReadinessEvent" (
|
||||
"id" TEXT PRIMARY KEY, "hourId" TEXT NOT NULL REFERENCES "ReportNotificationHour"("id"),
|
||||
"objectKey" TEXT NOT NULL, "cycle" INTEGER NOT NULL, "tenantId" TEXT NOT NULL,
|
||||
"reportType" TEXT NOT NULL, "signatureId" TEXT NOT NULL, "drainageItemId" TEXT,
|
||||
"applicationId" TEXT, "applicationName" TEXT, "signatureName" TEXT NOT NULL,
|
||||
"targetName" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
UNIQUE ("objectKey", "cycle")
|
||||
);
|
||||
CREATE INDEX "ReportReadinessEvent_hour_idx" ON "ReportReadinessEvent" ("hourId", "createdAt", "id");
|
||||
CREATE TABLE "ReportNotificationRead" (
|
||||
"userId" TEXT NOT NULL, "hourId" TEXT NOT NULL REFERENCES "ReportNotificationHour"("id"),
|
||||
"revision" INTEGER NOT NULL, PRIMARY KEY ("userId", "hourId")
|
||||
);
|
||||
|
||||
CREATE FUNCTION cmpp_report_ready_mask(kind TEXT, signature_id TEXT, drainage_id TEXT) RETURNS INTEGER
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT COALESCE(sum(bit),0)::integer FROM (VALUES ('mobile',1),('unicom',2),('telecom',4)) AS carriers(name,bit)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM "SmsChannel" c
|
||||
WHERE c.status='active' AND c."sendRegion"='全国'
|
||||
AND (CASE WHEN cardinality(c.carriers)>0 THEN carriers.name=ANY(c.carriers)
|
||||
ELSE c.carrier IN (carriers.name,'all') END)
|
||||
AND (SELECT t.status FROM "ChannelSignatureReportTask" t
|
||||
WHERE t."channelId"=c.id AND t."signatureId"=signature_id AND t."reportType"=kind
|
||||
AND (kind='signature' OR t."drainageItemId"=drainage_id)
|
||||
AND (t.carrier=carriers.name OR (t.carrier IS NULL AND t."approvalScope"='legacy_channel'))
|
||||
ORDER BY (t.carrier=carriers.name) DESC NULLS LAST, t."updatedAt" DESC, t.id DESC LIMIT 1)='approved'
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Seed only state. Existing successes must never become historical unread notices.
|
||||
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed)
|
||||
SELECT 'signature:'||id, mask, mask=0 FROM "SmsSignature" s
|
||||
CROSS JOIN LATERAL (SELECT cmpp_report_ready_mask('signature',s.id,NULL) AS mask) m;
|
||||
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed)
|
||||
SELECT 'drainage:'||id, mask, mask=0 FROM "SmsDrainageInfo" d
|
||||
CROSS JOIN LATERAL (SELECT cmpp_report_ready_mask('drainage',d."signatureId",d.id) AS mask) m;
|
||||
|
||||
CREATE FUNCTION cmpp_report_readiness_before() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r "ChannelSignatureReportTask"; k TEXT; m INTEGER;
|
||||
BEGIN
|
||||
r := CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
|
||||
IF TG_OP='UPDATE' AND (OLD."signatureId",OLD."drainageItemId",OLD."reportType") IS DISTINCT FROM
|
||||
(NEW."signatureId",NEW."drainageItemId",NEW."reportType") THEN
|
||||
RAISE EXCEPTION 'Reporting task identity is immutable';
|
||||
END IF;
|
||||
k := r."reportType"||':'||CASE WHEN r."reportType"='drainage' THEN r."drainageItemId" ELSE r."signatureId" END;
|
||||
IF k IS NULL THEN RETURN r; END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(k, 20260906));
|
||||
m := cmpp_report_ready_mask(r."reportType",r."signatureId",r."drainageItemId");
|
||||
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed) VALUES(k,m,m=0) ON CONFLICT DO NOTHING;
|
||||
-- A configuration change can remove eligibility without changing a reporting task.
|
||||
UPDATE "ReportReadinessState" SET mask=m, armed=armed OR m=0 WHERE "objectKey"=k;
|
||||
RETURN r;
|
||||
END;
|
||||
$$;
|
||||
CREATE FUNCTION cmpp_report_readiness_after() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r "ChannelSignatureReportTask"; k TEXT; m INTEGER; st "ReportReadinessState";
|
||||
sig "SmsSignature"; tenant_name TEXT; app_name TEXT; target_name TEXT;
|
||||
app_id TEXT; hour_value TIMESTAMP(3); hour_id TEXT; next_cycle INTEGER;
|
||||
BEGIN
|
||||
r := CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
|
||||
k := r."reportType"||':'||CASE WHEN r."reportType"='drainage' THEN r."drainageItemId" ELSE r."signatureId" END;
|
||||
IF k IS NULL THEN RETURN r; END IF;
|
||||
SELECT * INTO st FROM "ReportReadinessState" WHERE "objectKey"=k FOR UPDATE;
|
||||
m := cmpp_report_ready_mask(r."reportType",r."signatureId",r."drainageItemId");
|
||||
IF m=7 AND st.armed THEN
|
||||
SELECT * INTO sig FROM "SmsSignature" WHERE id=r."signatureId";
|
||||
IF sig.id IS NOT NULL THEN
|
||||
SELECT name INTO tenant_name FROM "Tenant" WHERE id=sig."tenantId";
|
||||
app_id:=sig."applicationId"; target_name:=sig.name;
|
||||
IF r."reportType"='drainage' THEN
|
||||
SELECT COALESCE(d."applicationId",sig."applicationId"), d.url INTO app_id,target_name
|
||||
FROM "SmsDrainageInfo" d WHERE id=r."drainageItemId";
|
||||
END IF;
|
||||
SELECT name INTO app_name FROM "SmsApplication" WHERE id=app_id;
|
||||
hour_value:=date_trunc('hour',timezone('UTC',statement_timestamp()));
|
||||
hour_id:=md5(sig."tenantId"||':'||hour_value::text);
|
||||
next_cycle:=st.cycle+1;
|
||||
INSERT INTO "ReportNotificationHour" (id,"tenantId","tenantName",hour,"signatureCount","drainageCount")
|
||||
VALUES(hour_id,sig."tenantId",tenant_name,hour_value,(r."reportType"='signature')::integer,(r."reportType"='drainage')::integer)
|
||||
ON CONFLICT ("tenantId",hour) DO UPDATE SET revision="ReportNotificationHour".revision+1,
|
||||
"signatureCount"="ReportNotificationHour"."signatureCount"+EXCLUDED."signatureCount",
|
||||
"drainageCount"="ReportNotificationHour"."drainageCount"+EXCLUDED."drainageCount",
|
||||
"updatedAt"=timezone('UTC',statement_timestamp()) RETURNING id INTO hour_id;
|
||||
INSERT INTO "ReportReadinessEvent" (id,"hourId","objectKey",cycle,"tenantId","reportType","signatureId","drainageItemId","applicationId","applicationName","signatureName","targetName")
|
||||
VALUES(md5(k||':'||next_cycle),hour_id,k,next_cycle,sig."tenantId",r."reportType",sig.id,r."drainageItemId",app_id,app_name,sig.name,target_name);
|
||||
UPDATE "ReportReadinessState" SET cycle=next_cycle,armed=false WHERE "objectKey"=k;
|
||||
END IF;
|
||||
END IF;
|
||||
UPDATE "ReportReadinessState" SET mask=m,armed=armed OR m=0,"updatedAt"=timezone('UTC',statement_timestamp()) WHERE "objectKey"=k;
|
||||
RETURN r;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER report_readiness_before BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
|
||||
FOR EACH ROW EXECUTE FUNCTION cmpp_report_readiness_before();
|
||||
CREATE TRIGGER report_readiness_after AFTER INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
|
||||
FOR EACH ROW EXECUTE FUNCTION cmpp_report_readiness_after();
|
||||
@@ -0,0 +1,73 @@
|
||||
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "firstWireSubmitAt" TIMESTAMP(3), ADD COLUMN "wireTimeSource" TEXT;
|
||||
ALTER TABLE "SmsMessageSegmentAudit" ADD COLUMN "firstWireSubmitAt" TIMESTAMP(3), ADD COLUMN "wireTimeSource" TEXT;
|
||||
ALTER TABLE "UpstreamReceiptInbox" ADD COLUMN "gatewayReceivedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "receiptRequested" BOOLEAN;
|
||||
ALTER TABLE "SmsMessageSegmentAudit" ADD COLUMN "receiptRequested" BOOLEAN;
|
||||
CREATE INDEX "SmsSubmitRecord_monitor_updated_idx" ON "SmsSubmitRecord" ("updatedAt",id);
|
||||
CREATE INDEX "SmsSubmitRecord_monitor_created_idx" ON "SmsSubmitRecord" ("createdAt",id);
|
||||
CREATE INDEX "UpstreamReceiptInbox_monitor_updated_idx" ON "UpstreamReceiptInbox" ("updatedAt",id);
|
||||
CREATE INDEX "UpstreamReceiptInbox_monitor_message_idx" ON "UpstreamReceiptInbox" ("matchedMessageRecordId","gatewayMessageId");
|
||||
|
||||
CREATE TABLE "SendingMonitorTarget" (
|
||||
"channelId" TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL, version INTEGER NOT NULL,
|
||||
"effectiveFrom" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
"updatedBy" TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE "SendingMonitorTargetVersion" (
|
||||
"channelId" TEXT NOT NULL, version INTEGER NOT NULL, enabled BOOLEAN NOT NULL,
|
||||
"effectiveFrom" TIMESTAMP(3) NOT NULL, "updatedBy" TEXT NOT NULL,
|
||||
PRIMARY KEY("channelId",version)
|
||||
);
|
||||
CREATE TABLE "SendingMonitorRule" (
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, "scopeKey" TEXT NOT NULL, scope JSONB NOT NULL,
|
||||
config JSONB NOT NULL, version INTEGER NOT NULL, "effectiveAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedBy" TEXT NOT NULL,
|
||||
UNIQUE(type,"scopeKey")
|
||||
);
|
||||
CREATE TABLE "SendingMonitorRuleVersion" (
|
||||
"ruleId" TEXT NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, scope JSONB NOT NULL,
|
||||
config JSONB NOT NULL, "effectiveAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
"createdBy" TEXT NOT NULL, PRIMARY KEY("ruleId",version)
|
||||
);
|
||||
CREATE INDEX "SendingMonitorRuleVersion_effective_idx" ON "SendingMonitorRuleVersion" (type,"effectiveAt");
|
||||
CREATE TABLE "SendingMonitorFact" (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, "sourceId" TEXT NOT NULL, "dimensionKey" TEXT NOT NULL,
|
||||
"messageRecordId" TEXT REFERENCES "SmsMessageRecord"(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
dimensions JSONB NOT NULL, "submittedAt" TIMESTAMP(3) NOT NULL, "successAt" TIMESTAMP(3),
|
||||
verification BOOLEAN NOT NULL, reason TEXT, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
UNIQUE(kind,"sourceId")
|
||||
);
|
||||
CREATE INDEX "SendingMonitorFact_window_idx" ON "SendingMonitorFact" (kind,"submittedAt","dimensionKey");
|
||||
CREATE INDEX "SendingMonitorFact_message_idx" ON "SendingMonitorFact" ("messageRecordId");
|
||||
CREATE TABLE "SendingMonitorMinute" (
|
||||
kind TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, minute TIMESTAMP(3) NOT NULL,
|
||||
verification BOOLEAN NOT NULL, dimensions JSONB NOT NULL, metrics JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
PRIMARY KEY(kind,"dimensionKey",minute,verification)
|
||||
);
|
||||
CREATE INDEX "SendingMonitorMinute_window_idx" ON "SendingMonitorMinute" (kind,minute);
|
||||
CREATE TABLE "SendingMonitorSnapshot" (
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, dimensions JSONB NOT NULL,
|
||||
"evaluationAt" TIMESTAMP(3) NOT NULL, "windowFrom" TIMESTAMP(3) NOT NULL,
|
||||
"observedUntil" TIMESTAMP(3) NOT NULL, stage TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
||||
metrics JSONB NOT NULL, rule JSONB, status TEXT NOT NULL, completeness JSONB NOT NULL,
|
||||
"computedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
UNIQUE(type,"dimensionKey","evaluationAt")
|
||||
);
|
||||
CREATE INDEX "SendingMonitorSnapshot_latest_idx" ON "SendingMonitorSnapshot" (type,"evaluationAt" DESC,status);
|
||||
CREATE INDEX "SendingMonitorSnapshot_history_idx" ON "SendingMonitorSnapshot" (type,"dimensionKey","evaluationAt");
|
||||
CREATE TABLE "SendingMonitorAlert" (
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, dimensions JSONB NOT NULL,
|
||||
state TEXT NOT NULL, "openedAt" TIMESTAMP(3) NOT NULL, "lastEvaluatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"closedAt" TIMESTAMP(3), "closeReason" TEXT, "ruleKey" TEXT NOT NULL,
|
||||
latest JSONB NOT NULL, worst JSONB NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
);
|
||||
CREATE UNIQUE INDEX "SendingMonitorAlert_one_active_idx" ON "SendingMonitorAlert" (type,"dimensionKey") WHERE state='active';
|
||||
CREATE INDEX "SendingMonitorAlert_state_idx" ON "SendingMonitorAlert" (state,"openedAt" DESC);
|
||||
CREATE TABLE "SendingMonitorAlertRead" (
|
||||
"alertId" TEXT NOT NULL, "userId" TEXT NOT NULL, "readAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
PRIMARY KEY("alertId","userId")
|
||||
);
|
||||
CREATE TABLE "SendingMonitorCheckpoint" (
|
||||
id TEXT PRIMARY KEY, data JSONB NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
);
|
||||
@@ -696,6 +696,56 @@ model HttpWebhookAttempt {
|
||||
@@unique([deliveryId, attemptNo])
|
||||
}
|
||||
|
||||
model ReportReadinessState {
|
||||
objectKey String @id
|
||||
mask Int
|
||||
armed Boolean
|
||||
cycle Int @default(0)
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
}
|
||||
|
||||
model ReportNotificationHour {
|
||||
id String @id
|
||||
tenantId String
|
||||
tenantName String
|
||||
hour DateTime
|
||||
revision Int @default(1)
|
||||
signatureCount Int @default(0)
|
||||
drainageCount Int @default(0)
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
events ReportReadinessEvent[]
|
||||
reads ReportNotificationRead[]
|
||||
@@unique([tenantId, hour])
|
||||
@@index([hour(sort: Desc)], map: "ReportNotificationHour_hour_idx")
|
||||
}
|
||||
|
||||
model ReportReadinessEvent {
|
||||
id String @id
|
||||
hourId String
|
||||
objectKey String
|
||||
cycle Int
|
||||
tenantId String
|
||||
reportType String
|
||||
signatureId String
|
||||
drainageItemId String?
|
||||
applicationId String?
|
||||
applicationName String?
|
||||
signatureName String
|
||||
targetName String
|
||||
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
hour ReportNotificationHour @relation(fields: [hourId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
@@unique([objectKey, cycle])
|
||||
@@index([hourId, createdAt, id], map: "ReportReadinessEvent_hour_idx")
|
||||
}
|
||||
|
||||
model ReportNotificationRead {
|
||||
userId String
|
||||
hourId String
|
||||
revision Int
|
||||
hour ReportNotificationHour @relation(fields: [hourId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
@@id([userId, hourId])
|
||||
}
|
||||
|
||||
model SmsSignature {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -1705,6 +1755,7 @@ model SmsApiRequest {
|
||||
}
|
||||
|
||||
model SmsMessageRecord {
|
||||
monitorFacts SendingMonitorFact[]
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
batchTaskId String?
|
||||
@@ -1813,6 +1864,9 @@ model SmsSubmitRecord {
|
||||
errorCode String?
|
||||
errorMessage String?
|
||||
submittedAt DateTime?
|
||||
firstWireSubmitAt DateTime?
|
||||
wireTimeSource String?
|
||||
receiptRequested Boolean?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1832,6 +1886,8 @@ model SmsSubmitRecord {
|
||||
@@index([gatewayMessageId])
|
||||
@@index([channelId, gatewayMessageId])
|
||||
@@index([channelGroupId])
|
||||
@@index([updatedAt,id], map: "SmsSubmitRecord_monitor_updated_idx")
|
||||
@@index([createdAt,id], map: "SmsSubmitRecord_monitor_created_idx")
|
||||
}
|
||||
|
||||
model GatewaySubmitOutbox {
|
||||
@@ -1958,6 +2014,9 @@ model SmsMessageSegmentAudit {
|
||||
errorCode String?
|
||||
errorMessage String?
|
||||
submittedAt DateTime?
|
||||
firstWireSubmitAt DateTime?
|
||||
wireTimeSource String?
|
||||
receiptRequested Boolean?
|
||||
deliveredAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2339,6 +2398,7 @@ model UpstreamReceiptInbox {
|
||||
errorCode String?
|
||||
errorMessage String?
|
||||
deliveredAt DateTime
|
||||
gatewayReceivedAt DateTime?
|
||||
receivedAt DateTime @default(now())
|
||||
status String @default("pending")
|
||||
matchedMessageRecordId String?
|
||||
@@ -2355,6 +2415,8 @@ model UpstreamReceiptInbox {
|
||||
@@index([gatewayMessageId, phoneNumber])
|
||||
@@index([incomingChannelId, receivedAt])
|
||||
@@index([matchedMessageRecordId])
|
||||
@@index([updatedAt,id], map: "UpstreamReceiptInbox_monitor_updated_idx")
|
||||
@@index([matchedMessageRecordId,gatewayMessageId], map: "UpstreamReceiptInbox_monitor_message_idx")
|
||||
}
|
||||
|
||||
model GatewaySubmitDeadLetter {
|
||||
@@ -2563,3 +2625,127 @@ model InfrastructureAlertRead {
|
||||
@@unique([fingerprint, userId])
|
||||
@@index([userId, readAt])
|
||||
}
|
||||
|
||||
model SendingMonitorTarget {
|
||||
channelId String @id
|
||||
enabled Boolean
|
||||
version Int
|
||||
effectiveFrom DateTime
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
updatedBy String
|
||||
}
|
||||
|
||||
model SendingMonitorRule {
|
||||
id String @id
|
||||
type String
|
||||
scopeKey String
|
||||
scope Json
|
||||
config Json
|
||||
version Int
|
||||
effectiveAt DateTime
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
updatedBy String
|
||||
@@unique([type,scopeKey])
|
||||
}
|
||||
|
||||
model SendingMonitorRuleVersion {
|
||||
ruleId String
|
||||
version Int
|
||||
type String
|
||||
scope Json
|
||||
config Json
|
||||
effectiveAt DateTime
|
||||
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
createdBy String
|
||||
@@id([ruleId,version])
|
||||
@@index([type,effectiveAt], map: "SendingMonitorRuleVersion_effective_idx")
|
||||
}
|
||||
|
||||
model SendingMonitorFact {
|
||||
id String @id
|
||||
kind String
|
||||
sourceId String
|
||||
messageRecordId String?
|
||||
messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id], onDelete: SetNull)
|
||||
dimensionKey String
|
||||
dimensions Json
|
||||
submittedAt DateTime
|
||||
successAt DateTime?
|
||||
verification Boolean
|
||||
reason String?
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
@@unique([kind,sourceId])
|
||||
@@index([messageRecordId], map: "SendingMonitorFact_message_idx")
|
||||
@@index([kind,submittedAt,dimensionKey], map: "SendingMonitorFact_window_idx")
|
||||
}
|
||||
|
||||
model SendingMonitorMinute {
|
||||
kind String
|
||||
dimensionKey String
|
||||
minute DateTime
|
||||
verification Boolean
|
||||
dimensions Json
|
||||
metrics Json
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
@@id([kind,dimensionKey,minute,verification])
|
||||
@@index([kind,minute], map: "SendingMonitorMinute_window_idx")
|
||||
}
|
||||
|
||||
model SendingMonitorSnapshot {
|
||||
id String @id
|
||||
type String
|
||||
dimensionKey String
|
||||
dimensions Json
|
||||
evaluationAt DateTime
|
||||
windowFrom DateTime
|
||||
observedUntil DateTime
|
||||
stage String
|
||||
revision Int @default(1)
|
||||
metrics Json
|
||||
rule Json?
|
||||
status String
|
||||
completeness Json
|
||||
computedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
@@unique([type,dimensionKey,evaluationAt])
|
||||
@@index([type,evaluationAt(sort: Desc),status], map: "SendingMonitorSnapshot_latest_idx")
|
||||
@@index([type,dimensionKey,evaluationAt], map: "SendingMonitorSnapshot_history_idx")
|
||||
}
|
||||
|
||||
model SendingMonitorAlert {
|
||||
id String @id
|
||||
type String
|
||||
dimensionKey String
|
||||
dimensions Json
|
||||
state String
|
||||
openedAt DateTime
|
||||
lastEvaluatedAt DateTime
|
||||
closedAt DateTime?
|
||||
closeReason String?
|
||||
ruleKey String
|
||||
latest Json
|
||||
worst Json
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
@@index([state,openedAt(sort: Desc)], map: "SendingMonitorAlert_state_idx")
|
||||
}
|
||||
|
||||
model SendingMonitorAlertRead {
|
||||
alertId String
|
||||
userId String
|
||||
readAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
@@id([alertId,userId])
|
||||
}
|
||||
|
||||
model SendingMonitorCheckpoint {
|
||||
id String @id
|
||||
data Json
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"))
|
||||
}
|
||||
|
||||
model SendingMonitorTargetVersion {
|
||||
channelId String
|
||||
version Int
|
||||
enabled Boolean
|
||||
effectiveFrom DateTime @db.Timestamp(3)
|
||||
updatedBy String
|
||||
@@id([channelId,version])
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import { UsersModule } from './users/users.module';
|
||||
import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
|
||||
import { SecurityDetectionModule } from './security-detection/security-detection.module';
|
||||
import { MetricsModule } from './metrics/metrics.module';
|
||||
import { ReportNotificationsModule } from './report-notifications/report-notifications.module';
|
||||
import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -57,12 +59,16 @@ import { MetricsModule } from './metrics/metrics.module';
|
||||
SignatureRetirementModule,
|
||||
SecurityDetectionModule,
|
||||
MetricsModule,
|
||||
ReportNotificationsModule,
|
||||
SendingMonitorModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware).forRoutes('*');
|
||||
consumer
|
||||
.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ export class AdminOperationsController {
|
||||
@Query('hasDrainage') hasDrainage?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('monitorSnapshotId') monitorSnapshotId?: string,
|
||||
) {
|
||||
return this.operations.listMessagesPage({
|
||||
monitorSnapshotId,
|
||||
tenantId,
|
||||
applicationId,
|
||||
channelId,
|
||||
@@ -80,8 +82,10 @@ export class AdminOperationsController {
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('hasDrainage') hasDrainage: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
@Query('monitorSnapshotId') monitorSnapshotId?: string,
|
||||
) {
|
||||
const exported = await this.operations.exportMessages({
|
||||
monitorSnapshotId,
|
||||
tenantId,
|
||||
applicationId,
|
||||
channelId,
|
||||
@@ -105,25 +109,37 @@ export class AdminOperationsController {
|
||||
}
|
||||
|
||||
@Get('message-segment-audits')
|
||||
messageSegmentAudits(
|
||||
@Query('messageId') messageId?: string,
|
||||
@Query('messageRecordId') messageRecordId?: string,
|
||||
) {
|
||||
messageSegmentAudits(@Query('messageId') messageId?: string, @Query('messageRecordId') messageRecordId?: string) {
|
||||
return this.operations.listMessageSegmentAudits({ messageId, messageRecordId });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
listUplinkMessages(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('startTime') startTime?: string,
|
||||
@Query('endTime') endTime?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return page || pageSize
|
||||
? this.operations.listUplinkMessagesPage({ tenantId, channelId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) })
|
||||
? this.operations.listUplinkMessagesPage({
|
||||
tenantId,
|
||||
channelId,
|
||||
phoneNumber,
|
||||
keyword,
|
||||
startTime,
|
||||
endTime,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
})
|
||||
: this.operations.listUplinkMessages({ tenantId, channelId, phoneNumber, keyword, startTime, endTime });
|
||||
}
|
||||
|
||||
@Post('uplink-messages/:id/claim')
|
||||
claimUplinkMatchCandidate(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { candidateId?: string; operatorId?: string },
|
||||
) {
|
||||
claimUplinkMatchCandidate(@Param('id') id: string, @Body() body: { candidateId?: string; operatorId?: string }) {
|
||||
return this.sendChain.claimUplinkMatchCandidate(id, String(body.candidateId ?? ''), body.operatorId);
|
||||
}
|
||||
|
||||
@@ -231,10 +247,7 @@ export class AdminOperationsController {
|
||||
}
|
||||
|
||||
@Post('gateway-submit-dead-letters/:id/resolve')
|
||||
resolveGatewaySubmitDeadLetter(
|
||||
@Param('id') id: string,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
resolveGatewaySubmitDeadLetter(@Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||
}
|
||||
|
||||
@@ -381,16 +394,23 @@ export class AdminOperationsController {
|
||||
@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);
|
||||
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) {
|
||||
listDownstreamRequeueTasks(
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@@ -407,7 +427,12 @@ export class AdminOperationsController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.sendChain.listDownstreamRequeueTaskItems(id, { status, keyword, page: Number(page), pageSize: Number(pageSize) });
|
||||
return this.sendChain.listDownstreamRequeueTaskItems(id, {
|
||||
status,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('downstream-requeue-tasks/:id/:action')
|
||||
@@ -441,7 +466,18 @@ export class AdminSystemLogsController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
|
||||
return this.protocolLogs.list({
|
||||
protocol,
|
||||
direction,
|
||||
eventType,
|
||||
status,
|
||||
keyword,
|
||||
range,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -457,11 +493,34 @@ export class AdminSystemLogsController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
|
||||
return this.operations.systemLogs({
|
||||
tenantId,
|
||||
userId,
|
||||
keyword,
|
||||
level,
|
||||
module,
|
||||
range,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('exports')
|
||||
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) {
|
||||
export(
|
||||
@Body()
|
||||
body: {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
},
|
||||
) {
|
||||
return this.operations.exportSystemLogs(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Stable controller/query contracts extracted in R2.
|
||||
|
||||
export interface MessageQuery {
|
||||
monitorSnapshotId?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
import type { MessageQuery } from '../operations.contracts';
|
||||
import {
|
||||
messageWhere,
|
||||
escapeCsvCell,
|
||||
formatExportTimestamp,
|
||||
clientMessageView,
|
||||
clientBatchTaskView,
|
||||
} from '../operations.helpers';
|
||||
|
||||
// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
// Message queries share the same bounded monitor sample filter for lists and exports.
|
||||
export class OperationsMessageQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
listBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
return this.prisma.smsBatchTask.findMany({
|
||||
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
|
||||
include: { apiRequests: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
const items = await this.listBatchTasks(query);
|
||||
return items.map(clientBatchTaskView);
|
||||
}
|
||||
listMessages(query: MessageQuery) {
|
||||
listMessages(query: MessageQuery) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
include: {
|
||||
@@ -38,10 +43,34 @@ listMessages(query: MessageQuery) {
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
async listMessagesPage(query: MessageQuery) {
|
||||
private async messageFilter(query: MessageQuery) {
|
||||
const where = messageWhere(query);
|
||||
if (query.monitorSnapshotId) {
|
||||
const snapshot = await this.prisma.sendingMonitorSnapshot.findUnique({ where: { id: query.monitorSnapshotId } });
|
||||
if (
|
||||
!snapshot ||
|
||||
(query.tenantId &&
|
||||
(snapshot.dimensions as { tenantId?: string }).tenantId &&
|
||||
(snapshot.dimensions as { tenantId?: string }).tenantId !== query.tenantId)
|
||||
)
|
||||
throw new NotFoundException('监控快照不存在');
|
||||
if (snapshot.windowFrom.getTime() < Date.now() - 72 * 3600000)
|
||||
throw new BadRequestException('该窗口已超过72小时样本保留期,历史快照仍可查询');
|
||||
where.monitorFacts = {
|
||||
some: {
|
||||
kind: snapshot.type === 'industry' ? 'attempt' : 'business',
|
||||
dimensionKey: snapshot.dimensionKey,
|
||||
submittedAt: { gte: snapshot.windowFrom, lt: snapshot.evaluationAt },
|
||||
...(snapshot.type === 'verification' ? { verification: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return where;
|
||||
}
|
||||
async listMessagesPage(query: MessageQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25)));
|
||||
const where = messageWhere(query);
|
||||
const where = await this.messageFilter(query);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
@@ -75,7 +104,7 @@ async listMessagesPage(query: MessageQuery) {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getMessage(id: string) {
|
||||
async getMessage(id: string) {
|
||||
const item = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -121,9 +150,10 @@ async getMessage(id: string) {
|
||||
if (!item) throw new NotFoundException('Message record not found');
|
||||
return item;
|
||||
}
|
||||
async exportMessages(query: MessageQuery) {
|
||||
async exportMessages(query: MessageQuery) {
|
||||
const where = await this.messageFilter(query);
|
||||
const items = await this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
where,
|
||||
select: {
|
||||
messageId: true,
|
||||
queuedAt: true,
|
||||
@@ -144,7 +174,22 @@ async exportMessages(query: MessageQuery) {
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'],
|
||||
[
|
||||
'消息编号',
|
||||
'企业',
|
||||
'应用',
|
||||
'提交时间',
|
||||
'手机号',
|
||||
'地区',
|
||||
'运营商',
|
||||
'计费条数',
|
||||
'金额',
|
||||
'通道',
|
||||
'状态',
|
||||
'是否含引流',
|
||||
'回执时间',
|
||||
'短信内容',
|
||||
],
|
||||
...items.map((item) => [
|
||||
item.messageId,
|
||||
item.tenant?.name ?? '',
|
||||
@@ -156,7 +201,9 @@ async exportMessages(query: MessageQuery) {
|
||||
String(item.billingUnits),
|
||||
String(moneyToNumber(item.amountCents)),
|
||||
item.channel?.name ?? '',
|
||||
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
|
||||
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '')
|
||||
? 'submit_failed'
|
||||
: item.status,
|
||||
item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测',
|
||||
item.deliveredAt?.toISOString() ?? '',
|
||||
item.content,
|
||||
@@ -167,11 +214,11 @@ async exportMessages(query: MessageQuery) {
|
||||
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
|
||||
};
|
||||
}
|
||||
async listClientMessages(query: MessageQuery) {
|
||||
async listClientMessages(query: MessageQuery) {
|
||||
const items = await this.listMessages(query);
|
||||
return items.map(clientMessageView);
|
||||
}
|
||||
async listClientMessagesPage(query: MessageQuery) {
|
||||
async listClientMessagesPage(query: MessageQuery) {
|
||||
const result = await this.listMessagesPage(query);
|
||||
return { ...result, items: result.items.map(clientMessageView) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||
|
||||
export function positivePage(value: unknown, fallback: number, max = 100000) {
|
||||
if (value === undefined) return fallback;
|
||||
const n = Number(value);
|
||||
if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new BadRequestException('页码、条数或版本无效');
|
||||
return n;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportNotificationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async scope(req: SessionRequest) {
|
||||
if (!req.sessionUserId) throw new ForbiddenException('请先登录');
|
||||
if (req.authSession?.portal === 'client' && req.sessionTenantId) return req.sessionTenantId;
|
||||
const admin =
|
||||
req.authSession?.portal === 'admin' &&
|
||||
(await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.sessionUserId,
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
roles: { some: { role: { code: 'platform_admin' } } },
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
if (!admin) throw new ForbiddenException('无消息查看权限');
|
||||
return null;
|
||||
}
|
||||
|
||||
async list(req: SessionRequest, query: { page?: string; pageSize?: string; unread?: string }) {
|
||||
const tenant = await this.scope(req);
|
||||
const page = positivePage(query.page, 1);
|
||||
const pageSize = positivePage(query.pageSize, 20, 100);
|
||||
const filter = Prisma.sql`WHERE (${tenant}::text IS NULL OR h."tenantId"=${tenant})
|
||||
AND (${query.unread === 'true'}=false OR COALESCE(r.revision,0)<h.revision)`;
|
||||
return this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const items = await tx.$queryRaw<Array<Record<string, unknown>>>(Prisma.sql`
|
||||
SELECT h.*,COALESCE(r.revision,0)<h.revision AS unread FROM "ReportNotificationHour" h
|
||||
LEFT JOIN "ReportNotificationRead" r ON r."hourId"=h.id AND r."userId"=${req.sessionUserId!}
|
||||
${filter} ORDER BY h.hour DESC,h.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`);
|
||||
const counts = await tx.$queryRaw<Array<{ total: number }>>(Prisma.sql`
|
||||
SELECT count(*)::integer AS total FROM "ReportNotificationHour" h
|
||||
LEFT JOIN "ReportNotificationRead" r ON r."hourId"=h.id AND r."userId"=${req.sessionUserId!} ${filter}`);
|
||||
return { items, total: counts[0].total, page, pageSize };
|
||||
},
|
||||
{ isolationLevel: 'RepeatableRead' },
|
||||
);
|
||||
}
|
||||
|
||||
async hour(req: SessionRequest, id: string) {
|
||||
const tenantId = await this.scope(req);
|
||||
const item = await this.prisma.reportNotificationHour.findFirst({
|
||||
where: { id, ...(tenantId ? { tenantId } : {}) },
|
||||
});
|
||||
if (!item) throw new NotFoundException('消息不存在');
|
||||
return item;
|
||||
}
|
||||
|
||||
async detail(req: SessionRequest, id: string, query: { page?: string; pageSize?: string }) {
|
||||
const hour = await this.hour(req, id);
|
||||
const page = positivePage(query.page, 1),
|
||||
pageSize = positivePage(query.pageSize, 20, 100);
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.reportReadinessEvent.findMany({
|
||||
where: { hourId: id },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportReadinessEvent.count({ where: { hourId: id } }),
|
||||
]);
|
||||
return { hour, items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async read(req: SessionRequest, id: string, revision: unknown) {
|
||||
const hour = await this.hour(req, id);
|
||||
const version = positivePage(revision, 0, 2147483647);
|
||||
if (!version || version > hour.revision) throw new BadRequestException('消息版本无效,请刷新');
|
||||
await this.prisma.$executeRaw`INSERT INTO "ReportNotificationRead" ("userId","hourId",revision)
|
||||
VALUES (${req.sessionUserId!},${id},${version}) ON CONFLICT ("userId","hourId")
|
||||
DO UPDATE SET revision=GREATEST("ReportNotificationRead".revision,EXCLUDED.revision)`;
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller(['admin/report-notifications', 'client/report-notifications'])
|
||||
class ReportNotificationsController {
|
||||
constructor(private readonly service: ReportNotificationsService) {}
|
||||
@Get() list(@Req() req: SessionRequest, @Query() query: { page?: string; pageSize?: string; unread?: string }) {
|
||||
return this.service.list(req, query);
|
||||
}
|
||||
@Get('summary') async summary(@Req() req: SessionRequest) {
|
||||
const result = await this.service.list(req, { pageSize: '1', unread: 'true' });
|
||||
return { count: result.total };
|
||||
}
|
||||
@Get(':id') detail(
|
||||
@Req() req: SessionRequest,
|
||||
@Param('id') id: string,
|
||||
@Query() query: { page?: string; pageSize?: string },
|
||||
) {
|
||||
return this.service.detail(req, id, query);
|
||||
}
|
||||
@Post(':id/read') read(@Req() req: SessionRequest, @Param('id') id: string, @Body() body: { revision?: unknown }) {
|
||||
return this.service.read(req, id, body?.revision);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ReportNotificationsController],
|
||||
providers: [ReportNotificationsService],
|
||||
})
|
||||
export class ReportNotificationsModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReportNotificationsService, positivePage } from './report-notifications.module';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||
|
||||
describe('report notification access and read versions', () => {
|
||||
const db = { reportNotificationHour: { findFirst: jest.fn() }, $executeRaw: jest.fn() };
|
||||
const service = new ReportNotificationsService(db as unknown as PrismaService);
|
||||
const req = {
|
||||
sessionUserId: 'reader',
|
||||
sessionTenantId: 'tenant-a',
|
||||
authSession: { portal: 'client' },
|
||||
} as SessionRequest;
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
it('rejects anonymous access', async () => {
|
||||
await expect(service.scope({} as SessionRequest)).rejects.toThrow('请先登录');
|
||||
});
|
||||
it('uses the session tenant and hides inaccessible hours', async () => {
|
||||
db.reportNotificationHour.findFirst.mockResolvedValue(null);
|
||||
await expect(service.hour(req, 'tenant-b-hour')).rejects.toThrow('消息不存在');
|
||||
expect(db.reportNotificationHour.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: 'tenant-b-hour', tenantId: 'tenant-a' },
|
||||
});
|
||||
});
|
||||
it('rejects future read revisions without writing and accepts a displayed older revision', async () => {
|
||||
db.reportNotificationHour.findFirst.mockResolvedValue({ id: 'hour', revision: 4 });
|
||||
await expect(service.read(req, 'hour', 5)).rejects.toThrow('消息版本无效');
|
||||
expect(db.$executeRaw).not.toHaveBeenCalled();
|
||||
await expect(service.read(req, 'hour', 3)).resolves.toEqual({ success: true });
|
||||
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('bounds pages and rejects fractional or zero values', () => {
|
||||
for (const value of ['0', '-1', '1.5', '101', 'NaN']) expect(() => positivePage(value, 20, 100)).toThrow();
|
||||
expect(positivePage(undefined, 20, 100)).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { wireTiming } from './send-gateway-result.service';
|
||||
describe('Gateway wire timing compatibility', () => {
|
||||
it('does not invent timestamps for old events or pre-write failures', () => {
|
||||
expect(wireTiming({})).toEqual({});
|
||||
expect(wireTiming({ wireTimeSource: 'not_written', receiptRequested: true })).toEqual({
|
||||
wireTimeSource: 'not_written',
|
||||
receiptRequested: true,
|
||||
});
|
||||
});
|
||||
it('preserves the earliest actual time when replaying aggregate results', () => {
|
||||
const prior = new Date('2026-09-06T04:00:00Z');
|
||||
expect(
|
||||
wireTiming(
|
||||
{ firstWireSubmitAt: '2026-09-06T04:00:05Z', wireTimeSource: 'gateway_write_complete', receiptRequested: true },
|
||||
prior,
|
||||
).firstWireSubmitAt,
|
||||
).toEqual(prior);
|
||||
});
|
||||
it('rejects invalid dates and unverified sources', () => {
|
||||
expect(() => wireTiming({ firstWireSubmitAt: 'invalid', wireTimeSource: 'gateway_write_complete' })).toThrow();
|
||||
expect(() => wireTiming({ firstWireSubmitAt: '2026-09-06T04:00:00Z', wireTimeSource: 'response_time' })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,9 @@ export interface GatewaySubmitResultDto {
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
firstWireSubmitAt?: string;
|
||||
wireTimeSource?: string;
|
||||
receiptRequested?: boolean;
|
||||
segments?: Array<{
|
||||
segmentTotal?: number;
|
||||
segmentIndex?: number;
|
||||
@@ -79,6 +82,9 @@ export interface GatewaySubmitResultDto {
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
firstWireSubmitAt?: string;
|
||||
wireTimeSource?: string;
|
||||
receiptRequested?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -96,6 +102,9 @@ export interface GatewaySubmitSegmentResultDto {
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
firstWireSubmitAt?: string;
|
||||
wireTimeSource?: string;
|
||||
receiptRequested?: boolean;
|
||||
}
|
||||
|
||||
export interface GatewayReceiptEventDto {
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto } from './send-chain.contracts';
|
||||
import { normalizeSubmitStatus } from './send-chain.helpers';
|
||||
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
/**
|
||||
* R10 gatewayResult implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export function wireTiming(
|
||||
data: { firstWireSubmitAt?: string; wireTimeSource?: string; receiptRequested?: boolean },
|
||||
previous?: Date | null,
|
||||
): { firstWireSubmitAt?: Date; wireTimeSource?: string; receiptRequested?: boolean } {
|
||||
const receipt = typeof data.receiptRequested === 'boolean' ? { receiptRequested: data.receiptRequested } : {};
|
||||
if (!data.firstWireSubmitAt)
|
||||
return data.wireTimeSource ? { ...receipt, wireTimeSource: data.wireTimeSource } : receipt;
|
||||
const at = new Date(data.firstWireSubmitAt);
|
||||
if (!Number.isFinite(at.getTime()) || data.wireTimeSource !== 'gateway_write_complete') {
|
||||
throw new BadRequestException('Invalid Gateway wire timestamp');
|
||||
}
|
||||
return {
|
||||
...receipt,
|
||||
firstWireSubmitAt: previous && previous < at ? previous : at,
|
||||
wireTimeSource: data.wireTimeSource,
|
||||
};
|
||||
}
|
||||
|
||||
export class SendGatewayResultService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
@@ -31,27 +47,39 @@ export class SendGatewayResultService {
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||
const effectiveSubmitId = submitRecord.submitId;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.facade.recordSubmitSegments(message, {
|
||||
messageId: data.messageId,
|
||||
channelId: data.channelId,
|
||||
submitId: effectiveSubmitId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId ?? '',
|
||||
submitStatus: normalizeSubmitStatus(data.submitStatus),
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
segments: [{
|
||||
segmentTotal: data.segmentTotal,
|
||||
segmentIndex: data.segmentIndex,
|
||||
await this.facade.recordSubmitSegments(
|
||||
message,
|
||||
{
|
||||
messageId: data.messageId,
|
||||
channelId: data.channelId,
|
||||
submitId: effectiveSubmitId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
gatewayMessageId: data.gatewayMessageId ?? '',
|
||||
submitStatus: normalizeSubmitStatus(data.submitStatus),
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
}],
|
||||
}, submittedAt);
|
||||
firstWireSubmitAt: data.firstWireSubmitAt,
|
||||
wireTimeSource: data.wireTimeSource,
|
||||
receiptRequested: data.receiptRequested,
|
||||
segments: [
|
||||
{
|
||||
segmentTotal: data.segmentTotal,
|
||||
segmentIndex: data.segmentIndex,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
firstWireSubmitAt: data.firstWireSubmitAt,
|
||||
wireTimeSource: data.wireTimeSource,
|
||||
receiptRequested: data.receiptRequested,
|
||||
},
|
||||
],
|
||||
},
|
||||
submittedAt,
|
||||
);
|
||||
if (data.gatewayMessageId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
@@ -68,10 +96,7 @@ export class SendGatewayResultService {
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { submitId: data.submitId },
|
||||
@@ -81,13 +106,15 @@ export class SendGatewayResultService {
|
||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||
(exact.channelId && exact.channelId !== data.channelId)
|
||||
) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
submitId: data.submitId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
})}`);
|
||||
this.logger.error(
|
||||
`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
submitId: data.submitId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
})}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
||||
);
|
||||
@@ -104,24 +131,26 @@ export class SendGatewayResultService {
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
this.logger.error(
|
||||
`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
candidateCount: candidates.length,
|
||||
})}`,
|
||||
);
|
||||
throw new BadRequestException('Gateway SubmitSegmentResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(
|
||||
`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult without submitId cannot be matched uniquely',
|
||||
);
|
||||
}
|
||||
this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
submitId: candidates[0].submitId,
|
||||
})}`,
|
||||
);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
@@ -148,6 +177,7 @@ export class SendGatewayResultService {
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
...wireTiming(data, submitRecord.firstWireSubmitAt),
|
||||
},
|
||||
});
|
||||
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||
@@ -156,7 +186,8 @@ export class SendGatewayResultService {
|
||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
const status =
|
||||
data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
await this.facade.chargeAcceptedMessage(businessMessage);
|
||||
@@ -164,7 +195,12 @@ export class SendGatewayResultService {
|
||||
if (latest?.status === 'failed') {
|
||||
await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款');
|
||||
}
|
||||
} else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
} else if (
|
||||
data.submitStatus !== 'accepted' &&
|
||||
!isStandaloneChannelTest &&
|
||||
message.tenantId &&
|
||||
message.batchTaskId
|
||||
) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
@@ -176,13 +212,17 @@ export class SendGatewayResultService {
|
||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
await this.facade.releaseMessageReservation(
|
||||
businessMessage,
|
||||
data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结',
|
||||
);
|
||||
}
|
||||
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
|
||||
const updated = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: data.submitStatus === 'accepted'
|
||||
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
||||
: { id: message.id, status: { not: 'delivered' } },
|
||||
where:
|
||||
data.submitStatus === 'accepted'
|
||||
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
||||
: { id: message.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
@@ -202,7 +242,12 @@ export class SendGatewayResultService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) {
|
||||
if (
|
||||
data.submitStatus !== 'accepted' &&
|
||||
batchTask?.sourceType === 'cmpp' &&
|
||||
message.tenantId &&
|
||||
message.applicationId
|
||||
) {
|
||||
await this.facade.recordCmppFailureReceipt(
|
||||
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
|
||||
data.errorCode || 'SUBMIT',
|
||||
@@ -212,10 +257,9 @@ export class SendGatewayResultService {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [
|
||||
{ submitId: effectiveData.submitId },
|
||||
data.messageId ? { messageId: data.messageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>,
|
||||
OR: [{ submitId: effectiveData.submitId }, data.messageId ? { messageId: data.messageId } : undefined].filter(
|
||||
Boolean,
|
||||
) as Array<{ submitId?: string; messageId?: string }>,
|
||||
},
|
||||
data: {
|
||||
status: 'resolved',
|
||||
@@ -252,9 +296,11 @@ export class SendGatewayResultService {
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||
if (!exact
|
||||
|| (exact.messageRecordId && exact.messageRecordId !== messageRecordId)
|
||||
|| (exact.channelId && exact.channelId !== data.channelId)) {
|
||||
if (
|
||||
!exact ||
|
||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||
(exact.channelId && exact.channelId !== data.channelId)
|
||||
) {
|
||||
throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel');
|
||||
}
|
||||
return exact;
|
||||
@@ -272,34 +318,40 @@ export class SendGatewayResultService {
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({
|
||||
this.logger.error(
|
||||
`gateway_submit_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
candidateCount: candidates.length,
|
||||
})}`,
|
||||
);
|
||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(
|
||||
`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
submitId: candidates[0].submitId,
|
||||
})}`,
|
||||
);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
return (
|
||||
this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}
|
||||
).smsMessageSegmentAudit;
|
||||
}
|
||||
|
||||
async recordSubmitSegments(
|
||||
@@ -327,74 +379,84 @@ export class SendGatewayResultService {
|
||||
});
|
||||
const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`;
|
||||
const attempt = submitRecord
|
||||
? Math.max(0, await this.prisma.smsSubmitRecord.count({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
createdAt: { lte: submitRecord.createdAt },
|
||||
},
|
||||
}) - 1)
|
||||
? Math.max(
|
||||
0,
|
||||
(await this.prisma.smsSubmitRecord.count({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
createdAt: { lte: submitRecord.createdAt },
|
||||
},
|
||||
})) - 1,
|
||||
)
|
||||
: 0;
|
||||
const fallbackSegments = [{
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: data.submittedAt,
|
||||
}];
|
||||
const fallbackSegments = [
|
||||
{
|
||||
firstWireSubmitAt: data.firstWireSubmitAt,
|
||||
wireTimeSource: data.wireTimeSource,
|
||||
receiptRequested: data.receiptRequested,
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: data.submittedAt,
|
||||
},
|
||||
];
|
||||
const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments;
|
||||
const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1)));
|
||||
await Promise.all(segments.map((segment, index) => {
|
||||
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
||||
const status = segment.submitStatus ?? data.submitStatus;
|
||||
return segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId,
|
||||
segmentIndex,
|
||||
await Promise.all(
|
||||
segments.map((segment, index) => {
|
||||
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
||||
const status = segment.submitStatus ?? data.submitStatus;
|
||||
return segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId,
|
||||
segmentIndex,
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
segmentIndex,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
update: {
|
||||
...wireTiming(segment),
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
create: {
|
||||
...wireTiming(segment),
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
segmentIndex,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
|
||||
Boolean,
|
||||
) as Array<{
|
||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(Boolean) as Array<{
|
||||
messageId?: string;
|
||||
gatewayMessageId?: string;
|
||||
}>;
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey, longMessageReceiptMode } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { GatewayReceiptEventDto } from './send-chain.contracts';
|
||||
import {
|
||||
positiveInteger,
|
||||
normalizeReceiptStatus,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
aggregateReceiptSegmentState,
|
||||
isSameUpstreamEndpointIdentity,
|
||||
receiptEventKey,
|
||||
longMessageReceiptMode,
|
||||
} from './send-chain.helpers';
|
||||
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
||||
|
||||
|
||||
/**
|
||||
* R10 receipt implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
@@ -68,6 +77,7 @@ export class SendReceiptService {
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
|
||||
status: 'pending',
|
||||
nextRetryAt: new Date(),
|
||||
},
|
||||
@@ -81,8 +91,8 @@ export class SendReceiptService {
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(
|
||||
now.getTime()
|
||||
- positiveInteger(
|
||||
now.getTime() -
|
||||
positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
@@ -110,8 +120,8 @@ export class SendReceiptService {
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
const staleBefore = new Date(
|
||||
Date.now()
|
||||
- positiveInteger(
|
||||
Date.now() -
|
||||
positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
@@ -119,10 +129,7 @@ export class SendReceiptService {
|
||||
const claimed = await this.prisma.upstreamReceiptInbox.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ status: { in: ['pending', 'retrying'] } },
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
OR: [{ status: { in: ['pending', 'retrying'] } }, { status: 'processing', updatedAt: { lte: staleBefore } }],
|
||||
},
|
||||
data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null },
|
||||
});
|
||||
@@ -130,25 +137,28 @@ export class SendReceiptService {
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } });
|
||||
if (!inbox) return false;
|
||||
try {
|
||||
const message = await this.facade.handleReceipt({
|
||||
messageId: inbox.provisionalMessageId ?? undefined,
|
||||
channelId: inbox.incomingChannelId,
|
||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||
sequenceId: inbox.sequenceId ?? undefined,
|
||||
gatewayMessageId: inbox.gatewayMessageId,
|
||||
phoneNumber: inbox.phoneNumber ?? undefined,
|
||||
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
|
||||
rawStatus: inbox.rawStatus,
|
||||
errorCode: inbox.errorCode ?? undefined,
|
||||
errorMessage: inbox.errorMessage ?? undefined,
|
||||
deliveredAt: inbox.deliveredAt.toISOString(),
|
||||
}, {
|
||||
account: inbox.upstreamAccount,
|
||||
gatewayHost: inbox.upstreamHost,
|
||||
gatewayPort: inbox.upstreamPort,
|
||||
protocol: inbox.protocol,
|
||||
cmppVersion: inbox.protocolVersion,
|
||||
});
|
||||
const message = await this.facade.handleReceipt(
|
||||
{
|
||||
messageId: inbox.provisionalMessageId ?? undefined,
|
||||
channelId: inbox.incomingChannelId,
|
||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||
sequenceId: inbox.sequenceId ?? undefined,
|
||||
gatewayMessageId: inbox.gatewayMessageId,
|
||||
phoneNumber: inbox.phoneNumber ?? undefined,
|
||||
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
|
||||
rawStatus: inbox.rawStatus,
|
||||
errorCode: inbox.errorCode ?? undefined,
|
||||
errorMessage: inbox.errorMessage ?? undefined,
|
||||
deliveredAt: inbox.deliveredAt.toISOString(),
|
||||
},
|
||||
{
|
||||
account: inbox.upstreamAccount,
|
||||
gatewayHost: inbox.upstreamHost,
|
||||
gatewayPort: inbox.upstreamPort,
|
||||
protocol: inbox.protocol,
|
||||
cmppVersion: inbox.protocolVersion,
|
||||
},
|
||||
);
|
||||
const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId;
|
||||
const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined;
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
@@ -171,8 +181,8 @@ export class SendReceiptService {
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
);
|
||||
const exhausted = inbox.attemptCount >= maxAttempts
|
||||
|| inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000;
|
||||
const exhausted =
|
||||
inbox.attemptCount >= maxAttempts || inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000;
|
||||
const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8));
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
@@ -204,7 +214,13 @@ export class SendReceiptService {
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
incomingIdentity?: {
|
||||
account: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
protocol: string;
|
||||
cmppVersion: string;
|
||||
},
|
||||
) {
|
||||
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
|
||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||
@@ -263,9 +279,8 @@ export class SendReceiptService {
|
||||
}
|
||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||
const receiptMode = Number(message.billingUnits ?? 1) > 1
|
||||
? await this.getLongMessageReceiptMode(logicalChannelId)
|
||||
: 'per_segment';
|
||||
const receiptMode =
|
||||
Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment';
|
||||
if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') {
|
||||
await this.applyMessageLevelSuccess(
|
||||
message,
|
||||
@@ -287,12 +302,10 @@ export class SendReceiptService {
|
||||
}
|
||||
const status = aggregate.status;
|
||||
const isCurrentAttempt =
|
||||
(!message.channelId || message.channelId === logicalChannelId)
|
||||
&& (
|
||||
!message.gatewayMessageId
|
||||
|| message.gatewayMessageId === data.gatewayMessageId
|
||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
||||
);
|
||||
(!message.channelId || message.channelId === logicalChannelId) &&
|
||||
(!message.gatewayMessageId ||
|
||||
message.gatewayMessageId === data.gatewayMessageId ||
|
||||
(aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId)));
|
||||
if (!isCurrentAttempt) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
@@ -314,11 +327,7 @@ export class SendReceiptService {
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
'回执失败补发',
|
||||
resolved.submitRecordId,
|
||||
);
|
||||
const retried = await this.facade.retryMessageIfAllowed(businessMessage, '回执失败补发', resolved.submitRecordId);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
@@ -339,32 +348,31 @@ export class SendReceiptService {
|
||||
},
|
||||
});
|
||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||
await queueFinalReceiptDeliveries(
|
||||
this.prisma,
|
||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
||||
{
|
||||
message,
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
rawStatus: aggregate.rawStatus,
|
||||
errorCode: aggregate.errorCode,
|
||||
deliveredAt: aggregate.deliveredAt.toISOString(),
|
||||
},
|
||||
segmentPayloads: Object.fromEntries(
|
||||
aggregate.segments
|
||||
.filter((segment) => segment.receiptStatus)
|
||||
.map((segment) => [segment.segmentIndex, {
|
||||
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
|
||||
message,
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
rawStatus: aggregate.rawStatus,
|
||||
errorCode: aggregate.errorCode,
|
||||
deliveredAt: aggregate.deliveredAt.toISOString(),
|
||||
},
|
||||
segmentPayloads: Object.fromEntries(
|
||||
aggregate.segments
|
||||
.filter((segment) => segment.receiptStatus)
|
||||
.map((segment) => [
|
||||
segment.segmentIndex,
|
||||
{
|
||||
receiptStatus: segment.receiptStatus,
|
||||
rawStatus: segment.rawStatus,
|
||||
errorCode: segment.errorCode,
|
||||
deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(),
|
||||
}]),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
@@ -388,8 +396,9 @@ export class SendReceiptService {
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId)
|
||||
&& (!message.submitId || message.submitId === submitId);
|
||||
const belongsToCurrentAttempt =
|
||||
(!message.channelId || message.channelId === data.channelId) &&
|
||||
(!message.submitId || message.submitId === submitId);
|
||||
if (!belongsToCurrentAttempt) return;
|
||||
const attemptWhere = submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
@@ -402,7 +411,9 @@ export class SendReceiptService {
|
||||
select: { id: true, receiptStatus: true },
|
||||
});
|
||||
if (segments.length <= 1) return;
|
||||
if (segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))) {
|
||||
if (
|
||||
segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// This supplier contract reports one message-level success for a multipart SMS.
|
||||
@@ -506,9 +517,9 @@ export class SendReceiptService {
|
||||
const submitRecord = submitRecordId
|
||||
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
|
||||
: await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
await segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
@@ -575,7 +586,13 @@ export class SendReceiptService {
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
incomingIdentity?: {
|
||||
account: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
protocol: string;
|
||||
cmppVersion: string;
|
||||
},
|
||||
) {
|
||||
const exactMessage = data.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
|
||||
@@ -641,8 +658,8 @@ export class SendReceiptService {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
|
||||
const incomingChannel = incomingIdentity
|
||||
?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
const incomingChannel =
|
||||
incomingIdentity ?? (await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }));
|
||||
if (!incomingChannel) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
@@ -665,8 +682,9 @@ export class SendReceiptService {
|
||||
channelId: exactSegmentMatches[0].channelId,
|
||||
};
|
||||
}
|
||||
const sameSupplierSegments = segmentMatches.filter((candidate) =>
|
||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
||||
const sameSupplierSegments = segmentMatches.filter(
|
||||
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
|
||||
);
|
||||
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSegments[0].messageRecord,
|
||||
@@ -685,8 +703,9 @@ export class SendReceiptService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) =>
|
||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
||||
const sameSupplierSubmits = crossConnectionSubmits.filter(
|
||||
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
|
||||
);
|
||||
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSubmits[0].messageRecord,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { projectMessages } from './sending-monitor/monitor-projection';
|
||||
import { evaluateWindow } from './sending-monitor/monitor-evaluation';
|
||||
import type { MonitorType } from './sending-monitor/monitor-metrics';
|
||||
|
||||
process.env.TZ = 'UTC';
|
||||
type Cursor = { from: string; to: string; at: string; id: string; completeAt?: string };
|
||||
async function checkpoint(db: PoolClient, id: string, data: unknown) {
|
||||
await db.query(
|
||||
`INSERT INTO "SendingMonitorCheckpoint" (id,data) VALUES($1,$2::jsonb) ON CONFLICT(id) DO UPDATE SET data=EXCLUDED.data,"updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`,
|
||||
[id, JSON.stringify(data)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function monitorTick(db: PoolClient, now = new Date()) {
|
||||
await db.query(`SET TIME ZONE 'UTC'`);
|
||||
const locked = (await db.query(`SELECT pg_try_advisory_lock(20260906,710) locked`)).rows[0].locked;
|
||||
if (!locked) return;
|
||||
try {
|
||||
await db.query('BEGIN');
|
||||
await db.query(`SET LOCAL statement_timeout='20s'`);
|
||||
const dirtyMinutes = new Set<string>();
|
||||
const cursors: Cursor[] = [];
|
||||
for (const source of ['submit', 'receipt'] as const) {
|
||||
const stored = (await db.query(`SELECT data FROM "SendingMonitorCheckpoint" WHERE id=$1`, [source])).rows[0]
|
||||
?.data as Cursor | undefined;
|
||||
const from = stored?.completeAt
|
||||
? new Date(new Date(stored.completeAt).getTime() - 120000).toISOString()
|
||||
: new Date(now.getTime() - 7200000).toISOString();
|
||||
const cursor: Cursor =
|
||||
stored && stored.id
|
||||
? stored
|
||||
: {
|
||||
from,
|
||||
to: new Date(now.getTime() - 2000).toISOString(),
|
||||
at: from,
|
||||
id: '',
|
||||
completeAt: stored?.completeAt,
|
||||
};
|
||||
const table = source === 'submit' ? 'SmsSubmitRecord' : 'UpstreamReceiptInbox';
|
||||
const field = source === 'submit' ? 'messageRecordId' : 'matchedMessageRecordId';
|
||||
const batch = (
|
||||
await db.query(
|
||||
`SELECT id,"updatedAt","${field}" "messageId" FROM "${table}" WHERE ("updatedAt",id)>($1::timestamp,$2) AND "updatedAt"<$3::timestamp ORDER BY "updatedAt",id LIMIT 1000`,
|
||||
[cursor.at, cursor.id, cursor.to],
|
||||
)
|
||||
).rows;
|
||||
for (const minute of (await projectMessages(db, [
|
||||
...new Set(batch.map((b) => b.messageId).filter(Boolean)),
|
||||
] as string[])) ?? [])
|
||||
dirtyMinutes.add(minute);
|
||||
if (batch.length === 1000) {
|
||||
const last = batch[batch.length - 1];
|
||||
cursor.at = last.updatedAt.toISOString();
|
||||
cursor.id = last.id;
|
||||
} else {
|
||||
cursor.completeAt = cursor.to;
|
||||
cursor.id = '';
|
||||
}
|
||||
await checkpoint(db, source, cursor);
|
||||
cursors.push(cursor);
|
||||
}
|
||||
// A bounded, independent 72-hour reconciliation cursor recovers transactions older than the overlap.
|
||||
const recon = (await db.query(`SELECT data FROM "SendingMonitorCheckpoint" WHERE id='reconcile'`)).rows[0]?.data;
|
||||
if (!recon?.nextAt || new Date(recon.nextAt) <= now) {
|
||||
const start = recon?.id ? recon.at : new Date(now.getTime() - 72 * 3600000).toISOString();
|
||||
const batch = (
|
||||
await db.query(
|
||||
`SELECT id,"createdAt","messageRecordId" FROM "SmsSubmitRecord" WHERE ("createdAt",id)>($1::timestamp,$2) AND "createdAt"<$3 ORDER BY "createdAt",id LIMIT 500`,
|
||||
[start, recon?.id ?? '', new Date(now.getTime() - 120000)],
|
||||
)
|
||||
).rows;
|
||||
for (const minute of (await projectMessages(db, [...new Set(batch.map((b) => b.messageRecordId))] as string[])) ??
|
||||
[])
|
||||
dirtyMinutes.add(minute);
|
||||
const last = batch[batch.length - 1];
|
||||
await checkpoint(
|
||||
db,
|
||||
'reconcile',
|
||||
batch.length === 500
|
||||
? { at: last.createdAt.toISOString(), id: last.id }
|
||||
: { id: '', nextAt: new Date(now.getTime() + 1800000).toISOString() },
|
||||
);
|
||||
}
|
||||
const pending = (
|
||||
await db.query(
|
||||
`SELECT count(*)::int count,min("receivedAt") oldest FROM "UpstreamReceiptInbox" WHERE status IN ('pending','processing','retrying') AND "receivedAt">$1`,
|
||||
[new Date(now.getTime() - 72 * 3600000)],
|
||||
)
|
||||
).rows[0];
|
||||
const complete =
|
||||
cursors.every((c) => c.completeAt && now.getTime() - new Date(c.completeAt).getTime() < 30000) &&
|
||||
pending.count === 0;
|
||||
await checkpoint(db, 'health', {
|
||||
complete,
|
||||
pendingReceipts: pending.count,
|
||||
oldestPending: pending.oldest,
|
||||
submitCompleteAt: cursors[0].completeAt,
|
||||
receiptCompleteAt: cursors[1].completeAt,
|
||||
checkedAt: now.toISOString(),
|
||||
poolMax: 2,
|
||||
});
|
||||
// Persist dirty windows with the projection transaction. Reconciliation and late matching revise only affected windows.
|
||||
const pendingWindows = new Map<string, { type: MonitorType; at: string }>(
|
||||
(await db.query(`SELECT data FROM "SendingMonitorCheckpoint" WHERE id='dirty-windows'`)).rows[0]?.data?.windows ??
|
||||
[],
|
||||
);
|
||||
const schedule =
|
||||
(await db.query(`SELECT data FROM "SendingMonitorCheckpoint" WHERE id='schedule'`)).rows[0]?.data ?? {};
|
||||
for (const type of ['industry', 'verification', 'overall'] as MonitorType[]) {
|
||||
const period = type === 'overall' ? 600000 : 300000;
|
||||
const t = new Date(Math.floor(now.getTime() / period) * period);
|
||||
for (let back = 1; back >= 0; back--) {
|
||||
const boundary = new Date(t.getTime() - back * period);
|
||||
const final = type === 'overall' || now.getTime() >= boundary.getTime() + 90000;
|
||||
const key = `${type}:${boundary.toISOString()}`;
|
||||
const phase = `${final}:${complete}`;
|
||||
if (schedule[key] !== phase) pendingWindows.set(key, { type, at: boundary.toISOString() });
|
||||
}
|
||||
for (const minute of dirtyMinutes) {
|
||||
const first = (Math.floor(new Date(minute).getTime() / period) + 1) * period;
|
||||
for (let n = 0; n < (type === 'overall' ? 3 : 1); n++) {
|
||||
const at = new Date(first + n * period).toISOString();
|
||||
if (new Date(at) <= now) pendingWindows.set(`${type}:${at}`, { type, at });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Newer windows first; older revisions must not replace current alert state. Work stays bounded per tick.
|
||||
for (const [key, window] of [...pendingWindows.entries()]
|
||||
.sort((a, b) => b[1].at.localeCompare(a[1].at))
|
||||
.slice(0, 12)
|
||||
.sort((a, b) => a[1].at.localeCompare(b[1].at))) {
|
||||
const final = window.type === 'overall' || now.getTime() >= new Date(window.at).getTime() + 90000;
|
||||
await evaluateWindow(db, window.type, new Date(window.at), final, complete);
|
||||
schedule[key] = `${final}:${complete}`;
|
||||
pendingWindows.delete(key);
|
||||
}
|
||||
for (const key of Object.keys(schedule)) {
|
||||
if (new Date(key.slice(key.indexOf(':') + 1)).getTime() < now.getTime() - 1200000) delete schedule[key];
|
||||
}
|
||||
await checkpoint(db, 'schedule', schedule);
|
||||
await checkpoint(db, 'dirty-windows', { windows: [...pendingWindows.entries()] });
|
||||
await db.query('COMMIT');
|
||||
// Retention only touches monitor-owned tables. Never delete SMS, receipt, queue or audit business data.
|
||||
await db.query(
|
||||
`DELETE FROM "SendingMonitorFact" WHERE id IN (SELECT id FROM "SendingMonitorFact" WHERE "submittedAt"<(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '72 hours' LIMIT 1000)`,
|
||||
);
|
||||
await db.query(
|
||||
`DELETE FROM "SendingMonitorMinute" WHERE (kind,"dimensionKey",minute,verification) IN (SELECT kind,"dimensionKey",minute,verification FROM "SendingMonitorMinute" WHERE minute<(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '7 days' LIMIT 1000)`,
|
||||
);
|
||||
await db.query(
|
||||
`DELETE FROM "SendingMonitorSnapshot" WHERE id IN (SELECT id FROM "SendingMonitorSnapshot" WHERE "evaluationAt"<(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '30 days' LIMIT 1000)`,
|
||||
);
|
||||
await db.query(
|
||||
`DELETE FROM "SendingMonitorAlertRead" WHERE "alertId" IN (SELECT id FROM "SendingMonitorAlert" WHERE state<>'active' AND "closedAt"<(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '90 days' LIMIT 1000)`,
|
||||
);
|
||||
await db.query(
|
||||
`DELETE FROM "SendingMonitorAlert" WHERE id IN (SELECT id FROM "SendingMonitorAlert" WHERE state<>'active' AND "closedAt"<(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '90 days' LIMIT 1000)`,
|
||||
);
|
||||
} catch (error) {
|
||||
await db.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await db.query('SELECT pg_advisory_unlock(20260906,710)');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connectionString = process.env.SENDING_MONITOR_DATABASE_URL || process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('SENDING_MONITOR_DATABASE_URL or DATABASE_URL is required');
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 2,
|
||||
application_name: 'cmpp-sending-monitor',
|
||||
statement_timeout: 25000,
|
||||
});
|
||||
let stopping = false;
|
||||
process.on('SIGTERM', () => {
|
||||
stopping = true;
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
stopping = true;
|
||||
});
|
||||
while (!stopping) {
|
||||
const db = await pool.connect();
|
||||
try {
|
||||
await monitorTick(db);
|
||||
} catch (error) {
|
||||
console.error('sending_monitor_tick_failed', error instanceof Error ? error.message : 'unknown');
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
if (!stopping) await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
if (require.main === module)
|
||||
void main().catch((error) => {
|
||||
console.error('sending_monitor_start_failed', error instanceof Error ? error.message : 'unknown');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PoolClient } from 'pg';
|
||||
import {
|
||||
horizons,
|
||||
matchRules,
|
||||
metric,
|
||||
type Dimensions,
|
||||
type Metric,
|
||||
type MonitorType,
|
||||
type Rule,
|
||||
} from './monitor-metrics';
|
||||
import { keyOf } from './monitor-projection';
|
||||
|
||||
type Aggregate = {
|
||||
dimensionKey: string;
|
||||
dimensions: Dimensions;
|
||||
total: number;
|
||||
eligible: number;
|
||||
unassessable: number;
|
||||
seconds: number;
|
||||
mature: number;
|
||||
success: number;
|
||||
};
|
||||
type Measurements = { total: number; unassessable: number; metrics: Metric[] };
|
||||
type Evaluation = {
|
||||
id: string;
|
||||
type: MonitorType;
|
||||
dimensionKey: string;
|
||||
dimensions: Dimensions;
|
||||
evaluationAt: string;
|
||||
windowFrom: string;
|
||||
observedUntil: string;
|
||||
stage: string;
|
||||
metrics: Measurements;
|
||||
rule: Rule | null;
|
||||
status: string;
|
||||
completeness: { complete: boolean; reason?: string };
|
||||
computedAt: string;
|
||||
};
|
||||
|
||||
type CounterState = { bad: number; good: number; t: string; ruleKey: string };
|
||||
type StoredAlert = {
|
||||
id: string;
|
||||
dimensionKey: string;
|
||||
ruleKey: string;
|
||||
openedAt: Date;
|
||||
worst: Evaluation & { revision: number };
|
||||
};
|
||||
export async function evaluateWindow(db: PoolClient, type: MonitorType, t: Date, final: boolean, complete: boolean) {
|
||||
const observed = new Date(t.getTime() + (final && type !== 'overall' ? 60000 : 0));
|
||||
const from = new Date(t.getTime() - (type === 'overall' ? 1800000 : 300000));
|
||||
const kind = type === 'industry' ? 'attempt' : 'business';
|
||||
// Whole mature minutes come from sparse buckets. Only the maturity minute uses exact millisecond facts.
|
||||
const data: Aggregate[] = (
|
||||
await db.query(
|
||||
`WITH buckets AS (
|
||||
SELECT b.* FROM "SendingMonitorMinute" b LEFT JOIN LATERAL (SELECT * FROM "SendingMonitorTargetVersion" v WHERE v."channelId"=b.dimensions->>'channelId' AND v."effectiveFrom"<=$3::timestamp ORDER BY version DESC LIMIT 1) target ON true
|
||||
WHERE b.kind=$1 AND b.minute >= $2::timestamp AND b.minute < $3::timestamp AND ($4<>'verification' OR b.verification)
|
||||
AND ($4<>'industry' OR (target.enabled AND b.minute>=target."effectiveFrom"))
|
||||
), totals AS (SELECT "dimensionKey",(jsonb_agg(dimensions ORDER BY minute DESC))->0 dimensions,
|
||||
sum((metrics->>'total')::int)::int total,sum((metrics->>'eligible')::int)::int eligible,
|
||||
sum((metrics->>'unassessable')::int)::int unassessable FROM buckets GROUP BY "dimensionKey"),
|
||||
hs AS (SELECT unnest($6::int[]) seconds), full_minutes AS (
|
||||
SELECT b."dimensionKey",h.seconds,sum((b.metrics->>'eligible')::int)::int mature,
|
||||
sum((b.metrics->>('s'||h.seconds))::int)::int success FROM buckets b CROSS JOIN hs h
|
||||
WHERE b.minute<date_trunc('minute',$5::timestamp-h.seconds*interval '1 second') GROUP BY b."dimensionKey",h.seconds
|
||||
), boundary AS (
|
||||
SELECT f."dimensionKey",h.seconds,count(*)::int mature,
|
||||
count(*) FILTER(WHERE f."successAt"-f."submittedAt" BETWEEN interval '0 seconds' AND h.seconds*interval '1 second')::int success
|
||||
FROM "SendingMonitorFact" f CROSS JOIN hs h LEFT JOIN LATERAL (SELECT * FROM "SendingMonitorTargetVersion" v WHERE v."channelId"=f.dimensions->>'channelId' AND v."effectiveFrom"<=$3::timestamp ORDER BY version DESC LIMIT 1) target ON true
|
||||
WHERE f.kind=$1 AND f."submittedAt">=$2::timestamp AND f."submittedAt"<$3::timestamp AND f.reason IS NULL
|
||||
AND ($4<>'verification' OR f.verification) AND ($4<>'industry' OR (target.enabled AND f."submittedAt">=target."effectiveFrom"))
|
||||
AND f."submittedAt">=date_trunc('minute',$5::timestamp-h.seconds*interval '1 second') AND f."submittedAt"<=$5::timestamp-h.seconds*interval '1 second'
|
||||
GROUP BY f."dimensionKey",h.seconds)
|
||||
SELECT totals.*,hs.seconds,COALESCE(m.mature,0)+COALESCE(b.mature,0) mature,COALESCE(m.success,0)+COALESCE(b.success,0) success
|
||||
FROM totals CROSS JOIN hs LEFT JOIN full_minutes m USING("dimensionKey",seconds) LEFT JOIN boundary b USING("dimensionKey",seconds)`,
|
||||
[kind, from, t, type, observed, [...horizons[type]]],
|
||||
)
|
||||
).rows;
|
||||
const rules: Rule[] = (
|
||||
await db.query(
|
||||
`SELECT DISTINCT ON ("ruleId") * FROM "SendingMonitorRuleVersion" WHERE type=$1 AND "effectiveAt"<=$2 ORDER BY "ruleId",version DESC`,
|
||||
[type, t],
|
||||
)
|
||||
).rows;
|
||||
const groups = new Map<string, Aggregate[]>();
|
||||
for (const row of data) {
|
||||
const list = groups.get(row.dimensionKey) ?? [];
|
||||
list.push(row);
|
||||
groups.set(row.dimensionKey, list);
|
||||
}
|
||||
if (type === 'industry') {
|
||||
const targets = (
|
||||
await db.query(
|
||||
`SELECT c.id,c.name,CASE WHEN cardinality(c.carriers)>0 THEN c.carriers WHEN c.carrier='all' THEN ARRAY['mobile','unicom','telecom'] ELSE ARRAY[COALESCE(c.carrier,'unknown')] END carriers
|
||||
FROM (SELECT DISTINCT ON ("channelId") * FROM "SendingMonitorTargetVersion" WHERE "effectiveFrom"<=$1 ORDER BY "channelId",version DESC) t JOIN "SmsChannel" c ON c.id=t."channelId" WHERE t.enabled AND t."effectiveFrom"<=$1 AND c.status<>'deleted'`,
|
||||
[t],
|
||||
)
|
||||
).rows;
|
||||
for (const c of targets)
|
||||
for (const carrier of c.carriers) {
|
||||
const key = keyOf([c.id, carrier]);
|
||||
if (!groups.has(key))
|
||||
groups.set(
|
||||
key,
|
||||
horizons[type].map((seconds) => ({
|
||||
dimensionKey: key,
|
||||
dimensions: { channelId: c.id, channelName: c.name, carrier },
|
||||
total: 0,
|
||||
eligible: 0,
|
||||
unassessable: 0,
|
||||
seconds,
|
||||
mature: 0,
|
||||
success: 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (type !== 'industry') {
|
||||
const previousDimensions = (
|
||||
await db.query(
|
||||
`SELECT "dimensionKey",dimensions FROM "SendingMonitorSnapshot" WHERE type=$1 AND "evaluationAt"=(SELECT max("evaluationAt") FROM "SendingMonitorSnapshot" WHERE type=$1)`,
|
||||
[type],
|
||||
)
|
||||
).rows;
|
||||
for (const item of previousDimensions)
|
||||
if (!groups.has(item.dimensionKey))
|
||||
groups.set(
|
||||
item.dimensionKey,
|
||||
horizons[type].map((seconds) => ({
|
||||
dimensionKey: item.dimensionKey,
|
||||
dimensions: item.dimensions,
|
||||
total: 0,
|
||||
eligible: 0,
|
||||
unassessable: 0,
|
||||
seconds,
|
||||
mature: 0,
|
||||
success: 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const activeDimensions = (
|
||||
await db.query(`SELECT "dimensionKey",dimensions FROM "SendingMonitorAlert" WHERE type=$1 AND state='active'`, [
|
||||
type,
|
||||
])
|
||||
).rows;
|
||||
for (const item of activeDimensions) {
|
||||
if (!groups.has(item.dimensionKey))
|
||||
groups.set(
|
||||
item.dimensionKey,
|
||||
horizons[type].map((seconds) => ({
|
||||
dimensionKey: item.dimensionKey,
|
||||
dimensions: item.dimensions,
|
||||
total: 0,
|
||||
eligible: 0,
|
||||
unassessable: 0,
|
||||
seconds,
|
||||
mature: 0,
|
||||
success: 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const activeAlerts = new Map(
|
||||
(
|
||||
await db.query<StoredAlert>(`SELECT * FROM "SendingMonitorAlert" WHERE type=$1 AND state='active'`, [type])
|
||||
).rows.map((a) => [a.dimensionKey, a]),
|
||||
);
|
||||
const counters = new Map(
|
||||
(
|
||||
await db.query<{ id: string; data: CounterState }>(
|
||||
`SELECT id,data FROM "SendingMonitorCheckpoint" WHERE id=ANY($1::text[])`,
|
||||
[[...groups.keys()].map((key) => `alert-state:${type}:${key}`)],
|
||||
)
|
||||
).rows.map((c) => [c.id, c.data]),
|
||||
);
|
||||
for (const [key, rows] of groups) {
|
||||
const first = rows[0],
|
||||
rule = matchRules(rules, type, first.dimensions)[0] ?? null;
|
||||
const values = horizons[type].map((seconds, i) => {
|
||||
const row = rows.find((r) => r.seconds === seconds)!;
|
||||
return metric(
|
||||
seconds,
|
||||
row.success,
|
||||
row.mature,
|
||||
Math.max(0, row.eligible - row.mature),
|
||||
rule?.config.thresholds[i] ?? null,
|
||||
rule?.config.minSamples ?? 1,
|
||||
);
|
||||
});
|
||||
const metrics = { total: first.total, unassessable: first.unassessable, metrics: values };
|
||||
const status = !complete
|
||||
? 'stale'
|
||||
: first.unassessable
|
||||
? 'unassessable'
|
||||
: !rule
|
||||
? 'unconfigured'
|
||||
: !rule.config.enabled
|
||||
? 'disabled'
|
||||
: !first.total
|
||||
? 'no_data'
|
||||
: values.some((m) => m.bad)
|
||||
? 'abnormal'
|
||||
: values.some((m) => m.threshold !== null && m.insufficient)
|
||||
? 'sample_insufficient'
|
||||
: 'normal';
|
||||
const snapshot: Evaluation = {
|
||||
id: keyOf([type, key, t.toISOString()]),
|
||||
type,
|
||||
dimensionKey: key,
|
||||
dimensions: first.dimensions,
|
||||
evaluationAt: t.toISOString(),
|
||||
windowFrom: from.toISOString(),
|
||||
observedUntil: observed.toISOString(),
|
||||
stage: final ? 'final' : 'initial',
|
||||
metrics,
|
||||
rule,
|
||||
status,
|
||||
completeness: { complete, reason: complete ? undefined : '采集未完成或回执待匹配,暂停告警判断' },
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
const saved = await db.query(
|
||||
`INSERT INTO "SendingMonitorSnapshot" (id,type,"dimensionKey",dimensions,"evaluationAt","windowFrom","observedUntil",stage,metrics,rule,status,completeness)
|
||||
VALUES($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12::jsonb)
|
||||
ON CONFLICT(id) DO UPDATE SET "observedUntil"=EXCLUDED."observedUntil",stage=EXCLUDED.stage,metrics=EXCLUDED.metrics,rule=EXCLUDED.rule,status=EXCLUDED.status,completeness=EXCLUDED.completeness,
|
||||
revision="SendingMonitorSnapshot".revision+1,"computedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
WHERE ("SendingMonitorSnapshot".metrics,"SendingMonitorSnapshot".stage,"SendingMonitorSnapshot".status,"SendingMonitorSnapshot".rule,"SendingMonitorSnapshot".completeness)
|
||||
IS DISTINCT FROM (EXCLUDED.metrics,EXCLUDED.stage,EXCLUDED.status,EXCLUDED.rule,EXCLUDED.completeness) RETURNING revision`,
|
||||
[
|
||||
snapshot.id,
|
||||
type,
|
||||
key,
|
||||
JSON.stringify(snapshot.dimensions),
|
||||
t,
|
||||
from,
|
||||
observed,
|
||||
snapshot.stage,
|
||||
JSON.stringify(metrics),
|
||||
JSON.stringify(rule),
|
||||
status,
|
||||
JSON.stringify(snapshot.completeness),
|
||||
],
|
||||
);
|
||||
if (saved.rows.length)
|
||||
await updateAlert(
|
||||
db,
|
||||
snapshot,
|
||||
Number(saved.rows[0].revision),
|
||||
counters.get(`alert-state:${type}:${key}`),
|
||||
activeAlerts.get(key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAlert(
|
||||
db: PoolClient,
|
||||
s: Evaluation,
|
||||
revision: number,
|
||||
stored?: CounterState,
|
||||
active?: StoredAlert,
|
||||
) {
|
||||
const id = `alert-state:${s.type}:${s.dimensionKey}`;
|
||||
const previous = stored ?? { bad: 0, good: 0, t: '', ruleKey: '' };
|
||||
if (previous.t > s.evaluationAt) return;
|
||||
const ruleKey = s.rule ? `${s.rule.ruleId}:${s.rule.version}` : 'none';
|
||||
if (active && active.ruleKey !== ruleKey) {
|
||||
await db.query(
|
||||
`UPDATE "SendingMonitorAlert" SET state='closed',"closedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"closeReason"=$2 WHERE id=$1`,
|
||||
[active.id, s.rule?.config.enabled ? 'rule_changed' : 'disabled'],
|
||||
);
|
||||
active = undefined;
|
||||
}
|
||||
if (previous.ruleKey !== ruleKey) {
|
||||
previous.bad = 0;
|
||||
previous.good = 0;
|
||||
previous.ruleKey = ruleKey;
|
||||
previous.t = '';
|
||||
}
|
||||
const config = s.rule?.config;
|
||||
const usable = s.completeness.complete && s.metrics.unassessable === 0 && config?.enabled && s.metrics.total > 0;
|
||||
const bad = usable && s.metrics.metrics.some((m) => m.bad);
|
||||
const good = usable && s.metrics.metrics.filter((m) => m.threshold !== null).every((m) => !m.bad && !m.insufficient);
|
||||
const final = s.stage === 'final';
|
||||
if (final && previous.t !== s.evaluationAt && usable) {
|
||||
if (bad) {
|
||||
previous.bad++;
|
||||
previous.good = 0;
|
||||
} else if (good) {
|
||||
previous.good++;
|
||||
previous.bad = 0;
|
||||
}
|
||||
}
|
||||
if (bad && !active && ((final && previous.bad >= config!.consecutiveBad) || config!.consecutiveBad === 1)) {
|
||||
const alertId = randomUUID();
|
||||
await db.query(
|
||||
`INSERT INTO "SendingMonitorAlert" (id,type,"dimensionKey",dimensions,state,"openedAt","lastEvaluatedAt","ruleKey",latest,worst)
|
||||
VALUES($1,$2,$3,$4::jsonb,'active',$5,$5,$6,$7::jsonb,$7::jsonb)`,
|
||||
[
|
||||
alertId,
|
||||
s.type,
|
||||
s.dimensionKey,
|
||||
JSON.stringify(s.dimensions),
|
||||
s.evaluationAt,
|
||||
ruleKey,
|
||||
JSON.stringify({ ...s, revision }),
|
||||
],
|
||||
);
|
||||
} else if (active) {
|
||||
const corrected = revision > 1 && good && active.openedAt.toISOString() === s.evaluationAt;
|
||||
const recovered = final && good && previous.good >= config!.consecutiveGood;
|
||||
const worst =
|
||||
bad &&
|
||||
s.metrics.metrics.some((m, i) => m.rate !== null && (active.worst.metrics.metrics[i]?.rate ?? 101) > m.rate)
|
||||
? { ...s, revision }
|
||||
: active.worst;
|
||||
await db.query(
|
||||
`UPDATE "SendingMonitorAlert" SET latest=$2::jsonb,worst=$3::jsonb,"lastEvaluatedAt"=$4,"updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
state=$5,"closedAt"=CASE WHEN $5<>'active' THEN (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ELSE NULL END,"closeReason"=$6 WHERE id=$1`,
|
||||
[
|
||||
active.id,
|
||||
JSON.stringify({ ...s, revision }),
|
||||
JSON.stringify(worst),
|
||||
s.evaluationAt,
|
||||
corrected ? 'closed' : recovered ? 'recovered' : 'active',
|
||||
corrected ? 'data_corrected' : recovered ? 'recovered' : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (final && usable) previous.t = s.evaluationAt;
|
||||
await db.query(
|
||||
`INSERT INTO "SendingMonitorCheckpoint" (id,data) VALUES($1,$2::jsonb) ON CONFLICT(id) DO UPDATE SET data=EXCLUDED.data,"updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`,
|
||||
[id, JSON.stringify(previous)],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { completeAttempt, evaluateSamples, matchRules, validateRule, type Rule } from './monitor-metrics';
|
||||
const base = Date.parse('2026-09-06T04:30:00Z');
|
||||
const rule: Rule = {
|
||||
ruleId: 'r',
|
||||
version: 1,
|
||||
type: 'industry',
|
||||
scope: {},
|
||||
effectiveAt: new Date(base).toISOString(),
|
||||
config: { enabled: true, minSamples: 1, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 },
|
||||
};
|
||||
describe('sending monitor business metrics', () => {
|
||||
it('uses exact milliseconds and does not count early successes before maturity', () => {
|
||||
const samples = [4999, 5000, 5001].map((ms) => ({
|
||||
submittedAt: new Date(base - 60000),
|
||||
successAt: new Date(base - 60000 + ms),
|
||||
}));
|
||||
const result = evaluateSamples(
|
||||
[...samples, { submittedAt: new Date(base - 1000), successAt: new Date(base - 500) }],
|
||||
'industry',
|
||||
new Date(base),
|
||||
rule,
|
||||
);
|
||||
expect(result.metrics[0]).toMatchObject({ success: 2, mature: 3, observing: 1, bad: true });
|
||||
expect(result.metrics[2]).toMatchObject({ success: 3, mature: 3, observing: 1, bad: false });
|
||||
});
|
||||
it('finalizes the last second of a fixed five-minute window once mature', () => {
|
||||
const samples = [{ submittedAt: new Date(base - 1000), successAt: new Date(base + 49000) }];
|
||||
expect(evaluateSamples(samples, 'industry', new Date(base), rule).metrics[2].mature).toBe(0);
|
||||
expect(evaluateSamples(samples, 'industry', new Date(base + 60000), rule).metrics[2]).toMatchObject({
|
||||
mature: 1,
|
||||
success: 1,
|
||||
});
|
||||
});
|
||||
it('keeps unknown times out of denominator and null for no mature samples', () => {
|
||||
const result = evaluateSamples(
|
||||
[{ submittedAt: new Date(base - 60000), successAt: null, reason: 'missing_wire_time' }],
|
||||
'industry',
|
||||
new Date(base),
|
||||
rule,
|
||||
);
|
||||
expect(result.unassessable).toBe(1);
|
||||
expect(result.metrics[0].rate).toBeNull();
|
||||
expect(result.metrics[0].bad).toBe(false);
|
||||
});
|
||||
it('only matures the first ten minutes for the 20-minute overall metric', () => {
|
||||
const samples = Array.from({ length: 30 }, (_, i) => ({
|
||||
submittedAt: new Date(base - 1800000 + i * 60000 + 1),
|
||||
successAt: null,
|
||||
}));
|
||||
expect(evaluateSamples(samples, 'overall', new Date(base)).metrics[2]).toMatchObject({ mature: 10, observing: 20 });
|
||||
});
|
||||
it('requires every unique fragment of the same attempt', () => {
|
||||
expect(completeAttempt([{ index: 1, total: 2, firstSuccess: new Date(base) }])).toBeNull();
|
||||
expect(
|
||||
completeAttempt([
|
||||
{ index: 1, total: 2, firstSuccess: new Date(base) },
|
||||
{ index: 2, total: 2, firstSuccess: new Date(base + 5000) },
|
||||
])?.getTime(),
|
||||
).toBe(base + 5000);
|
||||
expect(
|
||||
completeAttempt([
|
||||
{ index: 1, total: 2, firstSuccess: new Date(base) },
|
||||
{ index: 1, total: 2, firstSuccess: new Date(base) },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
it('matches full rule precedence without crossing tenants', () => {
|
||||
const scopes = [
|
||||
{},
|
||||
{ tenantId: 't', applicationId: 'a' },
|
||||
{ tenantId: 't', signatureId: 's' },
|
||||
{ tenantId: 't', applicationId: 'a', signatureId: 's' },
|
||||
{ tenantId: 'other', signatureId: 's' },
|
||||
];
|
||||
const rules = scopes.map((scope, i) => ({ ...rule, ruleId: String(i), type: 'overall' as const, scope }));
|
||||
expect(
|
||||
matchRules(rules, 'overall', { tenantId: 't', applicationId: 'a', signatureId: 's' }).map((r) => r.ruleId),
|
||||
).toEqual(['3', '2', '1', '0']);
|
||||
});
|
||||
it('validates sample count, threshold order, scope and consecutive counts', () => {
|
||||
expect(validateRule('industry', {}, rule.config)).toBeNull();
|
||||
expect(validateRule('industry', {}, { ...rule.config, minSamples: 0 })).toBeTruthy();
|
||||
expect(validateRule('industry', {}, { ...rule.config, thresholds: [99, 95, null] })).toBeTruthy();
|
||||
expect(validateRule('industry', {}, { ...rule.config, thresholds: [null, null, null] })).toBeTruthy();
|
||||
expect(validateRule('overall', { applicationId: 'a' }, rule.config)).toBeTruthy();
|
||||
expect(validateRule('overall', {}, { ...rule.config, consecutiveGood: 6 })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
export const horizons = { industry: [5, 20, 60], verification: [5, 20, 60], overall: [60, 300, 1200] } as const;
|
||||
export type MonitorType = keyof typeof horizons;
|
||||
export type Dimensions = {
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
signatureId?: string;
|
||||
signatureName?: string;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
carrier?: string;
|
||||
};
|
||||
export type MonitorScope = { tenantId?: string; applicationId?: string; signatureId?: string };
|
||||
export type RuleConfig = {
|
||||
enabled: boolean;
|
||||
minSamples: number;
|
||||
thresholds: (number | null)[];
|
||||
consecutiveBad: number;
|
||||
consecutiveGood: number;
|
||||
deleted?: boolean;
|
||||
};
|
||||
export type Rule = {
|
||||
ruleId: string;
|
||||
version: number;
|
||||
type: MonitorType;
|
||||
scope: MonitorScope;
|
||||
config: RuleConfig;
|
||||
effectiveAt: string;
|
||||
};
|
||||
export type Metric = {
|
||||
seconds: number;
|
||||
success: number;
|
||||
mature: number;
|
||||
observing: number;
|
||||
rate: number | null;
|
||||
threshold: number | null;
|
||||
insufficient: boolean;
|
||||
bad: boolean;
|
||||
};
|
||||
export type Sample = { submittedAt: Date; successAt: Date | null; reason?: string | null };
|
||||
|
||||
export function evaluateSamples(samples: Sample[], type: MonitorType, observedUntil: Date, rule?: Rule | null) {
|
||||
const eligible = samples.filter((s) => !s.reason);
|
||||
const metrics: Metric[] = horizons[type].map((seconds, i) => {
|
||||
const mature = eligible.filter((s) => s.submittedAt.getTime() + seconds * 1000 <= observedUntil.getTime());
|
||||
const success = mature.filter(
|
||||
(s) =>
|
||||
s.successAt &&
|
||||
s.successAt >= s.submittedAt &&
|
||||
s.successAt.getTime() - s.submittedAt.getTime() <= seconds * 1000,
|
||||
).length;
|
||||
return metric(
|
||||
seconds,
|
||||
success,
|
||||
mature.length,
|
||||
eligible.length - mature.length,
|
||||
rule?.config.thresholds[i] ?? null,
|
||||
rule?.config.minSamples ?? 1,
|
||||
);
|
||||
});
|
||||
return { total: samples.length, unassessable: samples.length - eligible.length, metrics };
|
||||
}
|
||||
|
||||
export function metric(
|
||||
seconds: number,
|
||||
success: number,
|
||||
mature: number,
|
||||
observing: number,
|
||||
threshold: number | null,
|
||||
min: number,
|
||||
): Metric {
|
||||
return {
|
||||
seconds,
|
||||
success,
|
||||
mature,
|
||||
observing,
|
||||
threshold,
|
||||
rate: mature ? (100 * success) / mature : null,
|
||||
insufficient: mature < min,
|
||||
bad: threshold !== null && mature >= min && success * 10000 < Math.round(threshold * 100) * mature,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchRules(rules: Rule[], type: MonitorType, dimension: Dimensions) {
|
||||
return rules
|
||||
.filter(
|
||||
(r) =>
|
||||
r.type === type &&
|
||||
!r.config.deleted &&
|
||||
Object.entries(r.scope).every(([key, value]) => !value || dimension[key as keyof Dimensions] === value),
|
||||
)
|
||||
.sort((a, b) => score(b.scope) - score(a.scope));
|
||||
}
|
||||
const score = (scope: MonitorScope) => (scope.signatureId ? 2 : 0) + (scope.applicationId ? 1 : 0);
|
||||
|
||||
export function validateRule(type: string, scope: MonitorScope, input: RuleConfig): string | null {
|
||||
if (!Object.hasOwn(horizons, type)) return '监控类型无效';
|
||||
if (!scope || Object.keys(scope).some((k) => !['tenantId', 'applicationId', 'signatureId'].includes(k)))
|
||||
return '规则范围无效';
|
||||
if (Object.values(scope).some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) return '规则范围ID无效';
|
||||
if (type !== 'overall' && Object.keys(scope).length) return '仅整体兜底支持个性规则';
|
||||
if (Object.keys(scope).length && (!scope.tenantId || (!scope.applicationId && !scope.signatureId)))
|
||||
return '个性规则须指定企业和应用或签名';
|
||||
if (
|
||||
!input ||
|
||||
typeof input.enabled !== 'boolean' ||
|
||||
!Number.isSafeInteger(input.minSamples) ||
|
||||
input.minSamples < 1 ||
|
||||
input.minSamples > 100000000
|
||||
)
|
||||
return '最低样本量须为正整数';
|
||||
if (![input.consecutiveBad, input.consecutiveGood].every((n) => Number.isInteger(n) && n >= 1 && n <= 5))
|
||||
return '连续次数须为1至5';
|
||||
if (!Array.isArray(input.thresholds) || input.thresholds.length !== 3 || input.thresholds.every((n) => n === null))
|
||||
return '至少设置一项时效指标';
|
||||
if (input.deleted !== undefined && typeof input.deleted !== 'boolean') return '删除标记无效';
|
||||
let previous = -1;
|
||||
for (const n of input.thresholds) {
|
||||
if (n === null) continue;
|
||||
if (
|
||||
typeof n !== 'number' ||
|
||||
!Number.isFinite(n) ||
|
||||
n < 0 ||
|
||||
n > 100 ||
|
||||
Math.abs(n * 100 - Math.round(n * 100)) > 0.000001 ||
|
||||
n < previous
|
||||
)
|
||||
return '下限须为0至100,最多两位小数,按时限递增';
|
||||
previous = n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Success fragments from different attempts must never be combined.
|
||||
export function completeAttempt(segments: { index: number; total: number; firstSuccess: Date | null }[]): Date | null {
|
||||
const total = Math.max(1, ...segments.map((s) => s.total));
|
||||
const unique = new Map(segments.map((s) => [s.index, s]));
|
||||
if (unique.size !== total) return null;
|
||||
let last = 0;
|
||||
for (let i = 1; i <= total; i++) {
|
||||
const at = unique.get(i)?.firstSuccess;
|
||||
if (!at) return null;
|
||||
last = Math.max(last, at.getTime());
|
||||
}
|
||||
return new Date(last);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PoolClient } from 'pg';
|
||||
import { completeAttempt, type Dimensions } from './monitor-metrics';
|
||||
|
||||
type Attempt = {
|
||||
id: string;
|
||||
messageRecordId: string;
|
||||
channelId: string;
|
||||
createdAt: Date;
|
||||
firstWireSubmitAt: Date | null;
|
||||
wireTimeSource: string | null;
|
||||
submitStatus: string;
|
||||
tenantId: string | null;
|
||||
applicationId: string | null;
|
||||
signatureId: string | null;
|
||||
tenantName: string | null;
|
||||
applicationName: string | null;
|
||||
signatureName: string | null;
|
||||
channelName: string;
|
||||
carrier: string | null;
|
||||
content: string;
|
||||
receiptRequested: boolean | null;
|
||||
};
|
||||
type Segment = {
|
||||
submitRecordId: string;
|
||||
segmentIndex: number;
|
||||
segmentTotal: number;
|
||||
firstWireSubmitAt: Date | null;
|
||||
wireTimeSource: string | null;
|
||||
firstSuccess: Date | null;
|
||||
missingReceiptTime: boolean;
|
||||
ambiguousReceipt: boolean;
|
||||
receiptRequested: boolean | null;
|
||||
};
|
||||
type Fact = {
|
||||
id: string;
|
||||
kind: string;
|
||||
sourceId: string;
|
||||
messageRecordId: string;
|
||||
dimensionKey: string;
|
||||
dimensions: Dimensions;
|
||||
submittedAt: string;
|
||||
successAt: string | null;
|
||||
verification: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
export const keyOf = (parts: unknown[]) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');
|
||||
|
||||
export async function projectMessages(db: PoolClient, ids: string[]) {
|
||||
if (!ids.length) return;
|
||||
const attempts: Attempt[] = (
|
||||
await db.query(
|
||||
`SELECT s.id,s."messageRecordId",s."channelId",s."createdAt",s."firstWireSubmitAt",s."wireTimeSource",s."submitStatus",s."receiptRequested",
|
||||
m."tenantId",m."applicationId",m."signatureId",m.carrier,m.content,m."cmppRegisteredDelivery",t.name "tenantName",a.name "applicationName",g.name "signatureName",c.name "channelName"
|
||||
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId" LEFT JOIN "Tenant" t ON t.id=m."tenantId"
|
||||
LEFT JOIN "SmsApplication" a ON a.id=m."applicationId" LEFT JOIN "SmsSignature" g ON g.id=m."signatureId" JOIN "SmsChannel" c ON c.id=s."channelId"
|
||||
WHERE s."messageRecordId"=ANY($1::text[])`,
|
||||
[ids],
|
||||
)
|
||||
).rows;
|
||||
const segments: Segment[] = (
|
||||
await db.query(
|
||||
`SELECT a."submitRecordId",a."segmentIndex",a."segmentTotal",a."firstWireSubmitAt",a."wireTimeSource",a."receiptRequested",
|
||||
EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" other WHERE other."messageRecordId"=a."messageRecordId" AND other."gatewayMessageId"=a."gatewayMessageId" AND other."channelId"=a."channelId" AND other."submitId"<>a."submitId") "ambiguousReceipt",
|
||||
min(i."gatewayReceivedAt") FILTER(WHERE i."receiptStatus"='delivered' AND i.status='matched') "firstSuccess",
|
||||
bool_or(i."receiptStatus"='delivered' AND i."gatewayReceivedAt" IS NULL) "missingReceiptTime"
|
||||
FROM "SmsMessageSegmentAudit" a LEFT JOIN "UpstreamReceiptInbox" i ON i."matchedMessageRecordId"=a."messageRecordId" AND i."gatewayMessageId"=a."gatewayMessageId"
|
||||
AND (i."incomingChannelId"=a."channelId" OR i."matchedChannelId"=a."channelId")
|
||||
AND NOT EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" other WHERE other."messageRecordId"=a."messageRecordId" AND other."gatewayMessageId"=a."gatewayMessageId" AND other."channelId"=a."channelId" AND other."submitId"<>a."submitId")
|
||||
WHERE a."messageRecordId"=ANY($1::text[]) GROUP BY a.id`,
|
||||
[ids],
|
||||
)
|
||||
).rows;
|
||||
const previous: Fact[] = (
|
||||
await db.query(`SELECT * FROM "SendingMonitorFact" WHERE "sourceId"=ANY($1::text[]) AND kind='business'`, [ids])
|
||||
).rows;
|
||||
const existing = new Map(previous.map((p) => [p.sourceId, p]));
|
||||
const facts: Fact[] = [],
|
||||
byMessage = new Map<string, { attempt: Attempt; facts: Fact[] }>();
|
||||
for (const a of attempts) {
|
||||
const parts = segments.filter((s) => s.submitRecordId === a.id);
|
||||
const times = [a.firstWireSubmitAt, ...parts.map((p) => p.firstWireSubmitAt)].filter((t): t is Date => Boolean(t));
|
||||
const at = times.length ? new Date(Math.min(...times.map((t) => t.getTime()))) : a.createdAt;
|
||||
const source = times.length ? 'gateway_write_complete' : a.wireTimeSource;
|
||||
// A queued attempt with no result may have crashed after write. Keep it visible as unassessable.
|
||||
if (source === 'not_written' && !times.length) continue;
|
||||
const successAt = completeAttempt(
|
||||
parts.map((p) => ({ index: p.segmentIndex, total: p.segmentTotal, firstSuccess: p.firstSuccess })),
|
||||
);
|
||||
const receiptRequested = a.receiptRequested ?? parts.find((p) => p.receiptRequested !== null)?.receiptRequested;
|
||||
const reason =
|
||||
source !== 'gateway_write_complete'
|
||||
? source === 'write_uncertain'
|
||||
? 'write_uncertain'
|
||||
: 'missing_wire_time'
|
||||
: receiptRequested === false
|
||||
? 'receipt_not_requested'
|
||||
: receiptRequested !== true
|
||||
? 'missing_receipt_configuration'
|
||||
: !parts.length
|
||||
? 'missing_segment_snapshot'
|
||||
: parts.some((p) => p.ambiguousReceipt)
|
||||
? 'ambiguous_receipt'
|
||||
: parts.some((p) => p.firstSuccess && p.firstWireSubmitAt && p.firstSuccess < p.firstWireSubmitAt)
|
||||
? 'negative_latency'
|
||||
: parts.some((p) => p.missingReceiptTime && !p.firstSuccess)
|
||||
? 'missing_receipt_time'
|
||||
: successAt && successAt < at
|
||||
? 'negative_latency'
|
||||
: null;
|
||||
const dim: Dimensions = { channelId: a.channelId, channelName: a.channelName, carrier: a.carrier ?? 'unknown' };
|
||||
const fact: Fact = {
|
||||
id: `attempt:${a.id}`,
|
||||
kind: 'attempt',
|
||||
sourceId: a.id,
|
||||
messageRecordId: a.messageRecordId,
|
||||
dimensionKey: keyOf([a.channelId, dim.carrier]),
|
||||
dimensions: dim,
|
||||
submittedAt: at.toISOString(),
|
||||
successAt: successAt?.toISOString() ?? null,
|
||||
verification: existing.get(a.messageRecordId)?.verification ?? a.content.includes('验证码'),
|
||||
reason,
|
||||
};
|
||||
facts.push(fact);
|
||||
const group = byMessage.get(a.messageRecordId) ?? { attempt: a, facts: [] };
|
||||
group.facts.push(fact);
|
||||
byMessage.set(a.messageRecordId, group);
|
||||
}
|
||||
for (const [id, { attempt: a, facts: tries }] of byMessage) {
|
||||
const prev = existing.get(id);
|
||||
const dimensions = prev?.dimensions ?? {
|
||||
tenantId: a.tenantId ?? '',
|
||||
tenantName: a.tenantName ?? '未识别企业',
|
||||
applicationId: a.applicationId ?? '',
|
||||
applicationName: a.applicationName ?? '未识别应用',
|
||||
signatureId: a.signatureId ?? '',
|
||||
signatureName: a.signatureName ?? '未识别签名',
|
||||
};
|
||||
const at = new Date(Math.min(...tries.map((f) => new Date(f.submittedAt).getTime())));
|
||||
const successes = tries.filter((f) => !f.reason && f.successAt).map((f) => new Date(f.successAt!).getTime());
|
||||
const successAt = successes.length ? new Date(Math.min(...successes)) : null;
|
||||
const reason =
|
||||
!a.tenantId || !a.applicationId || !a.signatureId
|
||||
? 'missing_dimension'
|
||||
: (tries.find((f) => f.reason === 'missing_wire_time' || f.reason === 'write_uncertain')?.reason ??
|
||||
(tries.every((f) => f.reason) ? tries[0].reason : successAt && successAt < at ? 'negative_latency' : null));
|
||||
facts.push({
|
||||
id: `business:${id}`,
|
||||
kind: 'business',
|
||||
sourceId: id,
|
||||
messageRecordId: id,
|
||||
dimensionKey: keyOf([a.tenantId, a.applicationId, a.signatureId]),
|
||||
dimensions,
|
||||
submittedAt: at.toISOString(),
|
||||
successAt: successAt?.toISOString() ?? null,
|
||||
verification: prev?.verification ?? a.content.includes('验证码'),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
if (!facts.length) return;
|
||||
const old = (
|
||||
await db.query(`SELECT id,"submittedAt" FROM "SendingMonitorFact" WHERE id=ANY($1::text[])`, [
|
||||
facts.map((f) => f.id),
|
||||
])
|
||||
).rows;
|
||||
const changed = await db.query(
|
||||
`INSERT INTO "SendingMonitorFact" (id,kind,"sourceId","messageRecordId","dimensionKey",dimensions,"submittedAt","successAt",verification,reason)
|
||||
SELECT id,kind,"sourceId","messageRecordId","dimensionKey",dimensions,"submittedAt","successAt",verification,reason FROM jsonb_to_recordset($1::jsonb)
|
||||
AS f(id text,kind text,"sourceId" text,"messageRecordId" text,"dimensionKey" text,dimensions jsonb,"submittedAt" timestamp,"successAt" timestamp,verification boolean,reason text)
|
||||
ON CONFLICT(id) DO UPDATE SET "submittedAt"=EXCLUDED."submittedAt","successAt"=EXCLUDED."successAt",reason=EXCLUDED.reason,"updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
WHERE ("SendingMonitorFact"."submittedAt","SendingMonitorFact"."successAt","SendingMonitorFact".reason) IS DISTINCT FROM (EXCLUDED."submittedAt",EXCLUDED."successAt",EXCLUDED.reason) RETURNING id,"submittedAt"`,
|
||||
[JSON.stringify(facts)],
|
||||
);
|
||||
if (!changed.rows.length) return [];
|
||||
const changedIds = new Set(changed.rows.map((f) => f.id));
|
||||
const minutes = [
|
||||
...new Set(
|
||||
[...changed.rows, ...old.filter((f) => changedIds.has(f.id))].map((f) =>
|
||||
new Date(Math.floor(new Date(f.submittedAt).getTime() / 60000) * 60000).toISOString(),
|
||||
),
|
||||
),
|
||||
];
|
||||
await db.query(`DELETE FROM "SendingMonitorMinute" WHERE minute=ANY($1::timestamp[])`, [minutes]);
|
||||
await db.query(
|
||||
`INSERT INTO "SendingMonitorMinute" (kind,"dimensionKey",minute,verification,dimensions,metrics)
|
||||
SELECT kind,"dimensionKey",date_trunc('minute',"submittedAt"),verification,(jsonb_agg(dimensions ORDER BY "updatedAt" DESC))->0,
|
||||
jsonb_build_object('total',count(*),'eligible',count(*) FILTER(WHERE reason IS NULL),'unassessable',count(*) FILTER(WHERE reason IS NOT NULL),
|
||||
's5',count(*) FILTER(WHERE reason IS NULL AND "successAt"-"submittedAt" BETWEEN interval '0 seconds' AND interval '5 seconds'),
|
||||
's20',count(*) FILTER(WHERE reason IS NULL AND "successAt"-"submittedAt" BETWEEN interval '0 seconds' AND interval '20 seconds'),
|
||||
's60',count(*) FILTER(WHERE reason IS NULL AND "successAt"-"submittedAt" BETWEEN interval '0 seconds' AND interval '60 seconds'),
|
||||
's300',count(*) FILTER(WHERE reason IS NULL AND "successAt"-"submittedAt" BETWEEN interval '0 seconds' AND interval '300 seconds'),
|
||||
's1200',count(*) FILTER(WHERE reason IS NULL AND "successAt"-"submittedAt" BETWEEN interval '0 seconds' AND interval '1200 seconds'))
|
||||
FROM "SendingMonitorFact" WHERE "submittedAt">=ANY($1::timestamp[]) AND date_trunc('minute',"submittedAt")=ANY($1::timestamp[])
|
||||
GROUP BY kind,"dimensionKey",date_trunc('minute',"submittedAt"),verification`,
|
||||
[minutes],
|
||||
);
|
||||
return minutes;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||
import {
|
||||
horizons,
|
||||
matchRules,
|
||||
validateRule,
|
||||
type MonitorScope,
|
||||
type MonitorType,
|
||||
type Rule,
|
||||
type RuleConfig,
|
||||
} from './monitor-metrics';
|
||||
|
||||
type QueryParams = Record<string, string | undefined>;
|
||||
const pageNumber = (value: string | undefined, fallback: number, max = 100000) => {
|
||||
const n = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new BadRequestException('分页参数无效');
|
||||
return n;
|
||||
};
|
||||
const healthy = `EXISTS(SELECT 1 FROM "SendingMonitorCheckpoint" WHERE id='health' AND "updatedAt">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '30 seconds' AND data->>'complete'='true')`;
|
||||
const monitorType = (value = 'industry'): MonitorType => {
|
||||
if (!Object.hasOwn(horizons, value)) throw new BadRequestException('监控类型无效');
|
||||
return value as MonitorType;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SendingMonitorService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
async authorize(req: SessionRequest, permission: 'view' | 'rules' | 'targets' = 'view') {
|
||||
const user =
|
||||
req.authSession?.portal === 'admin' &&
|
||||
req.sessionUserId &&
|
||||
(await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.sessionUserId,
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
roles: { some: { role: { code: 'platform_admin' } } },
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
if (!user) throw new ForbiddenException(`无发送监控${permission === 'view' ? '查看' : '配置'}权限`);
|
||||
return user.id;
|
||||
}
|
||||
async rows(query: QueryParams) {
|
||||
const type = monitorType(query.type),
|
||||
page = pageNumber(query.page, 1),
|
||||
pageSize = pageNumber(query.pageSize, 20, 100);
|
||||
const filter = `s.type=$1 AND s."evaluationAt"=(SELECT max("evaluationAt") FROM "SendingMonitorSnapshot" WHERE type=$1)
|
||||
AND ($2='' OR (CASE WHEN ${healthy} THEN s.status ELSE 'stale' END)=$2) AND ($3='' OR s.dimensions::text ILIKE '%'||$3||'%')
|
||||
AND ($4='' OR s.dimensions->>'tenantId'=$4) AND ($5='' OR s.dimensions->>'applicationId'=$5) AND ($6='' OR s.dimensions->>'signatureId'=$6)`;
|
||||
const args = [
|
||||
type,
|
||||
query.status ?? '',
|
||||
query.keyword?.trim() ?? '',
|
||||
query.tenantId ?? '',
|
||||
query.applicationId ?? '',
|
||||
query.signatureId ?? '',
|
||||
];
|
||||
const [items, count, checkpoint] = await Promise.all([
|
||||
this.prisma.$queryRawUnsafe(
|
||||
`SELECT s.*,CASE WHEN ${healthy} THEN s.status ELSE 'stale' END status FROM "SendingMonitorSnapshot" s WHERE ${filter} ORDER BY (s.status='abnormal') DESC,s."dimensionKey" LIMIT $7 OFFSET $8`,
|
||||
...args,
|
||||
pageSize,
|
||||
(page - 1) * pageSize,
|
||||
),
|
||||
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
|
||||
`SELECT count(*)::int total FROM "SendingMonitorSnapshot" s WHERE ${filter}`,
|
||||
...args,
|
||||
),
|
||||
this.prisma.$queryRawUnsafe<Array<{ data: unknown; updatedAt: Date }>>(
|
||||
`SELECT data,"updatedAt" FROM "SendingMonitorCheckpoint" WHERE id='health'`,
|
||||
),
|
||||
]);
|
||||
return { items, total: count[0].total, page, pageSize, health: checkpoint[0] ?? null };
|
||||
}
|
||||
async overview(query: QueryParams) {
|
||||
const type = monitorType(query.type);
|
||||
const rows = await this.prisma.$queryRawUnsafe(
|
||||
`SELECT CASE WHEN ${healthy} THEN status ELSE 'stale' END status,count(*)::int dimensions,sum((metrics->>'total')::bigint)::text total,max("evaluationAt") "evaluationAt",max("computedAt") "computedAt"
|
||||
FROM "SendingMonitorSnapshot" WHERE type=$1 AND "evaluationAt"=(SELECT max("evaluationAt") FROM "SendingMonitorSnapshot" WHERE type=$1) GROUP BY 1`,
|
||||
type,
|
||||
);
|
||||
return { type, rows, permissions: { view: true, rules: true, targets: true } };
|
||||
}
|
||||
async history(query: QueryParams) {
|
||||
const type = monitorType(query.type);
|
||||
if (!query.dimensionId || query.dimensionId.length > 500) throw new BadRequestException('维度无效');
|
||||
const hours = query.range === '24h' ? 24 : 2;
|
||||
return this.prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM "SendingMonitorSnapshot" WHERE type=$1 AND "dimensionKey"=$2 AND "evaluationAt">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-($3::int*interval '1 hour') ORDER BY "evaluationAt" LIMIT 300`,
|
||||
type,
|
||||
query.dimensionId,
|
||||
hours,
|
||||
);
|
||||
}
|
||||
async targets() {
|
||||
return this.prisma.$queryRawUnsafe(
|
||||
`SELECT c.id,c.name,c.carrier,c.carriers,c.status,COALESCE(t.enabled,false) enabled,COALESCE(t.version,0) version,t."effectiveFrom" FROM "SmsChannel" c LEFT JOIN "SendingMonitorTarget" t ON t."channelId"=c.id WHERE c.status<>'deleted' ORDER BY c.name,c.id`,
|
||||
);
|
||||
}
|
||||
async target(id: string, body: { enabled?: boolean; version?: number }, actor: string) {
|
||||
if (!body || typeof body.enabled !== 'boolean' || !Number.isSafeInteger(body.version) || body.version! < 0)
|
||||
throw new BadRequestException('监控配置参数无效');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const channel = await tx.smsChannel.findFirst({
|
||||
where: { id, status: { not: 'deleted' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!channel) throw new NotFoundException('通道不存在');
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-target:${id}`},0))`;
|
||||
const old = await tx.$queryRawUnsafe<Array<{ version: number; enabled: boolean }>>(
|
||||
`SELECT * FROM "SendingMonitorTarget" WHERE "channelId"=$1`,
|
||||
id,
|
||||
);
|
||||
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('监控范围已被修改,请刷新');
|
||||
const result = await tx.$queryRawUnsafe(
|
||||
`INSERT INTO "SendingMonitorTarget" ("channelId",enabled,version,"effectiveFrom","updatedBy") VALUES($1,$2,1,to_timestamp((floor(extract(epoch FROM (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))/300)+1)*300) AT TIME ZONE 'UTC',$3)
|
||||
ON CONFLICT("channelId") DO UPDATE SET enabled=EXCLUDED.enabled,version="SendingMonitorTarget".version+1,"effectiveFrom"=EXCLUDED."effectiveFrom","updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"updatedBy"=$3 RETURNING *`,
|
||||
id,
|
||||
body.enabled,
|
||||
actor,
|
||||
);
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO "SendingMonitorTargetVersion" ("channelId",version,enabled,"effectiveFrom","updatedBy") SELECT "channelId",version,enabled,"effectiveFrom","updatedBy" FROM "SendingMonitorTarget" WHERE "channelId"=$1`,
|
||||
id,
|
||||
);
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: actor,
|
||||
action: 'sending_monitor.target',
|
||||
resource: 'sending_monitor',
|
||||
resourceId: id,
|
||||
detail: { before: old[0] ?? null, after: { enabled: body.enabled, version: body.version! + 1 } },
|
||||
},
|
||||
});
|
||||
if (!body.enabled)
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "SendingMonitorAlert" SET state='closed',"closedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"closeReason"='enrollment_removed' WHERE type='industry' AND dimensions->>'channelId'=$1 AND state='active'`,
|
||||
id,
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async rules() {
|
||||
return this.prisma.$queryRawUnsafe(`SELECT * FROM "SendingMonitorRule" ORDER BY type,"scopeKey"`);
|
||||
}
|
||||
async options(q: QueryParams) {
|
||||
const page = pageNumber(q.page, 1),
|
||||
take = 20,
|
||||
skip = (page - 1) * take,
|
||||
keyword = q.keyword?.trim() ?? '';
|
||||
if (q.kind === 'tenant')
|
||||
return this.prisma.tenant.findMany({
|
||||
where: { name: { contains: keyword } },
|
||||
select: { id: true, name: true },
|
||||
take,
|
||||
skip,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
if (!q.tenantId) throw new BadRequestException('请先选择企业');
|
||||
if (q.kind === 'application')
|
||||
return this.prisma.smsApplication.findMany({
|
||||
where: { tenantId: q.tenantId, name: { contains: keyword } },
|
||||
select: { id: true, name: true },
|
||||
take,
|
||||
skip,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
if (q.kind === 'signature')
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: { tenantId: q.tenantId, applicationId: q.applicationId || undefined, name: { contains: keyword } },
|
||||
select: { id: true, name: true },
|
||||
take,
|
||||
skip,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
throw new BadRequestException('选项类型无效');
|
||||
}
|
||||
async effective(query: QueryParams) {
|
||||
const type = monitorType(query.type);
|
||||
const rows = await this.prisma.$queryRawUnsafe<Rule[]>(
|
||||
`SELECT DISTINCT ON ("ruleId") * FROM "SendingMonitorRuleVersion" WHERE type=$1 AND "effectiveAt"<=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ORDER BY "ruleId",version DESC`,
|
||||
type,
|
||||
);
|
||||
return matchRules(rows, type, {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
signatureId: query.signatureId,
|
||||
});
|
||||
}
|
||||
async saveRule(body: { type: string; scope: MonitorScope; config: RuleConfig; version: number }, actor: string) {
|
||||
if (!body) throw new BadRequestException('规则参数无效');
|
||||
const error = validateRule(body.type, body.scope, body.config);
|
||||
if (error) throw new BadRequestException(error);
|
||||
if (!Number.isInteger(body.version) || body.version < 0) throw new BadRequestException('版本无效');
|
||||
const scope = Object.fromEntries(
|
||||
Object.entries(body.scope)
|
||||
.filter(([, v]) => Boolean(v))
|
||||
.sort(),
|
||||
) as MonitorScope;
|
||||
if (
|
||||
scope.applicationId &&
|
||||
!(await this.prisma.smsApplication.findFirst({
|
||||
where: { id: scope.applicationId, tenantId: scope.tenantId },
|
||||
select: { id: true },
|
||||
}))
|
||||
)
|
||||
throw new BadRequestException('应用不属于该企业');
|
||||
if (
|
||||
scope.signatureId &&
|
||||
!(await this.prisma.smsSignature.findFirst({
|
||||
where: {
|
||||
id: scope.signatureId,
|
||||
tenantId: scope.tenantId,
|
||||
...(scope.applicationId ? { applicationId: scope.applicationId } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
}))
|
||||
)
|
||||
throw new BadRequestException('签名不属于该企业或应用');
|
||||
const scopeKey = JSON.stringify(scope),
|
||||
type = monitorType(body.type);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-rule:${type}:${scopeKey}`},0))`;
|
||||
const old = await tx.$queryRawUnsafe<Array<{ id: string; version: number }>>(
|
||||
`SELECT * FROM "SendingMonitorRule" WHERE type=$1 AND "scopeKey"=$2`,
|
||||
type,
|
||||
scopeKey,
|
||||
);
|
||||
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('规则已被修改,请刷新后重试');
|
||||
const id = old[0]?.id ?? randomUUID(),
|
||||
period = type === 'overall' ? 600 : 300;
|
||||
const config = {
|
||||
enabled: body.config.enabled,
|
||||
minSamples: body.config.minSamples,
|
||||
thresholds: body.config.thresholds,
|
||||
consecutiveBad: body.config.consecutiveBad,
|
||||
consecutiveGood: body.config.consecutiveGood,
|
||||
deleted: Boolean(body.config.deleted),
|
||||
};
|
||||
const result = await tx.$queryRawUnsafe(
|
||||
`INSERT INTO "SendingMonitorRule" (id,type,"scopeKey",scope,config,version,"effectiveAt","updatedBy")
|
||||
VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,to_timestamp((floor(extract(epoch FROM (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))/$7::int)+1)*$7::int) AT TIME ZONE 'UTC',$8)
|
||||
ON CONFLICT(type,"scopeKey") DO UPDATE SET config=EXCLUDED.config,version=EXCLUDED.version,"effectiveAt"=EXCLUDED."effectiveAt","updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"updatedBy"=$8 RETURNING *`,
|
||||
id,
|
||||
type,
|
||||
scopeKey,
|
||||
JSON.stringify(scope),
|
||||
JSON.stringify(config),
|
||||
body.version + 1,
|
||||
period,
|
||||
actor,
|
||||
);
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO "SendingMonitorRuleVersion" ("ruleId",version,type,scope,config,"effectiveAt","createdBy") SELECT id,version,type,scope,config,"effectiveAt","updatedBy" FROM "SendingMonitorRule" WHERE id=$1`,
|
||||
id,
|
||||
);
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: actor,
|
||||
action: 'sending_monitor.rule',
|
||||
resource: 'sending_monitor',
|
||||
resourceId: id,
|
||||
detail: { before: old[0] ?? null, after: { scope, config, version: body.version + 1 } },
|
||||
},
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async alerts(query: QueryParams, user: string) {
|
||||
const page = pageNumber(query.page, 1),
|
||||
size = pageNumber(query.pageSize, 20, 100);
|
||||
const state = query.state ?? '';
|
||||
if (state && !['active', 'recovered', 'closed'].includes(state)) throw new BadRequestException('告警状态无效');
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.$queryRawUnsafe(
|
||||
`SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) ORDER BY a."openedAt" DESC,a.id LIMIT $3 OFFSET $4`,
|
||||
user,
|
||||
state,
|
||||
size,
|
||||
(page - 1) * size,
|
||||
),
|
||||
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
|
||||
`SELECT count(*)::int total FROM "SendingMonitorAlert" WHERE ($1='' OR state=$1)`,
|
||||
state,
|
||||
),
|
||||
]);
|
||||
return { items, total: total[0].total, page, pageSize: size };
|
||||
}
|
||||
async summary(user: string) {
|
||||
const rows = await this.prisma.$queryRawUnsafe(
|
||||
`SELECT count(*) FILTER(WHERE r."readAt" IS NULL)::int count,count(*)::int "activeCount",NOT (${healthy}) unavailable FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE a.state='active'`,
|
||||
user,
|
||||
);
|
||||
return (rows as object[])[0];
|
||||
}
|
||||
async alert(id: string, user: string) {
|
||||
const rows = await this.prisma.$queryRawUnsafe<object[]>(
|
||||
`SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$2 WHERE a.id=$1`,
|
||||
id,
|
||||
user,
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException('告警不存在');
|
||||
return rows[0];
|
||||
}
|
||||
async read(id: string, user: string) {
|
||||
const rows = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
|
||||
`SELECT id FROM "SendingMonitorAlert" WHERE id=$1`,
|
||||
id,
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException('告警不存在');
|
||||
await this.prisma.$executeRawUnsafe(
|
||||
`INSERT INTO "SendingMonitorAlertRead" ("alertId","userId") VALUES($1,$2) ON CONFLICT DO NOTHING`,
|
||||
id,
|
||||
user,
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/sending-monitor')
|
||||
class SendingMonitorController {
|
||||
constructor(private readonly service: SendingMonitorService) {}
|
||||
@Get('rows') async rows(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.rows(q);
|
||||
}
|
||||
@Get('overview') async overview(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.overview(q);
|
||||
}
|
||||
@Get('history') async history(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.history(q);
|
||||
}
|
||||
@Get('targets') async targets(@Req() req: SessionRequest) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.targets();
|
||||
}
|
||||
@Put('targets/:id') async target(
|
||||
@Req() req: SessionRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { enabled?: boolean; version?: number },
|
||||
) {
|
||||
return this.service.target(id, body, await this.service.authorize(req, 'targets'));
|
||||
}
|
||||
@Get('rules') async rules(@Req() req: SessionRequest) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.rules();
|
||||
}
|
||||
@Get('options') async options(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.options(q);
|
||||
}
|
||||
@Post('rules') async save(
|
||||
@Req() req: SessionRequest,
|
||||
@Body() body: { type: string; scope: MonitorScope; config: RuleConfig; version: number },
|
||||
) {
|
||||
return this.service.saveRule(body, await this.service.authorize(req, 'rules'));
|
||||
}
|
||||
@Get('effective-rule') async effective(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||
await this.service.authorize(req);
|
||||
return this.service.effective(q);
|
||||
}
|
||||
@Get('alerts') async alerts(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||
return this.service.alerts(q, await this.service.authorize(req));
|
||||
}
|
||||
@Get('notification-summary') async summary(@Req() req: SessionRequest) {
|
||||
return this.service.summary(await this.service.authorize(req));
|
||||
}
|
||||
@Get('alerts/:id') async alert(@Req() req: SessionRequest, @Param('id') id: string) {
|
||||
return this.service.alert(id, await this.service.authorize(req));
|
||||
}
|
||||
@Post('alerts/:id/read') async read(@Req() req: SessionRequest, @Param('id') id: string) {
|
||||
return this.service.read(id, await this.service.authorize(req));
|
||||
}
|
||||
}
|
||||
@Module({ imports: [PrismaModule], controllers: [SendingMonitorController], providers: [SendingMonitorService] })
|
||||
export class SendingMonitorModule {}
|
||||
Reference in New Issue
Block a user