feat: 实现发送质量监控与报备状态消息通知
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 19:22:49 +08:00
parent 69e3d7368d
commit 457319e627
66 changed files with 6992 additions and 489 deletions
@@ -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')
);
+186
View File
@@ -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])
}