@@ -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])
|
@@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 {
|
model SmsSignature {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
@@ -1705,6 +1755,7 @@ model SmsApiRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsMessageRecord {
|
model SmsMessageRecord {
|
||||||
|
monitorFacts SendingMonitorFact[]
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
batchTaskId String?
|
batchTaskId String?
|
||||||
@@ -1813,6 +1864,9 @@ model SmsSubmitRecord {
|
|||||||
errorCode String?
|
errorCode String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
submittedAt DateTime?
|
submittedAt DateTime?
|
||||||
|
firstWireSubmitAt DateTime?
|
||||||
|
wireTimeSource String?
|
||||||
|
receiptRequested Boolean?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1832,6 +1886,8 @@ model SmsSubmitRecord {
|
|||||||
@@index([gatewayMessageId])
|
@@index([gatewayMessageId])
|
||||||
@@index([channelId, gatewayMessageId])
|
@@index([channelId, gatewayMessageId])
|
||||||
@@index([channelGroupId])
|
@@index([channelGroupId])
|
||||||
|
@@index([updatedAt,id], map: "SmsSubmitRecord_monitor_updated_idx")
|
||||||
|
@@index([createdAt,id], map: "SmsSubmitRecord_monitor_created_idx")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GatewaySubmitOutbox {
|
model GatewaySubmitOutbox {
|
||||||
@@ -1958,6 +2014,9 @@ model SmsMessageSegmentAudit {
|
|||||||
errorCode String?
|
errorCode String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
submittedAt DateTime?
|
submittedAt DateTime?
|
||||||
|
firstWireSubmitAt DateTime?
|
||||||
|
wireTimeSource String?
|
||||||
|
receiptRequested Boolean?
|
||||||
deliveredAt DateTime?
|
deliveredAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -2339,6 +2398,7 @@ model UpstreamReceiptInbox {
|
|||||||
errorCode String?
|
errorCode String?
|
||||||
errorMessage String?
|
errorMessage String?
|
||||||
deliveredAt DateTime
|
deliveredAt DateTime
|
||||||
|
gatewayReceivedAt DateTime?
|
||||||
receivedAt DateTime @default(now())
|
receivedAt DateTime @default(now())
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
matchedMessageRecordId String?
|
matchedMessageRecordId String?
|
||||||
@@ -2355,6 +2415,8 @@ model UpstreamReceiptInbox {
|
|||||||
@@index([gatewayMessageId, phoneNumber])
|
@@index([gatewayMessageId, phoneNumber])
|
||||||
@@index([incomingChannelId, receivedAt])
|
@@index([incomingChannelId, receivedAt])
|
||||||
@@index([matchedMessageRecordId])
|
@@index([matchedMessageRecordId])
|
||||||
|
@@index([updatedAt,id], map: "UpstreamReceiptInbox_monitor_updated_idx")
|
||||||
|
@@index([matchedMessageRecordId,gatewayMessageId], map: "UpstreamReceiptInbox_monitor_message_idx")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GatewaySubmitDeadLetter {
|
model GatewaySubmitDeadLetter {
|
||||||
@@ -2563,3 +2625,127 @@ model InfrastructureAlertRead {
|
|||||||
@@unique([fingerprint, userId])
|
@@unique([fingerprint, userId])
|
||||||
@@index([userId, readAt])
|
@@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 { SignatureRetirementModule } from './signature-retirement/signature-retirement.module';
|
||||||
import { SecurityDetectionModule } from './security-detection/security-detection.module';
|
import { SecurityDetectionModule } from './security-detection/security-detection.module';
|
||||||
import { MetricsModule } from './metrics/metrics.module';
|
import { MetricsModule } from './metrics/metrics.module';
|
||||||
|
import { ReportNotificationsModule } from './report-notifications/report-notifications.module';
|
||||||
|
import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -57,12 +59,16 @@ import { MetricsModule } from './metrics/metrics.module';
|
|||||||
SignatureRetirementModule,
|
SignatureRetirementModule,
|
||||||
SecurityDetectionModule,
|
SecurityDetectionModule,
|
||||||
MetricsModule,
|
MetricsModule,
|
||||||
|
ReportNotificationsModule,
|
||||||
|
SendingMonitorModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
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('hasDrainage') hasDrainage?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('monitorSnapshotId') monitorSnapshotId?: string,
|
||||||
) {
|
) {
|
||||||
return this.operations.listMessagesPage({
|
return this.operations.listMessagesPage({
|
||||||
|
monitorSnapshotId,
|
||||||
tenantId,
|
tenantId,
|
||||||
applicationId,
|
applicationId,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -80,8 +82,10 @@ export class AdminOperationsController {
|
|||||||
@Query('status') status: string | undefined,
|
@Query('status') status: string | undefined,
|
||||||
@Query('hasDrainage') hasDrainage: string | undefined,
|
@Query('hasDrainage') hasDrainage: string | undefined,
|
||||||
@Res() response: DownloadResponse,
|
@Res() response: DownloadResponse,
|
||||||
|
@Query('monitorSnapshotId') monitorSnapshotId?: string,
|
||||||
) {
|
) {
|
||||||
const exported = await this.operations.exportMessages({
|
const exported = await this.operations.exportMessages({
|
||||||
|
monitorSnapshotId,
|
||||||
tenantId,
|
tenantId,
|
||||||
applicationId,
|
applicationId,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -105,25 +109,37 @@ export class AdminOperationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('message-segment-audits')
|
@Get('message-segment-audits')
|
||||||
messageSegmentAudits(
|
messageSegmentAudits(@Query('messageId') messageId?: string, @Query('messageRecordId') messageRecordId?: string) {
|
||||||
@Query('messageId') messageId?: string,
|
|
||||||
@Query('messageRecordId') messageRecordId?: string,
|
|
||||||
) {
|
|
||||||
return this.operations.listMessageSegmentAudits({ messageId, messageRecordId });
|
return this.operations.listMessageSegmentAudits({ messageId, messageRecordId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('uplink-messages')
|
@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
|
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 });
|
: this.operations.listUplinkMessages({ tenantId, channelId, phoneNumber, keyword, startTime, endTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('uplink-messages/:id/claim')
|
@Post('uplink-messages/:id/claim')
|
||||||
claimUplinkMatchCandidate(
|
claimUplinkMatchCandidate(@Param('id') id: string, @Body() body: { candidateId?: string; operatorId?: string }) {
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: { candidateId?: string; operatorId?: string },
|
|
||||||
) {
|
|
||||||
return this.sendChain.claimUplinkMatchCandidate(id, String(body.candidateId ?? ''), body.operatorId);
|
return this.sendChain.claimUplinkMatchCandidate(id, String(body.candidateId ?? ''), body.operatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +247,7 @@ export class AdminOperationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('gateway-submit-dead-letters/:id/resolve')
|
@Post('gateway-submit-dead-letters/:id/resolve')
|
||||||
resolveGatewaySubmitDeadLetter(
|
resolveGatewaySubmitDeadLetter(@Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
|
||||||
@Param('id') id: string,
|
|
||||||
@CurrentSessionUserId() operatorId?: string,
|
|
||||||
) {
|
|
||||||
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
|
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,16 +394,23 @@ export class AdminOperationsController {
|
|||||||
@Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
|
@Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
|
||||||
@CurrentSessionUserId() operatorId?: string,
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
) {
|
) {
|
||||||
return this.sendChain.createDownstreamRequeueTask({
|
return this.sendChain.createDownstreamRequeueTask(
|
||||||
|
{
|
||||||
previewToken: body.previewToken ?? '',
|
previewToken: body.previewToken ?? '',
|
||||||
reason: body.reason ?? '',
|
reason: body.reason ?? '',
|
||||||
ratePerSecond: body.ratePerSecond,
|
ratePerSecond: body.ratePerSecond,
|
||||||
consecutiveFailureLimit: body.consecutiveFailureLimit,
|
consecutiveFailureLimit: body.consecutiveFailureLimit,
|
||||||
}, operatorId);
|
},
|
||||||
|
operatorId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('downstream-requeue-tasks')
|
@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) });
|
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +427,12 @@ export class AdminOperationsController {
|
|||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: 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')
|
@Post('downstream-requeue-tasks/:id/:action')
|
||||||
@@ -441,7 +466,18 @@ export class AdminSystemLogsController {
|
|||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: 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()
|
@Get()
|
||||||
@@ -457,11 +493,34 @@ export class AdminSystemLogsController {
|
|||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: 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')
|
@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);
|
return this.operations.exportSystemLogs(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Stable controller/query contracts extracted in R2.
|
// Stable controller/query contracts extracted in R2.
|
||||||
|
|
||||||
export interface MessageQuery {
|
export interface MessageQuery {
|
||||||
|
monitorSnapshotId?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { moneyToNumber } from '../../common/money';
|
import { moneyToNumber } from '../../common/money';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
import type { MessageQuery } 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 {
|
||||||
|
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 {
|
export class OperationsMessageQueries {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
@@ -38,10 +43,34 @@ listMessages(query: MessageQuery) {
|
|||||||
orderBy: { queuedAt: 'desc' },
|
orderBy: { queuedAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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) {
|
async listMessagesPage(query: MessageQuery) {
|
||||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
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 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([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.smsMessageRecord.findMany({
|
this.prisma.smsMessageRecord.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -122,8 +151,9 @@ async getMessage(id: string) {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
async exportMessages(query: MessageQuery) {
|
async exportMessages(query: MessageQuery) {
|
||||||
|
const where = await this.messageFilter(query);
|
||||||
const items = await this.prisma.smsMessageRecord.findMany({
|
const items = await this.prisma.smsMessageRecord.findMany({
|
||||||
where: messageWhere(query),
|
where,
|
||||||
select: {
|
select: {
|
||||||
messageId: true,
|
messageId: true,
|
||||||
queuedAt: true,
|
queuedAt: true,
|
||||||
@@ -144,7 +174,22 @@ async exportMessages(query: MessageQuery) {
|
|||||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||||
});
|
});
|
||||||
const rows = [
|
const rows = [
|
||||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'],
|
[
|
||||||
|
'消息编号',
|
||||||
|
'企业',
|
||||||
|
'应用',
|
||||||
|
'提交时间',
|
||||||
|
'手机号',
|
||||||
|
'地区',
|
||||||
|
'运营商',
|
||||||
|
'计费条数',
|
||||||
|
'金额',
|
||||||
|
'通道',
|
||||||
|
'状态',
|
||||||
|
'是否含引流',
|
||||||
|
'回执时间',
|
||||||
|
'短信内容',
|
||||||
|
],
|
||||||
...items.map((item) => [
|
...items.map((item) => [
|
||||||
item.messageId,
|
item.messageId,
|
||||||
item.tenant?.name ?? '',
|
item.tenant?.name ?? '',
|
||||||
@@ -156,7 +201,9 @@ async exportMessages(query: MessageQuery) {
|
|||||||
String(item.billingUnits),
|
String(item.billingUnits),
|
||||||
String(moneyToNumber(item.amountCents)),
|
String(moneyToNumber(item.amountCents)),
|
||||||
item.channel?.name ?? '',
|
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.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测',
|
||||||
item.deliveredAt?.toISOString() ?? '',
|
item.deliveredAt?.toISOString() ?? '',
|
||||||
item.content,
|
item.content,
|
||||||
|
|||||||
@@ -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;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
|
firstWireSubmitAt?: string;
|
||||||
|
wireTimeSource?: string;
|
||||||
|
receiptRequested?: boolean;
|
||||||
segments?: Array<{
|
segments?: Array<{
|
||||||
segmentTotal?: number;
|
segmentTotal?: number;
|
||||||
segmentIndex?: number;
|
segmentIndex?: number;
|
||||||
@@ -79,6 +82,9 @@ export interface GatewaySubmitResultDto {
|
|||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
|
firstWireSubmitAt?: string;
|
||||||
|
wireTimeSource?: string;
|
||||||
|
receiptRequested?: boolean;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +102,9 @@ export interface GatewaySubmitSegmentResultDto {
|
|||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
|
firstWireSubmitAt?: string;
|
||||||
|
wireTimeSource?: string;
|
||||||
|
receiptRequested?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayReceiptEventDto {
|
export interface GatewayReceiptEventDto {
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { moneyToNumber } from '../common/money';
|
|
||||||
import type { OpenApiService } from '../open-api/open-api.service';
|
import type { OpenApiService } from '../open-api/open-api.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto } 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 { normalizeSubmitStatus } from './send-chain.helpers';
|
||||||
import type { SendSubmissionService } from './send-submission.service';
|
|
||||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
|
||||||
|
|
||||||
|
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R10 gatewayResult implementation.
|
* R10 gatewayResult implementation.
|
||||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
* 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 {
|
export class SendGatewayResultService {
|
||||||
private readonly logger = new Logger('SendChainService');
|
private readonly logger = new Logger('SendChainService');
|
||||||
|
|
||||||
@@ -31,7 +47,9 @@ export class SendGatewayResultService {
|
|||||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||||
const effectiveSubmitId = submitRecord.submitId;
|
const effectiveSubmitId = submitRecord.submitId;
|
||||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||||
await this.facade.recordSubmitSegments(message, {
|
await this.facade.recordSubmitSegments(
|
||||||
|
message,
|
||||||
|
{
|
||||||
messageId: data.messageId,
|
messageId: data.messageId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
submitId: effectiveSubmitId,
|
submitId: effectiveSubmitId,
|
||||||
@@ -41,7 +59,11 @@ export class SendGatewayResultService {
|
|||||||
errorCode: data.errorCode,
|
errorCode: data.errorCode,
|
||||||
errorMessage: data.errorMessage,
|
errorMessage: data.errorMessage,
|
||||||
submittedAt: submittedAt.toISOString(),
|
submittedAt: submittedAt.toISOString(),
|
||||||
segments: [{
|
firstWireSubmitAt: data.firstWireSubmitAt,
|
||||||
|
wireTimeSource: data.wireTimeSource,
|
||||||
|
receiptRequested: data.receiptRequested,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
segmentTotal: data.segmentTotal,
|
segmentTotal: data.segmentTotal,
|
||||||
segmentIndex: data.segmentIndex,
|
segmentIndex: data.segmentIndex,
|
||||||
sequenceId: data.sequenceId,
|
sequenceId: data.sequenceId,
|
||||||
@@ -50,8 +72,14 @@ export class SendGatewayResultService {
|
|||||||
errorCode: data.errorCode,
|
errorCode: data.errorCode,
|
||||||
errorMessage: data.errorMessage,
|
errorMessage: data.errorMessage,
|
||||||
submittedAt: submittedAt.toISOString(),
|
submittedAt: submittedAt.toISOString(),
|
||||||
}],
|
firstWireSubmitAt: data.firstWireSubmitAt,
|
||||||
}, submittedAt);
|
wireTimeSource: data.wireTimeSource,
|
||||||
|
receiptRequested: data.receiptRequested,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
submittedAt,
|
||||||
|
);
|
||||||
if (data.gatewayMessageId) {
|
if (data.gatewayMessageId) {
|
||||||
await this.prisma.smsSubmitRecord.updateMany({
|
await this.prisma.smsSubmitRecord.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -68,10 +96,7 @@ export class SendGatewayResultService {
|
|||||||
return { accepted: true };
|
return { accepted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveSubmitRecordForGatewaySegmentResult(
|
async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
|
||||||
messageRecordId: string,
|
|
||||||
data: GatewaySubmitSegmentResultDto,
|
|
||||||
) {
|
|
||||||
if (data.submitId) {
|
if (data.submitId) {
|
||||||
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
||||||
where: { submitId: data.submitId },
|
where: { submitId: data.submitId },
|
||||||
@@ -81,13 +106,15 @@ export class SendGatewayResultService {
|
|||||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||||
(exact.channelId && exact.channelId !== data.channelId)
|
(exact.channelId && exact.channelId !== data.channelId)
|
||||||
) {
|
) {
|
||||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
this.logger.error(
|
||||||
|
`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||||
messageId: data.messageId,
|
messageId: data.messageId,
|
||||||
messageRecordId,
|
messageRecordId,
|
||||||
submitId: data.submitId,
|
submitId: data.submitId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
segmentIndex: data.segmentIndex,
|
segmentIndex: data.segmentIndex,
|
||||||
})}`);
|
})}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
||||||
);
|
);
|
||||||
@@ -104,24 +131,26 @@ export class SendGatewayResultService {
|
|||||||
take: 2,
|
take: 2,
|
||||||
});
|
});
|
||||||
if (candidates.length !== 1) {
|
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,
|
messageId: data.messageId,
|
||||||
messageRecordId,
|
messageRecordId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
segmentIndex: data.segmentIndex,
|
segmentIndex: data.segmentIndex,
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
})}`);
|
})}`,
|
||||||
throw new BadRequestException(
|
|
||||||
'Gateway SubmitSegmentResult without submitId cannot be matched uniquely',
|
|
||||||
);
|
);
|
||||||
|
throw new BadRequestException('Gateway SubmitSegmentResult without submitId cannot be matched uniquely');
|
||||||
}
|
}
|
||||||
this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
this.logger.warn(
|
||||||
|
`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||||
messageId: data.messageId,
|
messageId: data.messageId,
|
||||||
messageRecordId,
|
messageRecordId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
segmentIndex: data.segmentIndex,
|
segmentIndex: data.segmentIndex,
|
||||||
submitId: candidates[0].submitId,
|
submitId: candidates[0].submitId,
|
||||||
})}`);
|
})}`,
|
||||||
|
);
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +177,7 @@ export class SendGatewayResultService {
|
|||||||
errorCode: data.errorCode,
|
errorCode: data.errorCode,
|
||||||
errorMessage: data.errorMessage,
|
errorMessage: data.errorMessage,
|
||||||
submittedAt,
|
submittedAt,
|
||||||
|
...wireTiming(data, submitRecord.firstWireSubmitAt),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||||
@@ -156,7 +186,8 @@ export class SendGatewayResultService {
|
|||||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
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) {
|
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||||
await this.facade.chargeAcceptedMessage(businessMessage);
|
await this.facade.chargeAcceptedMessage(businessMessage);
|
||||||
@@ -164,7 +195,12 @@ export class SendGatewayResultService {
|
|||||||
if (latest?.status === 'failed') {
|
if (latest?.status === 'failed') {
|
||||||
await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款');
|
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 businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||||
const retried = await this.facade.retryMessageIfAllowed(
|
const retried = await this.facade.retryMessageIfAllowed(
|
||||||
businessMessage,
|
businessMessage,
|
||||||
@@ -176,11 +212,15 @@ export class SendGatewayResultService {
|
|||||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||||
return retried;
|
return retried;
|
||||||
}
|
}
|
||||||
await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
await this.facade.releaseMessageReservation(
|
||||||
|
businessMessage,
|
||||||
|
data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
|
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
|
||||||
const updated = await this.prisma.smsMessageRecord.updateMany({
|
const updated = await this.prisma.smsMessageRecord.updateMany({
|
||||||
where: data.submitStatus === 'accepted'
|
where:
|
||||||
|
data.submitStatus === 'accepted'
|
||||||
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
||||||
: { id: message.id, status: { not: 'delivered' } },
|
: { id: message.id, status: { not: 'delivered' } },
|
||||||
data: {
|
data: {
|
||||||
@@ -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(
|
await this.facade.recordCmppFailureReceipt(
|
||||||
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
|
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
|
||||||
data.errorCode || 'SUBMIT',
|
data.errorCode || 'SUBMIT',
|
||||||
@@ -212,10 +257,9 @@ export class SendGatewayResultService {
|
|||||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||||
OR: [
|
OR: [{ submitId: effectiveData.submitId }, data.messageId ? { messageId: data.messageId } : undefined].filter(
|
||||||
{ submitId: effectiveData.submitId },
|
Boolean,
|
||||||
data.messageId ? { messageId: data.messageId } : undefined,
|
) as Array<{ submitId?: string; messageId?: string }>,
|
||||||
].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>,
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: 'resolved',
|
status: 'resolved',
|
||||||
@@ -252,9 +296,11 @@ export class SendGatewayResultService {
|
|||||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||||
if (data.submitId) {
|
if (data.submitId) {
|
||||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||||
if (!exact
|
if (
|
||||||
|| (exact.messageRecordId && exact.messageRecordId !== messageRecordId)
|
!exact ||
|
||||||
|| (exact.channelId && exact.channelId !== data.channelId)) {
|
(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');
|
throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel');
|
||||||
}
|
}
|
||||||
return exact;
|
return exact;
|
||||||
@@ -272,34 +318,40 @@ export class SendGatewayResultService {
|
|||||||
take: 2,
|
take: 2,
|
||||||
});
|
});
|
||||||
if (candidates.length !== 1) {
|
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,
|
messageId: data.messageId,
|
||||||
messageRecordId,
|
messageRecordId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
gatewayMessageId: data.gatewayMessageId,
|
gatewayMessageId: data.gatewayMessageId,
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
})}`);
|
})}`,
|
||||||
|
);
|
||||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||||
}
|
}
|
||||||
this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({
|
this.logger.warn(
|
||||||
|
`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||||
messageId: data.messageId,
|
messageId: data.messageId,
|
||||||
messageRecordId,
|
messageRecordId,
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
gatewayMessageId: data.gatewayMessageId,
|
gatewayMessageId: data.gatewayMessageId,
|
||||||
submitId: candidates[0].submitId,
|
submitId: candidates[0].submitId,
|
||||||
})}`);
|
})}`,
|
||||||
|
);
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
smsMessageSegmentAuditDelegate() {
|
smsMessageSegmentAuditDelegate() {
|
||||||
return (this.prisma as PrismaService & {
|
return (
|
||||||
|
this.prisma as PrismaService & {
|
||||||
smsMessageSegmentAudit: {
|
smsMessageSegmentAudit: {
|
||||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||||
};
|
};
|
||||||
}).smsMessageSegmentAudit;
|
}
|
||||||
|
).smsMessageSegmentAudit;
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordSubmitSegments(
|
async recordSubmitSegments(
|
||||||
@@ -327,14 +379,21 @@ export class SendGatewayResultService {
|
|||||||
});
|
});
|
||||||
const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`;
|
const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`;
|
||||||
const attempt = submitRecord
|
const attempt = submitRecord
|
||||||
? Math.max(0, await this.prisma.smsSubmitRecord.count({
|
? Math.max(
|
||||||
|
0,
|
||||||
|
(await this.prisma.smsSubmitRecord.count({
|
||||||
where: {
|
where: {
|
||||||
messageRecordId: message.id,
|
messageRecordId: message.id,
|
||||||
createdAt: { lte: submitRecord.createdAt },
|
createdAt: { lte: submitRecord.createdAt },
|
||||||
},
|
},
|
||||||
}) - 1)
|
})) - 1,
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const fallbackSegments = [{
|
const fallbackSegments = [
|
||||||
|
{
|
||||||
|
firstWireSubmitAt: data.firstWireSubmitAt,
|
||||||
|
wireTimeSource: data.wireTimeSource,
|
||||||
|
receiptRequested: data.receiptRequested,
|
||||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||||
segmentIndex: 1,
|
segmentIndex: 1,
|
||||||
sequenceId: data.sequenceId,
|
sequenceId: data.sequenceId,
|
||||||
@@ -343,10 +402,12 @@ export class SendGatewayResultService {
|
|||||||
errorCode: data.errorCode,
|
errorCode: data.errorCode,
|
||||||
errorMessage: data.errorMessage,
|
errorMessage: data.errorMessage,
|
||||||
submittedAt: data.submittedAt,
|
submittedAt: data.submittedAt,
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments;
|
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)));
|
const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1)));
|
||||||
await Promise.all(segments.map((segment, index) => {
|
await Promise.all(
|
||||||
|
segments.map((segment, index) => {
|
||||||
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
||||||
const status = segment.submitStatus ?? data.submitStatus;
|
const status = segment.submitStatus ?? data.submitStatus;
|
||||||
return segmentAudits.upsert({
|
return segmentAudits.upsert({
|
||||||
@@ -358,6 +419,7 @@ export class SendGatewayResultService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
...wireTiming(segment),
|
||||||
submitRecordId: submitRecord?.id ?? null,
|
submitRecordId: submitRecord?.id ?? null,
|
||||||
channelId: data.channelId ?? message.channelId ?? null,
|
channelId: data.channelId ?? message.channelId ?? null,
|
||||||
attempt,
|
attempt,
|
||||||
@@ -370,6 +432,7 @@ export class SendGatewayResultService {
|
|||||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
|
...wireTiming(segment),
|
||||||
tenantId: message.tenantId,
|
tenantId: message.tenantId,
|
||||||
batchTaskId: message.batchTaskId,
|
batchTaskId: message.batchTaskId,
|
||||||
messageRecordId: message.id,
|
messageRecordId: message.id,
|
||||||
@@ -388,13 +451,12 @@ export class SendGatewayResultService {
|
|||||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
|
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(Boolean) as Array<{
|
||||||
Boolean,
|
|
||||||
) as Array<{
|
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
gatewayMessageId?: 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 { Prisma } from '@prisma/client';
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { moneyToNumber } from '../common/money';
|
|
||||||
import type { OpenApiService } from '../open-api/open-api.service';
|
import type { OpenApiService } from '../open-api/open-api.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 type { GatewayReceiptEventDto } 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 {
|
||||||
import type { SendSubmissionService } from './send-submission.service';
|
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 type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||||
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R10 receipt implementation.
|
* R10 receipt implementation.
|
||||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
* 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,
|
errorCode: data.errorCode,
|
||||||
errorMessage: data.errorMessage,
|
errorMessage: data.errorMessage,
|
||||||
deliveredAt,
|
deliveredAt,
|
||||||
|
gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
nextRetryAt: new Date(),
|
nextRetryAt: new Date(),
|
||||||
},
|
},
|
||||||
@@ -81,8 +91,8 @@ export class SendReceiptService {
|
|||||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const staleBefore = new Date(
|
const staleBefore = new Date(
|
||||||
now.getTime()
|
now.getTime() -
|
||||||
- positiveInteger(
|
positiveInteger(
|
||||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||||
),
|
),
|
||||||
@@ -110,8 +120,8 @@ export class SendReceiptService {
|
|||||||
|
|
||||||
async processUpstreamReceiptInboxRecord(id: string) {
|
async processUpstreamReceiptInboxRecord(id: string) {
|
||||||
const staleBefore = new Date(
|
const staleBefore = new Date(
|
||||||
Date.now()
|
Date.now() -
|
||||||
- positiveInteger(
|
positiveInteger(
|
||||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||||
DEFAULT_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({
|
const claimed = await this.prisma.upstreamReceiptInbox.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
OR: [
|
OR: [{ status: { in: ['pending', 'retrying'] } }, { status: 'processing', updatedAt: { lte: staleBefore } }],
|
||||||
{ status: { in: ['pending', 'retrying'] } },
|
|
||||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null },
|
data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null },
|
||||||
});
|
});
|
||||||
@@ -130,7 +137,8 @@ export class SendReceiptService {
|
|||||||
const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } });
|
const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } });
|
||||||
if (!inbox) return false;
|
if (!inbox) return false;
|
||||||
try {
|
try {
|
||||||
const message = await this.facade.handleReceipt({
|
const message = await this.facade.handleReceipt(
|
||||||
|
{
|
||||||
messageId: inbox.provisionalMessageId ?? undefined,
|
messageId: inbox.provisionalMessageId ?? undefined,
|
||||||
channelId: inbox.incomingChannelId,
|
channelId: inbox.incomingChannelId,
|
||||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||||
@@ -142,13 +150,15 @@ export class SendReceiptService {
|
|||||||
errorCode: inbox.errorCode ?? undefined,
|
errorCode: inbox.errorCode ?? undefined,
|
||||||
errorMessage: inbox.errorMessage ?? undefined,
|
errorMessage: inbox.errorMessage ?? undefined,
|
||||||
deliveredAt: inbox.deliveredAt.toISOString(),
|
deliveredAt: inbox.deliveredAt.toISOString(),
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
account: inbox.upstreamAccount,
|
account: inbox.upstreamAccount,
|
||||||
gatewayHost: inbox.upstreamHost,
|
gatewayHost: inbox.upstreamHost,
|
||||||
gatewayPort: inbox.upstreamPort,
|
gatewayPort: inbox.upstreamPort,
|
||||||
protocol: inbox.protocol,
|
protocol: inbox.protocol,
|
||||||
cmppVersion: inbox.protocolVersion,
|
cmppVersion: inbox.protocolVersion,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId;
|
const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId;
|
||||||
const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined;
|
const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined;
|
||||||
await this.prisma.upstreamReceiptInbox.update({
|
await this.prisma.upstreamReceiptInbox.update({
|
||||||
@@ -171,8 +181,8 @@ export class SendReceiptService {
|
|||||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||||
);
|
);
|
||||||
const exhausted = inbox.attemptCount >= maxAttempts
|
const exhausted =
|
||||||
|| inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000;
|
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));
|
const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8));
|
||||||
await this.prisma.upstreamReceiptInbox.update({
|
await this.prisma.upstreamReceiptInbox.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -204,7 +214,13 @@ export class SendReceiptService {
|
|||||||
|
|
||||||
async handleReceipt(
|
async handleReceipt(
|
||||||
data: GatewayReceiptEventDto,
|
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 resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
|
||||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||||
@@ -263,9 +279,8 @@ export class SendReceiptService {
|
|||||||
}
|
}
|
||||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||||
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||||
const receiptMode = Number(message.billingUnits ?? 1) > 1
|
const receiptMode =
|
||||||
? await this.getLongMessageReceiptMode(logicalChannelId)
|
Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment';
|
||||||
: 'per_segment';
|
|
||||||
if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') {
|
if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') {
|
||||||
await this.applyMessageLevelSuccess(
|
await this.applyMessageLevelSuccess(
|
||||||
message,
|
message,
|
||||||
@@ -287,12 +302,10 @@ export class SendReceiptService {
|
|||||||
}
|
}
|
||||||
const status = aggregate.status;
|
const status = aggregate.status;
|
||||||
const isCurrentAttempt =
|
const isCurrentAttempt =
|
||||||
(!message.channelId || message.channelId === logicalChannelId)
|
(!message.channelId || message.channelId === logicalChannelId) &&
|
||||||
&& (
|
(!message.gatewayMessageId ||
|
||||||
!message.gatewayMessageId
|
message.gatewayMessageId === data.gatewayMessageId ||
|
||||||
|| message.gatewayMessageId === data.gatewayMessageId
|
(aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId)));
|
||||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
|
||||||
);
|
|
||||||
if (!isCurrentAttempt) {
|
if (!isCurrentAttempt) {
|
||||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||||
}
|
}
|
||||||
@@ -314,11 +327,7 @@ export class SendReceiptService {
|
|||||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||||
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||||
const retried = await this.facade.retryMessageIfAllowed(
|
const retried = await this.facade.retryMessageIfAllowed(businessMessage, '回执失败补发', resolved.submitRecordId);
|
||||||
businessMessage,
|
|
||||||
'回执失败补发',
|
|
||||||
resolved.submitRecordId,
|
|
||||||
);
|
|
||||||
if (retried) {
|
if (retried) {
|
||||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||||
return retried;
|
return retried;
|
||||||
@@ -339,10 +348,7 @@ export class SendReceiptService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||||
await queueFinalReceiptDeliveries(
|
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
|
||||||
this.prisma,
|
|
||||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
|
||||||
{
|
|
||||||
message,
|
message,
|
||||||
payload: {
|
payload: {
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
@@ -356,15 +362,17 @@ export class SendReceiptService {
|
|||||||
segmentPayloads: Object.fromEntries(
|
segmentPayloads: Object.fromEntries(
|
||||||
aggregate.segments
|
aggregate.segments
|
||||||
.filter((segment) => segment.receiptStatus)
|
.filter((segment) => segment.receiptStatus)
|
||||||
.map((segment) => [segment.segmentIndex, {
|
.map((segment) => [
|
||||||
|
segment.segmentIndex,
|
||||||
|
{
|
||||||
receiptStatus: segment.receiptStatus,
|
receiptStatus: segment.receiptStatus,
|
||||||
rawStatus: segment.rawStatus,
|
rawStatus: segment.rawStatus,
|
||||||
errorCode: segment.errorCode,
|
errorCode: segment.errorCode,
|
||||||
deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(),
|
deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(),
|
||||||
}]),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
);
|
]),
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (message.batchTaskId) {
|
if (message.batchTaskId) {
|
||||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||||
@@ -388,8 +396,9 @@ export class SendReceiptService {
|
|||||||
submitRecordId?: string,
|
submitRecordId?: string,
|
||||||
submitId?: string,
|
submitId?: string,
|
||||||
) {
|
) {
|
||||||
const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId)
|
const belongsToCurrentAttempt =
|
||||||
&& (!message.submitId || message.submitId === submitId);
|
(!message.channelId || message.channelId === data.channelId) &&
|
||||||
|
(!message.submitId || message.submitId === submitId);
|
||||||
if (!belongsToCurrentAttempt) return;
|
if (!belongsToCurrentAttempt) return;
|
||||||
const attemptWhere = submitRecordId
|
const attemptWhere = submitRecordId
|
||||||
? { messageRecordId: message.id, submitRecordId }
|
? { messageRecordId: message.id, submitRecordId }
|
||||||
@@ -402,7 +411,9 @@ export class SendReceiptService {
|
|||||||
select: { id: true, receiptStatus: true },
|
select: { id: true, receiptStatus: true },
|
||||||
});
|
});
|
||||||
if (segments.length <= 1) return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// This supplier contract reports one message-level success for a multipart SMS.
|
// This supplier contract reports one message-level success for a multipart SMS.
|
||||||
@@ -575,7 +586,13 @@ export class SendReceiptService {
|
|||||||
|
|
||||||
async resolveReceiptMessage(
|
async resolveReceiptMessage(
|
||||||
data: GatewayReceiptEventDto,
|
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
|
const exactMessage = data.messageId
|
||||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: 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');
|
throw new NotFoundException('SMS message record not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingChannel = incomingIdentity
|
const incomingChannel =
|
||||||
?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
incomingIdentity ?? (await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }));
|
||||||
if (!incomingChannel) {
|
if (!incomingChannel) {
|
||||||
throw new NotFoundException('SMS message record not found');
|
throw new NotFoundException('SMS message record not found');
|
||||||
}
|
}
|
||||||
@@ -665,8 +682,9 @@ export class SendReceiptService {
|
|||||||
channelId: exactSegmentMatches[0].channelId,
|
channelId: exactSegmentMatches[0].channelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sameSupplierSegments = segmentMatches.filter((candidate) =>
|
const sameSupplierSegments = segmentMatches.filter(
|
||||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
|
||||||
|
);
|
||||||
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
|
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
|
||||||
return {
|
return {
|
||||||
message: sameSupplierSegments[0].messageRecord,
|
message: sameSupplierSegments[0].messageRecord,
|
||||||
@@ -685,8 +703,9 @@ export class SendReceiptService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) =>
|
const sameSupplierSubmits = crossConnectionSubmits.filter(
|
||||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
|
||||||
|
);
|
||||||
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
|
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
|
||||||
return {
|
return {
|
||||||
message: sameSupplierSubmits[0].messageRecord,
|
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 {}
|
||||||
@@ -130,6 +130,9 @@
|
|||||||
"submitStatus": { "enum": ["accepted", "rejected", "timeout"] },
|
"submitStatus": { "enum": ["accepted", "rejected", "timeout"] },
|
||||||
"errorCode": { "type": "string" },
|
"errorCode": { "type": "string" },
|
||||||
"errorMessage": { "type": "string" },
|
"errorMessage": { "type": "string" },
|
||||||
|
"firstWireSubmitAt": { "type": "string", "format": "date-time" },
|
||||||
|
"wireTimeSource": { "enum": ["not_written", "write_uncertain", "gateway_write_complete"] },
|
||||||
|
"receiptRequested": { "type": "boolean" },
|
||||||
"submittedAt": { "type": "string", "format": "date-time" },
|
"submittedAt": { "type": "string", "format": "date-time" },
|
||||||
"segments": {
|
"segments": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -144,6 +147,9 @@
|
|||||||
"submitStatus": { "enum": ["accepted", "rejected", "timeout"] },
|
"submitStatus": { "enum": ["accepted", "rejected", "timeout"] },
|
||||||
"errorCode": { "type": "string" },
|
"errorCode": { "type": "string" },
|
||||||
"errorMessage": { "type": "string" },
|
"errorMessage": { "type": "string" },
|
||||||
|
"firstWireSubmitAt": { "type": "string", "format": "date-time" },
|
||||||
|
"wireTimeSource": { "enum": ["not_written", "write_uncertain", "gateway_write_complete"] },
|
||||||
|
"receiptRequested": { "type": "boolean" },
|
||||||
"submittedAt": { "type": "string", "format": "date-time" }
|
"submittedAt": { "type": "string", "format": "date-time" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,3 +264,13 @@ bash tools/deploy/production-deploy.sh
|
|||||||
用户明确授权创建并保留后续复用的专用测试管理员。2026-09-06在测试环境100.93.204.60创建`codex_qa_admin`,显示名“自动化验收管理员”,用户ID`cmtphgp340000yrle7v11jemc`,角色`platform_admin`、状态`active`;正常验证码登录和退出已验证。该账号仅在测试环境创建,预生产不适用。
|
用户明确授权创建并保留后续复用的专用测试管理员。2026-09-06在测试环境100.93.204.60创建`codex_qa_admin`,显示名“自动化验收管理员”,用户ID`cmtphgp340000yrle7v11jemc`,角色`platform_admin`、状态`active`;正常验证码登录和退出已验证。该账号仅在测试环境创建,预生产不适用。
|
||||||
|
|
||||||
安全认证入口:工作站`C:\Users\hectorzhao\.config\cmpp-qa\test-admin.json`,仅当前Windows用户及SYSTEM可访问;测试机备份入口`/home/hector/.config/cmpp-qa/test-admin.json`,目录0700、文件0600、所有者hector。文件保存随机生成密码和登录地址,禁止打印内容、提交Git或将密码写进命令、截图和报告。后续通过既有SSH安全入口读取到内存或使用本机受限文件完成正常登录;账号和凭据按用户要求保留,测试结束退出会话即可。使用前重新核验账号有效性,不覆盖已有账号、不因认证失败重置其他管理员;账号存在不构成发送短信、改余额或通道/客户配置的授权。
|
安全认证入口:工作站`C:\Users\hectorzhao\.config\cmpp-qa\test-admin.json`,仅当前Windows用户及SYSTEM可访问;测试机备份入口`/home/hector/.config/cmpp-qa/test-admin.json`,目录0700、文件0600、所有者hector。文件保存随机生成密码和登录地址,禁止打印内容、提交Git或将密码写进命令、截图和报告。后续通过既有SSH安全入口读取到内存或使用本机受限文件完成正常登录;账号和凭据按用户要求保留,测试结束退出会话即可。使用前重新核验账号有效性,不覆盖已有账号、不因认证失败重置其他管理员;账号存在不构成发送短信、改余额或通道/客户配置的授权。
|
||||||
|
|
||||||
|
## 发送质量监控与报备消息发布(2026-09-06)
|
||||||
|
|
||||||
|
本轮仅授权测试环境。候选版本新增20260906170000_report_readiness_notifications及20260906171000_sending_monitor两项兼容迁移;新增表/索引/报备触发器、Gateway可选时间元数据,以及独立cmpp-sending-monitor服务。默认规则和行业纳管均为空,不能套用原型阈值。
|
||||||
|
|
||||||
|
cmpp-sending-monitor以cmpp-api:cmpp-security运行,TZ=UTC,PostgreSQL池上限2、单实例排他锁、5秒采集;读取已有持久事实,不发送短信、不新增发送链路同步依赖。工作目录api,入口dist/sending-monitor-worker.js,日志logs/sending-monitor,目录0750。标准发布脚本已安装并启用该服务;存量部署按本轮范围建立相同单元,保留已有存储及安全drop-in,不为本功能运行初始化/账号重置/存储配置脚本。
|
||||||
|
|
||||||
|
发布前分别核对迁移、存储、DB连接余量、近72小时尝试量和磁盘预算,备份新触发器上线前的数据库。事实72小时、分钟7天、快照30天、关闭告警90天;当前测试机零近期发送、24GB可用空间适用于此次测试准入,不证明500TPS或预生产容量达标。后续高峰上线须按方案容量和CPU/IO/延迟预算实测。
|
||||||
|
|
||||||
|
发布顺序:独立候选归档构建→迁移→切换受保护的原运行目录→callback(已启用时)→Gateway健康→API健康→既有Worker→新监控Worker。日志、队列、对象存储和环境沿用;不重启无关存储、不清理历史恢复点、不手工ACK/补发。验证健康检查点更新、规则为空、真实分页API、双端消息页、三尺寸、匿名/越权拒绝及队列基线。应用回退时先停止新Worker,旧代码忽略新增字段;兼容新增表/触发器可保留,数据回退另行评估,不直接覆盖库。
|
||||||
|
|||||||
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,184 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="1600" height="1000" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="248" height="1000" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="248" y1="0" x2="248" y2="1000" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="24" y="24" width="38" height="38" rx="8" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="31" y="54" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#2563EB">聆</text>
|
||||||
|
<text x="74" y="41" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#111827">聆界短信管理平台</text>
|
||||||
|
<text x="74" y="61" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">平台运营工作区</text>
|
||||||
|
<text x="28" y="117" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">运营概览</text>
|
||||||
|
<rect x="29" y="136" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="148" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营看板</text>
|
||||||
|
<rect x="16" y="171" width="216" height="40" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<rect x="16" y="171" width="3" height="40" rx="0" fill="#2563EB" stroke="none"/>
|
||||||
|
<rect x="29" y="180" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#2563EB"/>
|
||||||
|
<text x="56" y="192" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">发送监控</text>
|
||||||
|
<rect x="29" y="224" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="236" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">网关异常</text>
|
||||||
|
<rect x="29" y="268" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="280" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">签名质量检测</text>
|
||||||
|
<text x="28" y="339" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">客户管理</text>
|
||||||
|
<rect x="29" y="358" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="370" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业管理</text>
|
||||||
|
<rect x="29" y="402" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="414" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用管理</text>
|
||||||
|
<rect x="29" y="446" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="458" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业签名管理</text>
|
||||||
|
<rect x="29" y="490" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="502" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业模板管理</text>
|
||||||
|
<text x="28" y="561" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">短信业务</text>
|
||||||
|
<rect x="29" y="580" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="592" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信通道</text>
|
||||||
|
<rect x="29" y="624" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="636" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信记录</text>
|
||||||
|
<rect x="29" y="668" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="680" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">任务进度</text>
|
||||||
|
<text x="28" y="739" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">系统管理</text>
|
||||||
|
<rect x="29" y="758" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="770" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">用户管理</text>
|
||||||
|
<rect x="29" y="802" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="814" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">系统监控</text>
|
||||||
|
<text x="24" y="964" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">设计原型 · 非真实业务数据</text>
|
||||||
|
<rect x="249" y="0" width="1351" height="65" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="249" y1="65" x2="1600" y2="65" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="276" y="37" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">运营概览 / 发送监控</text>
|
||||||
|
<rect x="1000" y="15" width="140" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1014" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">预警中心 6</text>
|
||||||
|
<rect x="1152" y="15" width="148" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1166" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">待审核任务</text>
|
||||||
|
<rect x="1312" y="15" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1326" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">报备任务提醒</text>
|
||||||
|
<text x="1500" y="40" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">管理员</text>
|
||||||
|
<text x="276" y="115" font-family="Microsoft YaHei, sans-serif" font-size="24" font-weight="600" fill="#111827">发送监控</text>
|
||||||
|
<text x="276" y="138" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">监测时效变化,定位异常通道与应用签名</text>
|
||||||
|
<rect x="1370" y="91" width="138" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1379" y="109" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态示例 · 待实施</text>
|
||||||
|
<text x="284" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#2563EB">行业通道</text>
|
||||||
|
<line x1="276" y1="223" x2="376" y2="223" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<text x="418" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">验证码</text>
|
||||||
|
<text x="552" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">整体兜底</text>
|
||||||
|
<text x="686" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">告警记录</text>
|
||||||
|
<line x1="276" y1="225" x2="1572" y2="225" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="276" y="150" width="1296" height="28" rx="5" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="288" y="169" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#2563EB">以平台收到成功回执为准;未满观察时长的短信不参与该指标评估。</text>
|
||||||
|
<rect x="1210" y="183" width="80" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1224" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">刷新</text>
|
||||||
|
<rect x="1300" y="183" width="118" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1314" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">阈值设置</text>
|
||||||
|
<rect x="1428" y="183" width="144" height="36" rx="6" fill="#2563EB" stroke="none"/>
|
||||||
|
<text x="1442" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#FFFFFF">监控通道</text>
|
||||||
|
<rect x="276" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="294" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">监控维度</text>
|
||||||
|
<text x="294" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">16</text>
|
||||||
|
<text x="391" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通道 × 运营商</text>
|
||||||
|
<rect x="606" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="624" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">异常维度</text>
|
||||||
|
<text x="624" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#DC2626">2</text>
|
||||||
|
<text x="721" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">点击仅看异常</text>
|
||||||
|
<rect x="936" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="954" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="954" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">3</text>
|
||||||
|
<text x="1051" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">不足最低成熟条数</text>
|
||||||
|
<rect x="1266" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1284" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">窗口提交量</text>
|
||||||
|
<text x="1284" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">3,458</text>
|
||||||
|
<text x="1381" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">次发送尝试</text>
|
||||||
|
<rect x="276" y="363" width="1296" height="72" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="294" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">统计窗口</text>
|
||||||
|
<text x="294" y="415" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">12:25 – 12:30</text>
|
||||||
|
<text x="490" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">评估时刻 12:30:00</text>
|
||||||
|
<text x="490" y="414" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#1F2937">初评 · 12:31补齐</text>
|
||||||
|
<text x="686" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">更新于 12:30:18</text>
|
||||||
|
<text x="686" y="414" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">查看最近2小时趋势</text>
|
||||||
|
<rect x="934" y="382" width="168" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="948" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">搜索通道 / 编号</text>
|
||||||
|
<rect x="1114" y="382" width="168" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1128" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营商:全部</text>
|
||||||
|
<rect x="1294" y="382" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1308" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">状态:全部</text>
|
||||||
|
<rect x="276" y="455" width="1296" height="462" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="277" y="456" width="1294" height="47" rx="7" fill="#F9FAFB" stroke="none"/>
|
||||||
|
<text x="294" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">通道 / 运营商</text>
|
||||||
|
<text x="574" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">窗口提交</text>
|
||||||
|
<text x="727" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">5秒到达率</text>
|
||||||
|
<text x="889" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">20秒到达率</text>
|
||||||
|
<text x="1051" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">1分钟到达率</text>
|
||||||
|
<text x="1220" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">状态 / 规则</text>
|
||||||
|
<text x="1384" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">操作</text>
|
||||||
|
<rect x="277" y="504" width="1294" height="74" rx="0" fill="#FFFBFB" stroke="none"/>
|
||||||
|
<text x="294" y="534" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">行业通道 A</text>
|
||||||
|
<text x="294" y="556" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">CH-001 · 移动</text>
|
||||||
|
<text x="574" y="539" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">1,280</text>
|
||||||
|
<text x="727" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">83.97%</text>
|
||||||
|
<text x="727" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,058 / 1,260 成熟</text>
|
||||||
|
<text x="889" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">94.00%</text>
|
||||||
|
<text x="889" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,128 / 1,200 成熟</text>
|
||||||
|
<text x="1051" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">97.00%</text>
|
||||||
|
<text x="1051" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,067 / 1,100 成熟</text>
|
||||||
|
<rect x="1220" y="515" width="42" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="1229" y="533" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">异常</text>
|
||||||
|
<text x="1220" y="560" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="544" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="578" x2="1571" y2="578" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="609" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">行业通道 A</text>
|
||||||
|
<text x="294" y="631" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">CH-001 · 联通</text>
|
||||||
|
<text x="574" y="614" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">920</text>
|
||||||
|
<text x="727" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">97.11%</text>
|
||||||
|
<text x="727" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">874 / 900 成熟</text>
|
||||||
|
<text x="889" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">97.27%</text>
|
||||||
|
<text x="889" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">856 / 880 成熟</text>
|
||||||
|
<text x="1051" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">99.15%</text>
|
||||||
|
<text x="1051" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">813 / 820 成熟</text>
|
||||||
|
<rect x="1220" y="590" width="42" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="1229" y="608" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">正常</text>
|
||||||
|
<text x="1220" y="635" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="619" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="653" x2="1571" y2="653" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="684" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">行业通道 B</text>
|
||||||
|
<text x="294" y="706" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">CH-002 · 电信</text>
|
||||||
|
<text x="574" y="689" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">760</text>
|
||||||
|
<text x="727" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">91.89%</text>
|
||||||
|
<text x="727" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">680 / 740 成熟</text>
|
||||||
|
<text x="889" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">95.14%</text>
|
||||||
|
<text x="889" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">666 / 700 成熟</text>
|
||||||
|
<text x="1051" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">98.46%</text>
|
||||||
|
<text x="1051" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">640 / 650 成熟</text>
|
||||||
|
<rect x="1220" y="665" width="42" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="1229" y="683" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">正常</text>
|
||||||
|
<text x="1220" y="710" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="694" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="728" x2="1571" y2="728" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="759" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">行业通道 C</text>
|
||||||
|
<text x="294" y="781" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">CH-003 · 移动</text>
|
||||||
|
<text x="574" y="764" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">68</text>
|
||||||
|
<text x="727" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">93.33%</text>
|
||||||
|
<text x="727" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">56 / 60 成熟</text>
|
||||||
|
<text x="889" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">96.36%</text>
|
||||||
|
<text x="889" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">53 / 55 成熟</text>
|
||||||
|
<text x="1051" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">97.83%</text>
|
||||||
|
<text x="1051" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">45 / 46 成熟</text>
|
||||||
|
<rect x="1220" y="740" width="66" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1229" y="758" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="1220" y="785" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="769" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="803" x2="1571" y2="803" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="277" y="804" width="1294" height="74" rx="0" fill="#FFFBFB" stroke="none"/>
|
||||||
|
<text x="294" y="834" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">行业通道 D</text>
|
||||||
|
<text x="294" y="856" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">CH-004 · 联通</text>
|
||||||
|
<text x="574" y="839" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">430</text>
|
||||||
|
<text x="727" y="838" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">73.81%</text>
|
||||||
|
<text x="727" y="859" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">310 / 420 成熟</text>
|
||||||
|
<text x="889" y="838" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">90.50%</text>
|
||||||
|
<text x="889" y="859" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">362 / 400 成熟</text>
|
||||||
|
<text x="1051" y="838" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">96.67%</text>
|
||||||
|
<text x="1051" y="859" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">348 / 360 成熟</text>
|
||||||
|
<rect x="1220" y="815" width="42" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="1229" y="833" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">异常</text>
|
||||||
|
<text x="1220" y="860" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="844" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="878" x2="1571" y2="878" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="904" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。</text>
|
||||||
|
<text x="1274" y="904" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">展示5行 ‹ 1 2 3 ›</text>
|
||||||
|
<text x="276" y="952" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">行业通道监控 · 图中所有数值仅用于需求评审</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 183 KiB |
@@ -0,0 +1,180 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="1600" height="1000" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="248" height="1000" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="248" y1="0" x2="248" y2="1000" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="24" y="24" width="38" height="38" rx="8" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="31" y="54" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#2563EB">聆</text>
|
||||||
|
<text x="74" y="41" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#111827">聆界短信管理平台</text>
|
||||||
|
<text x="74" y="61" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">平台运营工作区</text>
|
||||||
|
<text x="28" y="117" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">运营概览</text>
|
||||||
|
<rect x="29" y="136" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="148" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营看板</text>
|
||||||
|
<rect x="16" y="171" width="216" height="40" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<rect x="16" y="171" width="3" height="40" rx="0" fill="#2563EB" stroke="none"/>
|
||||||
|
<rect x="29" y="180" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#2563EB"/>
|
||||||
|
<text x="56" y="192" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">发送监控</text>
|
||||||
|
<rect x="29" y="224" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="236" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">网关异常</text>
|
||||||
|
<rect x="29" y="268" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="280" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">签名质量检测</text>
|
||||||
|
<text x="28" y="339" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">客户管理</text>
|
||||||
|
<rect x="29" y="358" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="370" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业管理</text>
|
||||||
|
<rect x="29" y="402" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="414" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用管理</text>
|
||||||
|
<rect x="29" y="446" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="458" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业签名管理</text>
|
||||||
|
<rect x="29" y="490" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="502" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业模板管理</text>
|
||||||
|
<text x="28" y="561" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">短信业务</text>
|
||||||
|
<rect x="29" y="580" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="592" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信通道</text>
|
||||||
|
<rect x="29" y="624" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="636" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信记录</text>
|
||||||
|
<rect x="29" y="668" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="680" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">任务进度</text>
|
||||||
|
<text x="28" y="739" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">系统管理</text>
|
||||||
|
<rect x="29" y="758" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="770" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">用户管理</text>
|
||||||
|
<rect x="29" y="802" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="814" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">系统监控</text>
|
||||||
|
<text x="24" y="964" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">设计原型 · 非真实业务数据</text>
|
||||||
|
<rect x="249" y="0" width="1351" height="65" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="249" y1="65" x2="1600" y2="65" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="276" y="37" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">运营概览 / 发送监控</text>
|
||||||
|
<rect x="1000" y="15" width="140" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1014" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">预警中心 6</text>
|
||||||
|
<rect x="1152" y="15" width="148" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1166" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">待审核任务</text>
|
||||||
|
<rect x="1312" y="15" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1326" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">报备任务提醒</text>
|
||||||
|
<text x="1500" y="40" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">管理员</text>
|
||||||
|
<text x="276" y="115" font-family="Microsoft YaHei, sans-serif" font-size="24" font-weight="600" fill="#111827">发送监控</text>
|
||||||
|
<text x="276" y="138" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">监测时效变化,定位异常通道与应用签名</text>
|
||||||
|
<rect x="1370" y="91" width="138" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1379" y="109" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态示例 · 待实施</text>
|
||||||
|
<text x="284" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">行业通道</text>
|
||||||
|
<text x="418" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#2563EB">验证码</text>
|
||||||
|
<line x1="410" y1="223" x2="510" y2="223" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<text x="552" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">整体兜底</text>
|
||||||
|
<text x="686" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">告警记录</text>
|
||||||
|
<line x1="276" y1="225" x2="1572" y2="225" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="276" y="150" width="1296" height="28" rx="5" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="288" y="169" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#2563EB">仅统计最终正文包含“验证码”的业务短信;补发不重复计数,以成功回执为准。</text>
|
||||||
|
<rect x="1210" y="183" width="80" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1224" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">刷新</text>
|
||||||
|
<rect x="1300" y="183" width="118" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1314" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">阈值设置</text>
|
||||||
|
<rect x="1428" y="183" width="144" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1442" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运行概况</text>
|
||||||
|
<rect x="276" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="294" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">监控维度</text>
|
||||||
|
<text x="294" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">48</text>
|
||||||
|
<text x="391" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">企业应用 × 签名</text>
|
||||||
|
<rect x="606" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="624" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">异常维度</text>
|
||||||
|
<text x="624" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#DC2626">1</text>
|
||||||
|
<text x="721" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">点击仅看异常</text>
|
||||||
|
<rect x="936" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="954" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="954" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">3</text>
|
||||||
|
<text x="1051" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">不足最低成熟条数</text>
|
||||||
|
<rect x="1266" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1284" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">窗口提交量</text>
|
||||||
|
<text x="1284" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">3,028</text>
|
||||||
|
<text x="1381" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">条业务短信</text>
|
||||||
|
<rect x="276" y="363" width="1296" height="72" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="294" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">统计窗口</text>
|
||||||
|
<text x="294" y="415" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">12:25 – 12:30</text>
|
||||||
|
<text x="490" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">评估时刻 12:30:00</text>
|
||||||
|
<text x="490" y="414" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#1F2937">初评 · 12:31补齐</text>
|
||||||
|
<text x="686" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">更新于 12:30:18</text>
|
||||||
|
<text x="686" y="414" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">查看最近2小时趋势</text>
|
||||||
|
<rect x="934" y="382" width="168" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="948" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业 / 应用</text>
|
||||||
|
<rect x="1114" y="382" width="168" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1128" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">搜索签名</text>
|
||||||
|
<rect x="1294" y="382" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1308" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">状态:全部</text>
|
||||||
|
<rect x="276" y="455" width="1296" height="462" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="277" y="456" width="1294" height="47" rx="7" fill="#F9FAFB" stroke="none"/>
|
||||||
|
<text x="294" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">企业应用 / 签名</text>
|
||||||
|
<text x="574" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">窗口提交</text>
|
||||||
|
<text x="727" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">5秒到达率</text>
|
||||||
|
<text x="889" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">20秒到达率</text>
|
||||||
|
<text x="1051" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">1分钟到达率</text>
|
||||||
|
<text x="1220" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">状态 / 规则</text>
|
||||||
|
<text x="1384" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">操作</text>
|
||||||
|
<rect x="277" y="504" width="1294" height="74" rx="0" fill="#FFFBFB" stroke="none"/>
|
||||||
|
<text x="294" y="534" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例零售 · 登录应用</text>
|
||||||
|
<text x="294" y="556" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例商城】</text>
|
||||||
|
<text x="574" y="539" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">1,280</text>
|
||||||
|
<text x="727" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">83.97%</text>
|
||||||
|
<text x="727" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,058 / 1,260 成熟</text>
|
||||||
|
<text x="889" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">94.00%</text>
|
||||||
|
<text x="889" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,128 / 1,200 成熟</text>
|
||||||
|
<text x="1051" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">97.00%</text>
|
||||||
|
<text x="1051" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,067 / 1,100 成熟</text>
|
||||||
|
<rect x="1220" y="515" width="42" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="1229" y="533" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">异常</text>
|
||||||
|
<text x="1220" y="560" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">验证码通用</text>
|
||||||
|
<text x="1384" y="544" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="578" x2="1571" y2="578" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="609" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例服务 · 用户中心</text>
|
||||||
|
<text x="294" y="631" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例服务】</text>
|
||||||
|
<text x="574" y="614" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">920</text>
|
||||||
|
<text x="727" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">97.11%</text>
|
||||||
|
<text x="727" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">874 / 900 成熟</text>
|
||||||
|
<text x="889" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">97.27%</text>
|
||||||
|
<text x="889" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">856 / 880 成熟</text>
|
||||||
|
<text x="1051" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">99.15%</text>
|
||||||
|
<text x="1051" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">813 / 820 成熟</text>
|
||||||
|
<rect x="1220" y="590" width="42" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="1229" y="608" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">正常</text>
|
||||||
|
<text x="1220" y="635" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">验证码通用</text>
|
||||||
|
<text x="1384" y="619" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="653" x2="1571" y2="653" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="684" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例物流 · 商户应用</text>
|
||||||
|
<text x="294" y="706" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例物流】</text>
|
||||||
|
<text x="574" y="689" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">760</text>
|
||||||
|
<text x="727" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">91.89%</text>
|
||||||
|
<text x="727" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">680 / 740 成熟</text>
|
||||||
|
<text x="889" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">95.14%</text>
|
||||||
|
<text x="889" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">666 / 700 成熟</text>
|
||||||
|
<text x="1051" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">98.46%</text>
|
||||||
|
<text x="1051" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">640 / 650 成熟</text>
|
||||||
|
<rect x="1220" y="665" width="42" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="1229" y="683" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">正常</text>
|
||||||
|
<text x="1220" y="710" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">验证码通用</text>
|
||||||
|
<text x="1384" y="694" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="728" x2="1571" y2="728" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="759" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例教育 · 学员登录</text>
|
||||||
|
<text x="294" y="781" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例学堂】</text>
|
||||||
|
<text x="574" y="764" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">68</text>
|
||||||
|
<text x="727" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">93.33%</text>
|
||||||
|
<text x="727" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">56 / 60 成熟</text>
|
||||||
|
<text x="889" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">96.36%</text>
|
||||||
|
<text x="889" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">53 / 55 成熟</text>
|
||||||
|
<text x="1051" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">97.83%</text>
|
||||||
|
<text x="1051" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">45 / 46 成熟</text>
|
||||||
|
<rect x="1220" y="740" width="66" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1229" y="758" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="1220" y="785" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">验证码通用</text>
|
||||||
|
<text x="1384" y="769" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="803" x2="1571" y2="803" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="834" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例平台 · 会员应用</text>
|
||||||
|
<text x="294" y="856" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例会员】</text>
|
||||||
|
<text x="574" y="839" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">—</text>
|
||||||
|
<text x="727" y="838" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">— 待更新</text>
|
||||||
|
<text x="889" y="838" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">— 待更新</text>
|
||||||
|
<text x="1051" y="838" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">— 待更新</text>
|
||||||
|
<rect x="1220" y="815" width="66" height="25" rx="5" fill="#FFFBEB" stroke="none"/>
|
||||||
|
<text x="1229" y="833" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#B45309">数据延迟</text>
|
||||||
|
<text x="1220" y="860" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">验证码通用</text>
|
||||||
|
<text x="1384" y="844" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="878" x2="1571" y2="878" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="904" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。</text>
|
||||||
|
<text x="1274" y="904" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">展示5行 ‹ 1 2 3 ›</text>
|
||||||
|
<text x="276" y="952" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">验证码监控 · 图中所有数值仅用于需求评审</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,181 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="1600" height="1000" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="248" height="1000" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="248" y1="0" x2="248" y2="1000" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="24" y="24" width="38" height="38" rx="8" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="31" y="54" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#2563EB">聆</text>
|
||||||
|
<text x="74" y="41" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#111827">聆界短信管理平台</text>
|
||||||
|
<text x="74" y="61" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">平台运营工作区</text>
|
||||||
|
<text x="28" y="117" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">运营概览</text>
|
||||||
|
<rect x="29" y="136" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="148" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营看板</text>
|
||||||
|
<rect x="16" y="171" width="216" height="40" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<rect x="16" y="171" width="3" height="40" rx="0" fill="#2563EB" stroke="none"/>
|
||||||
|
<rect x="29" y="180" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#2563EB"/>
|
||||||
|
<text x="56" y="192" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">发送监控</text>
|
||||||
|
<rect x="29" y="224" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="236" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">网关异常</text>
|
||||||
|
<rect x="29" y="268" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="280" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">签名质量检测</text>
|
||||||
|
<text x="28" y="339" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">客户管理</text>
|
||||||
|
<rect x="29" y="358" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="370" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业管理</text>
|
||||||
|
<rect x="29" y="402" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="414" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用管理</text>
|
||||||
|
<rect x="29" y="446" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="458" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业签名管理</text>
|
||||||
|
<rect x="29" y="490" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="502" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业模板管理</text>
|
||||||
|
<text x="28" y="561" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">短信业务</text>
|
||||||
|
<rect x="29" y="580" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="592" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信通道</text>
|
||||||
|
<rect x="29" y="624" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="636" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信记录</text>
|
||||||
|
<rect x="29" y="668" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="680" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">任务进度</text>
|
||||||
|
<text x="28" y="739" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">系统管理</text>
|
||||||
|
<rect x="29" y="758" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="770" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">用户管理</text>
|
||||||
|
<rect x="29" y="802" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="814" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">系统监控</text>
|
||||||
|
<text x="24" y="964" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">设计原型 · 非真实业务数据</text>
|
||||||
|
<rect x="249" y="0" width="1351" height="65" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="249" y1="65" x2="1600" y2="65" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="276" y="37" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">运营概览 / 发送监控</text>
|
||||||
|
<rect x="1000" y="15" width="140" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1014" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">预警中心 6</text>
|
||||||
|
<rect x="1152" y="15" width="148" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1166" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">待审核任务</text>
|
||||||
|
<rect x="1312" y="15" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1326" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">报备任务提醒</text>
|
||||||
|
<text x="1500" y="40" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">管理员</text>
|
||||||
|
<text x="276" y="115" font-family="Microsoft YaHei, sans-serif" font-size="24" font-weight="600" fill="#111827">发送监控</text>
|
||||||
|
<text x="276" y="138" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">监测时效变化,定位异常通道与应用签名</text>
|
||||||
|
<rect x="1370" y="91" width="138" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1379" y="109" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态示例 · 待实施</text>
|
||||||
|
<text x="284" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">行业通道</text>
|
||||||
|
<text x="418" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">验证码</text>
|
||||||
|
<text x="552" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#2563EB">整体兜底</text>
|
||||||
|
<line x1="544" y1="223" x2="644" y2="223" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<text x="686" y="207" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#6B7280">告警记录</text>
|
||||||
|
<line x1="276" y1="225" x2="1572" y2="225" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="276" y="150" width="1296" height="28" rx="5" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="288" y="169" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#2563EB">近30分钟整体监控;1 / 5 / 20分钟分别使用成熟分母,不将新提交短信计作失败。</text>
|
||||||
|
<rect x="1210" y="183" width="80" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1224" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">刷新</text>
|
||||||
|
<rect x="1300" y="183" width="118" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1314" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">阈值设置</text>
|
||||||
|
<rect x="1428" y="183" width="144" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1442" y="206" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运行概况</text>
|
||||||
|
<rect x="276" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="294" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">监控维度</text>
|
||||||
|
<text x="294" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">48</text>
|
||||||
|
<text x="391" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">企业应用 × 签名</text>
|
||||||
|
<rect x="606" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="624" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">异常维度</text>
|
||||||
|
<text x="624" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#DC2626">1</text>
|
||||||
|
<text x="721" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">点击仅看异常</text>
|
||||||
|
<rect x="936" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="954" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="954" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">3</text>
|
||||||
|
<text x="1051" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">不足最低成熟条数</text>
|
||||||
|
<rect x="1266" y="246" width="306" height="97" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1284" y="273" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">窗口提交量</text>
|
||||||
|
<text x="1284" y="314" font-family="Microsoft YaHei, sans-serif" font-size="28" font-weight="600" fill="#111827">5,230</text>
|
||||||
|
<text x="1381" y="312" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">条业务短信</text>
|
||||||
|
<rect x="276" y="363" width="1296" height="72" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="294" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">统计窗口</text>
|
||||||
|
<text x="294" y="415" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">12:00 – 12:30</text>
|
||||||
|
<text x="490" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">评估时刻 12:30:00</text>
|
||||||
|
<text x="490" y="414" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#1F2937">每10分钟评估</text>
|
||||||
|
<text x="686" y="390" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">更新于 12:30:18</text>
|
||||||
|
<text x="686" y="414" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">查看最近2小时趋势</text>
|
||||||
|
<rect x="934" y="382" width="168" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="948" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业 / 应用</text>
|
||||||
|
<rect x="1114" y="382" width="168" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1128" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">搜索签名</text>
|
||||||
|
<rect x="1294" y="382" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1308" y="405" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">状态:全部</text>
|
||||||
|
<rect x="276" y="455" width="1296" height="462" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="277" y="456" width="1294" height="47" rx="7" fill="#F9FAFB" stroke="none"/>
|
||||||
|
<text x="294" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">企业应用 / 签名</text>
|
||||||
|
<text x="574" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">窗口提交</text>
|
||||||
|
<text x="727" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">1分钟到达率</text>
|
||||||
|
<text x="889" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">5分钟到达率</text>
|
||||||
|
<text x="1051" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">20分钟到达率</text>
|
||||||
|
<text x="1220" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">状态 / 规则</text>
|
||||||
|
<text x="1384" y="485" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">操作</text>
|
||||||
|
<text x="294" y="534" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例零售 · 通知应用</text>
|
||||||
|
<text x="294" y="556" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例商城】</text>
|
||||||
|
<text x="574" y="539" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">300</text>
|
||||||
|
<text x="727" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">88.62%</text>
|
||||||
|
<text x="727" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">257 / 290 成熟</text>
|
||||||
|
<text x="889" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">92.17%</text>
|
||||||
|
<text x="889" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">212 / 230 成熟</text>
|
||||||
|
<text x="1051" y="538" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#6B7280">94.44%</text>
|
||||||
|
<text x="1051" y="559" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">85 / 90 成熟</text>
|
||||||
|
<rect x="1220" y="515" width="66" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1229" y="533" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="1220" y="560" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">应用×签名</text>
|
||||||
|
<text x="1384" y="544" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="578" x2="1571" y2="578" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="277" y="579" width="1294" height="74" rx="0" fill="#FFFBFB" stroke="none"/>
|
||||||
|
<text x="294" y="609" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例服务 · 业务应用</text>
|
||||||
|
<text x="294" y="631" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例服务】</text>
|
||||||
|
<text x="574" y="614" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">2,500</text>
|
||||||
|
<text x="727" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">90.00%</text>
|
||||||
|
<text x="727" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">2,115 / 2,350 成熟</text>
|
||||||
|
<text x="889" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">97.00%</text>
|
||||||
|
<text x="889" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,940 / 2,000 成熟</text>
|
||||||
|
<text x="1051" y="613" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#DC2626">95.00%</text>
|
||||||
|
<text x="1051" y="634" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">950 / 1,000 成熟</text>
|
||||||
|
<rect x="1220" y="590" width="42" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="1229" y="608" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">异常</text>
|
||||||
|
<text x="1220" y="635" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">签名规则</text>
|
||||||
|
<text x="1384" y="619" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="653" x2="1571" y2="653" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="684" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例物流 · 商户应用</text>
|
||||||
|
<text x="294" y="706" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例物流】</text>
|
||||||
|
<text x="574" y="689" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">1,800</text>
|
||||||
|
<text x="727" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">98.00%</text>
|
||||||
|
<text x="727" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,666 / 1,700 成熟</text>
|
||||||
|
<text x="889" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">99.00%</text>
|
||||||
|
<text x="889" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">1,485 / 1,500 成熟</text>
|
||||||
|
<text x="1051" y="688" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">99.44%</text>
|
||||||
|
<text x="1051" y="709" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">895 / 900 成熟</text>
|
||||||
|
<rect x="1220" y="665" width="42" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="1229" y="683" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">正常</text>
|
||||||
|
<text x="1220" y="710" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">应用规则</text>
|
||||||
|
<text x="1384" y="694" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="728" x2="1571" y2="728" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="759" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例教育 · 通知应用</text>
|
||||||
|
<text x="294" y="781" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例学堂】</text>
|
||||||
|
<text x="574" y="764" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">630</text>
|
||||||
|
<text x="727" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">93.00%</text>
|
||||||
|
<text x="727" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">558 / 600 成熟</text>
|
||||||
|
<text x="889" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">95.60%</text>
|
||||||
|
<text x="889" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">478 / 500 成熟</text>
|
||||||
|
<text x="1051" y="763" font-family="Microsoft YaHei, sans-serif" font-size="19" font-weight="600" fill="#15803D">99.17%</text>
|
||||||
|
<text x="1051" y="784" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">238 / 240 成熟</text>
|
||||||
|
<rect x="1220" y="740" width="42" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="1229" y="758" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">正常</text>
|
||||||
|
<text x="1220" y="785" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="769" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="803" x2="1571" y2="803" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="834" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">示例平台 · 消息应用</text>
|
||||||
|
<text x="294" y="856" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例会员】</text>
|
||||||
|
<text x="574" y="839" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">—</text>
|
||||||
|
<text x="727" y="838" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">— 待更新</text>
|
||||||
|
<text x="889" y="838" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">— 待更新</text>
|
||||||
|
<text x="1051" y="838" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">— 待更新</text>
|
||||||
|
<rect x="1220" y="815" width="66" height="25" rx="5" fill="#FFFBEB" stroke="none"/>
|
||||||
|
<text x="1229" y="833" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#B45309">数据延迟</text>
|
||||||
|
<text x="1220" y="860" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">通用规则</text>
|
||||||
|
<text x="1384" y="844" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">趋势 / 详情</text>
|
||||||
|
<line x1="277" y1="878" x2="1571" y2="878" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="294" y="904" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。</text>
|
||||||
|
<text x="1274" y="904" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">展示5行 ‹ 1 2 3 ›</text>
|
||||||
|
<text x="276" y="952" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">整体兜底监控 · 图中所有数值仅用于需求评审</text>
|
||||||
|
<text x="276" y="981" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#2563EB">示例首行:20分钟指标85/90,最低100条,尚不触发告警;另有210条观察中。</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,137 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="1600" height="1000" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="248" height="1000" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="248" y1="0" x2="248" y2="1000" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="24" y="24" width="38" height="38" rx="8" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="31" y="54" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#2563EB">聆</text>
|
||||||
|
<text x="74" y="41" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#111827">聆界短信管理平台</text>
|
||||||
|
<text x="74" y="61" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">平台运营工作区</text>
|
||||||
|
<text x="28" y="117" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">运营概览</text>
|
||||||
|
<rect x="29" y="136" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="148" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营看板</text>
|
||||||
|
<rect x="16" y="171" width="216" height="40" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<rect x="16" y="171" width="3" height="40" rx="0" fill="#2563EB" stroke="none"/>
|
||||||
|
<rect x="29" y="180" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#2563EB"/>
|
||||||
|
<text x="56" y="192" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">发送监控</text>
|
||||||
|
<rect x="29" y="224" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="236" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">网关异常</text>
|
||||||
|
<rect x="29" y="268" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="280" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">签名质量检测</text>
|
||||||
|
<text x="28" y="339" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">客户管理</text>
|
||||||
|
<rect x="29" y="358" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="370" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业管理</text>
|
||||||
|
<rect x="29" y="402" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="414" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用管理</text>
|
||||||
|
<rect x="29" y="446" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="458" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业签名管理</text>
|
||||||
|
<rect x="29" y="490" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="502" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业模板管理</text>
|
||||||
|
<text x="28" y="561" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">短信业务</text>
|
||||||
|
<rect x="29" y="580" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="592" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信通道</text>
|
||||||
|
<rect x="29" y="624" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="636" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信记录</text>
|
||||||
|
<rect x="29" y="668" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="680" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">任务进度</text>
|
||||||
|
<text x="28" y="739" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">系统管理</text>
|
||||||
|
<rect x="29" y="758" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="770" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">用户管理</text>
|
||||||
|
<rect x="29" y="802" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="814" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">系统监控</text>
|
||||||
|
<text x="24" y="964" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">设计原型 · 非真实业务数据</text>
|
||||||
|
<rect x="249" y="0" width="1351" height="65" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="249" y1="65" x2="1600" y2="65" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="276" y="37" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">运营概览 / 发送监控</text>
|
||||||
|
<rect x="1000" y="15" width="140" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1014" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">预警中心 6</text>
|
||||||
|
<rect x="1152" y="15" width="148" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1166" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">待审核任务</text>
|
||||||
|
<rect x="1312" y="15" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1326" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">报备任务提醒</text>
|
||||||
|
<text x="1500" y="40" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">管理员</text>
|
||||||
|
<text x="276" y="115" font-family="Microsoft YaHei, sans-serif" font-size="24" font-weight="600" fill="#111827">告警阈值设置</text>
|
||||||
|
<text x="276" y="138" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">整体兜底 · 通用规则与个性规则</text>
|
||||||
|
<rect x="1370" y="91" width="138" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1379" y="109" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态示例 · 待实施</text>
|
||||||
|
<rect x="1458" y="126" width="114" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1472" y="149" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">返回监控</text>
|
||||||
|
<rect x="276" y="176" width="600" height="706" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="298" y="215" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">生效优先级</text>
|
||||||
|
<rect x="298" y="231" width="554" height="43" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="312" y="259" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#2563EB">应用×签名 > 签名 > 应用 > 通用</text>
|
||||||
|
<text x="298" y="304" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">命中最高优先级后,整套规则覆盖。</text>
|
||||||
|
<text x="298" y="348" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">规则列表</text>
|
||||||
|
<rect x="730" y="320" width="122" height="36" rx="6" fill="#2563EB" stroke="none"/>
|
||||||
|
<text x="744" y="343" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#FFFFFF">新增规则</text>
|
||||||
|
<rect x="298" y="373" width="554" height="83" rx="6" fill="#EFF6FF" stroke="#2563EB"/>
|
||||||
|
<rect x="313" y="385" width="78" height="25" rx="5" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="322" y="403" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#2563EB">应用×签名</text>
|
||||||
|
<text x="426" y="403" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">通知应用 / 【示例商城】</text>
|
||||||
|
<text x="426" y="433" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">100条 · 85% / 90% / 95%</text>
|
||||||
|
<rect x="298" y="471" width="554" height="83" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="313" y="483" width="42" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="322" y="501" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">签名</text>
|
||||||
|
<text x="426" y="501" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">【示例服务】</text>
|
||||||
|
<text x="426" y="531" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">100条 · 90% / 95% / 98%</text>
|
||||||
|
<rect x="298" y="569" width="554" height="83" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="313" y="581" width="42" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="322" y="599" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">应用</text>
|
||||||
|
<text x="426" y="599" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">商户应用</text>
|
||||||
|
<text x="426" y="629" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">100条 · 90% / 95% / 98%</text>
|
||||||
|
<rect x="298" y="667" width="554" height="83" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="313" y="679" width="42" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="322" y="697" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">通用</text>
|
||||||
|
<text x="426" y="697" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">全部未覆盖对象</text>
|
||||||
|
<text x="426" y="727" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">100条 · 90% / 95% / 98%</text>
|
||||||
|
<text x="298" y="807" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">实际生效示例</text>
|
||||||
|
<text x="298" y="836" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#1F2937">示例零售 / 通知应用 / 【示例商城】</text>
|
||||||
|
<text x="298" y="860" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#2563EB">采用应用×签名规则,覆盖下层通用设置。</text>
|
||||||
|
<rect x="900" y="176" width="672" height="706" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="925" y="217" font-family="Microsoft YaHei, sans-serif" font-size="18" font-weight="600" fill="#1F2937">编辑个性规则</text>
|
||||||
|
<rect x="1398" y="198" width="114" height="25" rx="5" fill="#FFFBEB" stroke="none"/>
|
||||||
|
<text x="1407" y="216" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#B45309">草稿 · 未保存</text>
|
||||||
|
<text x="925" y="259" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">作用范围</text>
|
||||||
|
<rect x="1085" y="238" width="455" height="38" rx="5" fill="#FAFAFA" stroke="#E5E7EB"/>
|
||||||
|
<text x="1097" y="263" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用 × 签名</text>
|
||||||
|
<text x="925" y="326" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">企业 / 应用</text>
|
||||||
|
<rect x="1085" y="305" width="455" height="38" rx="5" fill="#FAFAFA" stroke="#E5E7EB"/>
|
||||||
|
<text x="1097" y="330" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">示例零售 / 通知应用</text>
|
||||||
|
<text x="925" y="393" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">签名</text>
|
||||||
|
<rect x="1085" y="372" width="455" height="38" rx="5" fill="#FAFAFA" stroke="#E5E7EB"/>
|
||||||
|
<text x="1097" y="397" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">【示例商城】</text>
|
||||||
|
<line x1="925" y1="445" x2="1540" y2="445" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="925" y="483" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">最低成熟条数</text>
|
||||||
|
<rect x="1170" y="458" width="160" height="40" rx="5" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1183" y="487" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="400" fill="#1F2937">100</text>
|
||||||
|
<text x="1340" y="486" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">条 / 每项指标</text>
|
||||||
|
<text x="925" y="520" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">仅有样本数达到门槛的指标才参与告警。</text>
|
||||||
|
<rect x="925" y="549" width="18" height="18" rx="3" fill="#2563EB" stroke="none"/>
|
||||||
|
<line x1="929" y1="558" x2="933" y2="562" stroke="#FFFFFF" stroke-width="2"/>
|
||||||
|
<line x1="933" y1="562" x2="939" y2="554" stroke="#FFFFFF" stroke-width="2"/>
|
||||||
|
<text x="954" y="565" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">1分钟到达率下限</text>
|
||||||
|
<rect x="1280" y="541" width="260" height="38" rx="5" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1293" y="566" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">85.00 %</text>
|
||||||
|
<rect x="925" y="603" width="18" height="18" rx="3" fill="#2563EB" stroke="none"/>
|
||||||
|
<line x1="929" y1="612" x2="933" y2="616" stroke="#FFFFFF" stroke-width="2"/>
|
||||||
|
<line x1="933" y1="616" x2="939" y2="608" stroke="#FFFFFF" stroke-width="2"/>
|
||||||
|
<text x="954" y="619" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">5分钟到达率下限</text>
|
||||||
|
<rect x="1280" y="595" width="260" height="38" rx="5" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1293" y="620" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">90.00 %</text>
|
||||||
|
<rect x="925" y="657" width="18" height="18" rx="3" fill="#2563EB" stroke="none"/>
|
||||||
|
<line x1="929" y1="666" x2="933" y2="670" stroke="#FFFFFF" stroke-width="2"/>
|
||||||
|
<line x1="933" y1="670" x2="939" y2="662" stroke="#FFFFFF" stroke-width="2"/>
|
||||||
|
<text x="954" y="673" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">20分钟到达率下限</text>
|
||||||
|
<rect x="1280" y="649" width="260" height="38" rx="5" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1293" y="674" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">95.00 %</text>
|
||||||
|
<text x="925" y="731" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#1F2937">连续异常 1 次触发 · 连续恢复 2 次关闭</text>
|
||||||
|
<text x="925" y="762" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">预计下周期生效;旧告警记录保留原规则版本。</text>
|
||||||
|
<line x1="901" y1="791" x2="1571" y2="791" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="925" y="817" width="120" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="939" y="840" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">恢复继承</text>
|
||||||
|
<rect x="1300" y="817" width="80" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1314" y="840" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">取消</text>
|
||||||
|
<rect x="1392" y="817" width="148" height="36" rx="6" fill="#2563EB" stroke="none"/>
|
||||||
|
<text x="1406" y="840" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#FFFFFF">保存规则</text>
|
||||||
|
<text x="276" y="933" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">交互说明:保存失败保留输入;版本冲突重新加载;未保存离开须确认。</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 126 KiB |
@@ -0,0 +1,82 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="1600" height="1000" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="248" height="1000" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="248" y1="0" x2="248" y2="1000" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="24" y="24" width="38" height="38" rx="8" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="31" y="54" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#2563EB">聆</text>
|
||||||
|
<text x="74" y="41" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#111827">聆界短信管理平台</text>
|
||||||
|
<text x="74" y="61" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">平台运营工作区</text>
|
||||||
|
<text x="28" y="117" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">运营概览</text>
|
||||||
|
<rect x="29" y="136" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="148" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营看板</text>
|
||||||
|
<rect x="16" y="171" width="216" height="40" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<rect x="16" y="171" width="3" height="40" rx="0" fill="#2563EB" stroke="none"/>
|
||||||
|
<rect x="29" y="180" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#2563EB"/>
|
||||||
|
<text x="56" y="192" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">发送监控</text>
|
||||||
|
<rect x="29" y="224" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="236" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">网关异常</text>
|
||||||
|
<rect x="29" y="268" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="280" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">签名质量检测</text>
|
||||||
|
<text x="28" y="339" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">客户管理</text>
|
||||||
|
<rect x="29" y="358" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="370" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业管理</text>
|
||||||
|
<rect x="29" y="402" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="414" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用管理</text>
|
||||||
|
<rect x="29" y="446" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="458" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业签名管理</text>
|
||||||
|
<rect x="29" y="490" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="502" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业模板管理</text>
|
||||||
|
<text x="28" y="561" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">短信业务</text>
|
||||||
|
<rect x="29" y="580" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="592" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信通道</text>
|
||||||
|
<rect x="29" y="624" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="636" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信记录</text>
|
||||||
|
<rect x="29" y="668" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="680" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">任务进度</text>
|
||||||
|
<text x="28" y="739" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">系统管理</text>
|
||||||
|
<rect x="29" y="758" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="770" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">用户管理</text>
|
||||||
|
<rect x="29" y="802" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="814" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">系统监控</text>
|
||||||
|
<text x="24" y="964" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">设计原型 · 非真实业务数据</text>
|
||||||
|
<rect x="249" y="0" width="1351" height="65" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="249" y1="65" x2="1600" y2="65" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="276" y="37" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">运营概览 / 发送监控</text>
|
||||||
|
<rect x="1000" y="15" width="140" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1014" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">预警中心 6</text>
|
||||||
|
<rect x="1152" y="15" width="148" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1166" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">待审核任务</text>
|
||||||
|
<rect x="1312" y="15" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1326" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">报备任务提醒</text>
|
||||||
|
<text x="1500" y="40" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">管理员</text>
|
||||||
|
<text x="276" y="115" font-family="Microsoft YaHei, sans-serif" font-size="24" font-weight="600" fill="#111827">短信通道</text>
|
||||||
|
<text x="276" y="138" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">新建 / 复制通道保存成功之后,独立询问是否纳管</text>
|
||||||
|
<rect x="1370" y="91" width="138" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1379" y="109" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态示例 · 待实施</text>
|
||||||
|
<rect x="276" y="180" width="1296" height="713" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="298" y="202" width="78" height="25" rx="5" fill="#F0FDF4" stroke="none"/>
|
||||||
|
<text x="307" y="220" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#15803D">通道已保存</text>
|
||||||
|
<text x="420" y="221" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">行业通道 E · 新通道编号 CH-005</text>
|
||||||
|
<text x="298" y="262" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">创建结果已生效。加入监控不会修改通道价格、连接或路由。</text>
|
||||||
|
<rect x="426" y="316" width="940" height="421" rx="9" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<rect x="420" y="310" width="940" height="421" rx="8" fill="#FFFFFF" stroke="#D1D5DB"/>
|
||||||
|
<text x="448" y="357" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#111827">是否加入行业通道监控?</text>
|
||||||
|
<text x="1313" y="358" font-family="Microsoft YaHei, sans-serif" font-size="23" font-weight="400" fill="#6B7280">×</text>
|
||||||
|
<line x1="421" y1="386" x2="1359" y2="386" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="448" y="409" width="884" height="48" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="468" y="440" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#2563EB">如果是行业短信通道建议加入</text>
|
||||||
|
<text x="448" y="499" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">新通道:行业通道 E(CH-005) · 支持:移动 / 联通 / 电信</text>
|
||||||
|
<text x="448" y="541" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">将采用行业通道通用规则</text>
|
||||||
|
<text x="448" y="575" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">最低100条成熟样本;5秒 ≥90%,20秒 ≥95%,1分钟 ≥98%。</text>
|
||||||
|
<text x="448" y="615" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">现在不加入,也可稍后从发送监控 → 监控通道中添加。</text>
|
||||||
|
<line x1="421" y1="655" x2="1359" y2="655" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="1058" y="678" width="118" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1072" y="701" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">暂不加入</text>
|
||||||
|
<rect x="1190" y="678" width="142" height="36" rx="6" fill="#2563EB" stroke="none"/>
|
||||||
|
<text x="1204" y="701" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#FFFFFF">加入监控</text>
|
||||||
|
<rect x="420" y="761" width="940" height="79" rx="6" fill="#FFFBEB" stroke="none"/>
|
||||||
|
<text x="440" y="792" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#B45309">加入失败状态:通道已保存,加入监控失败。</text>
|
||||||
|
<text x="440" y="821" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#B45309">仅重试加入监控,不重复创建通道;关闭弹窗保留原创建结果。</text>
|
||||||
|
<text x="276" y="943" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">触发点:服务端返回新ID之后。复制通道不继承原通道监控身份。</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 157 KiB |
@@ -0,0 +1,198 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="1600" height="1000" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="248" height="1000" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="248" y1="0" x2="248" y2="1000" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="24" y="24" width="38" height="38" rx="8" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="31" y="54" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#2563EB">聆</text>
|
||||||
|
<text x="74" y="41" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#111827">聆界短信管理平台</text>
|
||||||
|
<text x="74" y="61" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">平台运营工作区</text>
|
||||||
|
<text x="28" y="117" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">运营概览</text>
|
||||||
|
<rect x="29" y="136" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="148" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">运营看板</text>
|
||||||
|
<rect x="16" y="171" width="216" height="40" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<rect x="16" y="171" width="3" height="40" rx="0" fill="#2563EB" stroke="none"/>
|
||||||
|
<rect x="29" y="180" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#2563EB"/>
|
||||||
|
<text x="56" y="192" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">发送监控</text>
|
||||||
|
<rect x="29" y="224" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="236" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">网关异常</text>
|
||||||
|
<rect x="29" y="268" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="280" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">签名质量检测</text>
|
||||||
|
<text x="28" y="339" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">客户管理</text>
|
||||||
|
<rect x="29" y="358" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="370" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业管理</text>
|
||||||
|
<rect x="29" y="402" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="414" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业应用管理</text>
|
||||||
|
<rect x="29" y="446" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="458" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业签名管理</text>
|
||||||
|
<rect x="29" y="490" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="502" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">企业模板管理</text>
|
||||||
|
<text x="28" y="561" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">短信业务</text>
|
||||||
|
<rect x="29" y="580" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="592" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信通道</text>
|
||||||
|
<rect x="29" y="624" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="636" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">短信记录</text>
|
||||||
|
<rect x="29" y="668" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="680" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">任务进度</text>
|
||||||
|
<text x="28" y="739" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">系统管理</text>
|
||||||
|
<rect x="29" y="758" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="770" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">用户管理</text>
|
||||||
|
<rect x="29" y="802" width="14" height="14" rx="3" fill="#F8FAFC" stroke="#9CA3AF"/>
|
||||||
|
<text x="56" y="814" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">系统监控</text>
|
||||||
|
<text x="24" y="964" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">设计原型 · 非真实业务数据</text>
|
||||||
|
<rect x="249" y="0" width="1351" height="65" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="249" y1="65" x2="1600" y2="65" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="276" y="37" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">运营概览 / 发送监控</text>
|
||||||
|
<rect x="1000" y="15" width="140" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1014" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">预警中心 6</text>
|
||||||
|
<rect x="1152" y="15" width="148" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1166" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">待审核任务</text>
|
||||||
|
<rect x="1312" y="15" width="152" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1326" y="38" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">报备任务提醒</text>
|
||||||
|
<text x="1500" y="40" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">管理员</text>
|
||||||
|
<text x="276" y="115" font-family="Microsoft YaHei, sans-serif" font-size="24" font-weight="600" fill="#111827">发送质量告警</text>
|
||||||
|
<text x="276" y="138" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">聚合异常指标,记录处理与恢复过程</text>
|
||||||
|
<rect x="1370" y="91" width="138" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1379" y="109" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态示例 · 待实施</text>
|
||||||
|
<rect x="276" y="180" width="808" height="724" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<rect x="298" y="203" width="66" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="307" y="221" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">异常持续</text>
|
||||||
|
<text x="408" y="225" font-family="Microsoft YaHei, sans-serif" font-size="18" font-weight="600" fill="#1F2937">行业通道 A × 移动</text>
|
||||||
|
<text x="298" y="263" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">事件 AL-EXAMPLE-001 · 开始于12:30 · 最近评估12:35</text>
|
||||||
|
<rect x="298" y="282" width="764" height="54" rx="6" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="314" y="314" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#DC2626">三个时效指标低于阈值,合并为一条活动告警。</text>
|
||||||
|
<text x="298" y="379" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">命中指标</text>
|
||||||
|
<text x="548" y="379" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">实际值</text>
|
||||||
|
<text x="704" y="379" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">最低值</text>
|
||||||
|
<text x="851" y="379" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="600" fill="#6B7280">成熟样本</text>
|
||||||
|
<text x="298" y="420" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">5秒到达率</text>
|
||||||
|
<text x="548" y="420" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#DC2626">83.97%</text>
|
||||||
|
<text x="704" y="420" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">90.00%</text>
|
||||||
|
<text x="851" y="420" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">1058 / 1260</text>
|
||||||
|
<line x1="298" y1="440" x2="1062" y2="440" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="298" y="470" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">20秒到达率</text>
|
||||||
|
<text x="548" y="470" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#DC2626">94.00%</text>
|
||||||
|
<text x="704" y="470" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">95.00%</text>
|
||||||
|
<text x="851" y="470" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">1128 / 1200</text>
|
||||||
|
<line x1="298" y1="490" x2="1062" y2="490" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="298" y="520" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">1分钟到达率</text>
|
||||||
|
<text x="548" y="520" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#DC2626">97.00%</text>
|
||||||
|
<text x="704" y="520" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">98.00%</text>
|
||||||
|
<text x="851" y="520" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="400" fill="#1F2937">1067 / 1100</text>
|
||||||
|
<line x1="298" y1="540" x2="1062" y2="540" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="298" y="589" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">5秒到达率趋势</text>
|
||||||
|
<text x="798" y="589" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">实线:实际 / 虚线:阈值</text>
|
||||||
|
<line x1="350" y1="619" x2="1044" y2="619" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<line x1="350" y1="660" x2="1044" y2="660" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<line x1="350" y1="701" x2="1044" y2="701" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<line x1="350" y1="742" x2="1044" y2="742" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="298" y="624" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">100%</text>
|
||||||
|
<text x="305" y="686" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">90%</text>
|
||||||
|
<text x="305" y="749" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">80%</text>
|
||||||
|
<line x1="350" y1="681" x2="357" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="363" y1="681" x2="370" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="376" y1="681" x2="383" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="389" y1="681" x2="396" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="402" y1="681" x2="409" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="415" y1="681" x2="422" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="428" y1="681" x2="435" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="441" y1="681" x2="448" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="454" y1="681" x2="461" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="467" y1="681" x2="474" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="480" y1="681" x2="487" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="493" y1="681" x2="500" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="506" y1="681" x2="513" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="519" y1="681" x2="526" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="532" y1="681" x2="539" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="545" y1="681" x2="552" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="558" y1="681" x2="565" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="571" y1="681" x2="578" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="584" y1="681" x2="591" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="597" y1="681" x2="604" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="610" y1="681" x2="617" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="623" y1="681" x2="630" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="636" y1="681" x2="643" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="649" y1="681" x2="656" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="662" y1="681" x2="669" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="675" y1="681" x2="682" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="688" y1="681" x2="695" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="701" y1="681" x2="708" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="714" y1="681" x2="721" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="727" y1="681" x2="734" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="740" y1="681" x2="747" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="753" y1="681" x2="760" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="766" y1="681" x2="773" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="779" y1="681" x2="786" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="792" y1="681" x2="799" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="805" y1="681" x2="812" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="818" y1="681" x2="825" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="831" y1="681" x2="838" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="844" y1="681" x2="851" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="857" y1="681" x2="864" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="870" y1="681" x2="877" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="883" y1="681" x2="890" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="896" y1="681" x2="903" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="909" y1="681" x2="916" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="922" y1="681" x2="929" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="935" y1="681" x2="942" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="948" y1="681" x2="955" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="961" y1="681" x2="968" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="974" y1="681" x2="981" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="987" y1="681" x2="994" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="1000" y1="681" x2="1007" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="1013" y1="681" x2="1020" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="1026" y1="681" x2="1033" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="1039" y1="681" x2="1046" y2="681" stroke="#DC2626" stroke-width="1"/>
|
||||||
|
<line x1="350.0" y1="631.4" x2="419.4" y2="634.5" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="419.4" y1="634.5" x2="488.8" y2="637.6" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="488.8" y1="637.6" x2="558.2" y2="634.5" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="558.2" y1="634.5" x2="627.6" y2="643.8" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="627.6" y1="643.8" x2="697.0" y2="650.0" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="697.0" y1="650.0" x2="766.4000000000001" y2="656.2" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="766.4000000000001" y1="656.2" x2="835.8000000000001" y2="668.6" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="835.8000000000001" y1="668.6" x2="905.2" y2="674.8" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="905.2" y1="674.8" x2="974.6" y2="718.386" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<line x1="974.6" y1="718.386" x2="1044.0" y2="718.386" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<text x="350" y="780" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">11:45</text>
|
||||||
|
<text x="558" y="780" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">12:00</text>
|
||||||
|
<text x="766" y="780" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">12:15</text>
|
||||||
|
<text x="999" y="780" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">12:35</text>
|
||||||
|
<line x1="277" y1="821" x2="1083" y2="821" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="298" y="846" width="120" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="312" y="869" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">查看样本</text>
|
||||||
|
<rect x="834" y="846" width="224" height="36" rx="6" fill="#2563EB" stroke="none"/>
|
||||||
|
<text x="848" y="869" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#FFFFFF">标记已读(非恢复)</text>
|
||||||
|
<rect x="1110" y="180" width="462" height="376" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1132" y="220" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">预警中心弹层示意</text>
|
||||||
|
<text x="1132" y="250" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">与既有消息提醒保持相同交互</text>
|
||||||
|
<rect x="1130" y="277" width="420" height="70" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1146" y="304" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#1F2937">安全检测与封禁</text>
|
||||||
|
<rect x="1496" y="287" width="30" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1505" y="305" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">0</text>
|
||||||
|
<text x="1146" y="332" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">暂无待处置告警</text>
|
||||||
|
<rect x="1130" y="360" width="420" height="70" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1146" y="387" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#1F2937">系统监控告警</text>
|
||||||
|
<rect x="1496" y="370" width="30" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1505" y="388" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">0</text>
|
||||||
|
<text x="1146" y="415" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">暂无活动告警</text>
|
||||||
|
<rect x="1130" y="443" width="420" height="70" rx="6" fill="#EFF6FF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1146" y="470" font-family="Microsoft YaHei, sans-serif" font-size="15" font-weight="600" fill="#1F2937">发送质量告警</text>
|
||||||
|
<rect x="1496" y="453" width="30" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="1505" y="471" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">6</text>
|
||||||
|
<text x="1146" y="498" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">行业2 · 验证码1 · 整体兜底3</text>
|
||||||
|
<rect x="1110" y="580" width="462" height="324" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="1132" y="619" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">事件时间线</text>
|
||||||
|
<rect x="1132" y="645" width="78" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1141" y="663" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">12:30</text>
|
||||||
|
<text x="1212" y="663" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">首次异常</text>
|
||||||
|
<text x="1212" y="691" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">三项命中,创建1条事件</text>
|
||||||
|
<rect x="1132" y="725" width="78" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1141" y="743" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">12:35</text>
|
||||||
|
<text x="1212" y="743" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">持续异常</text>
|
||||||
|
<text x="1212" y="771" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">更新原事件,不重复刷屏</text>
|
||||||
|
<rect x="1132" y="805" width="42" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="1141" y="823" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">后续</text>
|
||||||
|
<text x="1212" y="823" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">连续2次正常后恢复</text>
|
||||||
|
<text x="1212" y="851" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">缺样本或数据延迟不算恢复</text>
|
||||||
|
<text x="276" y="955" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">未读状态按用户记录;同一异常多个窗口只计一个活动事件。所有数据均为设计示例。</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="844" viewBox="0 0 390 844">
|
||||||
|
<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>
|
||||||
|
<rect x="0" y="0" width="390" height="844" rx="0" fill="#F6F7F9" stroke="none"/>
|
||||||
|
<rect x="0" y="0" width="390" height="58" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="0" y1="58" x2="390" y2="58" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<line x1="17" y1="22" x2="33" y2="22" stroke="#6B7280" stroke-width="2"/>
|
||||||
|
<line x1="17" y1="28" x2="33" y2="28" stroke="#6B7280" stroke-width="2"/>
|
||||||
|
<line x1="17" y1="34" x2="33" y2="34" stroke="#6B7280" stroke-width="2"/>
|
||||||
|
<text x="49" y="37" font-family="Microsoft YaHei, sans-serif" font-size="18" font-weight="600" fill="#111827">发送监控</text>
|
||||||
|
<rect x="255" y="18" width="66" height="25" rx="5" fill="#FEF2F2" stroke="none"/>
|
||||||
|
<text x="264" y="36" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#DC2626">预警 6</text>
|
||||||
|
<rect x="349" y="27" width="3" height="3" rx="1" fill="#6B7280" stroke="none"/>
|
||||||
|
<rect x="355" y="27" width="3" height="3" rx="1" fill="#6B7280" stroke="none"/>
|
||||||
|
<rect x="361" y="27" width="3" height="3" rx="1" fill="#6B7280" stroke="none"/>
|
||||||
|
<text x="16" y="103" font-family="Microsoft YaHei, sans-serif" font-size="22" font-weight="600" fill="#111827">整体兜底</text>
|
||||||
|
<rect x="246" y="80" width="90" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="255" y="98" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">静态设计示例</text>
|
||||||
|
<text x="16" y="129" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#6B7280">近30分钟 · 每10分钟评估</text>
|
||||||
|
<text x="16" y="168" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">行业通道</text>
|
||||||
|
<text x="118" y="168" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">验证码</text>
|
||||||
|
<text x="204" y="168" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">整体兜底</text>
|
||||||
|
<text x="310" y="168" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#6B7280">告警</text>
|
||||||
|
<line x1="199" y1="183" x2="280" y2="183" stroke="#2563EB" stroke-width="3"/>
|
||||||
|
<rect x="16" y="202" width="358" height="73" rx="6" fill="#EFF6FF" stroke="none"/>
|
||||||
|
<text x="28" y="229" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#2563EB">以收到成功回执为准</text>
|
||||||
|
<text x="28" y="256" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#2563EB">未满时长仍在观察中,不计作失败。</text>
|
||||||
|
<rect x="16" y="290" width="107" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="30" y="313" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">筛选</text>
|
||||||
|
<rect x="132" y="290" width="116" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="146" y="313" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">阈值设置</text>
|
||||||
|
<rect x="257" y="290" width="117" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="271" y="313" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">刷新</text>
|
||||||
|
<text x="16" y="356" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">12:00 – 12:30 / 12:30:18更新</text>
|
||||||
|
<rect x="16" y="374" width="358" height="341" rx="8" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="32" y="408" font-family="Microsoft YaHei, sans-serif" font-size="16" font-weight="600" fill="#1F2937">示例零售 · 通知应用</text>
|
||||||
|
<text x="32" y="433" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">【示例商城】 · 应用×签名规则</text>
|
||||||
|
<rect x="269" y="390" width="66" height="25" rx="5" fill="#F3F4F6" stroke="none"/>
|
||||||
|
<text x="278" y="408" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="600" fill="#6B7280">样本不足</text>
|
||||||
|
<text x="32" y="467" font-family="Microsoft YaHei, sans-serif" font-size="13" font-weight="400" fill="#1F2937">窗口提交300条业务短信</text>
|
||||||
|
<line x1="32" y1="482" x2="358" y2="482" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="32" y="510" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">1分钟</text>
|
||||||
|
<text x="232" y="514" font-family="Microsoft YaHei, sans-serif" font-size="20" font-weight="600" fill="#15803D">88.62%</text>
|
||||||
|
<text x="122" y="534" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">257/290成熟 · 10条观察中</text>
|
||||||
|
<line x1="32" y1="547" x2="358" y2="547" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="32" y="575" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">5分钟</text>
|
||||||
|
<text x="232" y="579" font-family="Microsoft YaHei, sans-serif" font-size="20" font-weight="600" fill="#15803D">92.17%</text>
|
||||||
|
<text x="122" y="599" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">212/230成熟 · 70条观察中</text>
|
||||||
|
<line x1="32" y1="612" x2="358" y2="612" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<text x="32" y="640" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="600" fill="#1F2937">20分钟</text>
|
||||||
|
<text x="232" y="644" font-family="Microsoft YaHei, sans-serif" font-size="20" font-weight="600" fill="#6B7280">94.44%</text>
|
||||||
|
<text x="122" y="664" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">85/90成熟 · 210条观察中</text>
|
||||||
|
<text x="16" y="745" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">20分钟指标最低100条,当前90条。</text>
|
||||||
|
<text x="16" y="770" font-family="Microsoft YaHei, sans-serif" font-size="12" font-weight="400" fill="#6B7280">可评估项正常;不足项继续观察。</text>
|
||||||
|
<rect x="0" y="788" width="390" height="56" rx="0" fill="#FFFFFF" stroke="none"/>
|
||||||
|
<line x1="0" y1="788" x2="390" y2="788" stroke="#E5E7EB" stroke-width="1"/>
|
||||||
|
<rect x="16" y="798" width="171" height="36" rx="6" fill="#FFFFFF" stroke="#E5E7EB"/>
|
||||||
|
<text x="30" y="821" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#1F2937">查看趋势</text>
|
||||||
|
<rect x="201" y="798" width="173" height="36" rx="6" fill="#2563EB" stroke="none"/>
|
||||||
|
<text x="215" y="821" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="400" fill="#FFFFFF">告警记录</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
# 发送监控重做:主要页面原型
|
||||||
|
|
||||||
|
配套需求:[发送监控页面重做方案](../../sending-monitor-redesign-plan-20260906.md)。
|
||||||
|
|
||||||
|
本目录为2026-09-06需求评审材料。全部名称、告警和指标均为设计示例,不含真实业务数据,不连接API,不代表功能已经实现或通过真实页面验收。桌面图为1600×1000,窄屏图为390×844;1366×768的适配要求在需求方案中说明,本轮未绘制该尺寸。
|
||||||
|
|
||||||
|
## 1. 行业通道监控
|
||||||
|
|
||||||
|
按通道×运营商展开;展示近5分钟窗口、更新时间、三项到达率、成功数/成熟分母、异常与样本不足状态。顶部进入监控通道选择和阈值设置;行内进入趋势或详情。通道纳管不影响发送路由。
|
||||||
|
|
||||||
|
行业/验证码图展示12:30初评,12:31补齐同一窗口最后一分钟的观察样本;初评/定稿合并处理,不能重复计连续告警次数。详见需求方案4.3节。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](01-industry-monitor.svg)
|
||||||
|
|
||||||
|
## 2. 验证码监控
|
||||||
|
|
||||||
|
按企业应用×签名展开,只统计最终提交内容含“验证码”的业务短信。复用行业监控的时间口径、筛选和详情布局;数据延迟明确显示为待更新,不填充正常指标。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](02-verification-monitor.svg)
|
||||||
|
|
||||||
|
## 3. 整体兜底监控
|
||||||
|
|
||||||
|
暂按全部短信理解“兜底”,展示近30分钟、每10分钟评估的1/5/20分钟指标,以及当前生效规则来源。
|
||||||
|
|
||||||
|
首行采用应用×签名规则,最低100条、下限85%/90%/95%。前两项正常;20分钟成功85条、成熟90条,94.44%但尚不告警,另有210条观察中。该例用于说明三个指标的分母不同,不能用全窗口300条直接计算20分钟指标。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](03-overall-monitor.svg)
|
||||||
|
|
||||||
|
## 4. 阈值与个性规则
|
||||||
|
|
||||||
|
完整规则优先级为:应用×签名 > 签名 > 企业应用 > 通用。左侧选择范围,右侧编辑最低样本、各指标下限及异常/恢复连续次数;保存前预览生效范围。恢复继承需要清晰说明将采用哪一套规则。保存失败保留输入,版本冲突提示重新加载。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](04-threshold-rules.svg)
|
||||||
|
|
||||||
|
## 5. 新建或复制通道后的纳管提示
|
||||||
|
|
||||||
|
仅在服务端创建成功、取得新通道ID之后出现。提示“是否加入行业通道监控?”并标注“如果是行业短信通道建议加入”。暂不加入不影响创建结果;加入失败只重试纳管,不重复创建通道。图下方的失败提示用于展示另一种状态,并非成功时同时弹出错误。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](05-channel-enrollment.svg)
|
||||||
|
|
||||||
|
## 6. 预警中心与告警详情
|
||||||
|
|
||||||
|
右上角预警中心新增“发送质量告警”,汇总行业、验证码、整体兜底三类;保留既有待审核任务和报备任务提醒入口。点击进入告警列表,再进入左侧详情。图中把弹层、详情和时间线并列,以便评审,实际弹层仍由顶部按钮打开。
|
||||||
|
|
||||||
|
同一维度多项异常合成一条活动事件,持续异常更新原事件;标记已读不等于恢复。阈值线、当时分母、首次/最近异常时间及规则版本应可追溯。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](06-alert-center-detail.svg)
|
||||||
|
|
||||||
|
## 7. 窄屏监控
|
||||||
|
|
||||||
|
折叠导航,将宽表转为指标卡;每项独立展示百分比、成熟样本、观察中条数。筛选和配置在抽屉中完成,保留刷新、趋势和告警入口。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[可编辑SVG](07-mobile-monitor.svg)
|
||||||
|
|
||||||
|
## 文件维护与核验
|
||||||
|
|
||||||
|
- PNG用于直接评审,SVG保留文字和矢量形状,二者由同一绘制源生成。
|
||||||
|
- 绘制源:[render-prototypes.py](render-prototypes.py),依赖Python、Pillow及Windows微软雅黑字体;执行`python render-prototypes.py`只重建本目录七组PNG/SVG,不读取业务数据。
|
||||||
|
- 本轮检查图文内容、比例示例、图片尺寸、SVG可解析性和文档链接;原型不包含可操作的业务功能,也不代替后续三个尺寸的真实浏览器验收。
|
||||||
|
- 本目录未使用业务CSS,未修改应用样式或其他并行任务文件。
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Standalone design artifacts only. No network, application state or business APIs.
|
||||||
|
Regenerates only the PNG/SVG files in this prototype directory.
|
||||||
|
Requires Pillow and Windows Microsoft YaHei fonts.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from html import escape
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
FONT = Path('C:/Windows/Fonts/msyh.ttc')
|
||||||
|
BOLD = Path('C:/Windows/Fonts/msyhbd.ttc')
|
||||||
|
C = dict(bg='#F6F7F9', white='#FFFFFF', text='#1F2937', title='#111827', muted='#6B7280',
|
||||||
|
line='#E5E7EB', blue='#2563EB', soft='#EFF6FF', red='#DC2626', redbg='#FEF2F2',
|
||||||
|
green='#15803D', greenbg='#F0FDF4', amber='#B45309', amberbg='#FFFBEB')
|
||||||
|
fonts = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Canvas:
|
||||||
|
def __init__(self, w=1600, h=1000):
|
||||||
|
self.w, self.h = w, h
|
||||||
|
self.im = Image.new('RGB', (w, h), C['bg'])
|
||||||
|
self.d = ImageDraw.Draw(self.im)
|
||||||
|
self.svg = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">',
|
||||||
|
'<title>发送监控页面重做 · 静态设计原型(全部为示例数据)</title>']
|
||||||
|
self.rect(0, 0, w, h, C['bg'])
|
||||||
|
self.overflow = []
|
||||||
|
|
||||||
|
def rect(self, x, y, w, h, fill=None, stroke=None, radius=0):
|
||||||
|
fill = fill or C['white']
|
||||||
|
self.d.rounded_rectangle((x, y, x+w, y+h), radius=radius, fill=fill, outline=stroke)
|
||||||
|
self.svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{radius}" fill="{fill}" stroke="{stroke or "none"}"/>')
|
||||||
|
|
||||||
|
def line(self, x1, y1, x2, y2, color=None, width=1):
|
||||||
|
color = color or C['line']
|
||||||
|
self.d.line((x1, y1, x2, y2), fill=color, width=width)
|
||||||
|
self.svg.append(f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{width}"/>')
|
||||||
|
|
||||||
|
def text(self, x, y, s, size=14, color=None, bold=False, maxw=None):
|
||||||
|
key = (size, bold)
|
||||||
|
if key not in fonts:
|
||||||
|
fonts[key] = ImageFont.truetype(str(BOLD if bold else FONT), size)
|
||||||
|
font = fonts[key]
|
||||||
|
width = self.d.textlength(s, font=font)
|
||||||
|
if (maxw is not None and width > maxw) or x+width > self.w or y+size > self.h:
|
||||||
|
self.overflow.append((s, round(width, 1), maxw))
|
||||||
|
color = color or C['text']
|
||||||
|
self.d.text((x, y), s, font=font, fill=color, anchor='lt')
|
||||||
|
self.svg.append(f'<text x="{x}" y="{y+size}" font-family="Microsoft YaHei, sans-serif" font-size="{size}" font-weight="{600 if bold else 400}" fill="{color}">{escape(s)}</text>')
|
||||||
|
|
||||||
|
def button(self, x, y, w, label, primary=False):
|
||||||
|
self.rect(x,y,w,36,C['blue'] if primary else C['white'],None if primary else C['line'],6)
|
||||||
|
self.text(x+14,y+9,label,14,C['white'] if primary else C['text'],maxw=w-22)
|
||||||
|
|
||||||
|
def badge(self, x, y, label, tone='blue'):
|
||||||
|
palettes = {'blue':(C['soft'],C['blue']),'red':(C['redbg'],C['red']),
|
||||||
|
'green':(C['greenbg'],C['green']),'gray':('#F3F4F6',C['muted']),
|
||||||
|
'amber':(C['amberbg'],C['amber'])}
|
||||||
|
bg,fg=palettes[tone]
|
||||||
|
w= len(label)*12+18
|
||||||
|
self.rect(x,y,w,25,bg,radius=5)
|
||||||
|
self.text(x+9,y+6,label,12,fg,bold=True)
|
||||||
|
|
||||||
|
def save(self, name):
|
||||||
|
if self.overflow:
|
||||||
|
raise RuntimeError(f'{name}: overflowing text: {self.overflow}')
|
||||||
|
self.im.save(ROOT/(name+'.png'))
|
||||||
|
(ROOT/(name+'.svg')).write_text('\n'.join(self.svg+['</svg>']),encoding='utf-8')
|
||||||
|
print(name, f'{self.w}x{self.h}', 'OK')
|
||||||
|
|
||||||
|
|
||||||
|
def shell(c, title='发送监控', subtitle='监测时效变化,定位异常通道与应用签名'):
|
||||||
|
c.rect(0,0,248,c.h,C['white']); c.line(248,0,248,c.h)
|
||||||
|
c.rect(24,24,38,38,C['soft'],radius=8); c.text(31,32,'聆',22,C['blue'],True)
|
||||||
|
c.text(74,25,'聆界短信管理平台',16,C['title'],True); c.text(74,49,'平台运营工作区',12,C['muted'])
|
||||||
|
sections=[('运营概览',['运营看板','发送监控','网关异常','签名质量检测']),
|
||||||
|
('客户管理',['企业管理','企业应用管理','企业签名管理','企业模板管理']),
|
||||||
|
('短信业务',['短信通道','短信记录','任务进度']),('系统管理',['用户管理','系统监控'])]
|
||||||
|
y=105
|
||||||
|
for group,items in sections:
|
||||||
|
c.text(28,y,group,12,C['muted'],True); y+=29
|
||||||
|
for item in items:
|
||||||
|
if item=='发送监控':
|
||||||
|
c.rect(16,y-7,216,40,C['soft'],radius=6); c.rect(16,y-7,3,40,C['blue'])
|
||||||
|
c.rect(29,y+2,14,14,'#F8FAFC',C['blue'] if item=='发送监控' else '#9CA3AF',3)
|
||||||
|
c.text(56,y,item,14,C['blue'] if item=='发送监控' else C['text'],item=='发送监控')
|
||||||
|
y+=44
|
||||||
|
y+=17
|
||||||
|
c.text(24,952,'设计原型 · 非真实业务数据',12,C['muted'])
|
||||||
|
c.rect(249,0,1351,65,C['white']); c.line(249,65,1600,65)
|
||||||
|
c.text(276,24,'运营概览 / 发送监控',13,C['muted'])
|
||||||
|
c.button(1000,15,140,'预警中心 6'); c.button(1152,15,148,'待审核任务'); c.button(1312,15,152,'报备任务提醒')
|
||||||
|
c.text(1500,27,'管理员',13,C['muted'])
|
||||||
|
c.text(276,91,title,24,C['title'],True); c.text(276,125,subtitle,13,C['muted'])
|
||||||
|
c.badge(1370,91,'静态示例 · 待实施','gray')
|
||||||
|
|
||||||
|
|
||||||
|
def tabbar(c, selected):
|
||||||
|
tabs=['行业通道','验证码','整体兜底','告警记录']
|
||||||
|
for i,t in enumerate(tabs):
|
||||||
|
x=276+i*134
|
||||||
|
c.text(x+8,192,t,15,C['blue'] if i==selected else C['muted'],i==selected)
|
||||||
|
if i==selected: c.line(x,223,x+100,223,C['blue'],3)
|
||||||
|
c.line(276,225,1572,225)
|
||||||
|
|
||||||
|
|
||||||
|
CHANNEL = [
|
||||||
|
('行业通道 A','CH-001 · 移动',1280,[(1058,1260),(1128,1200),(1067,1100)],'异常','通用规则'),
|
||||||
|
('行业通道 A','CH-001 · 联通',920,[(874,900),(856,880),(813,820)],'正常','通用规则'),
|
||||||
|
('行业通道 B','CH-002 · 电信',760,[(680,740),(666,700),(640,650)],'正常','通用规则'),
|
||||||
|
('行业通道 C','CH-003 · 移动',68,[(56,60),(53,55),(45,46)],'样本不足','通用规则'),
|
||||||
|
('行业通道 D','CH-004 · 联通',430,[(310,420),(362,400),(348,360)],'异常','通用规则')]
|
||||||
|
OTP = [
|
||||||
|
('示例零售 · 登录应用','【示例商城】',1280,[(1058,1260),(1128,1200),(1067,1100)],'异常','验证码通用'),
|
||||||
|
('示例服务 · 用户中心','【示例服务】',920,[(874,900),(856,880),(813,820)],'正常','验证码通用'),
|
||||||
|
('示例物流 · 商户应用','【示例物流】',760,[(680,740),(666,700),(640,650)],'正常','验证码通用'),
|
||||||
|
('示例教育 · 学员登录','【示例学堂】',68,[(56,60),(53,55),(45,46)],'样本不足','验证码通用'),
|
||||||
|
('示例平台 · 会员应用','【示例会员】',0,[], '数据延迟','验证码通用')]
|
||||||
|
FALLBACK = [
|
||||||
|
('示例零售 · 通知应用','【示例商城】',300,[(257,290),(212,230),(85,90)],'样本不足','应用×签名'),
|
||||||
|
('示例服务 · 业务应用','【示例服务】',2500,[(2115,2350),(1940,2000),(950,1000)],'异常','签名规则'),
|
||||||
|
('示例物流 · 商户应用','【示例物流】',1800,[(1666,1700),(1485,1500),(895,900)],'正常','应用规则'),
|
||||||
|
('示例教育 · 通知应用','【示例学堂】',630,[(558,600),(478,500),(238,240)],'正常','通用规则'),
|
||||||
|
('示例平台 · 消息应用','【示例会员】',0,[], '数据延迟','通用规则')]
|
||||||
|
|
||||||
|
|
||||||
|
def overview(kind):
|
||||||
|
c=Canvas(); shell(c); tabbar(c,kind)
|
||||||
|
title=['行业通道监控','验证码监控','整体兜底监控'][kind]
|
||||||
|
c.rect(276,150,1296,28,C['soft'],radius=5)
|
||||||
|
note='以平台收到成功回执为准;未满观察时长的短信不参与该指标评估。'
|
||||||
|
if kind==1: note='仅统计最终正文包含“验证码”的业务短信;补发不重复计数,以成功回执为准。'
|
||||||
|
if kind==2: note='近30分钟整体监控;1 / 5 / 20分钟分别使用成熟分母,不将新提交短信计作失败。'
|
||||||
|
c.text(288,157,note,12,C['blue'])
|
||||||
|
c.button(1210,183,80,'刷新'); c.button(1300,183,118,'阈值设置')
|
||||||
|
c.button(1428,183,144,'监控通道' if kind==0 else '运行概况',kind==0)
|
||||||
|
values=[('监控维度','16' if kind==0 else '48','通道 × 运营商' if kind==0 else '企业应用 × 签名'),
|
||||||
|
('异常维度','2' if kind==0 else '1','点击仅看异常'),('样本不足','3','不足最低成熟条数'),
|
||||||
|
('窗口提交量','3,458' if kind==0 else ('3,028' if kind==1 else '5,230'),'次发送尝试' if kind==0 else '条业务短信')]
|
||||||
|
for i,(lab,val,desc) in enumerate(values):
|
||||||
|
x=276+i*330; c.rect(x,246,306,97,C['white'],C['line'],8)
|
||||||
|
c.text(x+18,260,lab,13,C['muted']); c.text(x+18,286,val,28,C['red'] if i==1 else C['title'],True)
|
||||||
|
c.text(x+115,300,desc,12,C['muted'])
|
||||||
|
c.rect(276,363,1296,72,C['white'],C['line'],8)
|
||||||
|
c.text(294,378,'统计窗口',12,C['muted']); c.text(294,401,'12:00 – 12:30' if kind==2 else '12:25 – 12:30',14,C['text'],True)
|
||||||
|
c.text(490,378,'评估时刻 12:30:00',12,C['muted']); c.text(490,401,'每10分钟评估' if kind==2 else '初评 · 12:31补齐',13)
|
||||||
|
c.text(686,378,'更新于 12:30:18',12,C['muted']); c.text(686,401,'查看最近2小时趋势',13,C['blue'])
|
||||||
|
c.button(934,382,168,'搜索通道 / 编号' if kind==0 else '企业 / 应用'); c.button(1114,382,168,'运营商:全部' if kind==0 else '搜索签名'); c.button(1294,382,152,'状态:全部')
|
||||||
|
c.rect(276,455,1296,462,C['white'],C['line'],8)
|
||||||
|
c.rect(277,456,1294,47,'#F9FAFB',radius=7)
|
||||||
|
xs=[294,574,727,889,1051,1220,1384]
|
||||||
|
headers=['通道 / 运营商' if kind==0 else '企业应用 / 签名','窗口提交',
|
||||||
|
'1分钟到达率' if kind==2 else '5秒到达率','5分钟到达率' if kind==2 else '20秒到达率',
|
||||||
|
'20分钟到达率' if kind==2 else '1分钟到达率','状态 / 规则','操作']
|
||||||
|
for x,h in zip(xs,headers): c.text(x,472,h,13,C['muted'],True)
|
||||||
|
data=[CHANNEL,OTP,FALLBACK][kind]
|
||||||
|
for i,(name,sub,total,metrics,state,rule) in enumerate(data):
|
||||||
|
y=504+i*75
|
||||||
|
if state=='异常': c.rect(277,y,1294,74,'#FFFBFB')
|
||||||
|
c.text(xs[0],y+16,name,14,bold=True,maxw=265); c.text(xs[0],y+40,sub,12,C['muted'])
|
||||||
|
c.text(xs[1],y+19,f'{total:,}' if metrics else '—',16,bold=True)
|
||||||
|
if metrics:
|
||||||
|
for j,(s,n) in enumerate(metrics):
|
||||||
|
x=xs[2+j]; minimum=100
|
||||||
|
thresholds = [.85,.90,.95] if kind==2 and i==0 else [.90,.95,.98]
|
||||||
|
low=s/n < thresholds[j]
|
||||||
|
tone=C['muted'] if n<minimum else (C['red'] if low else C['green'])
|
||||||
|
c.text(x,y+15,f'{s/n*100:.2f}%',19,tone,True)
|
||||||
|
c.text(x,y+43,f'{s:,} / {n:,} 成熟',12,C['muted'])
|
||||||
|
else:
|
||||||
|
for j in range(3): c.text(xs[2+j],y+20,'— 待更新',14,C['muted'])
|
||||||
|
tone={'正常':'green','异常':'red','样本不足':'gray','数据延迟':'amber'}[state]
|
||||||
|
c.badge(xs[5],y+11,state,tone); c.text(xs[5],y+44,rule,12,C['muted'])
|
||||||
|
c.text(xs[6],y+27,'趋势 / 详情',13,C['blue'])
|
||||||
|
c.line(277,y+74,1571,y+74)
|
||||||
|
c.text(294,892,'每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。',12,C['muted'])
|
||||||
|
c.text(1274,892,'展示5行 ‹ 1 2 3 ›',12,C['muted'])
|
||||||
|
c.text(276,940,title+' · 图中所有数值仅用于需求评审',12,C['muted'])
|
||||||
|
if kind==2: c.text(276,969,'示例首行:20分钟指标85/90,最低100条,尚不触发告警;另有210条观察中。',12,C['blue'])
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def policy():
|
||||||
|
c=Canvas(); shell(c,'告警阈值设置','整体兜底 · 通用规则与个性规则');
|
||||||
|
c.button(1458,126,114,'返回监控')
|
||||||
|
c.rect(276,176,600,706,C['white'],C['line'],8)
|
||||||
|
c.text(298,199,'生效优先级',16,bold=True)
|
||||||
|
c.rect(298,231,554,43,C['soft'],radius=6)
|
||||||
|
c.text(312,244,'应用×签名 > 签名 > 应用 > 通用',15,C['blue'],True)
|
||||||
|
c.text(298,291,'命中最高优先级后,整套规则覆盖。',13,C['muted'])
|
||||||
|
c.text(298,332,'规则列表',16,bold=True); c.button(730,320,122,'新增规则',True)
|
||||||
|
rules=[('应用×签名','通知应用 / 【示例商城】','100条 · 85% / 90% / 95%'),
|
||||||
|
('签名','【示例服务】','100条 · 90% / 95% / 98%'),
|
||||||
|
('应用','商户应用','100条 · 90% / 95% / 98%'),
|
||||||
|
('通用','全部未覆盖对象','100条 · 90% / 95% / 98%')]
|
||||||
|
for i,(scope,obj,value) in enumerate(rules):
|
||||||
|
y=373+i*98
|
||||||
|
c.rect(298,y,554,83,C['soft'] if i==0 else C['white'],C['blue'] if i==0 else C['line'],6)
|
||||||
|
c.badge(313,y+12,scope,'blue' if i==0 else 'gray'); c.text(426,y+16,obj,14,bold=True)
|
||||||
|
c.text(426,y+48,value,12,C['muted'])
|
||||||
|
c.text(298,793,'实际生效示例',14,bold=True)
|
||||||
|
c.text(298,823,'示例零售 / 通知应用 / 【示例商城】',13)
|
||||||
|
c.text(298,847,'采用应用×签名规则,覆盖下层通用设置。',13,C['blue'])
|
||||||
|
c.rect(900,176,672,706,C['white'],C['line'],8)
|
||||||
|
c.text(925,199,'编辑个性规则',18,bold=True); c.badge(1398,198,'草稿 · 未保存','amber')
|
||||||
|
labels=[('作用范围','企业应用 × 签名'),('企业 / 应用','示例零售 / 通知应用'),('签名','【示例商城】')]
|
||||||
|
for i,(lab,value) in enumerate(labels):
|
||||||
|
y=246+i*67; c.text(925,y,lab,13,C['muted']); c.rect(1085,y-8,455,38,'#FAFAFA',C['line'],5); c.text(1097,y+3,value,14)
|
||||||
|
c.line(925,445,1540,445)
|
||||||
|
c.text(925,469,'最低成熟条数',14,bold=True); c.rect(1170,458,160,40,C['white'],C['line'],5); c.text(1183,471,'100',16); c.text(1340,473,'条 / 每项指标',13,C['muted'])
|
||||||
|
c.text(925,508,'仅有样本数达到门槛的指标才参与告警。',12,C['muted'])
|
||||||
|
for i,(label,val) in enumerate([('1分钟到达率下限','85.00'),('5分钟到达率下限','90.00'),('20分钟到达率下限','95.00')]):
|
||||||
|
y=548+i*54; c.rect(925,y+1,18,18,C['blue'],radius=3)
|
||||||
|
c.line(929,y+10,933,y+14,C['white'],2); c.line(933,y+14,939,y+6,C['white'],2); c.text(954,y+3,label,14)
|
||||||
|
c.rect(1280,y-7,260,38,C['white'],C['line'],5); c.text(1293,y+3,val+' %',15)
|
||||||
|
c.text(925,718,'连续异常 1 次触发 · 连续恢复 2 次关闭',13)
|
||||||
|
c.text(925,750,'预计下周期生效;旧告警记录保留原规则版本。',12,C['muted'])
|
||||||
|
c.line(901,791,1571,791)
|
||||||
|
c.button(925,817,120,'恢复继承'); c.button(1300,817,80,'取消'); c.button(1392,817,148,'保存规则',True)
|
||||||
|
c.text(276,920,'交互说明:保存失败保留输入;版本冲突重新加载;未保存离开须确认。',13,C['muted'])
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def join_dialog():
|
||||||
|
c=Canvas(); shell(c,'短信通道','新建 / 复制通道保存成功之后,独立询问是否纳管')
|
||||||
|
c.rect(276,180,1296,713,C['white'],C['line'],8)
|
||||||
|
c.badge(298,202,'通道已保存','green'); c.text(420,207,'行业通道 E · 新通道编号 CH-005',14,bold=True)
|
||||||
|
c.text(298,249,'创建结果已生效。加入监控不会修改通道价格、连接或路由。',13,C['muted'])
|
||||||
|
c.rect(426,316,940,421,'#F3F4F6',radius=9)
|
||||||
|
c.rect(420,310,940,421,C['white'],'#D1D5DB',8)
|
||||||
|
c.text(448,335,'是否加入行业通道监控?',22,C['title'],True); c.text(1313,335,'×',23,C['muted'])
|
||||||
|
c.line(421,386,1359,386)
|
||||||
|
c.rect(448,409,884,48,C['soft'],radius=6); c.text(468,424,'如果是行业短信通道建议加入',16,C['blue'],True)
|
||||||
|
c.text(448,484,'新通道:行业通道 E(CH-005) · 支持:移动 / 联通 / 电信',15)
|
||||||
|
c.text(448,527,'将采用行业通道通用规则',14,bold=True)
|
||||||
|
c.text(448,561,'最低100条成熟样本;5秒 ≥90%,20秒 ≥95%,1分钟 ≥98%。',14,C['muted'])
|
||||||
|
c.text(448,602,'现在不加入,也可稍后从发送监控 → 监控通道中添加。',13,C['muted'])
|
||||||
|
c.line(421,655,1359,655); c.button(1058,678,118,'暂不加入'); c.button(1190,678,142,'加入监控',True)
|
||||||
|
c.rect(420,761,940,79,C['amberbg'],radius=6)
|
||||||
|
c.text(440,778,'加入失败状态:通道已保存,加入监控失败。',14,C['amber'],True)
|
||||||
|
c.text(440,808,'仅重试加入监控,不重复创建通道;关闭弹窗保留原创建结果。',13,C['amber'])
|
||||||
|
c.text(276,930,'触发点:服务端返回新ID之后。复制通道不继承原通道监控身份。',13,C['muted'])
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def alerts():
|
||||||
|
c=Canvas(); shell(c,'发送质量告警','聚合异常指标,记录处理与恢复过程')
|
||||||
|
c.rect(276,180,808,724,C['white'],C['line'],8)
|
||||||
|
c.badge(298,203,'异常持续','red'); c.text(408,207,'行业通道 A × 移动',18,bold=True)
|
||||||
|
c.text(298,250,'事件 AL-EXAMPLE-001 · 开始于12:30 · 最近评估12:35',13,C['muted'])
|
||||||
|
c.rect(298,282,764,54,C['redbg'],radius=6)
|
||||||
|
c.text(314,299,'三个时效指标低于阈值,合并为一条活动告警。',15,C['red'],True)
|
||||||
|
xs=[298,548,704,851]
|
||||||
|
for x,t in zip(xs,['命中指标','实际值','最低值','成熟样本']): c.text(x,366,t,13,C['muted'],True)
|
||||||
|
for i,(lab,val,limit,count) in enumerate([('5秒到达率','83.97%','90.00%','1058 / 1260'),('20秒到达率','94.00%','95.00%','1128 / 1200'),('1分钟到达率','97.00%','98.00%','1067 / 1100')]):
|
||||||
|
y=405+i*50
|
||||||
|
for j,(x,t) in enumerate(zip(xs,[lab,val,limit,count])): c.text(x,y,t,15,C['red'] if j==1 else C['text'],j==1)
|
||||||
|
c.line(298,y+35,1062,y+35)
|
||||||
|
c.text(298,573,'5秒到达率趋势',16,bold=True); c.text(798,577,'实线:实际 / 虚线:阈值',12,C['muted'])
|
||||||
|
for i in range(4): c.line(350,619+i*41,1044,619+i*41)
|
||||||
|
c.text(298,612,'100%',12,C['muted']); c.text(305,674,'90%',12,C['muted']); c.text(305,737,'80%',12,C['muted'])
|
||||||
|
for x in range(350,1044,13): c.line(x,681,x+7,681,C['red'],1)
|
||||||
|
points=[(350+i*69.4,619+(100-v)*6.2) for i,v in enumerate([98,97.5,97,97.5,96,95,94,92,91,83.97,83.97])]
|
||||||
|
for (x1,y1),(x2,y2) in zip(points,points[1:]): c.line(x1,y1,x2,y2,C['blue'],3)
|
||||||
|
for x,t in [(350,'11:45'),(558,'12:00'),(766,'12:15'),(999,'12:35')]: c.text(x,768,t,12,C['muted'])
|
||||||
|
c.line(277,821,1083,821); c.button(298,846,120,'查看样本'); c.button(834,846,224,'标记已读(非恢复)',True)
|
||||||
|
c.rect(1110,180,462,376,C['white'],C['line'],8)
|
||||||
|
c.text(1132,204,'预警中心弹层示意',16,bold=True); c.text(1132,238,'与既有消息提醒保持相同交互',12,C['muted'])
|
||||||
|
for i,(lab,count,desc) in enumerate([('安全检测与封禁','0','暂无待处置告警'),('系统监控告警','0','暂无活动告警'),('发送质量告警','6','行业2 · 验证码1 · 整体兜底3')]):
|
||||||
|
y=277+i*83
|
||||||
|
c.rect(1130,y,420,70,C['soft'] if i==2 else C['white'],C['line'],6)
|
||||||
|
c.text(1146,y+12,lab,15,bold=True); c.badge(1496,y+10,count,'red' if i==2 else 'gray')
|
||||||
|
c.text(1146,y+43,desc,12,C['muted'])
|
||||||
|
c.rect(1110,580,462,324,C['white'],C['line'],8)
|
||||||
|
c.text(1132,603,'事件时间线',16,bold=True)
|
||||||
|
for i,(time,status,desc) in enumerate([('12:30','首次异常','三项命中,创建1条事件'),('12:35','持续异常','更新原事件,不重复刷屏'),('后续','连续2次正常后恢复','缺样本或数据延迟不算恢复')]):
|
||||||
|
y=645+i*80; c.badge(1132,y,time,'gray'); c.text(1212,y+4,status,14,bold=True,maxw=338); c.text(1212,y+34,desc,12,C['muted'],maxw=330)
|
||||||
|
c.text(276,942,'未读状态按用户记录;同一异常多个窗口只计一个活动事件。所有数据均为设计示例。',13,C['muted'])
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def mobile():
|
||||||
|
c=Canvas(390,844)
|
||||||
|
c.rect(0,0,390,58,C['white']); c.line(0,58,390,58)
|
||||||
|
for y in [22,28,34]: c.line(17,y,33,y,C['muted'],2)
|
||||||
|
c.text(49,19,'发送监控',18,C['title'],True)
|
||||||
|
c.badge(255,18,'预警 6','red')
|
||||||
|
for x in [349,355,361]: c.rect(x,27,3,3,C['muted'],radius=1)
|
||||||
|
c.text(16,81,'整体兜底',22,C['title'],True); c.badge(246,80,'静态设计示例','gray')
|
||||||
|
c.text(16,116,'近30分钟 · 每10分钟评估',13,C['muted'])
|
||||||
|
for i,t in enumerate(['行业通道','验证码','整体兜底','告警']):
|
||||||
|
x=[16,118,204,310][i]; c.text(x,154,t,14,C['blue'] if i==2 else C['muted'],i==2)
|
||||||
|
c.line(199,183,280,183,C['blue'],3)
|
||||||
|
c.rect(16,202,358,73,C['soft'],radius=6)
|
||||||
|
c.text(28,215,'以收到成功回执为准',14,C['blue'],True)
|
||||||
|
c.text(28,244,'未满时长仍在观察中,不计作失败。',12,C['blue'])
|
||||||
|
c.button(16,290,107,'筛选'); c.button(132,290,116,'阈值设置'); c.button(257,290,117,'刷新')
|
||||||
|
c.text(16,344,'12:00 – 12:30 / 12:30:18更新',12,C['muted'])
|
||||||
|
c.rect(16,374,358,341,C['white'],C['line'],8)
|
||||||
|
c.text(32,392,'示例零售 · 通知应用',16,bold=True); c.text(32,421,'【示例商城】 · 应用×签名规则',12,C['muted'])
|
||||||
|
c.badge(269,390,'样本不足','gray'); c.text(32,454,'窗口提交300条业务短信',13)
|
||||||
|
rows=[('1分钟','88.62%','257/290成熟 · 10条观察中'),('5分钟','92.17%','212/230成熟 · 70条观察中'),('20分钟','94.44%','85/90成熟 · 210条观察中')]
|
||||||
|
for i,(label,val,desc) in enumerate(rows):
|
||||||
|
y=486+i*65; c.line(32,y-4,358,y-4); c.text(32,y+10,label,14,bold=True); c.text(232,y+8,val,20,C['muted'] if i==2 else C['green'],True)
|
||||||
|
c.text(122,y+36,desc,12,C['muted'],maxw=236)
|
||||||
|
c.text(16,733,'20分钟指标最低100条,当前90条。',12,C['muted'])
|
||||||
|
c.text(16,758,'可评估项正常;不足项继续观察。',12,C['muted'])
|
||||||
|
c.rect(0,788,390,56,C['white']); c.line(0,788,390,788)
|
||||||
|
c.button(16,798,171,'查看趋势'); c.button(201,798,173,'告警记录',True)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ROOT.mkdir(parents=True,exist_ok=True)
|
||||||
|
for i,n in enumerate(['01-industry-monitor','02-verification-monitor','03-overall-monitor']): overview(i).save(n)
|
||||||
|
policy().save('04-threshold-rules')
|
||||||
|
join_dialog().save('05-channel-enrollment')
|
||||||
|
alerts().save('06-alert-center-detail')
|
||||||
|
mobile().save('07-mobile-monitor')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 报备状态变化站内通知
|
||||||
|
|
||||||
|
2026-09-06;状态:实施中。补充报备工作台设计,不替代既有状态记录。
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
签名和每条引流信息独立计算移动、联通、电信的全国通道报备覆盖。通道必须启用、发送地区为全国并支持该运营商;签名的运营商专属报备覆盖同通道旧版全通道报备,不能用被覆盖的历史成功抵消新的失败。引流按自己的报备任务计算,不借用签名成功。
|
||||||
|
|
||||||
|
从零网成功开始记住一次变化过程,允许分次经过一网、两网成功;首次达到三网成功创建一条不可变站内事件。重复导入和三网状态重复保存不重复通知。只有再次回到零网成功才开启下一轮;三网降到两网再恢复不重复通知。存量状态只建基线,不追发历史通知。此处是报备覆盖通知,不保证余额、路由、风控等其他发送条件通过。
|
||||||
|
|
||||||
|
每个事件同时对平台运营人员和所属企业可见,企业按北京时间自然小时汇总。阅读状态按用户保存已读汇总版本;同小时追加事件后重新显示未读。不发送短信、邮件或第三方消息。通道组改动是否使资料重新进入待生成池是本轮独立调查项,不由通知改动顺便调整。
|
||||||
|
|
||||||
|
## 数据与事务
|
||||||
|
|
||||||
|
PostgreSQL 保存对象状态、不可变事件、企业小时汇总及用户阅读游标。报备任务触发器覆盖人工状态修改、导入、删除等全部写入口;在同一事务内按对象加锁、计算覆盖、推进状态、写事件及递增小时版本。回滚同时撤销通知,失败明确使状态写入失败,避免假成功。触发器只在报备任务写路径执行,不接入短信发送热路径。
|
||||||
|
|
||||||
|
迁移仅增加表、索引、函数和触发器,不回填虚假通知。通知保存企业、应用、签名和引流名称快照,删除或重命名不破坏历史展示;不存短信正文、手机号或验证码。
|
||||||
|
|
||||||
|
## API 与页面
|
||||||
|
|
||||||
|
`GET /api/{admin|client}/report-notifications` 分页企业小时汇总,`GET /:id` 分页查看该小时事件,`GET /summary` 返回个人未读小时数,`POST /:id/read` 携带已展示版本幂等标记已读。分页最大100;非法页码、版本返回400。运营必须平台管理员;客户企业由已验证会话确定,越权详情与不存在同为404,拒绝伪造租户。
|
||||||
|
|
||||||
|
运营“状态记录”保留原有筛选与详情,新增 URL 页签 `tab=readiness`。“报备任务提醒”增加“报备状态变化通知”跳转此页签。客户端新增“消息通知”页及导航入口。两端展示未读、企业/小时、签名/引流条数及展开明细;加载、失败、无通知有独立状态,读取失败不伪造已读。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
真实 PostgreSQL 隔离事务覆盖零→部分→全部、重复、回零再恢复、并发、回滚、历史基线、三网通道专属优先、省通道排除、引流独立;真实 API 验证双端会话、租户隔离、分页、小时聚合与按版本阅读。浏览器覆盖三尺寸、刷新、路由页签、详情与失败状态。部署仅测试环境,保留独立恢复资产。
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# 发送监控页面重做:需求、统计口径与页面方案
|
||||||
|
|
||||||
|
- 版本:V1.0实施版,2026-09-06。
|
||||||
|
- 状态:2026-09-06用户授权实施中;原型中的名称、条数和比率全部为设计示例。
|
||||||
|
- 本轮范围:用户已授权按方案修改、提交、推送、部署测试环境;默认阈值留空。原设计阶段及当前验证边界分别见第11、12节和testing-progress。
|
||||||
|
- 原型入口:[原型说明](prototypes/sending-monitor-20260906/README.md)。
|
||||||
|
|
||||||
|
## 1. 目标与页面定位
|
||||||
|
|
||||||
|
将运营端 `/admin/monitor` 从通道清单和总体数量页改为发送质量监控工作台:运营人员能快速回答“哪个通道/哪个应用签名发送异常、哪个时效指标下降、样本够不够、何时发生、采用什么阈值”。
|
||||||
|
|
||||||
|
提供三种监控,并将异常集中展示到右上角现有“预警中心”:
|
||||||
|
|
||||||
|
| 监控类型 | 聚合维度 | 执行频率 | 每次选取的提交窗口 | 指标 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 行业通道监控 | 通道 × 运营商 | 每5分钟 | 最近5分钟 | 5秒、20秒、1分钟到达率 |
|
||||||
|
| 验证码监控 | 企业应用 × 签名(含企业隔离键) | 每5分钟 | 最近5分钟,最终发送正文包含“验证码” | 5秒、20秒、1分钟到达率 |
|
||||||
|
| 整体兜底监控 | 企业应用 × 签名(含企业隔离键) | 每10分钟 | 最近30分钟 | 1分钟、5分钟、20分钟到达率 |
|
||||||
|
|
||||||
|
这是按固定周期评估的准实时业务监控,不宣称秒级持续告警。“刷新页面”刷新已计算的最新快照,不重启全量统计;界面同时展示样本窗口、评估时刻、最近计算时间和数据是否延迟。
|
||||||
|
|
||||||
|
系统监控 `/admin/system-monitoring` 继续负责CPU、内存、磁盘及服务指标;数据统计/签名质量报表继续承担长周期报表。不得把三类页面的分母和统计口径互相替换。
|
||||||
|
|
||||||
|
## 2. 原需求解释与待确认点
|
||||||
|
|
||||||
|
以下是为使方案可实施而采用的建议,不视为用户已确认:
|
||||||
|
|
||||||
|
1. “5到达率”按“5分钟到达率”处理。
|
||||||
|
2. “兜底”暂指**全部短信的整体质量兜底**,包含验证码和行业短信,与前两类监控允许重叠;不是仅筛选补发或兜底通道。若实际意图是后者,必须先定义哪些通道/重试事件属于兜底,并在发送事实中冻结标记,不能仅凭最终channelId推断。
|
||||||
|
3. “提交”建议指首次实际发往上游的 Submit,不包括尚在平台排队、定时未到、审核未通过或路由前拦截的消息。用户提交至平台的排队时长另作辅助信息,不混进供应商回执时延;若要验证码端到端SLA,应另加“平台受理起算”指标。
|
||||||
|
4. “到达”采用**平台收到有效成功回执**的可观测口径。页面可保留用户熟悉的“5秒到达率”等列名,但须常驻说明“以平台收到成功回执为准”。不等于终端实测到达或用户已读。
|
||||||
|
5. 各指标下限、最低样本量初始值待根据真实基线确定。原型中的100条、90%/95%/98%等只是示例,不作为生产默认值或行业标准自动启用。
|
||||||
|
6. 未满观察时长的短信不计为失败;采用第4节的按指标成熟样本口径。同一行三个指标可有不同分母,必须明确展示。
|
||||||
|
|
||||||
|
## 3. 当前实现核对与差距
|
||||||
|
|
||||||
|
本轮只读基线:2026-09-06,`main / 69e3d73`,本地相对跟踪`origin/main`领先2项;`ls-remote`回读远端为`442dda711d5c9f778f3f76fd6d8fd69f14414ce6`。工作区有其他任务的文档修改及草稿,均未作为本轮成果或纳入原型数据。未连接PostgreSQL、Redis或服务器,因此下列是代码事实,性能数字仅为设计目标。
|
||||||
|
|
||||||
|
| 位置 | 当前代码事实 | 本次设计影响 |
|
||||||
|
|---|---|---|
|
||||||
|
| [AdminMonitorPage.tsx](../src/apps/admin/AdminMonitorPage.tsx) | 调用通道列表和listMonitor,展示运行通道、总体成功率、消息总量;手动刷新 | 没有三类时效窗口、监控范围与告警策略 |
|
||||||
|
| [uplink.queries.ts](../api/src/operations/queries/uplink.queries.ts) 的monitor | 按messageWhere分组当前状态,另读最近消息/回执/上行;本页调用未提供有界时间窗口 | 不应让新自动刷新继续触发全表总体聚合 |
|
||||||
|
| [AdminLayout.tsx](../src/layouts/AdminLayout.tsx) | 全局用轻量接口轮询;预警中心含安全和系统告警;另有待审与报备任务提醒 | 新发送质量告警加入预警中心,不放入报备任务提醒,不复用重型dashboard接口 |
|
||||||
|
| [schema.prisma](../api/prisma/schema.prisma) | 有SmsMessageRecord、SmsSubmitRecord、分片审计、SmsReceiptRecord与UpstreamReceiptInbox | 能关联业务消息、发送尝试、分片和回执;还没有本方案的规则、快照与告警实体 |
|
||||||
|
| [upstream/submit.go](../gateway/internal/upstream/submit.go) | submitResult中的SubmittedAt为结果构造时刻,submitPart内才发生SendReqPkt | 现有submittedAt不能未经验证就当作最初发包时间计算5秒指标 |
|
||||||
|
| [upstream/deliver.go](../gateway/internal/upstream/deliver.go) | 回执事件DeliveredAt赋值time.Now().UTC() | 这是Gateway收到回执的时间,不是供应商DoneTime;不能将字段名直接理解为终端时间 |
|
||||||
|
| [send-receipt.service.ts](../api/src/send-chain/send-receipt.service.ts) | 入站回执持久化到Inbox,再匹配处理;重复回执有receiptKey | 复用现有持久化与匹配结果,区分Gateway接收时间和API处理时间,避免处理积压扭曲5秒指标 |
|
||||||
|
|
||||||
|
旧需求[5.10运营看板与监控](first-version-development-requirements.md)及[TC-ADMIN-011](system-functional-test-cases.md)还要求发送趋势、最近发送/回执/上行、通道状态和积压。本次以三类质量监控为主视图,保留“运行概况”和详情跳转入口承接这些功能;不因当前页面未展示某旧需求就擅自删除它。统计逻辑不复用运营看板的分片到达率。
|
||||||
|
|
||||||
|
## 4. 统一统计口径
|
||||||
|
|
||||||
|
### 4.1 计数单位与去重
|
||||||
|
|
||||||
|
- 行业通道:按`messageRecordId + submitId + channelId`唯一的**一次业务短信发送尝试**计1次,随后按真实收件号码运营商分组。一个三网通道拆为移动、联通、电信三行,不能按通道配置的“三网”重复累计同一条;未知运营商单列并提示数据质量。
|
||||||
|
- 验证码与整体兜底:按`tenantId + applicationId + messageRecordId`去重,每条业务短信计1条。补发、换通道不增加分母,也不重置第一次实际提交时间;成功可来自任一有效完整发送尝试,但不能将不同尝试的零散成功分片拼成一次完整成功。
|
||||||
|
- 行业通道中,原通道尝试失败、换通道成功分别反映各自质量;后者不得反向把前者改成成功。该业务短信在应用×签名监控中仍只计一次。
|
||||||
|
- 同号码多次不同业务发送各计一次,不按号码去重;网关重放同一事件不增加计数。批量多号码按平台稳定业务消息ID逐条统计。
|
||||||
|
- 长短信以完整业务消息为单位:该次有效尝试的全部必要分片成功回执收齐才成功,成功时刻取最后必要分片的首次成功回执接收时刻。分片总数必须来自真实发送快照,不能用当前计费单位随意替代。
|
||||||
|
- 实际已提交但上游拒绝、响应超时或最终失败的尝试保留在分母;未发包的路由/连接失败不伪造Submit样本,进入运行概况的“提交前失败”。对于“可能已写出但无法确认”的网络边界,单列不确定样本和完整性状态,不静默排除并显示健康。
|
||||||
|
- 未要求回执、不支持成功回执、无法匹配、缺少关键时间的记录,不制造100%或0%指标。展示不可评估数量和原因,触发数据质量提示;已确认提交且正常应有回执但未收到的样本,在成熟后计为未按时成功。
|
||||||
|
|
||||||
|
签名采用发送时已解析的`signatureId`及名称快照;应用用稳定ID,tenantId始终在后端隔离键中。签名重命名不拆历史序列;删除后历史快照仍可查。没有可验证签名/应用映射的历史数据单列“未识别”,不按当前同名签名猜测归并。验证码判断是最终展开、重组后的**发送正文包含字面量“验证码”**,只识别一次保存布尔值和规则版本;不以模板分类、登录图形验证码或模糊关键词替代,不存储验证码具体值到监控表。
|
||||||
|
|
||||||
|
### 4.2 时间定义
|
||||||
|
|
||||||
|
- `t0`:行业通道为该次尝试第一次成功写出Submit的时间;验证码/整体兜底为该业务消息所有有效尝试中最早的实际Submit时间。
|
||||||
|
- `ts`:满足完整成功条件的Gateway成功回执接收时刻;重复成功取首次、重复事件幂等。记录`timestampSource`和精度,禁止用API事务处理时间/updatedAt替代。
|
||||||
|
- 时间统一存UTC,页面显示北京时间,精确到毫秒计算;阈值边界使用`ts - t0 <= h`,5.000秒算5秒内,5.001秒不算。负延迟或时钟明显异常单列不可评估并暂停受影响告警,不强制归零。
|
||||||
|
- 真正Submit时间采集是实施前置项:扩展现有Gateway结果/分片事件携带`firstWireSubmitAt`及时间源,随已有耐久事件持久化,保持旧消费者兼容。不得靠解析全量文本日志实现常态监控;必须验证进程崩溃、无SubmitResp、分片和回执先到的边界,不能只在accepted结果里补时间。
|
||||||
|
- 旧数据缺少精确起点时可显示“历史口径不可比”,但不参与新5秒告警;不把SubmitResp时刻回填成已核实发包时刻。
|
||||||
|
|
||||||
|
### 4.3 周期、成熟样本与公式
|
||||||
|
|
||||||
|
设评估边界为`T`,5分钟任务在北京时间整5分钟对齐,10分钟任务在整10分钟对齐;按`[T-W, T)`选取提交样本,左闭右开。`W`为5分钟或30分钟。
|
||||||
|
|
||||||
|
对每个时效`h`独立计算。`B`是观察截止时刻,实时初评时`B=T`;行业/验证码补齐时`B=T+60秒`,详见本节末尾:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Q = 窗口内本维度可纳入时效统计的唯一提交样本
|
||||||
|
M_h = { m ∈ Q | t0(m) + h <= B } # 已等满h的成熟样本
|
||||||
|
N_h = |M_h| # 可评估条数
|
||||||
|
S_h = |{ m ∈ M_h | 完整成功且0 <= ts(m)-t0(m) <= h }|
|
||||||
|
R_h = S_h / N_h × 100% # N_h=0时为null
|
||||||
|
观察中_h = |Q| - N_h
|
||||||
|
触发_h = 指标开启 且 数据完整 且 N_h>=minSamples 且 R_h<下限_h
|
||||||
|
```
|
||||||
|
|
||||||
|
重要规则:尚未等满时长的样本,即使已成功或已失败,也统一先留在“观察中”,不能只把提前成功的样本塞入分子/分母造成幸存偏差。失败、超时、未知及成熟后未回执均在分母中;`N_h<minSamples`显示“样本不足”,不触发该指标。最低条数应用于**每项指标的成熟分母**,不是整行总提交量。
|
||||||
|
|
||||||
|
示例:12:30评估兜底窗口`[12:00,12:30)`。20分钟指标仅评估12:10及以前提交的样本,12:10之后的仍观察中;1分钟与5分钟分别评估至12:29、12:25。若全窗口300条,20分钟可评估90条、其中85条按时成功,则显示94.44%(85/90),最低100条时不告警,而不是85/300=28.33%。
|
||||||
|
|
||||||
|
同一批固定成熟样本的累计成功率随时限应不下降;本页面同一行三个时限使用不同成熟子集,比率**不保证单调**,不能前端排序或修正数字。每格显示`S_h/N_h`,悬停显示观察中数量及口径。全局合计必须先加分子分母再相除,不能对各行百分比做算术平均。
|
||||||
|
|
||||||
|
**5分钟固定窗口必须补齐尾部观察样本。**行业/验证码窗口不重叠,如果只在T计算一次,最后1分钟的短信可能永远不参与1分钟指标。故T时先给出实时初评,T+60秒再对同一`[T-5分钟,T)`生成完整结果,成熟判定时刻改为T+60秒,窗口提交边界保持不变,并再等待摄取宽限。历史趋势默认展示补齐后的完整结果;当前实时行显著标注“初评,待补齐”,补齐后显示“已定稿”。这最多增加约1分钟的完整窗口确认延迟,不改变每5分钟一个统计窗口。
|
||||||
|
|
||||||
|
初评可提前产生异常提示,补齐时更新同一事件,不能将初评和补齐计为两次连续异常。连续异常/恢复次数以每个窗口的定稿结果计数;配置连续1次时允许初评先报,随后定稿证实;配置大于1次时等定稿后计数。初评异常而定稿正常,事件按“窗口补齐后解除”关闭并保留两份证据,不伪称真实业务恢复。自动业务恢复只使用定稿窗口,禁止旧窗口补齐覆盖更新窗口的状态。整体兜底的窗口每10分钟重叠,每条样本会在后续窗口中成熟并纳入;需用边界用例证明任意提交时刻都能参与各时效指标,不能据此跳过行业/验证码的补齐任务。
|
||||||
|
|
||||||
|
### 4.4 快照完整性、迟到与回算
|
||||||
|
|
||||||
|
- 每个边界T生成版本化快照,记录窗口、观察截止B、初评/定稿阶段、计算时间、规则版本、样本量、完整性、水位和指标。建议在B之后给摄取/匹配预留30秒宽限(需实测调优),统计仍按B观察,不把宽限期新增提交算入窗口;补齐也不改变窗口右边界T。
|
||||||
|
- 已在时限内被Gateway接收但晚处理的回执,按原接收时间回算;真正超时后才收到的成功回执,不得因为最终成功而提高较短时限比率。
|
||||||
|
- 水位完整不是“最近一条事件很新”:需检查相关已提交事实、未处理/未匹配回执、检查点连续性及积压。数据未齐时显示“数据延迟”,冻结该维度新的低到达率告警和自动恢复,独立提示监控管道异常;不能把监控自身故障归咎于通道。
|
||||||
|
- 同一`类型+维度+T`只有一个快照身份,回算增加revision而不重复新建。保留告警首次评估值和更正值,迟到导致误报时标记“数据修正关闭”,不伪称业务自然恢复。
|
||||||
|
- 最近30分钟兜底窗口每10分钟重叠,样本可参与多个不同T的评估;同一T内只计一次。不得把重叠窗口累计为发送总量。
|
||||||
|
- 初次纳管/配置启用后默认只对生效时间之后的提交执行告警,等待成熟样本;历史数据仅作对比。回算保留时长建议72小时,以事件完整性及成本校准,超出范围显式提示未回算,不全表补算。
|
||||||
|
|
||||||
|
## 5. 监控配置
|
||||||
|
|
||||||
|
### 5.1 行业通道
|
||||||
|
|
||||||
|
- 监控范围由运营人员在“监控通道”弹窗中搜索、勾选、移除;展示通道名称/编号、支持运营商、业务启停状态、监控是否开启、生效时间。
|
||||||
|
- 通道监控开关独立于业务通道启停,不改变通道价格、路由、连接或发送配置。关闭业务通道不自动删除监控历史;展示停用状态,已有窗口继续评估,后续无样本不虚报异常。
|
||||||
|
- 设置一套行业通道通用阈值:最低成熟条数、5秒/20秒/1分钟到达率下限,各指标可单独开启/关闭;本期不强制增加每通道独立策略,避免无需求的配置膨胀。
|
||||||
|
- 新建通道、复制通道必须在**服务端保存成功且返回新ID之后**弹窗:“是否加入行业通道监控?”说明:“如果是行业短信通道建议加入”。按钮“暂不加入”“加入监控”,显示将使用的通用阈值摘要。
|
||||||
|
- 加入动作只调用独立监控配置API;成功提示“通道已保存,已加入监控”。加入失败提示“通道已保存,加入监控失败,可重试”,不可回滚已创建通道、重复提交创建或假报全部成功;重试对同一个通道ID幂等。
|
||||||
|
- 复制不继承原通道的监控成员身份或个性设置,一律对新通道再提醒;关闭弹窗等同暂不加入,后续可在监控范围加入。原通道不受影响。无监控管理权限者看到已保存结果和权限说明,不展示可执行加入按钮。
|
||||||
|
|
||||||
|
### 5.2 验证码
|
||||||
|
|
||||||
|
- 全平台有权限范围内的企业应用×签名自动覆盖,无需逐个添加。只纳入最终正文包含“验证码”的业务短信。
|
||||||
|
- 独立通用阈值:最低成熟条数、5秒/20秒/1分钟下限,不能与行业通道配置共用同一个值对象。
|
||||||
|
- 搜索条件:企业、应用、签名、状态;详情可按运营商/通道分解定位原因,但不改变主告警键,也不把通道尝试数当主分母。
|
||||||
|
|
||||||
|
### 5.3 整体兜底与个性阈值
|
||||||
|
|
||||||
|
- 通用规则:最低成熟条数、1分钟/5分钟/20分钟下限。
|
||||||
|
- 个性规则支持“某企业应用”“某签名”“某企业应用×签名”三个作用范围。所有ID均在同一企业权限范围内校验,不能按名字匹配跨企业签名。
|
||||||
|
- 建议优先级:**应用×签名 > 签名 > 应用 > 通用**。同时匹配签名和应用时采用签名规则,界面明确显示被覆盖来源;列表显示“实际生效规则”,详情能查看完整匹配链。
|
||||||
|
- 采用最高优先级的一整套规则,避免不同来源按字段拼接难以解释。创建个性规则时预填继承值,保存为独立完整规则集;“恢复继承”删除该层覆盖并展示将生效的下级策略。
|
||||||
|
- 同层同范围唯一,重复保存更新原规则;版本号乐观锁,冲突返回409并让用户重新加载。改规则记录操作者、修改前后值、生效时间;不对历史窗口用新阈值追溯制造告警。
|
||||||
|
- 编辑页实时展示一个示例维度的最终生效策略,并提示高优先级覆盖范围。不要让用户保存后仍猜测哪套生效。
|
||||||
|
|
||||||
|
### 5.4 参数校验与生效
|
||||||
|
|
||||||
|
- 最低成熟条数为正整数;到达率下限0~100%,保留两位小数;开启的累计下限建议满足短时限≤长时限,不合理顺序提示修正。至少开启一项时效指标。
|
||||||
|
- 百分比仅展示时四舍五入,告警比较使用整数基点/精确分子分母;等于下限不告警,低于下限告警。0%下限等于该项不会因低比率触发,界面明确提示。
|
||||||
|
- 每类可配置连续异常次数(默认建议1)、连续恢复次数(默认建议2),范围1~5;站内提醒默认新异常一次,持续异常更新原事件,不周期刷屏。
|
||||||
|
- 配置成功以真实持久化为准,页面显示版本和生效时间。配置修改后从下一个周期使用新版本;旧活动事件以“规则变更”关闭并重置连续计数,不算业务恢复。
|
||||||
|
|
||||||
|
## 6. 告警生命周期与预警中心
|
||||||
|
|
||||||
|
1. 任意开启的指标在成熟样本达标且数据完整时低于下限,累计对应连续异常次数;达到阈值创建一条该维度的发送质量告警,附全部命中指标。
|
||||||
|
2. 同一类型+维度只保留一个活动告警事件,多个时效指标合并展示;使用事务唯一约束/锁防止双Worker重复。业务维度键不包含窗口T,避免每个重叠窗口新建。
|
||||||
|
3. 活动事件更新最新值、最差值、持续时长和周期记录。用户“标记已读/已知悉”只影响个人未读状态,不等于恢复或关闭统计。
|
||||||
|
4. 所有开启指标均有充分成熟样本、数据完整且不再命中下限,连续满足恢复次数后自动恢复。样本不足、无发送、数据延迟均不累计恢复;活动告警显示“暂停评估”原因。
|
||||||
|
5. 恢复后再次异常创建新事件并重新未读;配置关闭、范围移除、规则变更分别记录关闭原因,不能冒充恢复。短暂静默仅隐藏个人/授权范围通知,统计仍进行并记录到期时间。
|
||||||
|
6. 预警中心新增“发送质量告警”,展示未读活动事件数及异常维度数,点击进入`/admin/monitor?tab=alerts`;弹层样式、键盘/点击外部关闭和窄屏行为复用现有AppShell。
|
||||||
|
7. 全局角标只调用独立轻量`notification-summary`,按既有30秒节奏、聚焦及已读事件刷新。返回失败保留上次值并标记不可用,不能清零或清空其他预警域。明细列表和计数使用相同权限、过滤和去重口径。
|
||||||
|
8. 发送质量告警属于预警中心;签名清退继续在现有报备任务提醒中,待审核任务不增加这些计数。首期只做站内告警,不发短信、邮件或第三方通知,不触发自动停通道、换路由、补发。
|
||||||
|
|
||||||
|
## 7. 页面与交互
|
||||||
|
|
||||||
|
### 7.1 监控主页面
|
||||||
|
|
||||||
|
- 路由保持`/admin/monitor`。一级页签:行业通道、验证码、整体兜底、告警记录;标题操作区为刷新、阈值设置、行业页特有的监控通道。
|
||||||
|
- 顶部持续显示口径、当前窗口、计算周期、最新成功计算时间/数据延迟;30秒页面取数不等同于5/10分钟重新评估。长时间挂后台暂停轮询,恢复焦点再拉取,手动刷新节流。
|
||||||
|
- 摘要卡:监控维度数、异常维度数、样本不足维度数、窗口提交量(行业用“提交尝试”,其他用“业务短信”)。无数据时显示—/0并区别未配置、无发送、计算中与接口失败。
|
||||||
|
- 表格以异常优先:维度、窗口提交量、三个到达率、实际阈值/规则来源、状态、趋势/详情。各指标格包含百分比、成功/成熟数;低于阈值的格红色强调,观察中/样本不足中性显示,不只依赖颜色。
|
||||||
|
- 用户能筛选全部/异常/正常/样本不足/数据延迟;应用/签名支持带企业名的远程搜索、分页。筛选保存在URL,首次进入、刷新和告警深链接能还原。
|
||||||
|
- 保留“运行概况”入口显示连接、积压及最近消息/回执/上行,不把业务启用标记等同实际连接健康。详情链接保持权限过滤。
|
||||||
|
|
||||||
|
### 7.2 趋势与告警详情
|
||||||
|
|
||||||
|
- 详情页/抽屉显示异常对象、类型、状态、开始/最近评估时间、规则快照及每项命中指标。
|
||||||
|
- 展示最近2小时/24小时曲线,点的时间含义为评估T;悬停显示窗口、分子分母、观察中条数和revision。阈值变更用分段线和注释,不能用今天阈值覆盖昨天曲线。
|
||||||
|
- “查看样本”跳转已有短信记录,带精确应用/签名/通道/时间/结果过滤;需有详情权限,手机号脱敏,不在告警摘要暴露正文或验证码。
|
||||||
|
- 异常、无数据、待计算、采集延迟、失败重试、权限不足、删除对象历史查看均有明确状态。配置弹窗保存失败保留输入,未保存离开有确认。
|
||||||
|
|
||||||
|
### 7.3 响应式与原型覆盖
|
||||||
|
|
||||||
|
- 原型采用项目白底侧栏、浅灰内容区、蓝色选中、紧凑表格和8px圆角;复用Breadcrumb/Button/Select/Table/Tag/Modal。
|
||||||
|
- 1600×1000完整展开;1366×768保持正文14px,表格自身横向滚动、弹窗仅Body滚动;390×844用折叠导航、横滑页签、每维度一张指标卡和全屏配置抽屉,不缩小字体挤宽表。
|
||||||
|
- 配套图覆盖三个主视图、阈值/个性规则、通道纳管提示、预警中心/详情、窄屏;均为静态示意,不含真实API、数据库或用户操作。
|
||||||
|
|
||||||
|
## 8. 数据流与性能方案
|
||||||
|
|
||||||
|
### 8.1 推荐架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
已有Submit结果 / 分片审计 / 回执Inbox(耐久业务事实)
|
||||||
|
→ 独立监控Worker:增量投影、完整性校验、幂等归并
|
||||||
|
→ 短期MonitorFact + 分钟汇总桶
|
||||||
|
→ 5/10分钟调度:生成窗口快照、评估规则、维护告警事件
|
||||||
|
→ 轻量分页API / 角标摘要 → 页面
|
||||||
|
```
|
||||||
|
|
||||||
|
原则是发送热路径不等待统计查询、规则匹配或通知,也不每条短信执行多次聚合SQL。精确时间需要扩展既有耐久事件,但尽量不新增独立同步消息或额外发送事务。监控读取失败只造成监控降级,不能阻断发送;基础事实持久化本身仍遵循原发送可靠性契约,不为性能跳过必要写入。
|
||||||
|
|
||||||
|
推荐一期由独立Worker读取**既有已落库事实**做短批增量投影,避免再造一套逐短信业务Outbox;如果无法在既有事实中证明完整性,必须在技术验证阶段调整采集契约,不宣称纯查询就能准确算5秒。不能直接消费现有短信Stream的同一个consumer group分走消息,也不能依赖已ACK/XDEL的Stream作为历史账本。
|
||||||
|
|
||||||
|
### 8.2 增量、桶与精度
|
||||||
|
|
||||||
|
- 数据提取针对提交事实与回执处理结果两条游标分别维护检查点。`createdAt/id`或`updatedAt/id`排序只提供扫描位置,不能假设数据库事务提交顺序与时间戳一致;采用重叠回读+按ID幂等覆盖,并定期有界对账。扫描到未完成关联的Inbox保留待匹配集合,直到已匹配或明确异常,不越过后遗忘。
|
||||||
|
- 监控事实保持稳定source key、源版本、t0、首次完整成功时间、类型标记和维度快照;同一源重复摄取以替换贡献/脏桶重建处理,不能直接重复INCR。事实、桶版本和检查点在监控侧短事务内一致提交,崩溃可回放。
|
||||||
|
- 以提交分钟建立稀疏桶,记录计数及5秒/20秒/1分钟/5分钟/20分钟累计按时成功数。长时限成熟边界可整分钟读取;5秒/20秒成熟边界落在分钟内部,必须从索引事实补算边界秒区间,不能把整分钟全算成熟,更不能把时间四舍五入到分钟。
|
||||||
|
- 每次评估读5或30个分钟桶及最多一分钟的边界事实,不对每个通道/签名单独发SQL循环。批量处理活跃维度,监控列表只读已保存快照。
|
||||||
|
- 三类监控共享一次业务消息/正文解析投影,验证码布尔只识别一次;行业通道尝试汇总与应用业务消息汇总分别维护。禁止常态运行`content LIKE '%验证码%'`扫描业务大表,禁止反复JSON反序列化正文。
|
||||||
|
- 固定维度ID;仅有流量或配置的组合建桶,不生成“企业×应用×签名×通道×运营商”笛卡尔积。应用签名不是Prometheus高基数标签;Prometheus仅观测Worker延迟、批量大小、错误率等低基数运行指标。
|
||||||
|
- 迟到事件只将相应提交分钟标脏并合并修正其相关快照;兜底每条样本最多影响3个滚动提交窗口,再处理有必要的历史revision,禁止重算所有维度全部历史。
|
||||||
|
|
||||||
|
### 8.3 表与索引候选(设计项,不直接执行迁移)
|
||||||
|
|
||||||
|
| 候选实体 | 内容及约束 |
|
||||||
|
|---|---|
|
||||||
|
| SendingMonitorTarget | 行业通道成员、enabled、effectiveFrom、version;channelId唯一 |
|
||||||
|
| SendingMonitorRule | 类型、作用域与ID、完整阈值JSON/结构字段、版本、生效时间、操作者;同类型同作用域唯一 |
|
||||||
|
| SendingMonitorFact | sourceKind/sourceId唯一、tenant/app/signature/channel/carrier快照、t0、successAt、时间源、验证码标记、完整性与源版本;不复制手机号和正文 |
|
||||||
|
| SendingMonitorMinute | type/dimensionKey/minute/口径版本唯一,累计计数、revision;稀疏存储 |
|
||||||
|
| SendingMonitorSnapshot | type/dimensionKey/T/口径版本唯一,三个S/N、观察中、规则快照、completeAt/revision;保留历史追溯 |
|
||||||
|
| SendingMonitorAlert / AlertRead | 活动事件唯一约束、规则版本、首次/最新/最差值、状态/原因;已读按eventId+userId唯一 |
|
||||||
|
| SendingMonitorCheckpoint | 按来源/分片的扫描位置、租约、扫描上下界、对账进度、未处理缺口 |
|
||||||
|
|
||||||
|
索引按实际谓词和EXPLAIN选:事实`(dimensionKey,t0)`支持边界范围;快照`(type,T,status)`及维度历史时间索引;源表新增`(updatedAt,id)`/相关状态时间索引前先验证现有查询计划。不能仅因表很大就堆宽索引,源表新增索引会增加发送/回执写放大;DDL锁与索引创建窗口需部署评审。
|
||||||
|
|
||||||
|
参考:[PostgreSQL复合索引说明](https://www.postgresql.org/docs/current/indexes-multicolumn.html)强调前导列约束对扫描范围的作用;[Prometheus标签规范](https://prometheus.io/docs/practices/naming/)提示避免无界高基数。应用版本能力以目标数据库为准,不将文档最新版优化当作当前服务器已具备。
|
||||||
|
|
||||||
|
### 8.4 调度与资源预算
|
||||||
|
|
||||||
|
- 一期建议1个独立监控Worker,连接池上限2、并行任务1,按哈希/时间分批;每批500~2000条是压测候选值,受statement_timeout和短事务约束,自适应退避,禁止Promise.all按全部签名并发。
|
||||||
|
- 以数据库租约/唯一任务键确保每个边界T只被一个执行者持有;过期可接管,重跑幂等。不要只使用进程内setInterval防重。调度边界固定,批次可错峰,不漂移窗口定义。
|
||||||
|
- 轻量摘要API缓存15~30秒,按权限作用域及版本隔离;缓存丢失只回源汇总表,禁止回源重扫业务表。使用Redis只缓存结果,不做每条短信一个TTL键/定时器。
|
||||||
|
- 建议热事实先按2小时估算,分钟桶7天、窗口快照30天、告警及规则审计90天,再按实际合规与磁盘容量评审。72小时是候选纠错范围,不要求保留等长的全量热明细;热事实过期后的纠错从既有事实按ID有界恢复,恢复成本超预算则明确标记未回算。清理仅针对独立监控数据,按分区/小批执行,不清业务消息、回执或短信队列。
|
||||||
|
- 新增实际发包时间字段、源表索引、监控投影和回算均有成本;不能承诺“零影响”。性能验证不通过时先缩小纳管范围、降低摄取批量或部署独立读副本(需确认复制延迟),不能以提高发送并发或削弱耐久性掩盖。
|
||||||
|
|
||||||
|
### 8.5 可解释的容量估算与验收预算
|
||||||
|
|
||||||
|
令业务短信速率为λ、平均尝试数a、平均分片数s,则已有提交/分片/回执事实处理量约按`λ×a×s`增长;重复回执另计,不能仅看业务TPS。
|
||||||
|
|
||||||
|
以假设500条业务短信/秒、a=1.1、s=1为例:每5分钟15万业务短信、约16.5万次尝试;每30分钟90万业务短信。每次对这些数据重新Join三遍会产生持续负载。监控分钟汇总将周期读取转为与活跃维度和窗口长度相关,而事实摄取仍与事件量线性相关。
|
||||||
|
|
||||||
|
该假设下业务+尝试投影若约1050行/秒,72小时约2.72亿行;即使每行连索引按粗估200字节,也约54GB且未计WAL/膨胀。**因此72小时明细不能不经容量测算直接上线**;可优先缩短热事实至2小时(约756万行、粗估1.5GB),更长回算在既有事实中按ID有界恢复,或评估分区/独立存储。这是容量风险示例,不代表现环境有500TPS、该磁盘余量或该行大小。
|
||||||
|
|
||||||
|
实现验收建议预算(待基线实测校准):
|
||||||
|
|
||||||
|
- 同负载对照开启/关闭监控,发送受理/提交及回执落库P95相对恶化不超过5%,无业务错误增加,发送Stream/Inbox无持续新增积压。
|
||||||
|
- 监控新增连接不超分配池,数据库总体CPU增幅目标不超过5个百分点,磁盘I/O和WAL写放大单独记录;不以单次低峰截图证明高峰达标。
|
||||||
|
- 周期任务P95在30秒内完成、必须小于调度周期;快照/摘要API P95≤300ms(不含公网延迟);不足则标记性能验收未通过,禁止藏掉延迟指标。
|
||||||
|
- 数据超过一个采集SLA(初值30秒)未完整时标为延迟;两个评估周期无新完整快照时告警“监控计算异常”。按真实依赖延迟校准,不误关正常短信发送。
|
||||||
|
- 先用脱敏历史快照/隔离数据集验证1倍及峰值2倍规模,覆盖高签名基数、长短信、重试与回执突发。真实发送压力测试另需授权;本设计阶段未运行压测。
|
||||||
|
|
||||||
|
## 9. API与权限边界(拟定)
|
||||||
|
|
||||||
|
统一使用`/api/admin/sending-monitor`,不重用系统监控或旧monitor的全量聚合接口:
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| GET /overview?type=... | 最新快照摘要、T、nextEvaluationAt、数据完整性 |
|
||||||
|
| GET /rows?type=...&page=... | 权限范围内分页维度、S/N/观察中、状态与生效规则 |
|
||||||
|
| GET /history?dimensionId=...&range=... | 有界历史快照及规则变更 |
|
||||||
|
| GET /targets;PUT /targets/:channelId | 行业纳管列表与幂等加入/移除,携带version |
|
||||||
|
| GET /rules;PUT /rules/:id;POST /rules | 通用与个性规则,完整校验与乐观锁 |
|
||||||
|
| GET /effective-rule?... | 返回生效规则及被覆盖层,用于编辑预览 |
|
||||||
|
| GET /alerts;GET /alerts/:id | 历史、活动及详情、过滤、分页 |
|
||||||
|
| GET /notification-summary | 轻量未读活动事件/异常维度数、更新时间,不聚合原始消息 |
|
||||||
|
| POST /alerts/:id/read | 个人幂等标记已读,不改变业务恢复状态 |
|
||||||
|
|
||||||
|
响应必须区分`rate=null`、`sample_insufficient`、`observing`、`stale`、`error`,不能统一0。越权资源返回一致404/403策略;后端从会话确定访问范围,不能相信前端tenantId。监控查看、阈值修改、通道纳管、消息详情分别校验权限,配置写入与版本审计在事务中完成。分页上限建议100,历史时间范围有限,错误明确返回而非静默成功。
|
||||||
|
|
||||||
|
## 10. 实施拆分与验收清单
|
||||||
|
|
||||||
|
### 10.1 分阶段建议
|
||||||
|
|
||||||
|
1. 先确认第2节口径与阈值,对真实代码/数据核验提交时间、回执匹配、分片合并和高峰规模。交付统计样本独立对账,不先接告警。
|
||||||
|
2. 实现兼容时间字段、独立投影/桶/快照,影子运行并与独立SQL按ID比对;监控关闭时不改变发送行为。完成容量和故障演练后才开启有限纳管。
|
||||||
|
3. 实现三页签、通用与个性规则、通道新建/复制提示,接预警中心轻量摘要和事件生命周期;先灰度一个测试范围再扩展。
|
||||||
|
4. 同步主需求、系统用例、技术设计及testing-progress;经授权提交、推送及部署,分别记录代码级与真实环境证据。
|
||||||
|
|
||||||
|
### 10.2 可直接转入系统用例的验收条目
|
||||||
|
|
||||||
|
| 编号 | 场景 | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| SMR-001 | 5分钟边界、10分钟边界、跨天和时区 | 提交窗口左闭右开、UTC计算一致,无跨周期漏重 |
|
||||||
|
| SMR-002 | 三网通道含移动/联通/电信/未知号码 | 按消息运营商拆分;未知单列,不重复三次计数 |
|
||||||
|
| SMR-003 | 同业务短信换通道补发 | 通道按各尝试,应用签名按1条;t0不因补发重置 |
|
||||||
|
| SMR-004 | 长短信、重复/乱序/冲突回执 | 全必要分片同一有效尝试完整成功才计成功;幂等,负时延异常可追溯 |
|
||||||
|
| SMR-005 | 4.999/5.000/5.001秒以及20秒/1分/5分/20分边界 | 精确分类,不由显示四舍五入决定 |
|
||||||
|
| SMR-006 | 尚未成熟但已成功/已失败的样本 | 两者都观察中;不能只纳入提前成功者;N_h独立 |
|
||||||
|
| SMR-007 | N_h为0、99、100且最低100 | null、样本不足、可评估,零数据不显示100%或自动恢复 |
|
||||||
|
| SMR-008 | 最新30分钟,20分钟成熟段只有前10分钟 | 分子分母/观察中符合第4.3例子,不把新提交直接计为未到达 |
|
||||||
|
| SMR-009 | 正文含验证码、模板分类验证码但正文不含、不同租户同名签名 | 字面正文匹配;稳定ID归属和租户隔离,不混淆 |
|
||||||
|
| SMR-010 | 全部规则层同时匹配、重复修改、恢复继承 | 应用×签名>签名>应用>通用;整套规则覆盖、409冲突、来源可解释 |
|
||||||
|
| SMR-011 | 新建/复制成功后加入监控成功或失败 | 主创建只一次、加入幂等;失败保留新通道,复制不继承成员资格 |
|
||||||
|
| SMR-012 | 新建失败/无权限/关闭纳管弹窗 | 不误弹成功、不越权加入、后续可手动纳管 |
|
||||||
|
| SMR-013 | 一个维度三项低于阈值、连续重叠周期 | 一条活动事件、多命中指标;无重复角标刷屏 |
|
||||||
|
| SMR-014 | 已读、持续异常、恢复、再异常、样本不足 | 已读非恢复;充分数据连续恢复;再异常新未读;不足暂停评估 |
|
||||||
|
| SMR-015 | 回执Gateway及时但API处理迟到、规则变更 | 延迟/回算有revision;保留原告警与修正原因,不伪称终端时延 |
|
||||||
|
| SMR-016 | 投影Worker重启、多实例抢占、数据库/Redis故障 | 检查点可恢复、幂等;监控显示降级,业务发送链无新增依赖阻断 |
|
||||||
|
| SMR-017 | 长事务晚提交、游标跨越、已扫描Inbox后匹配 | 重叠读取+待匹配集合+有界对账恢复,不永久漏统 |
|
||||||
|
| SMR-018 | 预警中心接口失败、权限变化、点告警深链接 | 其他域不清零,来源状态明确;同作用域计数与列表一致 |
|
||||||
|
| SMR-019 | 三尺寸、首次进入、刷新、筛选、弹窗失败与未保存离开 | 无页面级意外溢出,正文可读、Footer可见,表单状态保留 |
|
||||||
|
| SMR-020 | 高峰/高基数/长短信与监控开启关闭对照 | 按第8.5预算出真实CPU/IO/延迟/队列/存储报告,不用构建成功代替 |
|
||||||
|
| SMR-021 | 旧数据缺时间、不确定发包、不支持回执 | 单列不可评估和完整性,不伪造指标或混入健康样本 |
|
||||||
|
| SMR-022 | 旧功能入口与跨业务提醒分组 | 保留运行概况/最近事件入口,新告警只进预警中心,不污染报备和待审核 |
|
||||||
|
| SMR-023 | 固定窗口尾部与补齐任务 | 12:29:59提交的行业/验证码短信必须进入12:30窗口的12:31定稿;初评/定稿不重复计告警次数,旧定稿不覆盖新状态 |
|
||||||
|
|
||||||
|
## 11. 原型文件与本轮验证边界
|
||||||
|
|
||||||
|
参见[原型目录](prototypes/sending-monitor-20260906/README.md),PNG便于预览,SVG用于后续修改。所有表格为示例数据;正文未展示短信、手机号或验证码。原型是需求交流材料,不接后端,不可作为业务验收证据。
|
||||||
|
|
||||||
|
本轮已核验文档相对链接、Markdown代码块、空白、统计公式示例、绘制脚本语法、七组PNG/SVG尺寸及可解析性,并逐图检查文字与布局;文件范围仅本文及专属原型目录。没有真实API/数据库/Redis功能测试或性能压测。阈值、兜底范围、提交计时起点及容量预算必须在实施前评审,不能依据示例图直接启用告警。
|
||||||
|
|
||||||
|
## 12. 实施决策与验收边界(2026-09-06)
|
||||||
|
|
||||||
|
用户已确认默认阈值留空,由运营后续配置;任何原型阈值均不自动启用。前三节旧记录保留为设计阶段证据,不代表当前实施状态。
|
||||||
|
|
||||||
|
新增 Gateway 实际写出时间、时间来源及真实上游回执请求标记,随已有结果和分片耐久事件传递;API 持久化到尝试/分片,回执新增 gatewayReceivedAt 区分真实 Gateway 接收时间与 API 缺省时间。客户端是否要求下游回执不能代替上游实际请求标记。现有 Inbox 的 matchedSubmitRecordId 尚未写入,投影使用已匹配业务消息、通道和网关消息号关联,遇到尝试重号拒绝混合。
|
||||||
|
|
||||||
|
独立 Worker 使用最多2个 PostgreSQL 连接、单执行槽、数据库排他租约;提交及回执各自保留重叠游标,另有72小时有界对账,脏分钟只重算所属固定窗口/最多3个兜底窗口,调度队列与投影原子提交。指标读取成熟分钟桶加毫秒边界事实,不按页面刷新扫描业务大表。
|
||||||
|
|
||||||
|
事实保留期本次采用72小时(第8节2小时是原建议),以保证迟到修改能替换原贡献、不会在删除事实后把旧桶重建成局部样本;分钟7天、快照30天、关闭告警90天。新增表不存短信正文/手机号。需要以真实峰值容量预算再优化事实压缩/冷热分层;当前不得据此宣称500TPS下预算达标。真实短信压测未获授权,只使用隔离数据库样本验证统计和查询成本。
|
||||||
|
|
||||||
|
纳管范围使用不可变版本,按窗口评估时刻解析;以后移除/重新加入不改写旧统计。当前API规则按type+scope通过POST完整保存(version乐观锁),不另外提供重复的PUT更新入口。新Worker健康异常通过发送监控数据延迟和预警中心不可用说明展示;监控性能指标接Prometheus和真实峰值对照尚未验收。
|
||||||
@@ -5145,3 +5145,22 @@ npm run verify:phase8
|
|||||||
| TC-CHANNEL-ORDER-006 | 三尺寸首次进入、刷新、上移/下移/撤销、添加、搜索空态和跨路由切换 | 撤销和保存按钮宽度相同且紧凑;表格/弹窗可操作,无新增裁切或控制台异常;真实API验收不保存业务配置 |
|
| TC-CHANNEL-ORDER-006 | 三尺寸首次进入、刷新、上移/下移/撤销、添加、搜索空态和跨路由切换 | 撤销和保存按钮宽度相同且紧凑;表格/弹窗可操作,无新增裁切或控制台异常;真实API验收不保存业务配置 |
|
||||||
|
|
||||||
真实短信补发、真实保存业务通道组及真实权限/故障注入未授权时仍标记未执行;不能把纯函数、模拟保存或构建通过当作发送链投递验收。
|
真实短信补发、真实保存业务通道组及真实权限/故障注入未授权时仍标记未执行;不能把纯函数、模拟保存或构建通过当作发送链投递验收。
|
||||||
|
|
||||||
|
## 发送质量监控与报备状态消息(2026-09-06)
|
||||||
|
|
||||||
|
权威设计:[发送监控方案](sending-monitor-redesign-plan-20260906.md)、[报备状态通知](report-readiness-notifications-20260906.md)。发送监控 SMR-001~023 全部沿用方案第10.2节,不把未执行项视为通过。
|
||||||
|
|
||||||
|
| 编号 | 操作 | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| RRN-001 | 签名零网成功,依次更新移动、联通、电信全国成功 | 到第三网仅产生1条站内事件,双端均可见 |
|
||||||
|
| RRN-002 | 引流独立报备;全国三网通道旧版成功或多通道逐步成功 | 按引流自己的报备覆盖计算,不借签名状态 |
|
||||||
|
| RRN-003 | 重复导入;三→二→三;三→零→三 | 前两者不重复,回零再恢复新增下一轮 |
|
||||||
|
| RRN-004 | 同企业同小时多对象达到条件;读后同小时追加 | 企业×小时汇总计数,按用户和revision重新未读 |
|
||||||
|
| RRN-005 | 不同企业详情ID、伪造租户头、匿名及非法分页/版本 | 会话隔离,403/404/401/400,无越权读取或写入 |
|
||||||
|
| RRN-006 | 报备事务失败/回滚、并发保存相同对象 | 通知与事务同进退,对象锁和唯一键防重 |
|
||||||
|
| RRN-007 | 既有成功、省通道、运营商专属失败覆盖旧成功 | 不追发历史,排除省通道和被覆盖成功 |
|
||||||
|
| RRN-008 | 运营状态记录tab、顶栏跳转、客户端消息页,三尺寸及失败 | URL可刷新,原记录筛选保留,失败不伪造已读 |
|
||||||
|
| SMR-024 | 默认阈值及样本量为空,尚无任何规则 | 显示未配置,不启用告警;后续完整校验后保存 |
|
||||||
|
| SMR-025 | 纳管移除/重新加入后重算旧窗口 | 使用历史纳管版本,不改写原窗口范围 |
|
||||||
|
| SMR-026 | 从快照查看短信样本,再筛选和导出 | 均限定同一快照贡献范围,超过72小时明确提示 |
|
||||||
|
| RMP-OBS-001 | 已生成资料的对象所用组新增通道(只读代码调查) | 记录实际行为:pendingReport=false不会自动重入池;不把调查写成修复 |
|
||||||
|
|||||||
@@ -4605,3 +4605,16 @@ git diff --check
|
|||||||
- 动态环境:浏览器验收期间其他操作新增了一个真实停用通道,旧候选快照因此不再匹配;改为用当前页面实际收到的API响应核对过滤集合,未删除或改写该通道。只读数据库短信记录119509;三条短信Stream在核验时pending/lag均0且last-delivered/entries-read与本轮开始一致。未发送、补发、重投或重新入队短信,未修改余额、通道或客户配置。
|
- 动态环境:浏览器验收期间其他操作新增了一个真实停用通道,旧候选快照因此不再匹配;改为用当前页面实际收到的API响应核对过滤集合,未删除或改写该通道。只读数据库短信记录119509;三条短信Stream在核验时pending/lag均0且last-delivered/entries-read与本轮开始一致。未发送、补发、重投或重新入队短信,未修改余额、通道或客户配置。
|
||||||
- 证据目录:本机`%TEMP%/cmpp-channel-order-20260906`内before.png、after.json、editor-1600/1366/390.png、choices-1600/1366/390.png、前后浏览器脚本、build.log和api-tests.log。首轮浏览器脚本修正了API路径/定位器及草稿beforeunload确认处理;不把脚本超时当作功能通过。图片、响应报告不含密码,认证文件沿用部署文档受限入口。
|
- 证据目录:本机`%TEMP%/cmpp-channel-order-20260906`内before.png、after.json、editor-1600/1366/390.png、choices-1600/1366/390.png、前后浏览器脚本、build.log和api-tests.log。首轮浏览器脚本修正了API路径/定位器及草稿beforeunload确认处理;不把脚本超时当作功能通过。图片、响应报告不含密码,认证文件沿用部署文档受限入口。
|
||||||
- 验收边界:未真实保存现有通道组、未执行短信补发;参数映射/保存失败用组件隔离测试,补发顺序用真实代码、当前PostgreSQL/API顺序及无副作用策略测试交叉核对。无真实省网可选夹具,地区过滤和历史缺失项由组件测试覆盖;不冒充端到端投递或真实权限异常验收。仅本地提交,不推送、不部署测试或预生产。
|
- 验收边界:未真实保存现有通道组、未执行短信补发;参数映射/保存失败用组件隔离测试,补发顺序用真实代码、当前PostgreSQL/API顺序及无副作用策略测试交叉核对。无真实省网可选夹具,地区过滤和历史缺失项由组件测试覆盖;不冒充端到端投递或真实权限异常验收。仅本地提交,不推送、不部署测试或预生产。
|
||||||
|
|
||||||
|
## 2026-09-06 发送监控与报备状态消息实施(发布前)
|
||||||
|
|
||||||
|
- 范围:按用户授权修改、提交、推送、部署测试环境;本轮不访问或发布预生产。阈值及最低样本量默认留空、不告警,后续由用户配置。
|
||||||
|
- 起点:main/HEAD=69e3d73,2026-09-06 19:15回读origin/main=442dda711d5c9f778f3f76fd6d8fd69f14414ce6,差异0/2。原有AGENTS、开发/测试/部署规范、用例/进度及未跟踪方案受保护,仅暂存本轮文件或追加节。
|
||||||
|
- 实现:三种周期监控、真实Gateway写出和回执接收时间、尝试/业务去重、独立有界投影Worker、成熟桶/边界统计、规则与纳管版本、告警生命周期、历史快照和短信样本范围;顶部预警中心及运行概况保留。通知采用事务触发器、零网过程记忆、企业小时聚合、个人版本已读,双端页面和运营提醒入口。
|
||||||
|
- 根因证据:现有submittedAt是等待供应商响应后的时间,不能冒充发包时间;matchedSubmitRecordId实际未写入,使用已匹配消息/通道/网关号,重号不可评估;上游实际RegisteredDelivery与客户要求的下游回执不同,分别保留。测试库时区Asia/Shanghai,新表/SQL显式UTC。
|
||||||
|
- 资料池只读结论:pending-query要求pendingReport=true;生成完成置false;组添加通道没有重新置true。实际41个pendingReport=false签名存在有效路由和通道组,不会仅因新增通道自动重新进入待生成池;尚待生成对象可以动态计算新目标,但不会自动创建资料文件。本轮未修改此业务规则。
|
||||||
|
- 验证:前端20套99项、API59套638项、Gateway全量Go测试及vet通过;真实PostgreSQL隔离事务20项通过(含5秒边界、初评/定稿、重复、缺时间/回执请求、历史纳管版本、签名和引流通知、UTC、回滚)。数据库夹具明确为从不入队的隔离样本,全部回滚;测试替身不作为真实业务验收。新增API单测初次类型声明失败,修正后全量通过;引流夹具初次误用按运营商三条,与真实唯一约束冲突,改为既有全通道报备模型后通过。
|
||||||
|
- 本地TypeScript、前后端构建通过;格式、Stylelint、CSS所有权/级联治理及15项门禁测试通过,CSS全部由页面所有者导入,未修改历史模块或加载顺序。按当前门禁机械格式化了本轮涉及的旧代码文件,未变更其其他业务逻辑。ESLint无错误,有既有/关联Hook和any警告;入口gzip108.71KiB低于250KiB,图表chunk保留既有提示。
|
||||||
|
- 测试机19:05~19:08基线:旧部署442dda7;短信119509、近72小时尝试0、待匹配回执0;提交命令/结果Stream pending/lag均0;数据库约2363MB、连接13/100、根盘可用24GB。Gateway desired=6、connected=0是发布前既有状态,不宣称供应商链路通过。当前没有真实高峰/长短信负载;500TPS性能预算及故障演练尚未执行,不作容量承诺。
|
||||||
|
- 本次独立恢复点:/var/backups/cmpp-platform/20260906-sending-monitor-191302,含PostgreSQL custom dump、应用、环境/systemd/Nginx/Prometheus/fstab、Redis RDB、旧版本;pg_restore列表、tar可读性及SHA-256均通过。未执行实际恢复演练。
|
||||||
|
- 此节为发布前证据;提交、推送、测试发布、真实双端浏览器结果在后续发布节记录,不预先标记完成。
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ type Retry struct {
|
|||||||
|
|
||||||
type SubmitResult struct {
|
type SubmitResult struct {
|
||||||
Envelope
|
Envelope
|
||||||
|
FirstWireSubmitAt *time.Time `json:"firstWireSubmitAt,omitempty"`
|
||||||
|
WireTimeSource string `json:"wireTimeSource,omitempty"`
|
||||||
|
ReceiptRequested bool `json:"receiptRequested"`
|
||||||
SubmitID string `json:"submitId"`
|
SubmitID string `json:"submitId"`
|
||||||
SequenceID uint32 `json:"sequenceId"`
|
SequenceID uint32 `json:"sequenceId"`
|
||||||
GatewayMessageID string `json:"gatewayMessageId"`
|
GatewayMessageID string `json:"gatewayMessageId"`
|
||||||
@@ -94,6 +97,9 @@ type SubmitResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SubmitSegmentResult struct {
|
type SubmitSegmentResult struct {
|
||||||
|
FirstWireSubmitAt *time.Time `json:"firstWireSubmitAt,omitempty"`
|
||||||
|
WireTimeSource string `json:"wireTimeSource,omitempty"`
|
||||||
|
ReceiptRequested bool `json:"receiptRequested"`
|
||||||
SegmentTotal int `json:"segmentTotal"`
|
SegmentTotal int `json:"segmentTotal"`
|
||||||
SegmentIndex int `json:"segmentIndex"`
|
SegmentIndex int `json:"segmentIndex"`
|
||||||
SequenceID uint32 `json:"sequenceId"`
|
SequenceID uint32 `json:"sequenceId"`
|
||||||
|
|||||||
@@ -40,7 +40,15 @@ func (p *connectionPool) submit(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cmd queue.SubmitCommand,
|
cmd queue.SubmitCommand,
|
||||||
onSegment func(queue.SubmitSegmentResult) error,
|
onSegment func(queue.SubmitSegmentResult) error,
|
||||||
) (queue.SubmitResult, error) {
|
) (final queue.SubmitResult, finalErr error) {
|
||||||
|
defer func() {
|
||||||
|
for _, segment := range final.Segments {
|
||||||
|
if segment.FirstWireSubmitAt != nil && (final.FirstWireSubmitAt == nil || segment.FirstWireSubmitAt.Before(*final.FirstWireSubmitAt)) {
|
||||||
|
final.FirstWireSubmitAt = segment.FirstWireSubmitAt
|
||||||
|
final.WireTimeSource = "gateway_write_complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content)
|
parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := submitResult(cmd, 0, "", "rejected", "ENCODE_FAILED", err.Error())
|
result := submitResult(cmd, 0, "", "rejected", "ENCODE_FAILED", err.Error())
|
||||||
@@ -96,7 +104,10 @@ func (p *connectionPool) submit(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (uint32, string, queue.SubmitResult, error) {
|
func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, part submitPart) (finalSequence uint32, finalID string, final queue.SubmitResult, finalErr error) {
|
||||||
|
var wireAt *time.Time
|
||||||
|
wireSource := "not_written"
|
||||||
|
defer func() { final.FirstWireSubmitAt = wireAt; final.WireTimeSource = wireSource }()
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -116,7 +127,13 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa
|
|||||||
return 0, "", result, err
|
return 0, "", result, err
|
||||||
}
|
}
|
||||||
c.sendMu.Lock()
|
c.sendMu.Lock()
|
||||||
|
wireSource = "write_uncertain"
|
||||||
seq, err := client.SendReqPkt(pkt)
|
seq, err := client.SendReqPkt(pkt)
|
||||||
|
if err == nil {
|
||||||
|
at := time.Now().UTC()
|
||||||
|
wireAt = &at
|
||||||
|
wireSource = "gateway_write_complete"
|
||||||
|
}
|
||||||
c.sendMu.Unlock()
|
c.sendMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.emitProtocolLog(protocolLogEvent{
|
c.emitProtocolLog(protocolLogEvent{
|
||||||
@@ -326,6 +343,8 @@ func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID s
|
|||||||
ErrorCode: code,
|
ErrorCode: code,
|
||||||
ErrorMessage: message,
|
ErrorMessage: message,
|
||||||
SubmittedAt: time.Now().UTC(),
|
SubmittedAt: time.Now().UTC(),
|
||||||
|
WireTimeSource: "not_written",
|
||||||
|
ReceiptRequested: cmd.CMPP.RegisteredDelivery != 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +353,9 @@ func submitSegmentResult(part submitPart, sequenceID uint32, gatewayMessageID st
|
|||||||
gatewayMessageID = result.GatewayMessageID
|
gatewayMessageID = result.GatewayMessageID
|
||||||
}
|
}
|
||||||
return queue.SubmitSegmentResult{
|
return queue.SubmitSegmentResult{
|
||||||
|
FirstWireSubmitAt: result.FirstWireSubmitAt,
|
||||||
|
WireTimeSource: result.WireTimeSource,
|
||||||
|
ReceiptRequested: result.ReceiptRequested,
|
||||||
SegmentTotal: int(part.PkTotal),
|
SegmentTotal: int(part.PkTotal),
|
||||||
SegmentIndex: int(part.PkNumber),
|
SegmentIndex: int(part.PkNumber),
|
||||||
SequenceID: sequenceID,
|
SequenceID: sequenceID,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnavailableConnectionNeverClaimsWireSubmitTime(t *testing.T) {
|
||||||
|
conn := &connection{closed: true}
|
||||||
|
cmd := submitCommandForPacketTest("3.0")
|
||||||
|
parts, err := splitSubmitContent(cmd.CMPP.MsgFmt, cmd.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _, result, err := conn.submitPart(context.Background(), cmd, parts[0])
|
||||||
|
if err == nil || result.FirstWireSubmitAt != nil || result.WireTimeSource != "not_written" {
|
||||||
|
t.Fatalf("invalid pre-write timing: %+v / %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentCarriesActualWireTimeAndReceiptRequest(t *testing.T) {
|
||||||
|
cmd := submitCommandForPacketTest("3.0")
|
||||||
|
cmd.CMPP.RegisteredDelivery = 1
|
||||||
|
result := submitResult(cmd, 1, "upstream-1", "timeout", "SUBMIT_TIMEOUT", "no response")
|
||||||
|
at := time.Now().UTC().Add(-5 * time.Second)
|
||||||
|
result.FirstWireSubmitAt = &at
|
||||||
|
result.WireTimeSource = "gateway_write_complete"
|
||||||
|
segment := submitSegmentResult(submitPart{PkTotal: 2, PkNumber: 1}, 1, "upstream-1", result)
|
||||||
|
if segment.FirstWireSubmitAt == nil || !segment.FirstWireSubmitAt.Equal(at) || segment.WireTimeSource != "gateway_write_complete" || !segment.ReceiptRequested {
|
||||||
|
t.Fatalf("wire timing was lost: %+v", segment)
|
||||||
|
}
|
||||||
|
if !segment.SubmittedAt.After(*segment.FirstWireSubmitAt) {
|
||||||
|
t.Fatal("response timestamp must not replace earlier wire timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +1,349 @@
|
|||||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
import { request, requestBlob, withQuery } from '../core/httpClient';
|
||||||
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, DownstreamRequeueFilter, DownstreamRequeuePreview, DownstreamRequeueTask, DownstreamRequeueTaskItem, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
|
import type {
|
||||||
|
BatchRequeueResponse,
|
||||||
|
BatchTaskMessagePage,
|
||||||
|
DailyProfitReport,
|
||||||
|
DailyQualityReport,
|
||||||
|
DailyReconciliationReport,
|
||||||
|
DashboardResponse,
|
||||||
|
DownstreamDeliveryDashboard,
|
||||||
|
DownstreamDeliveryRecord,
|
||||||
|
DownstreamRecoveryStatusExportQuery,
|
||||||
|
DownstreamRecoveryStatusResponse,
|
||||||
|
DownstreamRequeueFilter,
|
||||||
|
DownstreamRequeuePreview,
|
||||||
|
DownstreamRequeueTask,
|
||||||
|
DownstreamRequeueTaskItem,
|
||||||
|
GatewayDownstreamRecoveryStatus,
|
||||||
|
GatewaySubmitException,
|
||||||
|
GatewaySubmitExceptionResponse,
|
||||||
|
OperationLogResponse,
|
||||||
|
PagedResponse,
|
||||||
|
PagedResult,
|
||||||
|
PendingAuditCounts,
|
||||||
|
ProfitReportSummary,
|
||||||
|
ProtocolInteractionLogResponse,
|
||||||
|
QualityReportSummary,
|
||||||
|
ReceiptAnomalyResponse,
|
||||||
|
ReconciliationReportSummary,
|
||||||
|
SendQualityResponse,
|
||||||
|
SignatureChannelQualityResponse,
|
||||||
|
SmsBatchTask,
|
||||||
|
SmsMessageRecord,
|
||||||
|
SmsMessageSegmentAudit,
|
||||||
|
SmsUplinkMessage,
|
||||||
|
SystemLogExportResult,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// Read-heavy operations endpoints are isolated from configuration mutations.
|
// Read-heavy operations endpoints are isolated from configuration mutations.
|
||||||
export const adminOperationsApi = {
|
export const adminOperationsApi = {
|
||||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
getDashboard: (tenantId?: string) =>
|
||||||
getPendingAudits: (tenantId?: string) => request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||||
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
getPendingAudits: (tenantId?: string) =>
|
||||||
|
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
||||||
|
getSendQuality: (date?: string) =>
|
||||||
|
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||||
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
|
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
|
||||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) =>
|
listSystemLogs: (query: {
|
||||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
tenantId?: string;
|
||||||
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) =>
|
keyword?: string;
|
||||||
request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
|
level?: string;
|
||||||
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
|
module?: string;
|
||||||
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
range?: string;
|
||||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
createdAtFrom?: string;
|
||||||
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(withQuery('/admin/reports/reconciliation', query)),
|
createdAtTo?: string;
|
||||||
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
|
page?: number;
|
||||||
requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
|
pageSize?: number;
|
||||||
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
}) => request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||||
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }>(withQuery('/admin/reports/profit', query)),
|
listProtocolInteractionLogs: (query: {
|
||||||
exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
protocol?: string;
|
||||||
requestBlob(withQuery('/admin/reports/profit/export', query)),
|
direction?: string;
|
||||||
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
eventType?: string;
|
||||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType']; summary: QualityReportSummary }>(withQuery('/admin/reports/quality', query)),
|
status?: string;
|
||||||
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
keyword?: string;
|
||||||
requestBlob(withQuery('/admin/reports/quality/export', query)),
|
range?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) => request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
|
||||||
|
exportSystemLogs: (query: {
|
||||||
|
tenantId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
level?: string;
|
||||||
|
module?: string;
|
||||||
|
range?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
}) => request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||||
|
listReconciliationReports: (
|
||||||
|
query: {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(
|
||||||
|
withQuery('/admin/reports/reconciliation', query),
|
||||||
|
),
|
||||||
|
exportReconciliationReports: (
|
||||||
|
query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {},
|
||||||
|
) => requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
|
||||||
|
listProfitReports: (
|
||||||
|
query: {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
dimensionType?: 'application' | 'channel';
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
request<
|
||||||
|
PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }
|
||||||
|
>(withQuery('/admin/reports/profit', query)),
|
||||||
|
exportProfitReports: (
|
||||||
|
query: {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
dimensionType?: 'application' | 'channel';
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
} = {},
|
||||||
|
) => requestBlob(withQuery('/admin/reports/profit/export', query)),
|
||||||
|
listQualityReports: (
|
||||||
|
query: {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
dimensionType?: 'application' | 'channel' | 'signature' | 'drainage';
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
request<
|
||||||
|
PagedResponse<DailyQualityReport> & {
|
||||||
|
dimensionType: DailyQualityReport['dimensionType'];
|
||||||
|
summary: QualityReportSummary;
|
||||||
|
}
|
||||||
|
>(withQuery('/admin/reports/quality', query)),
|
||||||
|
exportQualityReports: (
|
||||||
|
query: {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
dimensionType?: 'application' | 'channel' | 'signature' | 'drainage';
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
} = {},
|
||||||
|
) => requestBlob(withQuery('/admin/reports/quality/export', query)),
|
||||||
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
|
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
|
||||||
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
|
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
|
||||||
listAdminBatchTasksPage: (query: { tenantId?: string; status?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
|
listAdminBatchTasksPage: (query: {
|
||||||
request<PagedResult<SmsBatchTask>>(withQuery('/admin/send/batch-tasks', query)),
|
tenantId?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
enterpriseKeyword?: string;
|
||||||
|
applicationKeyword?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => request<PagedResult<SmsBatchTask>>(withQuery('/admin/send/batch-tasks', query)),
|
||||||
listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
|
listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<BatchTaskMessagePage>(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)),
|
request<BatchTaskMessagePage>(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)),
|
||||||
terminateAdminBatchTask: (id: string) =>
|
terminateAdminBatchTask: (id: string) =>
|
||||||
request<SmsBatchTask>(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }),
|
request<SmsBatchTask>(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }),
|
||||||
listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) =>
|
listAdminMessages: (
|
||||||
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
status?: string;
|
||||||
|
} = {},
|
||||||
|
) => request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
||||||
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
||||||
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
||||||
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
listOperationMessages: (
|
||||||
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
query: {
|
||||||
|
monitorSnapshotId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
channelKeyword?: string;
|
||||||
|
taskId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
contentKeyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
status?: string;
|
||||||
|
hasDrainage?: string;
|
||||||
|
queuedAtFrom?: string;
|
||||||
|
queuedAtTo?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) => request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
||||||
getOperationMessage: (id: string) => request<SmsMessageRecord>(`/admin/operations/messages/${id}`),
|
getOperationMessage: (id: string) => request<SmsMessageRecord>(`/admin/operations/messages/${id}`),
|
||||||
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
exportOperationMessages: (
|
||||||
requestBlob(withQuery('/admin/operations/messages/export', query)),
|
query: {
|
||||||
|
monitorSnapshotId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
channelKeyword?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
contentKeyword?: string;
|
||||||
|
carrier?: string;
|
||||||
|
status?: string;
|
||||||
|
hasDrainage?: string;
|
||||||
|
queuedAtFrom?: string;
|
||||||
|
queuedAtTo?: string;
|
||||||
|
} = {},
|
||||||
|
) => requestBlob(withQuery('/admin/operations/messages/export', query)),
|
||||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||||
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
||||||
listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) =>
|
listAdminUplinkMessagesPage: (query: {
|
||||||
request<PagedResult<SmsUplinkMessage>>(withQuery('/admin/operations/uplink-messages', query)),
|
tenantId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
keyword?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => request<PagedResult<SmsUplinkMessage>>(withQuery('/admin/operations/uplink-messages', query)),
|
||||||
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
|
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
|
||||||
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
|
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, {
|
||||||
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
|
method: 'POST',
|
||||||
listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
body: JSON.stringify(body),
|
||||||
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
}),
|
||||||
|
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||||
|
request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
|
||||||
|
listGatewaySubmitExceptions: (
|
||||||
|
query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) => request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||||
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
||||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
|
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
resolveGatewaySubmitException: (id: string) =>
|
resolveGatewaySubmitException: (id: string) =>
|
||||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, { method: 'POST', body: JSON.stringify({}) }),
|
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, {
|
||||||
listReceiptAnomalies: (query: { tenantId?: string; applicationId?: string; channelId?: string; anomalyType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
method: 'POST',
|
||||||
request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', query)),
|
body: JSON.stringify({}),
|
||||||
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
}),
|
||||||
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
listReceiptAnomalies: (
|
||||||
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
query: {
|
||||||
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
tenantId?: string;
|
||||||
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
|
applicationId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
anomalyType?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) => request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', query)),
|
||||||
|
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) =>
|
||||||
|
request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||||
|
getDownstreamDeliveryDashboard: (
|
||||||
|
query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
} = {},
|
||||||
|
) => request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||||
|
listDownstreamRecoveryStatuses: (
|
||||||
|
query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
state?: string;
|
||||||
|
failureCategory?: string;
|
||||||
|
keyword?: string;
|
||||||
|
updatedAtFrom?: string;
|
||||||
|
updatedAtTo?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
) => request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
|
||||||
getDownstreamRecoveryStatus: (id: string) =>
|
getDownstreamRecoveryStatus: (id: string) =>
|
||||||
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
|
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
|
||||||
exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) =>
|
exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) =>
|
||||||
requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)),
|
requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)),
|
||||||
listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
listDownstreamDeliveries: (
|
||||||
request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
|
query: {
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
createdAtFrom?: string;
|
||||||
|
createdAtTo?: string;
|
||||||
|
} = {},
|
||||||
|
) => request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
|
||||||
requeueDownstreamDelivery: (id: string) =>
|
requeueDownstreamDelivery: (id: string) =>
|
||||||
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
|
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
batchRequeueDownstreamDeliveries: (ids: string[]) =>
|
batchRequeueDownstreamDeliveries: (ids: string[]) =>
|
||||||
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }),
|
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
}),
|
||||||
previewDownstreamRequeueTask: (filter: DownstreamRequeueFilter) =>
|
previewDownstreamRequeueTask: (filter: DownstreamRequeueFilter) =>
|
||||||
request<DownstreamRequeuePreview>('/admin/operations/downstream-requeue-tasks/preview', { method: 'POST', body: JSON.stringify({ filter }) }),
|
request<DownstreamRequeuePreview>('/admin/operations/downstream-requeue-tasks/preview', {
|
||||||
createDownstreamRequeueTask: (body: { previewToken: string; reason: string; ratePerSecond: number; consecutiveFailureLimit: number }) =>
|
method: 'POST',
|
||||||
request<DownstreamRequeueTask>('/admin/operations/downstream-requeue-tasks', { method: 'POST', body: JSON.stringify(body) }),
|
body: JSON.stringify({ filter }),
|
||||||
|
}),
|
||||||
|
createDownstreamRequeueTask: (body: {
|
||||||
|
previewToken: string;
|
||||||
|
reason: string;
|
||||||
|
ratePerSecond: number;
|
||||||
|
consecutiveFailureLimit: number;
|
||||||
|
}) =>
|
||||||
|
request<DownstreamRequeueTask>('/admin/operations/downstream-requeue-tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
listDownstreamRequeueTasks: (query: { status?: string; page?: number; pageSize?: number } = {}) =>
|
listDownstreamRequeueTasks: (query: { status?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<PagedResponse<DownstreamRequeueTask>>(withQuery('/admin/operations/downstream-requeue-tasks', query)),
|
request<PagedResponse<DownstreamRequeueTask>>(withQuery('/admin/operations/downstream-requeue-tasks', query)),
|
||||||
getDownstreamRequeueTask: (id: string) => request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}`),
|
getDownstreamRequeueTask: (id: string) =>
|
||||||
listDownstreamRequeueTaskItems: (id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}`),
|
||||||
request<PagedResponse<DownstreamRequeueTaskItem>>(withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query)),
|
listDownstreamRequeueTaskItems: (
|
||||||
|
id: string,
|
||||||
|
query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {},
|
||||||
|
) =>
|
||||||
|
request<PagedResponse<DownstreamRequeueTaskItem>>(
|
||||||
|
withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query),
|
||||||
|
),
|
||||||
changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') =>
|
changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') =>
|
||||||
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { method: 'POST', body: JSON.stringify({}) }),
|
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ import { ChannelFormModal } from './channels/ChannelFormModal';
|
|||||||
import { ChannelLogModal } from './channels/ChannelLogModal';
|
import { ChannelLogModal } from './channels/ChannelLogModal';
|
||||||
import { ChannelTable } from './channels/ChannelTable';
|
import { ChannelTable } from './channels/ChannelTable';
|
||||||
import { SmsTestModal } from './channels/SmsTestModal';
|
import { SmsTestModal } from './channels/SmsTestModal';
|
||||||
import { buildChannelPayload, carrierOptions, mapApiChannel, mapUiStatusToApi, statusOptions } from './channels/channelModel';
|
import {
|
||||||
|
buildChannelPayload,
|
||||||
|
carrierOptions,
|
||||||
|
mapApiChannel,
|
||||||
|
mapUiStatusToApi,
|
||||||
|
statusOptions,
|
||||||
|
} from './channels/channelModel';
|
||||||
import type { ChannelConfirmAction, ChannelLogState, ChannelModalState, SmsChannel } from './channels/channelTypes';
|
import type { ChannelConfirmAction, ChannelLogState, ChannelModalState, SmsChannel } from './channels/channelTypes';
|
||||||
import './channels/AdminChannelsPage.css';
|
import './channels/AdminChannelsPage.css';
|
||||||
|
import { ChannelEnrollmentPrompt } from './sending-monitor/MonitorConfiguration';
|
||||||
|
|
||||||
export function AdminChannelsPage() {
|
export function AdminChannelsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [channels, setChannels] = useState<SmsChannel[]>([]);
|
const [channels, setChannels] = useState<SmsChannel[]>([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [enrollment, setEnrollment] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [carrier, setCarrier] = useState('all');
|
const [carrier, setCarrier] = useState('all');
|
||||||
const [status, setStatus] = useState('all');
|
const [status, setStatus] = useState('all');
|
||||||
@@ -29,13 +37,23 @@ export function AdminChannelsPage() {
|
|||||||
|
|
||||||
function loadChannels(targetPage = page, filters = { keyword, carrier, status }) {
|
function loadChannels(targetPage = page, filters = { keyword, carrier, status }) {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
adminApi.listChannelsPage({ keyword: filters.keyword.trim() || undefined, carrier: filters.carrier, status: filters.status, page: targetPage, pageSize }),
|
adminApi.listChannelsPage({
|
||||||
|
keyword: filters.keyword.trim() || undefined,
|
||||||
|
carrier: filters.carrier,
|
||||||
|
status: filters.status,
|
||||||
|
page: targetPage,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
adminApi.getSendQuality(),
|
adminApi.getSendQuality(),
|
||||||
])
|
])
|
||||||
.then(([result, quality]) => {
|
.then(([result, quality]) => {
|
||||||
const visibleChannels = result.items;
|
const visibleChannels = result.items;
|
||||||
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
|
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
|
||||||
setChannels(visibleChannels.map((item) => mapApiChannel(item, item.connectionStates ?? [], qualityByChannel.get(item.id))));
|
setChannels(
|
||||||
|
visibleChannels.map((item) =>
|
||||||
|
mapApiChannel(item, item.connectionStates ?? [], qualityByChannel.get(item.id)),
|
||||||
|
),
|
||||||
|
);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
@@ -54,11 +72,12 @@ export function AdminChannelsPage() {
|
|||||||
if (modal?.mode === 'edit' && modal.channel) {
|
if (modal?.mode === 'edit' && modal.channel) {
|
||||||
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||||
} else {
|
} else {
|
||||||
await adminApi.createChannel({
|
const created = await adminApi.createChannel({
|
||||||
code: `CH-${Date.now()}`,
|
code: `CH-${Date.now()}`,
|
||||||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
|
setEnrollment({ id: created.id, name: created.name });
|
||||||
}
|
}
|
||||||
loadChannels();
|
loadChannels();
|
||||||
setModal(null);
|
setModal(null);
|
||||||
@@ -74,7 +93,8 @@ export function AdminChannelsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyChannel(channel: SmsChannel) {
|
async function copyChannel(channel: SmsChannel) {
|
||||||
await adminApi.copyChannel(channel.id);
|
const created = await adminApi.copyChannel(channel.id);
|
||||||
|
setEnrollment({ id: created.id, name: created.name });
|
||||||
loadChannels();
|
loadChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +123,15 @@ export function AdminChannelsPage() {
|
|||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmTitle = confirmAction?.type === 'copy'
|
const confirmTitle =
|
||||||
|
confirmAction?.type === 'copy'
|
||||||
? '确认复制通道'
|
? '确认复制通道'
|
||||||
: confirmAction?.channel.status === 'stopped'
|
: confirmAction?.channel.status === 'stopped'
|
||||||
? '确认启用通道'
|
? '确认启用通道'
|
||||||
: '确认停用通道';
|
: '确认停用通道';
|
||||||
|
|
||||||
const confirmDescription = confirmAction?.type === 'copy'
|
const confirmDescription =
|
||||||
|
confirmAction?.type === 'copy'
|
||||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||||
: confirmAction?.channel.status === 'stopped'
|
: confirmAction?.channel.status === 'stopped'
|
||||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||||
@@ -119,18 +141,54 @@ export function AdminChannelsPage() {
|
|||||||
<section className="page-stack sms-channel-page">
|
<section className="page-stack sms-channel-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<Breadcrumb items={['短信通道管理']} />
|
<Breadcrumb items={['短信通道管理']} />
|
||||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>
|
||||||
|
添加通道
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="surface sms-channel-filter">
|
<div className="surface sms-channel-filter">
|
||||||
<div className="sms-channel-filter-grid">
|
<div className="sms-channel-filter-grid">
|
||||||
<Input label="通道名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入通道名称" value={keyword} />
|
<Input
|
||||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
label="通道名称"
|
||||||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="请输入通道名称"
|
||||||
|
value={keyword}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="运营商"
|
||||||
|
onChange={(event) => setCarrier(event.target.value)}
|
||||||
|
options={carrierOptions}
|
||||||
|
value={carrier}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="当前状态"
|
||||||
|
onChange={(event) => setStatus(event.target.value)}
|
||||||
|
options={statusOptions}
|
||||||
|
value={status}
|
||||||
|
/>
|
||||||
<div className="audit-filter-actions">
|
<div className="audit-filter-actions">
|
||||||
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else void loadChannels(1); }}>查询</Button>
|
<Button
|
||||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); if (page !== 1) setPage(1); else void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' }); }} variant="ghost">重置</Button>
|
icon={<Search size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
if (page !== 1) setPage(1);
|
||||||
|
else void loadChannels(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword('');
|
||||||
|
setCarrier('all');
|
||||||
|
setStatus('all');
|
||||||
|
if (page !== 1) setPage(1);
|
||||||
|
else void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' });
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +208,7 @@ export function AdminChannelsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{modal ? <ChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
{modal ? <ChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
||||||
|
{enrollment && <ChannelEnrollmentPrompt channel={enrollment} onClose={() => setEnrollment(null)} />}
|
||||||
{testChannel ? (
|
{testChannel ? (
|
||||||
<SmsTestModal
|
<SmsTestModal
|
||||||
channel={testChannel}
|
channel={testChannel}
|
||||||
@@ -163,12 +222,14 @@ export function AdminChannelsPage() {
|
|||||||
|
|
||||||
{confirmAction ? (
|
{confirmAction ? (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
<Button onClick={() => setConfirmAction(null)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
<Button onClick={submitConfirmAction}>确认</Button>
|
<Button onClick={submitConfirmAction}>确认</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
onClose={() => setConfirmAction(null)}
|
onClose={() => setConfirmAction(null)}
|
||||||
open
|
open
|
||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
.sending-monitor .sending-monitor__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__notice {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-selected-soft);
|
||||||
|
color: var(--color-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__summary > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__summary strong {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 2fr) minmax(200px, 1fr) minmax(140px, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__rate {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__rate--bad {
|
||||||
|
color: var(--color-danger, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__form,
|
||||||
|
.sending-monitor .sending-monitor__scope {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__rule {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__trend-point {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__bars > span {
|
||||||
|
height: 5px;
|
||||||
|
background: var(--color-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 767px) {
|
||||||
|
.sending-monitor .sending-monitor__summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__filters,
|
||||||
|
.sending-monitor .sending-monitor__form,
|
||||||
|
.sending-monitor .sending-monitor__scope {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__mobile {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__mobile > article {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sending-monitor .sending-monitor__mobile-metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,308 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Activity } from 'lucide-react';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
import { MonitorRuntimeOverview } from './MonitorRuntimeOverview';
|
||||||
|
import { MonitorRulesModal, MonitorScopePicker, MonitorTargetsModal } from './sending-monitor/MonitorConfiguration';
|
||||||
const columns: Array<TableColumn<AdminChannel>> = [
|
import { MonitorAlerts, MonitorHistory, MonitorPager, Rate } from './sending-monitor/MonitorDetails';
|
||||||
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
import {
|
||||||
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
monitorApi,
|
||||||
{ key: 'carrier', title: '运营商', render: (record) => {
|
names,
|
||||||
const carriers = record.carriers?.length ? record.carriers : record.carrier === 'all' ? ['mobile', 'unicom', 'telecom'] : record.carrier ? [record.carrier] : [];
|
ruleSource,
|
||||||
return carriers.length ? <span className="ui-carrier-tags">{carriers.map((carrier) => <CarrierTag carrier={carrier} key={carrier} />)}</span> : '-';
|
states,
|
||||||
} },
|
time,
|
||||||
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
|
title,
|
||||||
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
|
type MonitorType,
|
||||||
{
|
type Snapshot,
|
||||||
key: 'status',
|
} from './sending-monitor/monitorApi';
|
||||||
title: '状态',
|
import './AdminMonitorPage.css';
|
||||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'danger'}>{record.status === 'active' ? '运行中' : '已停用'}</Tag>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AdminMonitorPage() {
|
export function AdminMonitorPage() {
|
||||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
const [params, setParams] = useSearchParams();
|
||||||
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
|
const tab = params.get('tab') ?? 'industry';
|
||||||
const [loading, setLoading] = useState(true);
|
const type: MonitorType = tab === 'overall' || tab === 'verification' ? tab : 'industry';
|
||||||
const [error, setError] = useState('');
|
const [data, setData] = useState<Awaited<ReturnType<typeof monitorApi.rows>> | null>(null);
|
||||||
|
const [summary, setSummary] = useState<Awaited<ReturnType<typeof monitorApi.overview>> | null>(null);
|
||||||
function loadData() {
|
const [error, setError] = useState(''),
|
||||||
|
[loading, setLoading] = useState(false),
|
||||||
|
[dialog, setDialog] = useState<'rules' | 'targets' | null>(null),
|
||||||
|
[detail, setDetail] = useState<Snapshot | null>(null);
|
||||||
|
const lastLoad = useRef(0),
|
||||||
|
busy = useRef(false);
|
||||||
|
const page = Math.max(1, Number(params.get('page')) || 1),
|
||||||
|
queryKey = params.toString();
|
||||||
|
const update = (values: Record<string, string>) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
Object.entries(values).forEach(([key, value]) => (value ? next.set(key, value) : next.delete(key)));
|
||||||
|
setParams(next);
|
||||||
|
};
|
||||||
|
const load = useCallback(
|
||||||
|
async (force = false, signal?: AbortSignal) => {
|
||||||
|
if (
|
||||||
|
tab === 'alerts' ||
|
||||||
|
tab === 'runtime' ||
|
||||||
|
document.hidden ||
|
||||||
|
(busy.current && !force) ||
|
||||||
|
(!force && Date.now() - lastLoad.current < 1500)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
busy.current = true;
|
||||||
|
lastLoad.current = Date.now();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([adminApi.listChannels(), adminApi.listMonitor()])
|
const q = new URLSearchParams(queryKey);
|
||||||
.then(([channelItems, monitorData]) => {
|
try {
|
||||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
const [rows, overview] = await Promise.all([
|
||||||
setMonitor(monitorData);
|
monitorApi.rows(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
page: q.get('page') ?? '1',
|
||||||
|
status: q.get('status') ?? '',
|
||||||
|
keyword: q.get('keyword') ?? '',
|
||||||
|
tenantId: q.get('tenantId') ?? '',
|
||||||
|
applicationId: q.get('applicationId') ?? '',
|
||||||
|
signatureId: q.get('signatureId') ?? '',
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
monitorApi.overview(type),
|
||||||
|
]);
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
setData(rows);
|
||||||
|
setSummary(overview);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
|
||||||
.catch((reason: Error) => setError(reason.message || '监控数据加载失败'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) setError(e instanceof Error ? e.message : '监控加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
busy.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryKey, tab, type],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
const controller = new AbortController();
|
||||||
}, []);
|
void load(true, controller.signal);
|
||||||
|
const refresh = () => void load(false, controller.signal);
|
||||||
const enabledChannels = channels.filter((item) => item.status === 'active').length;
|
const timer = setInterval(refresh, 30000);
|
||||||
const statusGroups = Array.isArray(monitor.byStatus) ? monitor.byStatus as Array<{ status: string; _count: { _all: number } }> : [];
|
window.addEventListener('focus', refresh);
|
||||||
const totalMessages = useMemo(() => statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
|
document.addEventListener('visibilitychange', refresh);
|
||||||
const deliveredMessages = useMemo(() => statusGroups.filter((item) => item.status === 'delivered').reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
|
return () => {
|
||||||
const successRate = totalMessages > 0 ? ((deliveredMessages / totalMessages) * 100).toFixed(1) : '0.0';
|
controller.abort();
|
||||||
|
clearInterval(timer);
|
||||||
return (
|
window.removeEventListener('focus', refresh);
|
||||||
<section className="page-stack">
|
document.removeEventListener('visibilitychange', refresh);
|
||||||
<div className="page-heading">
|
};
|
||||||
|
}, [load]);
|
||||||
|
const health = data?.health?.data;
|
||||||
|
const stale = !health || !health.complete || Date.now() - new Date(health.checkedAt).getTime() > 30000;
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const columns: TableColumn<Snapshot>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: type === 'industry' ? '通道 / 运营商' : '企业 / 应用 / 签名',
|
||||||
|
width: '260px',
|
||||||
|
render: (row) => (
|
||||||
|
<div className="ui-table__long-text">
|
||||||
|
<strong>{title(row.dimensions)}</strong>
|
||||||
|
<p>{row.dimensions.channelId ?? row.dimensions.signatureId}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'total', title: '窗口提交量', width: '110px', render: (row) => row.metrics.total.toLocaleString() },
|
||||||
|
...(type === 'overall' ? [60, 300, 1200] : [5, 20, 60]).map((seconds, i): TableColumn<Snapshot> => ({
|
||||||
|
key: `rate${seconds}`,
|
||||||
|
title: `${seconds < 60 ? `${seconds}秒` : `${seconds / 60}分钟`}到达率`,
|
||||||
|
width: '165px',
|
||||||
|
render: (row) => <Rate value={row.metrics.metrics[i]} />,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
title: '状态 / 规则',
|
||||||
|
width: '140px',
|
||||||
|
render: (row) => (
|
||||||
|
<>
|
||||||
|
<Tag tone={!stale && row.status === 'abnormal' ? 'danger' : 'neutral'}>
|
||||||
|
{stale ? '数据延迟' : states[row.status]}
|
||||||
|
</Tag>
|
||||||
|
<p>
|
||||||
|
{ruleSource(row.rule)}
|
||||||
|
{row.rule ? ` v${row.rule.version}` : ''}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
title: '操作',
|
||||||
|
width: '110px',
|
||||||
|
render: (row) => (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetail(row)}>
|
||||||
|
趋势 / 详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const counts = summary?.rows ?? [];
|
||||||
|
const cards = [
|
||||||
|
['监控维度', counts.reduce((n, r) => n + r.dimensions, 0)],
|
||||||
|
['异常维度', counts.filter((r) => r.status === 'abnormal').reduce((n, r) => n + r.dimensions, 0)],
|
||||||
|
[
|
||||||
|
'样本不足',
|
||||||
|
counts
|
||||||
|
.filter((r) => ['sample_insufficient', 'unassessable'].includes(r.status))
|
||||||
|
.reduce((n, r) => n + r.dimensions, 0),
|
||||||
|
],
|
||||||
|
['窗口提交量', counts.reduce((n, r) => n + Number(r.total), 0)],
|
||||||
|
];
|
||||||
|
const content = (
|
||||||
|
<div className="page-stack">
|
||||||
|
<div className="sending-monitor__summary">
|
||||||
|
{cards.map(([label, value]) => (
|
||||||
|
<div className="surface" key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{data ? Number(value).toLocaleString() : '—'}</strong>
|
||||||
|
<small>{type === 'industry' ? '按通道发送尝试' : '按唯一业务短信'}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="surface sending-monitor__filters">
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb items={['发送监控']} />
|
<strong>评估时刻:{time(counts[0]?.evaluationAt)}</strong>
|
||||||
|
<p>
|
||||||
|
每{type === 'overall' ? '10' : '5'}分钟评估,提交窗口最近{type === 'overall' ? '30' : '5'}分钟
|
||||||
|
</p>
|
||||||
|
<small>
|
||||||
|
采集检查:{time(health?.checkedAt)}
|
||||||
|
{stale ? ' · 数据延迟或等待首次计算,暂停告警判断' : ' · 已完成采集'}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Activity size={16} />} onClick={loadData} variant="ghost">实时刷新</Button>
|
<Input
|
||||||
|
aria-label="搜索监控对象"
|
||||||
|
placeholder="搜索通道、企业、应用或签名"
|
||||||
|
value={params.get('keyword') ?? ''}
|
||||||
|
onChange={(e) => update({ keyword: e.target.value, page: '1' })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="状态"
|
||||||
|
value={params.get('status') ?? ''}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
...['abnormal', 'normal', 'sample_insufficient', 'stale', 'unassessable', 'unconfigured', 'no_data'].map(
|
||||||
|
(value) => ({ value, label: states[value] }),
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
onChange={(e) => update({ status: e.target.value, page: '1' })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <p className="muted">正在加载监控数据...</p> : null}
|
{type !== 'industry' && (
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
<details className="surface">
|
||||||
<div className="dashboard-grid">
|
<summary>按企业、应用、签名筛选</summary>
|
||||||
<div className="surface metric-card">
|
<MonitorScopePicker
|
||||||
<span>运行通道</span>
|
scope={{
|
||||||
<strong>{enabledChannels}</strong>
|
tenantId: params.get('tenantId') ?? undefined,
|
||||||
<small>共 {channels.length} 条通道</small>
|
applicationId: params.get('applicationId') ?? undefined,
|
||||||
|
signatureId: params.get('signatureId') ?? undefined,
|
||||||
|
}}
|
||||||
|
onChange={(scope) =>
|
||||||
|
update({
|
||||||
|
tenantId: scope.tenantId ?? '',
|
||||||
|
applicationId: scope.applicationId ?? '',
|
||||||
|
signatureId: scope.signatureId ?? '',
|
||||||
|
page: '1',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{loading && <p role="status">正在读取监控快照…</p>}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error},保留上次结果,请重试。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="surface sending-monitor__desktop">
|
||||||
|
<Table columns={columns} data={rows} rowKey="id" />
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="sending-monitor__mobile">
|
||||||
<span>平均成功率</span>
|
{rows.map((row) => (
|
||||||
<strong>{successRate}%</strong>
|
<article className="surface" key={row.id}>
|
||||||
<small>真实消息记录</small>
|
<h3>{title(row.dimensions)}</h3>
|
||||||
|
<Tag tone="neutral">{stale ? '数据延迟' : states[row.status]}</Tag>
|
||||||
|
<p>
|
||||||
|
窗口提交 {row.metrics.total} · {ruleSource(row.rule)}
|
||||||
|
</p>
|
||||||
|
{row.metrics.metrics.map((m) => (
|
||||||
|
<div className="sending-monitor__mobile-metric" key={m.seconds}>
|
||||||
|
<span>{m.seconds}秒到达率</span>
|
||||||
|
<Rate value={m} />
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
))}
|
||||||
<span>消息总量</span>
|
<Button variant="ghost" onClick={() => setDetail(row)}>
|
||||||
<strong>{totalMessages.toLocaleString('zh-CN')}</strong>
|
趋势 / 详情
|
||||||
<small>按当前查询聚合</small>
|
</Button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!loading && !rows.length && !error && (
|
||||||
|
<p className="surface">
|
||||||
|
暂无监控快照。行业通道需先加入监控;无流量不会生成应用签名维度。历史记录缺少发包时间时不制造到达率。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<MonitorPager page={page} total={data?.total ?? 0} onChange={(p) => update({ page: String(p) })} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="sending-monitor page-stack">
|
||||||
|
<div className="page-heading">
|
||||||
|
<Breadcrumb items={['运营概览', '发送监控']} />
|
||||||
|
<div className="sending-monitor__actions">
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={() => void load()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{!['alerts', 'runtime'].includes(tab) && (
|
||||||
|
<Button variant="secondary" onClick={() => setDialog('rules')}>
|
||||||
|
阈值设置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{tab === 'industry' && <Button onClick={() => setDialog('targets')}>监控通道</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface">
|
<p className="sending-monitor__notice">
|
||||||
<Table columns={columns} data={channels} rowKey="id" />
|
以平台收到完整成功回执为准;未满观察时长的短信不参与该时效评估。各指标分别展示成功数 / 成熟分母。
|
||||||
</div>
|
</p>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(value) => {
|
||||||
|
setData(null);
|
||||||
|
setSummary(null);
|
||||||
|
setParams({ tab: value });
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
...Object.entries(names).map(([value, label]) => ({ value, label, content })),
|
||||||
|
{ value: 'alerts', label: '告警记录', content: <MonitorAlerts /> },
|
||||||
|
{ value: 'runtime', label: '运行概况', content: <MonitorRuntimeOverview /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{dialog === 'rules' && (
|
||||||
|
<MonitorRulesModal
|
||||||
|
type={type}
|
||||||
|
onClose={() => {
|
||||||
|
setDialog(null);
|
||||||
|
void load(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dialog === 'targets' && (
|
||||||
|
<MonitorTargetsModal
|
||||||
|
onClose={() => {
|
||||||
|
setDialog(null);
|
||||||
|
void load(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail && <MonitorHistory row={detail} onClose={() => setDetail(null)} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { ReportNotificationsPage } from '../report-notifications/ReportNotificationsPage';
|
||||||
import { Clock3, Eye, Search } from 'lucide-react';
|
import { Clock3, Eye, Search } from 'lucide-react';
|
||||||
import { adminApi, type ReportRecord } from '@/api/adminApi';
|
import { adminApi, type ReportRecord } from '@/api/adminApi';
|
||||||
import {
|
import {
|
||||||
@@ -147,6 +149,34 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminReportRecordsPage() {
|
export function AdminReportRecordsPage() {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const readiness = params.get('tab') === 'readiness';
|
||||||
|
return (
|
||||||
|
<section className="page-stack">
|
||||||
|
<div className="page-heading" role="tablist" aria-label="状态记录类型">
|
||||||
|
<Button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={!readiness}
|
||||||
|
variant={readiness ? 'secondary' : 'primary'}
|
||||||
|
onClick={() => setParams({})}
|
||||||
|
>
|
||||||
|
状态记录
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={readiness}
|
||||||
|
variant={readiness ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setParams({ tab: 'readiness' })}
|
||||||
|
>
|
||||||
|
报备状态变化消息
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{readiness ? <ReportNotificationsPage portal="admin" /> : <ReportStatusRecords />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportStatusRecords() {
|
||||||
const [records, setRecords] = useState<ReportRecord[]>([]);
|
const [records, setRecords] = useState<ReportRecord[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||||
@@ -159,7 +189,15 @@ export function AdminReportRecordsPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', batchNo: '', operatorKeyword: '', statusAfter: 'all', sourceEntry: 'all' });
|
const [appliedFilters, setAppliedFilters] = useState({
|
||||||
|
keyword: '',
|
||||||
|
dateRange: {} as DateRangeValue,
|
||||||
|
reportType: 'all',
|
||||||
|
batchNo: '',
|
||||||
|
operatorKeyword: '',
|
||||||
|
statusAfter: 'all',
|
||||||
|
sourceEntry: 'all',
|
||||||
|
});
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
function loadData(targetPage = page, filters = appliedFilters) {
|
function loadData(targetPage = page, filters = appliedFilters) {
|
||||||
@@ -193,7 +231,11 @@ export function AdminReportRecordsPage() {
|
|||||||
key: 'task',
|
key: 'task',
|
||||||
title: '报备任务号',
|
title: '报备任务号',
|
||||||
width: '160px',
|
width: '160px',
|
||||||
render: (record) => <strong className="admin-task-id admin-report-record-id" title={record.taskId}>{record.taskId}</strong>,
|
render: (record) => (
|
||||||
|
<strong className="admin-task-id admin-report-record-id" title={record.taskId}>
|
||||||
|
{record.taskId}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ key: 'channel', title: '通道名称', width: '130px', render: (record) => record.channel?.name ?? '-' },
|
{ key: 'channel', title: '通道名称', width: '130px', render: (record) => record.channel?.name ?? '-' },
|
||||||
{
|
{
|
||||||
@@ -328,7 +370,15 @@ export function AdminReportRecordsPage() {
|
|||||||
<Button
|
<Button
|
||||||
icon={<Search size={16} />}
|
icon={<Search size={16} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const filters = { keyword: keyword.trim(), dateRange, reportType, batchNo: batchNo.trim(), operatorKeyword: operatorKeyword.trim(), statusAfter, sourceEntry };
|
const filters = {
|
||||||
|
keyword: keyword.trim(),
|
||||||
|
dateRange,
|
||||||
|
reportType,
|
||||||
|
batchNo: batchNo.trim(),
|
||||||
|
operatorKeyword: operatorKeyword.trim(),
|
||||||
|
statusAfter,
|
||||||
|
sourceEntry,
|
||||||
|
};
|
||||||
setAppliedFilters(filters);
|
setAppliedFilters(filters);
|
||||||
if (page !== 1) setPage(1);
|
if (page !== 1) setPage(1);
|
||||||
else loadData(1, filters);
|
else loadData(1, filters);
|
||||||
@@ -345,7 +395,15 @@ export function AdminReportRecordsPage() {
|
|||||||
setOperatorKeyword('');
|
setOperatorKeyword('');
|
||||||
setStatusAfter('all');
|
setStatusAfter('all');
|
||||||
setSourceEntry('all');
|
setSourceEntry('all');
|
||||||
const filters = { keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', batchNo: '', operatorKeyword: '', statusAfter: 'all', sourceEntry: 'all' };
|
const filters = {
|
||||||
|
keyword: '',
|
||||||
|
dateRange: {} as DateRangeValue,
|
||||||
|
reportType: 'all',
|
||||||
|
batchNo: '',
|
||||||
|
operatorKeyword: '',
|
||||||
|
statusAfter: 'all',
|
||||||
|
sourceEntry: 'all',
|
||||||
|
};
|
||||||
setAppliedFilters(filters);
|
setAppliedFilters(filters);
|
||||||
if (page !== 1) setPage(1);
|
if (page !== 1) setPage(1);
|
||||||
else loadData(1, filters);
|
else loadData(1, filters);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||||
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
|
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
|
||||||
import { SendDetailModal } from './sms-records/SendDetailModal';
|
import { SendDetailModal } from './sms-records/SendDetailModal';
|
||||||
@@ -11,10 +12,14 @@ import './sms-records/AdminSmsRecordsPage.css';
|
|||||||
const pageSize = 25;
|
const pageSize = 25;
|
||||||
|
|
||||||
export function AdminSmsRecordsPage() {
|
export function AdminSmsRecordsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const monitorSnapshotId = searchParams.get('monitorSnapshotId') ?? undefined;
|
||||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||||
const [enterprise, setEnterprise] = useState('all');
|
const [enterprise, setEnterprise] = useState('all');
|
||||||
const [application, setApplication] = useState('all');
|
const [application, setApplication] = useState('all');
|
||||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultSmsRecordDateRange);
|
const [dateRange, setDateRange] = useState<DateRangeValue>(() =>
|
||||||
|
monitorSnapshotId ? {} : defaultSmsRecordDateRange(),
|
||||||
|
);
|
||||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||||
const [contentKeyword, setContentKeyword] = useState('');
|
const [contentKeyword, setContentKeyword] = useState('');
|
||||||
const [channel, setChannel] = useState('all');
|
const [channel, setChannel] = useState('all');
|
||||||
@@ -27,10 +32,11 @@ export function AdminSmsRecordsPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
|
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
|
||||||
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
|
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
|
||||||
const [filterChannels, setFilterChannels] = useState<AdminChannel[]>([]);
|
const [filterChannels, setFilterChannels] = useState<AdminChannel[]>([]);
|
||||||
|
const listRequestSequence = useRef(0);
|
||||||
const detailRequestSequence = useRef(0);
|
const detailRequestSequence = useRef(0);
|
||||||
|
|
||||||
function currentFilters(): MessageFilters {
|
function currentFilters(): MessageFilters {
|
||||||
@@ -49,31 +55,44 @@ export function AdminSmsRecordsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadData(filters = currentFilters(), targetPage = page) {
|
function loadData(filters = currentFilters(), targetPage = page) {
|
||||||
setLoading(true);
|
const sequence = ++listRequestSequence.current;
|
||||||
adminApi.listOperationMessages({ ...filters, page: targetPage, pageSize })
|
adminApi
|
||||||
|
.listOperationMessages({ ...filters, monitorSnapshotId, page: targetPage, pageSize })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (sequence !== listRequestSequence.current) return;
|
||||||
setRecords(result.items);
|
setRecords(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setSelectedRecord((current) => current ? result.items.find((item) => item.id === current.id) ?? null : null);
|
setSelectedRecord((current) =>
|
||||||
|
current ? (result.items.find((item) => item.id === current.id) ?? null) : null,
|
||||||
|
);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'))
|
.catch((failure: Error) => {
|
||||||
.finally(() => setLoading(false));
|
if (sequence === listRequestSequence.current) setError(failure.message || '短信记录加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (sequence === listRequestSequence.current) setLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(currentFilters(), page);
|
loadData(currentFilters(), page);
|
||||||
}, [page]);
|
return () => {
|
||||||
|
listRequestSequence.current++;
|
||||||
|
};
|
||||||
|
}, [page, monitorSnapshotId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
|
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
|
||||||
.then(([tenants, applications, channels]) => {
|
.then(([tenants, applications, channels]) => {
|
||||||
setFilterTenants(tenants
|
setFilterTenants(
|
||||||
|
tenants.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, name: item.name })),
|
||||||
|
);
|
||||||
|
setFilterApplications(
|
||||||
|
applications
|
||||||
.filter((item) => item.status !== 'deleted')
|
.filter((item) => item.status !== 'deleted')
|
||||||
.map((item) => ({ id: item.id, name: item.name })));
|
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })),
|
||||||
setFilterApplications(applications
|
);
|
||||||
.filter((item) => item.status !== 'deleted')
|
|
||||||
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
|
|
||||||
setFilterChannels(channels.filter((item) => item.status !== 'deleted'));
|
setFilterChannels(channels.filter((item) => item.status !== 'deleted'));
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
|
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
|
||||||
@@ -116,17 +135,23 @@ export function AdminSmsRecordsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const applicationOptions = useMemo(
|
const applicationOptions = useMemo(
|
||||||
() => [{ label: '全部应用', value: 'all' }, ...filterApplications
|
() => [
|
||||||
|
{ label: '全部应用', value: 'all' },
|
||||||
|
...filterApplications
|
||||||
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
|
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
|
||||||
.map((item) => ({ label: item.name, value: item.id }))],
|
.map((item) => ({ label: item.name, value: item.id })),
|
||||||
|
],
|
||||||
[enterprise, filterApplications],
|
[enterprise, filterApplications],
|
||||||
);
|
);
|
||||||
|
|
||||||
const channelOptions = useMemo(
|
const channelOptions = useMemo(
|
||||||
() => [{ label: '全部通道', value: 'all' }, ...filterChannels.map((item) => ({
|
() => [
|
||||||
|
{ label: '全部通道', value: 'all' },
|
||||||
|
...filterChannels.map((item) => ({
|
||||||
label: item.code ? `${item.name}(${item.code})` : item.name,
|
label: item.code ? `${item.name}(${item.code})` : item.name,
|
||||||
value: item.id,
|
value: item.id,
|
||||||
}))],
|
})),
|
||||||
|
],
|
||||||
[filterChannels],
|
[filterChannels],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,6 +159,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
|
setLoading(true);
|
||||||
const defaultDateRange = defaultSmsRecordDateRange();
|
const defaultDateRange = defaultSmsRecordDateRange();
|
||||||
setEnterprise('all');
|
setEnterprise('all');
|
||||||
setApplication('all');
|
setApplication('all');
|
||||||
@@ -150,7 +176,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
|
|
||||||
async function exportRecords() {
|
async function exportRecords() {
|
||||||
try {
|
try {
|
||||||
const blob = await adminApi.exportOperationMessages(currentFilters());
|
const blob = await adminApi.exportOperationMessages({ ...currentFilters(), monitorSnapshotId });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
@@ -170,6 +196,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
<h1>短信记录</h1>
|
<h1>短信记录</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{monitorSnapshotId && <p className="muted">当前仅显示所选监控窗口的样本;筛选和导出均限定在该范围内。</p>}
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<SmsRecordFilter
|
<SmsRecordFilter
|
||||||
@@ -197,6 +224,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
onHasDrainageChange={setHasDrainage}
|
onHasDrainageChange={setHasDrainage}
|
||||||
onPhoneKeywordChange={setPhoneKeyword}
|
onPhoneKeywordChange={setPhoneKeyword}
|
||||||
onQuery={() => {
|
onQuery={() => {
|
||||||
|
setLoading(true);
|
||||||
if (page !== 1) setPage(1);
|
if (page !== 1) setPage(1);
|
||||||
else loadData(currentFilters(), 1);
|
else loadData(currentFilters(), 1);
|
||||||
}}
|
}}
|
||||||
@@ -212,7 +240,10 @@ export function AdminSmsRecordsPage() {
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onExport={() => void exportRecords()}
|
onExport={() => void exportRecords()}
|
||||||
onOpenDetail={openDetail}
|
onOpenDetail={openDetail}
|
||||||
onPageChange={setPage}
|
onPageChange={(next) => {
|
||||||
|
setLoading(true);
|
||||||
|
setPage(next);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedRecord ? (
|
{selectedRecord ? (
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Activity } from 'lucide-react';
|
||||||
|
import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
||||||
|
|
||||||
|
const columns: Array<TableColumn<AdminChannel>> = [
|
||||||
|
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
||||||
|
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
||||||
|
{
|
||||||
|
key: 'carrier',
|
||||||
|
title: '运营商',
|
||||||
|
render: (record) => {
|
||||||
|
const carriers = record.carriers?.length
|
||||||
|
? record.carriers
|
||||||
|
: record.carrier === 'all'
|
||||||
|
? ['mobile', 'unicom', 'telecom']
|
||||||
|
: record.carrier
|
||||||
|
? [record.carrier]
|
||||||
|
: [];
|
||||||
|
return carriers.length ? (
|
||||||
|
<span className="ui-carrier-tags">
|
||||||
|
{carriers.map((carrier) => (
|
||||||
|
<CarrierTag carrier={carrier} key={carrier} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
|
||||||
|
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '状态',
|
||||||
|
render: (record) => (
|
||||||
|
<Tag tone={record.status === 'active' ? 'success' : 'danger'}>
|
||||||
|
{record.status === 'active' ? '已启用' : '已停用'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MonitorRuntimeOverview() {
|
||||||
|
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||||
|
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
Promise.all([adminApi.listChannels(), adminApi.listMonitor()])
|
||||||
|
.then(([channelItems, monitorData]) => {
|
||||||
|
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||||
|
setMonitor(monitorData);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((reason: Error) => setError(reason.message || '监控数据加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enabledChannels = channels.filter((item) => item.status === 'active').length;
|
||||||
|
const statusGroups = Array.isArray(monitor.byStatus)
|
||||||
|
? (monitor.byStatus as Array<{ status: string; _count: { _all: number } }>)
|
||||||
|
: [];
|
||||||
|
const totalMessages = statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0);
|
||||||
|
const deliveredMessages = statusGroups
|
||||||
|
.filter((item) => item.status === 'delivered')
|
||||||
|
.reduce((sum, item) => sum + (item._count?._all ?? 0), 0);
|
||||||
|
const successRate = totalMessages ? ((deliveredMessages / totalMessages) * 100).toFixed(1) + '%' : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-stack">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={['发送监控']} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={<Activity size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
实时刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{loading ? <p className="muted">正在加载监控数据...</p> : null}
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>启用通道</span>
|
||||||
|
<strong>{enabledChannels}</strong>
|
||||||
|
<small>共 {channels.length} 条通道</small>
|
||||||
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>平均成功率</span>
|
||||||
|
<strong>{successRate}</strong>
|
||||||
|
<small>消息当前状态口径,与时效到达率不同</small>
|
||||||
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>消息总量</span>
|
||||||
|
<strong>{totalMessages.toLocaleString('zh-CN')}</strong>
|
||||||
|
<small>按当前查询聚合</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<Link to="/admin/system-monitoring">查看实时连接、队列与基础设施状态</Link> · 通道启用状态不代表连接正常。
|
||||||
|
</p>
|
||||||
|
<div className="surface">
|
||||||
|
<Table columns={columns} data={channels} rowKey="id" />
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
['recentMessages', '最近短信', '/admin/sms-records'],
|
||||||
|
['recentReceipts', '最近状态报告', '/admin/sms-records'],
|
||||||
|
['recentUplinks', '最近上行', '/admin/sms-uplink-records'],
|
||||||
|
].map(([key, label, to]) => (
|
||||||
|
<div className="surface" key={key}>
|
||||||
|
<h2>{label}</h2>
|
||||||
|
<Link to={to}>查看全部</Link>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
data={Array.isArray(monitor[key]) ? (monitor[key] as Record<string, unknown>[]) : []}
|
||||||
|
columns={[
|
||||||
|
{ key: 'messageId', title: '消息编号', render: (r) => String(r.messageId ?? r.gatewayMessageId ?? r.id) },
|
||||||
|
{ key: 'status', title: '状态', render: (r) => String(r.status ?? r.receiptStatus ?? '—') },
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
title: '时间',
|
||||||
|
render: (r) => new Date(String(r.queuedAt ?? r.deliveredAt ?? r.createdAt)).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { MonitorRulesModal, ChannelEnrollmentPrompt } from './MonitorConfiguration';
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
rules: vi.fn(),
|
||||||
|
effective: vi.fn(),
|
||||||
|
saveRule: vi.fn(),
|
||||||
|
targets: vi.fn(),
|
||||||
|
target: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('./monitorApi', async (importOriginal) => ({ ...(await importOriginal<object>()), monitorApi: api }));
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
api.rules.mockResolvedValue([]);
|
||||||
|
api.effective.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
it('leaves default thresholds empty and preserves input on actual save failure', async () => {
|
||||||
|
const user = userEvent.setup(),
|
||||||
|
close = vi.fn();
|
||||||
|
api.saveRule.mockRejectedValue(new Error('规则已被修改,请刷新后重试'));
|
||||||
|
render(<MonitorRulesModal type="industry" onClose={close} />);
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled());
|
||||||
|
expect(screen.getByLabelText('5秒到达率下限(%)')).toHaveValue(null);
|
||||||
|
expect(screen.getByLabelText('启用告警')).not.toBeChecked();
|
||||||
|
await user.type(screen.getByLabelText('最低成熟样本量'), '100');
|
||||||
|
await user.type(screen.getByLabelText('5秒到达率下限(%)'), '90');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存规则' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('规则已被修改');
|
||||||
|
expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(100);
|
||||||
|
expect(api.saveRule).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
version: 0,
|
||||||
|
scope: {},
|
||||||
|
config: expect.objectContaining({ enabled: false, minSamples: 100, thresholds: [90, null, null] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('retries enrollment against the same saved channel without creating another channel', async () => {
|
||||||
|
const user = userEvent.setup(),
|
||||||
|
close = vi.fn();
|
||||||
|
const channel = { id: 'saved-channel', name: '已保存通道', version: 0, enabled: false };
|
||||||
|
api.targets.mockResolvedValue([channel]);
|
||||||
|
api.target.mockRejectedValueOnce(new Error('网络中断')).mockResolvedValueOnce({ success: true });
|
||||||
|
render(<ChannelEnrollmentPrompt channel={channel} onClose={close} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: '加入监控' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('无需重新创建通道');
|
||||||
|
await user.click(screen.getByRole('button', { name: '加入监控' }));
|
||||||
|
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||||
|
expect(api.target.mock.calls).toEqual([
|
||||||
|
[channel, true],
|
||||||
|
[channel, true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
monitorApi,
|
||||||
|
names,
|
||||||
|
ruleSource,
|
||||||
|
time,
|
||||||
|
type Config,
|
||||||
|
type MonitorType,
|
||||||
|
type Rule,
|
||||||
|
type Scope,
|
||||||
|
type Target,
|
||||||
|
} from './monitorApi';
|
||||||
|
|
||||||
|
export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange: (scope: Scope) => void }) {
|
||||||
|
const [kind, setKind] = useState<'tenant' | 'application' | 'signature'>('tenant');
|
||||||
|
const [keyword, setKeyword] = useState(''),
|
||||||
|
[page, setPage] = useState(1);
|
||||||
|
const [options, setOptions] = useState<{ id: string; name: string }[]>([]),
|
||||||
|
[error, setError] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (kind !== 'tenant' && !scope.tenantId) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
monitorApi
|
||||||
|
.options(kind, scope, keyword, page)
|
||||||
|
.then((items) => {
|
||||||
|
if (live) {
|
||||||
|
setOptions(items);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (live) setError(e.message);
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [kind, scope.tenantId, scope.applicationId, keyword, page]);
|
||||||
|
return (
|
||||||
|
<div className="sending-monitor__scope">
|
||||||
|
<Select
|
||||||
|
label="查找维度"
|
||||||
|
value={kind}
|
||||||
|
options={[
|
||||||
|
{ value: 'tenant', label: '企业' },
|
||||||
|
{ value: 'application', label: '应用' },
|
||||||
|
{ value: 'signature', label: '签名' },
|
||||||
|
]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKind(e.target.value as typeof kind);
|
||||||
|
setPage(1);
|
||||||
|
setKeyword('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="远程搜索"
|
||||||
|
value={keyword}
|
||||||
|
placeholder="输入名称查询"
|
||||||
|
onChange={(e) => {
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="搜索结果"
|
||||||
|
value=""
|
||||||
|
options={[{ label: '请选择', value: '' }, ...options.map((o) => ({ label: o.name, value: o.id }))]}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) return;
|
||||||
|
const id = e.target.value;
|
||||||
|
onChange(
|
||||||
|
kind === 'tenant'
|
||||||
|
? { tenantId: id }
|
||||||
|
: kind === 'application'
|
||||||
|
? { tenantId: scope.tenantId, applicationId: id }
|
||||||
|
: { ...scope, signatureId: id },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="sending-monitor__actions">
|
||||||
|
<Button size="sm" variant="secondary" disabled={page === 1} onClick={() => setPage(page - 1)}>
|
||||||
|
上页
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" disabled={options.length < 20} onClick={() => setPage(page + 1)}>
|
||||||
|
下页
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onChange({})}>
|
||||||
|
清空维度
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{Object.keys(scope).length > 0 && (
|
||||||
|
<p className="muted">
|
||||||
|
已选:{scope.tenantId} {scope.applicationId} {scope.signatureId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const [rules, setRules] = useState<Rule[]>([]),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[loaded, setLoaded] = useState(false);
|
||||||
|
const [scope, setScope] = useState<Scope>({}),
|
||||||
|
[custom, setCustom] = useState(false),
|
||||||
|
[dirty, setDirty] = useState(false);
|
||||||
|
const [version, setVersion] = useState(0),
|
||||||
|
[matched, setMatched] = useState<Rule[]>([]);
|
||||||
|
const [min, setMin] = useState(''),
|
||||||
|
[thresholds, setThresholds] = useState(['', '', '']);
|
||||||
|
const [enabled, setEnabled] = useState(false),
|
||||||
|
[bad, setBad] = useState('1'),
|
||||||
|
[good, setGood] = useState('2');
|
||||||
|
useEffect(() => {
|
||||||
|
monitorApi
|
||||||
|
.rules()
|
||||||
|
.then((r) => {
|
||||||
|
setRules(r);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const current = rules.find(
|
||||||
|
(r) =>
|
||||||
|
r.type === type &&
|
||||||
|
JSON.stringify(Object.entries(r.scope).sort()) === JSON.stringify(Object.entries(scope).sort()),
|
||||||
|
);
|
||||||
|
setVersion(current?.version ?? 0);
|
||||||
|
setMin(current ? String(current.config.minSamples) : '');
|
||||||
|
setThresholds(current?.config.thresholds.map((v) => (v === null ? '' : String(v))) ?? ['', '', '']);
|
||||||
|
setEnabled(current?.config.enabled ?? false);
|
||||||
|
setBad(String(current?.config.consecutiveBad ?? 1));
|
||||||
|
setGood(String(current?.config.consecutiveGood ?? 2));
|
||||||
|
setDirty(false);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
let live = true;
|
||||||
|
monitorApi
|
||||||
|
.effective(type, scope)
|
||||||
|
.then((r) => {
|
||||||
|
if (live) {
|
||||||
|
setMatched(r);
|
||||||
|
const inherited = r[0]?.config;
|
||||||
|
if ((!current || current.config.deleted) && inherited && !dirtyRef.current) {
|
||||||
|
setMin(String(inherited.minSamples));
|
||||||
|
setThresholds(inherited.thresholds.map((v) => (v === null ? '' : String(v))));
|
||||||
|
setEnabled(inherited.enabled);
|
||||||
|
setBad(String(inherited.consecutiveBad));
|
||||||
|
setGood(String(inherited.consecutiveGood));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (live) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [rules, scope, type]);
|
||||||
|
useEffect(() => {
|
||||||
|
const before = (event: BeforeUnloadEvent) => {
|
||||||
|
if (dirty) event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', before);
|
||||||
|
return () => window.removeEventListener('beforeunload', before);
|
||||||
|
}, [dirty]);
|
||||||
|
const close = () => {
|
||||||
|
if (!dirty || window.confirm('有未保存的规则,确认放弃修改?')) onClose();
|
||||||
|
};
|
||||||
|
const changeScope = (next: Scope, nextCustom = custom) => {
|
||||||
|
if (dirty && !window.confirm('有未保存的规则,确认切换并放弃修改?')) return;
|
||||||
|
setCustom(nextCustom);
|
||||||
|
setScope(next);
|
||||||
|
};
|
||||||
|
async function save(deleted = false) {
|
||||||
|
const config: Config = {
|
||||||
|
enabled,
|
||||||
|
minSamples: Number(min),
|
||||||
|
thresholds: thresholds.map((v) => (v.trim() === '' ? null : Number(v))),
|
||||||
|
consecutiveBad: Number(bad),
|
||||||
|
consecutiveGood: Number(good),
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await monitorApi.saveRule({ type, scope: custom ? scope : {}, config, version });
|
||||||
|
setDirty(false);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '保存失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={`${names[type]} · 阈值设置`}
|
||||||
|
size="xl"
|
||||||
|
onClose={close}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={close}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy || !loaded || (custom && !scope.applicationId && !scope.signatureId)}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
保存规则
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="sending-monitor">
|
||||||
|
<p>规则下一周期生效。未配置时不告警;空白下限代表不启用该项,0%下限不会因低比率触发。</p>
|
||||||
|
{type === 'overall' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={custom}
|
||||||
|
onChange={(e) => {
|
||||||
|
changeScope({}, e.target.checked);
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
配置个性规则
|
||||||
|
</label>
|
||||||
|
{custom && <MonitorScopePicker scope={scope} onChange={changeScope} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="form-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loaded && <p>正在加载规则…</p>}
|
||||||
|
<div
|
||||||
|
className="sending-monitor__form"
|
||||||
|
onChange={() => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} /> 启用告警
|
||||||
|
</label>
|
||||||
|
<Input label="最低成熟样本量" type="number" min="1" value={min} onChange={(e) => setMin(e.target.value)} />
|
||||||
|
{(type === 'overall' ? ['1分钟', '5分钟', '20分钟'] : ['5秒', '20秒', '1分钟']).map((label, i) => (
|
||||||
|
<Input
|
||||||
|
key={label}
|
||||||
|
label={`${label}到达率下限(%)`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
value={thresholds[i]}
|
||||||
|
onChange={(e) => setThresholds((values) => values.map((v, index) => (index === i ? e.target.value : v)))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
label="连续异常次数"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
value={bad}
|
||||||
|
onChange={(e) => setBad(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="连续恢复次数"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
value={good}
|
||||||
|
onChange={(e) => setGood(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>当前编辑版本:{version || '新规则'}。生效顺序:应用×签名 → 签名 → 应用 → 通用,整套覆盖。</p>
|
||||||
|
<p>
|
||||||
|
当前生效:
|
||||||
|
{matched.length
|
||||||
|
? matched.map((r) => `${ruleSource(r)} v${r.version}(${time(r.effectiveAt)})`).join(' → ')
|
||||||
|
: '尚未配置'}
|
||||||
|
</p>
|
||||||
|
{custom && version > 0 && (
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={() => void save(true)}>
|
||||||
|
恢复继承
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{type === 'overall' &&
|
||||||
|
rules
|
||||||
|
.filter((r) => r.type === type && Object.keys(r.scope).length && !r.config.deleted)
|
||||||
|
.map((r) => (
|
||||||
|
<div key={r.id} className="sending-monitor__rule">
|
||||||
|
<span>
|
||||||
|
{ruleSource(r)} · {Object.values(r.scope).join(' / ')} · v{r.version}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
changeScope(r.scope, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitorTargetsModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [targets, setTargets] = useState<Target[]>([]),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[keyword, setKeyword] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
monitorApi
|
||||||
|
.targets()
|
||||||
|
.then(setTargets)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
async function toggle(t: Target) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await monitorApi.target(t, !t.enabled);
|
||||||
|
setTargets(await monitorApi.targets());
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '监控范围保存失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal open title="监控通道" size="xl" onClose={onClose} footer={<Button onClick={onClose}>关闭</Button>}>
|
||||||
|
<div className="sending-monitor">
|
||||||
|
<p>加入和移除仅影响行业质量监控,不改变通道路由或发送配置。下一周期生效。</p>
|
||||||
|
<Input placeholder="搜索通道名称" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{targets
|
||||||
|
.filter((t) => t.name.includes(keyword))
|
||||||
|
.map((t) => (
|
||||||
|
<div className="sending-monitor__rule" key={t.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{t.name}</strong>
|
||||||
|
<p>
|
||||||
|
{t.status === 'active' ? '业务启用' : '业务停用'} · {time(t.effectiveFrom)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Tag tone={t.enabled ? 'info' : 'neutral'}>{t.enabled ? '已纳管' : '未纳管'}</Tag>
|
||||||
|
<Button disabled={busy} size="sm" variant="secondary" onClick={() => void toggle(t)}>
|
||||||
|
{t.enabled ? '移除' : '加入'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelEnrollmentPrompt({
|
||||||
|
channel,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
channel: { id: string; name: string };
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState(''),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
async function enroll() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const targets = await monitorApi.targets();
|
||||||
|
const current = targets.find((t) => t.id === channel.id);
|
||||||
|
if (!current) throw new Error('通道已保存,但当前没有监控管理权限或通道不可用');
|
||||||
|
if (!current.enabled) await monitorApi.target(current, true);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '通道已保存,加入监控失败,可重试');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="通道已保存"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
暂不加入
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} onClick={() => void enroll()}>
|
||||||
|
加入监控
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{channel.name}已保存。是否加入行业通道监控?</p>
|
||||||
|
<p>关闭后仍可在发送监控中加入。复制通道不会继承原通道的监控设置。</p>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error},可重试加入,无需重新创建通道。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
monitorApi,
|
||||||
|
names,
|
||||||
|
ruleSource,
|
||||||
|
states,
|
||||||
|
time,
|
||||||
|
title,
|
||||||
|
type Alert,
|
||||||
|
type Metric,
|
||||||
|
type Page,
|
||||||
|
type Snapshot,
|
||||||
|
} from './monitorApi';
|
||||||
|
|
||||||
|
const TrendChart = lazy(() => import('@/components/ui/Chart').then((m) => ({ default: m.Chart })));
|
||||||
|
const closeReasons: Record<string, string> = {
|
||||||
|
recovered: '已连续恢复',
|
||||||
|
data_corrected: '迟到数据修正',
|
||||||
|
disabled: '规则已停用',
|
||||||
|
rule_changed: '规则版本变化',
|
||||||
|
enrollment_removed: '已移出监控',
|
||||||
|
};
|
||||||
|
export function Rate({ value }: { value: Metric }) {
|
||||||
|
return (
|
||||||
|
<div className={`sending-monitor__rate${value.bad ? ' sending-monitor__rate--bad' : ''}`}>
|
||||||
|
<strong>{value.rate === null ? '—' : `${value.rate.toFixed(2)}%`}</strong>
|
||||||
|
<span>
|
||||||
|
{value.success.toLocaleString()} / {value.mature.toLocaleString()} 成熟
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{value.observing} 条观察中{value.insufficient ? ' · 样本不足' : ''}
|
||||||
|
{value.bad ? ' · 低于下限' : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function MonitorHistory({ row, onClose, alert }: { row: Snapshot; onClose: () => void; alert?: Alert }) {
|
||||||
|
const [range, setRange] = useState('2h'),
|
||||||
|
[items, setItems] = useState<Snapshot[]>([]),
|
||||||
|
[error, setError] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
monitorApi
|
||||||
|
.history(row, range)
|
||||||
|
.then((r) => {
|
||||||
|
if (live) {
|
||||||
|
setItems(r);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (live) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [row, range]);
|
||||||
|
const chartOption = useMemo<EChartsOption>(
|
||||||
|
() => ({
|
||||||
|
tooltip: { trigger: 'axis', confine: true },
|
||||||
|
legend: { type: 'scroll', bottom: 0 },
|
||||||
|
grid: { left: 45, right: 20, top: 20, bottom: 65 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: items.map((item) =>
|
||||||
|
new Date(item.evaluationAt).toLocaleTimeString('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
|
||||||
|
series: row.metrics.metrics.flatMap((m, i) => [
|
||||||
|
{
|
||||||
|
name: `${m.seconds}秒到达率`,
|
||||||
|
type: 'line' as const,
|
||||||
|
connectNulls: false,
|
||||||
|
data: items.map((item) => item.metrics.metrics[i]?.rate ?? null),
|
||||||
|
showSymbol: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${m.seconds}秒下限`,
|
||||||
|
type: 'line' as const,
|
||||||
|
step: 'end' as const,
|
||||||
|
connectNulls: false,
|
||||||
|
data: items.map((item) => (item.rule?.config.enabled ? (item.metrics.metrics[i]?.threshold ?? null) : null)),
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { type: 'dashed' as const },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
[items, row.metrics.metrics],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Modal open title="发送质量趋势与详情" size="xl" onClose={onClose} footer={<Button onClick={onClose}>关闭</Button>}>
|
||||||
|
<div className="sending-monitor">
|
||||||
|
<h3>{title(row.dimensions)}</h3>
|
||||||
|
{alert && (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
告警开始:{time(alert.openedAt)} · 最近评估:{time(alert.lastEvaluatedAt)} · {states[alert.state]}{' '}
|
||||||
|
{closeReasons[alert.closeReason ?? ''] ?? ''}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
最差快照:{time(alert.worst.evaluationAt)} ·{' '}
|
||||||
|
{alert.worst.metrics.metrics
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`${m.seconds}秒 ${m.rate === null ? '—' : m.rate.toFixed(2) + '%'}(${m.success}/${m.mature})`,
|
||||||
|
)
|
||||||
|
.join(';')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link to={`/admin/sms-records?monitorSnapshotId=${encodeURIComponent(row.id)}`}>
|
||||||
|
查看该窗口样本(沿用短信详情权限,样本保留72小时)
|
||||||
|
</Link>
|
||||||
|
<p>
|
||||||
|
统计窗口:{time(row.windowFrom)} 至 {time(row.evaluationAt)}(不含结束时刻)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
观察截止:{time(row.observedUntil)} · {row.stage === 'final' ? '定稿' : '初评'} · 修订 {row.revision}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{ruleSource(row.rule)}{' '}
|
||||||
|
{row.rule
|
||||||
|
? `v${row.rule.version},最低成熟 ${row.rule.config.minSamples} 条,下限 ${row.rule.config.thresholds.map((v) => (v === null ? '未启用' : `${v}%`)).join(' / ')}`
|
||||||
|
: '等待配置阈值'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
不可评估 {row.metrics.unassessable} 条。{row.completeness.reason}
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
label="历史范围"
|
||||||
|
value={range}
|
||||||
|
options={[
|
||||||
|
{ value: '2h', label: '最近2小时' },
|
||||||
|
{ value: '24h', label: '最近24小时' },
|
||||||
|
]}
|
||||||
|
onChange={(e) => setRange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Suspense fallback={<p>正在加载趋势图…</p>}>
|
||||||
|
<TrendChart option={chartOption} height={280} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
<div className="sending-monitor__trend" aria-label="时效到达率历史趋势">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div className="sending-monitor__trend-point" key={item.id}>
|
||||||
|
<time>{time(item.evaluationAt)}</time>
|
||||||
|
<span>
|
||||||
|
{item.metrics.metrics
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`${m.seconds}秒:${m.rate === null ? '—' : `${m.rate.toFixed(2)}%`} (${m.success}/${m.mature})`,
|
||||||
|
)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{states[item.status]} · {ruleSource(item.rule)} v{item.rule?.version ?? '—'} · revision {item.revision}
|
||||||
|
</small>
|
||||||
|
<div className="sending-monitor__bars">
|
||||||
|
{item.metrics.metrics.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m.seconds}
|
||||||
|
title={`${m.seconds}秒 ${m.rate ?? '不可评估'}% / 下限${m.threshold ?? '未配置'}`}
|
||||||
|
style={{ width: `${m.rate ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!items.length && !error && <p>暂无该维度历史快照</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function MonitorPager({
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
onChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sending-monitor__actions">
|
||||||
|
<span>
|
||||||
|
共 {total} 条 · 第 {page} 页
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" disabled={page === 1} onClick={() => onChange(page - 1)}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={page * 20 >= total} onClick={() => onChange(page + 1)}>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function MonitorAlerts() {
|
||||||
|
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
|
||||||
|
[page, setPage] = useState(1),
|
||||||
|
[state, setState] = useState(''),
|
||||||
|
[error, setError] = useState('');
|
||||||
|
const [detail, setDetail] = useState<Alert | null>(null),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
const load = useCallback(
|
||||||
|
() =>
|
||||||
|
monitorApi
|
||||||
|
.alerts(page, state)
|
||||||
|
.then((r) => {
|
||||||
|
setData(r);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message)),
|
||||||
|
[page, state],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!document.hidden) void load();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [load]);
|
||||||
|
async function read(row: Alert) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await monitorApi.read(row.id);
|
||||||
|
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '标记已读失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const columns: TableColumn<Alert>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: '异常对象',
|
||||||
|
width: '300px',
|
||||||
|
render: (a) => (
|
||||||
|
<div className="ui-table__long-text">
|
||||||
|
{title(a.dimensions)}
|
||||||
|
<p>{names[a.type]}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
title: '状态',
|
||||||
|
width: '120px',
|
||||||
|
render: (a) => (
|
||||||
|
<Tag tone={a.state === 'active' ? 'danger' : 'neutral'}>
|
||||||
|
{states[a.state]}
|
||||||
|
{a.unread ? ' · 未读' : ''}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
title: '开始 / 最近评估',
|
||||||
|
width: '230px',
|
||||||
|
render: (a) => (
|
||||||
|
<>
|
||||||
|
{time(a.openedAt)}
|
||||||
|
<p>{time(a.lastEvaluatedAt)}</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'metrics',
|
||||||
|
title: '命中指标 / 关闭原因',
|
||||||
|
width: '280px',
|
||||||
|
render: (a) => (
|
||||||
|
<div className="ui-table__long-text">
|
||||||
|
{a.latest.metrics.metrics
|
||||||
|
.filter((m) => m.bad)
|
||||||
|
.map((m) => `${m.seconds}秒 ${m.rate?.toFixed(2)}% < ${m.threshold}%`)
|
||||||
|
.join(';') ||
|
||||||
|
closeReasons[a.closeReason ?? ''] ||
|
||||||
|
'暂停评估或等待恢复'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
width: '180px',
|
||||||
|
render: (a) => (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetail(a)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy || !a.unread} onClick={() => void read(a)}>
|
||||||
|
标记已读
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Select
|
||||||
|
label="告警状态"
|
||||||
|
value={state}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
...['active', 'recovered', 'closed'].map((value) => ({ value, label: states[value] })),
|
||||||
|
]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setState(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Table columns={columns} data={data.items} rowKey="id" />
|
||||||
|
<MonitorPager page={page} total={data.total} onChange={setPage} />
|
||||||
|
{detail && <MonitorHistory alert={detail} row={detail.latest} onClose={() => setDetail(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { request, withQuery } from '@/api/core/httpClient';
|
||||||
|
export type MonitorType = 'industry' | 'verification' | 'overall';
|
||||||
|
export type Scope = { tenantId?: string; applicationId?: string; signatureId?: string };
|
||||||
|
export type Dimensions = Scope & {
|
||||||
|
tenantName?: string;
|
||||||
|
applicationName?: string;
|
||||||
|
signatureName?: string;
|
||||||
|
channelId?: string;
|
||||||
|
channelName?: string;
|
||||||
|
carrier?: string;
|
||||||
|
};
|
||||||
|
export type Config = {
|
||||||
|
enabled: boolean;
|
||||||
|
minSamples: number;
|
||||||
|
thresholds: (number | null)[];
|
||||||
|
consecutiveBad: number;
|
||||||
|
consecutiveGood: number;
|
||||||
|
deleted?: boolean;
|
||||||
|
};
|
||||||
|
export type Rule = {
|
||||||
|
id: string;
|
||||||
|
ruleId?: string;
|
||||||
|
type: MonitorType;
|
||||||
|
scope: Scope;
|
||||||
|
config: Config;
|
||||||
|
version: number;
|
||||||
|
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 Snapshot = {
|
||||||
|
id: string;
|
||||||
|
type: MonitorType;
|
||||||
|
dimensionKey: string;
|
||||||
|
dimensions: Dimensions;
|
||||||
|
evaluationAt: string;
|
||||||
|
windowFrom: string;
|
||||||
|
observedUntil: string;
|
||||||
|
stage: string;
|
||||||
|
revision: number;
|
||||||
|
metrics: { total: number; unassessable: number; metrics: Metric[] };
|
||||||
|
rule: Rule | null;
|
||||||
|
status: string;
|
||||||
|
completeness: { complete: boolean; reason?: string };
|
||||||
|
computedAt: string;
|
||||||
|
};
|
||||||
|
export type Alert = {
|
||||||
|
id: string;
|
||||||
|
type: MonitorType;
|
||||||
|
dimensionKey: string;
|
||||||
|
dimensions: Dimensions;
|
||||||
|
state: string;
|
||||||
|
openedAt: string;
|
||||||
|
lastEvaluatedAt: string;
|
||||||
|
closeReason?: string;
|
||||||
|
latest: Snapshot;
|
||||||
|
worst: Snapshot;
|
||||||
|
unread: boolean;
|
||||||
|
};
|
||||||
|
export type Page<T> = { items: T[]; total: number; page: number; pageSize: number };
|
||||||
|
export type Target = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
status: string;
|
||||||
|
effectiveFrom?: string;
|
||||||
|
};
|
||||||
|
export const names = { industry: '行业通道', verification: '验证码', overall: '整体兜底' };
|
||||||
|
export const states: Record<string, string> = {
|
||||||
|
abnormal: '异常',
|
||||||
|
normal: '正常',
|
||||||
|
sample_insufficient: '样本不足',
|
||||||
|
stale: '数据延迟',
|
||||||
|
unassessable: '不可评估',
|
||||||
|
unconfigured: '未配置',
|
||||||
|
disabled: '规则停用',
|
||||||
|
no_data: '无发送',
|
||||||
|
active: '活动',
|
||||||
|
recovered: '已恢复',
|
||||||
|
closed: '已关闭',
|
||||||
|
};
|
||||||
|
export const time = (value?: string) =>
|
||||||
|
value ? new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) : '—';
|
||||||
|
export const title = (d: Dimensions) =>
|
||||||
|
d.channelName
|
||||||
|
? `${d.channelName} · ${{ mobile: '移动', unicom: '联通', telecom: '电信' }[d.carrier ?? ''] ?? '未知运营商'}`
|
||||||
|
: `${d.tenantName ?? '未识别企业'} / ${d.applicationName ?? '未识别应用'} / ${d.signatureName ?? '未识别签名'}`;
|
||||||
|
export const ruleSource = (r: Rule | null) =>
|
||||||
|
!r
|
||||||
|
? '尚未配置'
|
||||||
|
: r.scope.signatureId && r.scope.applicationId
|
||||||
|
? '应用×签名'
|
||||||
|
: r.scope.signatureId
|
||||||
|
? '签名规则'
|
||||||
|
: r.scope.applicationId
|
||||||
|
? '应用规则'
|
||||||
|
: '通用规则';
|
||||||
|
export const monitorApi = {
|
||||||
|
rows: (q: Record<string, string | number | undefined>, signal?: AbortSignal) =>
|
||||||
|
request<
|
||||||
|
Page<Snapshot> & { health: { data: { complete: boolean; checkedAt: string; pendingReceipts: number } } | null }
|
||||||
|
>(withQuery('/admin/sending-monitor/rows', q), { signal }),
|
||||||
|
overview: (type: MonitorType) =>
|
||||||
|
request<{
|
||||||
|
rows: { status: string; dimensions: number; total: string; evaluationAt: string; computedAt: string }[];
|
||||||
|
}>(withQuery('/admin/sending-monitor/overview', { type })),
|
||||||
|
history: (row: Snapshot, range: string) =>
|
||||||
|
request<Snapshot[]>(
|
||||||
|
withQuery('/admin/sending-monitor/history', { type: row.type, dimensionId: row.dimensionKey, range }),
|
||||||
|
),
|
||||||
|
targets: () => request<Target[]>('/admin/sending-monitor/targets'),
|
||||||
|
target: (t: Target, enabled: boolean) =>
|
||||||
|
request(`/admin/sending-monitor/targets/${t.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ enabled, version: t.version }),
|
||||||
|
}),
|
||||||
|
rules: () => request<Rule[]>('/admin/sending-monitor/rules'),
|
||||||
|
saveRule: (r: Pick<Rule, 'type' | 'scope' | 'config' | 'version'>) =>
|
||||||
|
request('/admin/sending-monitor/rules', { method: 'POST', body: JSON.stringify(r) }),
|
||||||
|
effective: (type: MonitorType, scope: Scope) =>
|
||||||
|
request<Rule[]>(withQuery('/admin/sending-monitor/effective-rule', { type, ...scope })),
|
||||||
|
options: (kind: string, scope: Scope, keyword: string, page: number) =>
|
||||||
|
request<{ id: string; name: string }[]>(
|
||||||
|
withQuery('/admin/sending-monitor/options', { kind, ...scope, keyword, page }),
|
||||||
|
),
|
||||||
|
alerts: (page: number, state: string, signal?: AbortSignal) =>
|
||||||
|
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
|
||||||
|
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { ReportNotificationsPage } from './ReportNotificationsPage';
|
||||||
|
const api = vi.hoisted(() => ({ request: vi.fn() }));
|
||||||
|
vi.mock('@/api/core/httpClient', () => api);
|
||||||
|
const hour = {
|
||||||
|
id: 'hour-1',
|
||||||
|
tenantName: '验收企业',
|
||||||
|
hour: '2026-09-06T04:00:00Z',
|
||||||
|
revision: 3,
|
||||||
|
signatureCount: 2,
|
||||||
|
drainageCount: 1,
|
||||||
|
unread: true,
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
api.request.mockImplementation((path: string) =>
|
||||||
|
path.includes('hour-1')
|
||||||
|
? Promise.resolve({
|
||||||
|
hour,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
reportType: 'signature',
|
||||||
|
signatureName: '验收签名',
|
||||||
|
targetName: '验收签名',
|
||||||
|
createdAt: hour.hour,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
})
|
||||||
|
: Promise.resolve({ items: [hour], total: 1, page: 1, pageSize: 20 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('uses the client API and explicitly marks the displayed revision read', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<ReportNotificationsPage />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: '查看消息' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: '标记已读' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.request).toHaveBeenCalledWith('/client/report-notifications/hour-1/read', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ revision: 3 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(api.request.mock.calls.some(([path]) => String(path).startsWith('/admin/'))).toBe(false);
|
||||||
|
});
|
||||||
|
it('reports a failed read without pretending success', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<ReportNotificationsPage portal="admin" />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: '查看消息' }));
|
||||||
|
api.request.mockRejectedValueOnce(new Error('标记失败'));
|
||||||
|
await user.click(await screen.findByRole('button', { name: '标记已读' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('标记失败');
|
||||||
|
expect(screen.getByRole('button', { name: '标记已读' })).toBeEnabled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Breadcrumb, Button, Modal, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import { request } from '@/api/core/httpClient';
|
||||||
|
import './report-notifications.css';
|
||||||
|
|
||||||
|
type Hour = {
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
hour: string;
|
||||||
|
revision: number;
|
||||||
|
signatureCount: number;
|
||||||
|
drainageCount: number;
|
||||||
|
unread: boolean;
|
||||||
|
};
|
||||||
|
type Event = {
|
||||||
|
id: string;
|
||||||
|
reportType: string;
|
||||||
|
applicationName?: string;
|
||||||
|
signatureName: string;
|
||||||
|
targetName: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
type Page<T> = { items: T[]; total: number; page: number; pageSize: number };
|
||||||
|
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||||
|
|
||||||
|
export function ReportNotificationsPage({ portal = 'client' }: { portal?: 'admin' | 'client' }) {
|
||||||
|
const [data, setData] = useState<Page<Hour>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [unread, setUnread] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [detail, setDetail] = useState<{ hour: Hour; items: Event[]; total: number; page: number } | null>(null);
|
||||||
|
const [detailError, setDetailError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const base = `/${portal}/report-notifications`;
|
||||||
|
const load = useCallback(
|
||||||
|
async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await request<Page<Hour>>(`${base}?page=${page}&unread=${unread}`, { signal });
|
||||||
|
setData(result);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) setError(e instanceof Error ? e.message : '通知加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[base, page, unread],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
async function open(item: Hour, detailPage = 1) {
|
||||||
|
setBusy(true);
|
||||||
|
setDetailError('');
|
||||||
|
try {
|
||||||
|
const result = await request<{ hour: Hour } & Page<Event>>(`${base}/${item.id}?page=${detailPage}`);
|
||||||
|
setDetail({ ...result, hour: { ...result.hour, unread: item.unread } });
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '详情加载失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function markRead() {
|
||||||
|
if (!detail) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await request(`${base}/${detail.hour.id}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ revision: detail.hour.revision }),
|
||||||
|
});
|
||||||
|
setDetail({ ...detail, hour: { ...detail.hour, unread: false } });
|
||||||
|
window.dispatchEvent(new Event('cmpp-report-notification-refresh'));
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setDetailError(e instanceof Error ? e.message : '标记已读失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const columns: TableColumn<Hour>[] = [
|
||||||
|
{
|
||||||
|
key: 'read',
|
||||||
|
title: '状态',
|
||||||
|
width: '90px',
|
||||||
|
render: (row) => <Tag tone={row.unread ? 'info' : 'neutral'}>{row.unread ? '未读' : '已读'}</Tag>,
|
||||||
|
},
|
||||||
|
{ key: 'tenant', title: '企业', width: '220px', render: (row) => row.tenantName },
|
||||||
|
{ key: 'hour', title: '汇总时段(北京时间)', width: '220px', render: (row) => `${time(row.hour)} 起一小时` },
|
||||||
|
{
|
||||||
|
key: 'content',
|
||||||
|
title: '报备状态变化消息',
|
||||||
|
width: '300px',
|
||||||
|
render: (row) => (
|
||||||
|
<div className="ui-table__long-text">
|
||||||
|
{row.signatureCount} 个签名、{row.drainageCount} 条引流信息已具备三网全国通道报备成功记录
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
width: '110px',
|
||||||
|
render: (row) => (
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void open(row)}>
|
||||||
|
查看消息
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<section className="report-notifications-page page-stack">
|
||||||
|
<div className="page-heading">
|
||||||
|
<Breadcrumb items={[portal === 'admin' ? '报备状态变化消息' : '消息通知']} />
|
||||||
|
<Button onClick={() => void load()} disabled={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
从三网均无全国通道报备成功,变为移动、联通、电信均有全国通道报备成功时通知。按企业与北京时间小时汇总。
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={unread}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUnread(e.target.checked);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
仅看未读
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p role="status">正在加载通知…</p>
|
||||||
|
) : (
|
||||||
|
<div className="surface">
|
||||||
|
<Table columns={columns} data={data.items} rowKey="id" />
|
||||||
|
{data.total === 0 && <p>暂无报备状态变化通知</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="report-notifications-page__pager">
|
||||||
|
<span>
|
||||||
|
共 {data.total} 条 · 第 {page} 页
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" disabled={loading || page === 1} onClick={() => setPage(page - 1)}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={loading || page * 20 >= data.total} onClick={() => setPage(page + 1)}>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="报备状态变化消息"
|
||||||
|
size="xl"
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setDetail(null)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy || !detail.hour.unread} onClick={() => void markRead()}>
|
||||||
|
标记已读
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="report-notifications-page">
|
||||||
|
<p>
|
||||||
|
{detail.hour.tenantName} · {time(detail.hour.hour)} · 版本 {detail.hour.revision}
|
||||||
|
</p>
|
||||||
|
{detailError && (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{detailError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{detail.items.map((item) => (
|
||||||
|
<article className="report-notifications-page__event" key={item.id}>
|
||||||
|
<Tag tone="success">三网报备成功</Tag>
|
||||||
|
<strong>
|
||||||
|
{item.reportType === 'signature' ? '签名' : '引流信息'}:{item.targetName}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
应用:{item.applicationName ?? '未关联应用'} · 签名:{item.signatureName}
|
||||||
|
</span>
|
||||||
|
<time>{time(item.createdAt)}</time>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
<div className="report-notifications-page__pager">
|
||||||
|
<span>共 {detail.total} 项</span>
|
||||||
|
<Button disabled={busy || detail.page === 1} onClick={() => void open(detail.hour, detail.page - 1)}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy || detail.page * 20 >= detail.total}
|
||||||
|
onClick={() => void open(detail.hour, detail.page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.report-notifications-page .report-notifications-page__pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-notifications-page .report-notifications-page__event {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
@@ -67,11 +67,19 @@ it('separates retirement counts from security/monitoring and leaves audit counts
|
|||||||
await user.click(screen.getByRole('button', { name: '预警通知' }));
|
await user.click(screen.getByRole('button', { name: '预警通知' }));
|
||||||
const alerts = screen.getByRole('menu', { name: '预警中心' });
|
const alerts = screen.getByRole('menu', { name: '预警中心' });
|
||||||
expect(within(alerts).queryByRole('menuitem', { name: /签名清退/ })).not.toBeInTheDocument();
|
expect(within(alerts).queryByRole('menuitem', { name: /签名清退/ })).not.toBeInTheDocument();
|
||||||
expect(within(alerts).getAllByRole('menuitem')).toHaveLength(2);
|
expect(within(alerts).getAllByRole('menuitem')).toHaveLength(3);
|
||||||
|
expect(within(alerts).getByRole('menuitem', { name: /发送质量告警/ })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/admin/monitor?tab=alerts',
|
||||||
|
);
|
||||||
expect(within(alerts).getByText('1 条严重告警待处置')).toBeVisible();
|
expect(within(alerts).getByText('1 条严重告警待处置')).toBeVisible();
|
||||||
await user.click(screen.getByRole('button', { name: '报备任务提醒' }));
|
await user.click(screen.getByRole('button', { name: '报备任务提醒' }));
|
||||||
expect(screen.queryByRole('menu', { name: '预警中心' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('menu', { name: '预警中心' })).not.toBeInTheDocument();
|
||||||
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
|
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
|
||||||
|
expect(within(reporting).getByRole('menuitem', { name: /报备状态变化通知/ })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/admin/report-records?tab=readiness',
|
||||||
|
);
|
||||||
expect(within(reporting).getByRole('menuitem', { name: /签名清退预警/ })).toHaveAttribute(
|
expect(within(reporting).getByRole('menuitem', { name: /签名清退预警/ })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/admin/signature-retirement',
|
'/admin/signature-retirement',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { adminApi, type PendingAuditCounts } from '@/api/adminApi';
|
|||||||
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
||||||
import { AppShell } from '@/layouts/AppShell';
|
import { AppShell } from '@/layouts/AppShell';
|
||||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||||
|
import { request } from '@/api/core/httpClient';
|
||||||
|
|
||||||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||||
enterpriseCertifications: 0,
|
enterpriseCertifications: 0,
|
||||||
@@ -54,6 +55,8 @@ export function AdminLayout() {
|
|||||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||||
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
||||||
|
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
|
||||||
|
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
|
||||||
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||||
@@ -72,8 +75,20 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
adminApi.getSignatureRetirementUnreadCount(),
|
adminApi.getSignatureRetirementUnreadCount(),
|
||||||
adminApi.getSecurityNotificationSummary(),
|
adminApi.getSecurityNotificationSummary(),
|
||||||
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
||||||
|
request<{ count: number }>('/admin/report-notifications/summary'),
|
||||||
|
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
|
||||||
])
|
])
|
||||||
.then(([audits, retirement, security, infrastructure]) => {
|
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
|
||||||
|
setMonitorSummary((previous) =>
|
||||||
|
monitor.status === 'fulfilled'
|
||||||
|
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
|
||||||
|
: { ...previous, unavailable: true },
|
||||||
|
);
|
||||||
|
setReportSummary((previous) =>
|
||||||
|
reporting.status === 'fulfilled'
|
||||||
|
? { count: reporting.value.count, unavailable: false }
|
||||||
|
: { ...previous, unavailable: true },
|
||||||
|
);
|
||||||
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
||||||
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
||||||
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
|
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
|
||||||
@@ -100,6 +115,8 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
window.addEventListener('focus', onFocus);
|
window.addEventListener('focus', onFocus);
|
||||||
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||||
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||||
|
window.addEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||||
|
window.addEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||||
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||||
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -107,6 +124,8 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
window.removeEventListener('focus', onFocus);
|
window.removeEventListener('focus', onFocus);
|
||||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||||
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||||
|
window.removeEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||||
|
window.removeEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||||
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||||
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||||
};
|
};
|
||||||
@@ -123,6 +142,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
userRole="平台管理员"
|
userRole="平台管理员"
|
||||||
onSessionLockedChange={setSessionLocked}
|
onSessionLockedChange={setSessionLocked}
|
||||||
reportingNotifications={[
|
reportingNotifications={[
|
||||||
|
{
|
||||||
|
label: '报备状态变化通知',
|
||||||
|
count: reportSummary.count,
|
||||||
|
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
|
||||||
|
to: '/admin/report-records?tab=readiness',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '签名清退预警',
|
label: '签名清退预警',
|
||||||
count: retirementUnreadCount,
|
count: retirementUnreadCount,
|
||||||
@@ -136,6 +161,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
alertNotifications={[
|
alertNotifications={[
|
||||||
|
{
|
||||||
|
label: '发送质量告警',
|
||||||
|
count: monitorSummary.count,
|
||||||
|
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
|
||||||
|
to: '/admin/monitor?tab=alerts',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '安全检测与封禁',
|
label: '安全检测与封禁',
|
||||||
count: securityAlertSummary.count,
|
count: securityAlertSummary.count,
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ export function AppShell({
|
|||||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [openNotice, setOpenNotice] = useState<'alerts' | 'reporting' | 'audits' | null>(null);
|
const [openNotice, setOpenNotice] = useState<'alerts' | 'reporting' | 'audits' | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openNotice) return;
|
||||||
|
const key = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setOpenNotice(null);
|
||||||
|
};
|
||||||
|
const outside = (event: PointerEvent) => {
|
||||||
|
if (event.target instanceof Element && !event.target.closest('.notice-menu-wrap')) setOpenNotice(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', key);
|
||||||
|
document.addEventListener('pointerdown', outside);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', key);
|
||||||
|
document.removeEventListener('pointerdown', outside);
|
||||||
|
};
|
||||||
|
}, [openNotice]);
|
||||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
PenLine,
|
PenLine,
|
||||||
ReceiptText,
|
|
||||||
Cable,
|
Cable,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
@@ -14,7 +13,11 @@ import { AppShell } from '@/layouts/AppShell';
|
|||||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||||
|
|
||||||
export function ClientLayout() {
|
export function ClientLayout() {
|
||||||
return <PortalSessionBoundary portal="client">{(session) => <ClientAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
return (
|
||||||
|
<PortalSessionBoundary portal="client">
|
||||||
|
{(session) => <ClientAuthenticatedLayout session={session} />}
|
||||||
|
</PortalSessionBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) {
|
function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) {
|
||||||
@@ -30,7 +33,10 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
|||||||
navSections={[
|
navSections={[
|
||||||
{
|
{
|
||||||
title: '概览',
|
title: '概览',
|
||||||
items: [{ label: '工作台', to: '/client', icon: Home }],
|
items: [
|
||||||
|
{ label: '工作台', to: '/client', icon: Home },
|
||||||
|
{ label: '消息通知', to: '/client/notifications', icon: MessageSquareText },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '短信业务',
|
title: '短信业务',
|
||||||
@@ -52,9 +58,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账户',
|
title: '账户',
|
||||||
items: [
|
items: [{ label: '账户余额', to: '/client/billing', icon: BadgeDollarSign }],
|
||||||
{ label: '账户余额', to: '/client/billing', icon: BadgeDollarSign },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '系统管理',
|
title: '系统管理',
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ const AdminGatewaySubmitExceptionsPage = lazyNamed(
|
|||||||
);
|
);
|
||||||
const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome');
|
const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome');
|
||||||
const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage');
|
const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage');
|
||||||
|
const ReportNotificationsPage = lazyNamed(
|
||||||
|
() => import('@/apps/report-notifications/ReportNotificationsPage'),
|
||||||
|
'ReportNotificationsPage',
|
||||||
|
);
|
||||||
const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage');
|
const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage');
|
||||||
const AdminRechargeRecordsPage = lazyNamed(
|
const AdminRechargeRecordsPage = lazyNamed(
|
||||||
() => import('@/apps/admin/AdminRechargeRecordsPage'),
|
() => import('@/apps/admin/AdminRechargeRecordsPage'),
|
||||||
@@ -173,6 +177,7 @@ export function AppRoutes() {
|
|||||||
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
||||||
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
||||||
<Route path="/client" element={<ClientLayout />}>
|
<Route path="/client" element={<ClientLayout />}>
|
||||||
|
<Route path="notifications" element={<ReportNotificationsPage />} />
|
||||||
<Route index element={<ClientHome />} />
|
<Route index element={<ClientHome />} />
|
||||||
<Route path="send" element={<ClientSendPage />} />
|
<Route path="send" element={<ClientSendPage />} />
|
||||||
<Route path="batch-tasks" element={<ClientBatchTasksPage />} />
|
<Route path="batch-tasks" element={<ClientBatchTasksPage />} />
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-pro
|
|||||||
chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
|
chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
|
||||||
|
|
||||||
echo "[deploy] Ensuring runtime log directories"
|
echo "[deploy] Ensuring runtime log directories"
|
||||||
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/send-worker" "$APP_DIR/logs/report-material-worker" "$APP_DIR/logs/submit-outbox" "$APP_DIR/logs/gateway-callback" "$APP_DIR/logs/protocol-log-worker" "$APP_DIR/logs/gateway"
|
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/send-worker" "$APP_DIR/logs/report-material-worker" "$APP_DIR/logs/sending-monitor" "$APP_DIR/logs/submit-outbox" "$APP_DIR/logs/gateway-callback" "$APP_DIR/logs/protocol-log-worker" "$APP_DIR/logs/gateway"
|
||||||
|
|
||||||
echo "[deploy] Installing split API and send-worker services"
|
echo "[deploy] Installing split API and send-worker services"
|
||||||
node_bin="$(command -v node)"
|
node_bin="$(command -v node)"
|
||||||
@@ -175,6 +175,31 @@ RestartSec=5
|
|||||||
StandardOutput=append:$APP_DIR/logs/report-material-worker/stdout.log
|
StandardOutput=append:$APP_DIR/logs/report-material-worker/stdout.log
|
||||||
StandardError=append:$APP_DIR/logs/report-material-worker/stderr.log
|
StandardError=append:$APP_DIR/logs/report-material-worker/stderr.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
cat >/etc/systemd/system/cmpp-sending-monitor.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=CMPP sending quality projection and evaluation worker
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=cmpp-api
|
||||||
|
Group=cmpp-security
|
||||||
|
WorkingDirectory=$APP_DIR/api
|
||||||
|
EnvironmentFile=$ENV_FILE
|
||||||
|
Environment=TZ=UTC
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=$APP_DIR/logs/sending-monitor
|
||||||
|
ExecStart=$node_bin dist/sending-monitor-worker.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:$APP_DIR/logs/sending-monitor/stdout.log
|
||||||
|
StandardError=append:$APP_DIR/logs/sending-monitor/stderr.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
@@ -241,6 +266,7 @@ EOF
|
|||||||
|
|
||||||
echo "[deploy] Installing restricted security boundary"
|
echo "[deploy] Installing restricted security boundary"
|
||||||
bash "$APP_DIR/tools/security/install-security-agent.sh"
|
bash "$APP_DIR/tools/security/install-security-agent.sh"
|
||||||
|
install -d -o cmpp-api -g cmpp-security -m 0750 "$APP_DIR/logs/sending-monitor"
|
||||||
|
|
||||||
echo "[deploy] Ensuring HTTP response compression"
|
echo "[deploy] Ensuring HTTP response compression"
|
||||||
compression_config=/etc/nginx/conf.d/cmpp-compression.conf
|
compression_config=/etc/nginx/conf.d/cmpp-compression.conf
|
||||||
@@ -294,6 +320,8 @@ systemctl restart cmpp-gateway
|
|||||||
systemctl restart cmpp-api
|
systemctl restart cmpp-api
|
||||||
systemctl restart cmpp-send-worker
|
systemctl restart cmpp-send-worker
|
||||||
systemctl restart cmpp-report-material-worker
|
systemctl restart cmpp-report-material-worker
|
||||||
|
systemctl enable --now cmpp-sending-monitor
|
||||||
|
systemctl restart cmpp-sending-monitor
|
||||||
systemctl restart nginx
|
systemctl restart nginx
|
||||||
|
|
||||||
echo "[deploy] Health checks"
|
echo "[deploy] Health checks"
|
||||||
@@ -314,6 +342,7 @@ wait_for_http() {
|
|||||||
wait_for_http "API" "http://127.0.0.1:${API_PORT:-3000}/api/health"
|
wait_for_http "API" "http://127.0.0.1:${API_PORT:-3000}/api/health"
|
||||||
wait_for_http "Send worker metrics" "http://127.0.0.1:${API_WORKER_METRICS_PORT:-9465}/metrics"
|
wait_for_http "Send worker metrics" "http://127.0.0.1:${API_WORKER_METRICS_PORT:-9465}/metrics"
|
||||||
systemctl is-active --quiet cmpp-report-material-worker
|
systemctl is-active --quiet cmpp-report-material-worker
|
||||||
|
systemctl is-active --quiet cmpp-sending-monitor
|
||||||
if [[ "${SEND_SUBMIT_OUTBOX_SEPARATE_PROCESS_ENABLED:-false}" == "true" ]]; then
|
if [[ "${SEND_SUBMIT_OUTBOX_SEPARATE_PROCESS_ENABLED:-false}" == "true" ]]; then
|
||||||
wait_for_http "Submit Outbox metrics" "http://127.0.0.1:${API_OUTBOX_METRICS_PORT:-9467}/metrics"
|
wait_for_http "Submit Outbox metrics" "http://127.0.0.1:${API_OUTBOX_METRICS_PORT:-9467}/metrics"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -257,6 +257,18 @@
|
|||||||
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"],
|
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"],
|
||||||
"stylelintLegacy": false,
|
"stylelintLegacy": false,
|
||||||
"roots": ["channel-route-editor"]
|
"roots": ["channel-route-editor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/report-notifications/report-notifications.css",
|
||||||
|
"owners": ["src/apps/report-notifications/ReportNotificationsPage.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["report-notifications-page"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/AdminMonitorPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminMonitorPage.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["sending-monitor"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import pg from '../../api/node_modules/pg/lib/index.js';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import projection from '../../api/dist/sending-monitor/monitor-projection.js';
|
||||||
|
import evaluation from '../../api/dist/sending-monitor/monitor-evaluation.js';
|
||||||
|
const { Client } = pg;
|
||||||
|
const { projectMessages, keyOf } = projection;
|
||||||
|
const { evaluateWindow } = evaluation;
|
||||||
|
process.env.TZ = 'UTC';
|
||||||
|
async function main() {
|
||||||
|
const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) throw new Error('QA_DATABASE_URL is required');
|
||||||
|
const db = new Client({ connectionString, application_name: 'cmpp-monitor-isolated-qa' });
|
||||||
|
const schema = `qa_monitor_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
if (!/^qa_monitor_[a-f0-9]{32}$/.test(schema)) throw new Error('Invalid isolated schema');
|
||||||
|
let checks = 0;
|
||||||
|
await db.connect();
|
||||||
|
try {
|
||||||
|
await db.query('BEGIN');
|
||||||
|
await db.query(`CREATE SCHEMA "${schema}"`);
|
||||||
|
await db.query(`SET LOCAL search_path TO "${schema}",public`);
|
||||||
|
await db.query(`SET LOCAL timezone='Asia/Shanghai'`);
|
||||||
|
for (const table of [
|
||||||
|
'Tenant',
|
||||||
|
'SmsApplication',
|
||||||
|
'SmsSignature',
|
||||||
|
'SmsDrainageInfo',
|
||||||
|
'SmsChannel',
|
||||||
|
'ChannelSignatureReportTask',
|
||||||
|
'SmsMessageRecord',
|
||||||
|
'SmsSubmitRecord',
|
||||||
|
'SmsMessageSegmentAudit',
|
||||||
|
'UpstreamReceiptInbox',
|
||||||
|
]) {
|
||||||
|
await db.query(`CREATE TABLE "${table}" (LIKE public."${table}" INCLUDING ALL)`);
|
||||||
|
if (['Tenant', 'SmsApplication', 'SmsSignature', 'SmsDrainageInfo', 'SmsChannel'].includes(table))
|
||||||
|
await db.query(`INSERT INTO "${table}" SELECT * FROM public."${table}"`);
|
||||||
|
}
|
||||||
|
for (const table of ['SmsSubmitRecord', 'SmsMessageSegmentAudit'])
|
||||||
|
await db.query(
|
||||||
|
`ALTER TABLE "${table}" DROP COLUMN IF EXISTS "firstWireSubmitAt", DROP COLUMN IF EXISTS "wireTimeSource", DROP COLUMN IF EXISTS "receiptRequested"`,
|
||||||
|
);
|
||||||
|
await db.query(`ALTER TABLE "UpstreamReceiptInbox" DROP COLUMN IF EXISTS "gatewayReceivedAt"`);
|
||||||
|
// LIKE copies indexes under generated names; explicit migration index names are unique within the new schema.
|
||||||
|
for (const path of ['20260906170000_report_readiness_notifications', '20260906171000_sending_monitor'])
|
||||||
|
await db.query(
|
||||||
|
readFileSync(resolve(import.meta.dirname, '../../api/prisma/migrations', path, 'migration.sql'), 'utf8'),
|
||||||
|
);
|
||||||
|
const sig = (
|
||||||
|
await db.query(
|
||||||
|
`SELECT id,"tenantId","applicationId" FROM "SmsSignature" WHERE "applicationId" IS NOT NULL LIMIT 1`,
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
const channel = (await db.query(`SELECT id FROM "SmsChannel" LIMIT 1`)).rows[0].id;
|
||||||
|
assert(sig && channel, 'Need existing metadata to copy into isolated fixtures');
|
||||||
|
await db.query(
|
||||||
|
`UPDATE "SmsChannel" SET status='active',carrier='all',carriers=ARRAY['mobile','unicom','telecom'],"sendRegion"='全国' WHERE id=$1`,
|
||||||
|
[channel],
|
||||||
|
);
|
||||||
|
for (const carrier of ['mobile', 'unicom', 'telecom']) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "ChannelSignatureReportTask" (id,"tenantId","signatureId","channelId",carrier,"approvalScope",status,"updatedAt") VALUES($1,$2,$3,$4,$5,'carrier_specific','approved',CURRENT_TIMESTAMP)`,
|
||||||
|
[`qa-${carrier}`, sig.tenantId, sig.id, channel, carrier],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Number((await db.query(`SELECT count(*) n FROM "ReportReadinessEvent"`)).rows[0].n),
|
||||||
|
carrier === 'telecom' ? 1 : 0,
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
}
|
||||||
|
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='approved'`);
|
||||||
|
assert.equal(Number((await db.query(`SELECT count(*) n FROM "ReportReadinessEvent"`)).rows[0].n), 1);
|
||||||
|
checks++;
|
||||||
|
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='failed'`);
|
||||||
|
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='approved'`);
|
||||||
|
assert.equal(Number((await db.query(`SELECT revision FROM "ReportNotificationHour"`)).rows[0].revision), 2);
|
||||||
|
checks++;
|
||||||
|
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='failed' WHERE carrier='mobile'`);
|
||||||
|
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='approved' WHERE carrier='mobile'`);
|
||||||
|
assert.equal(Number((await db.query(`SELECT count(*) n FROM "ReportReadinessEvent"`)).rows[0].n), 2);
|
||||||
|
checks++;
|
||||||
|
await db.query(`UPDATE "SmsChannel" SET "sendRegion"='广东' WHERE id=$1`, [channel]);
|
||||||
|
assert.equal((await db.query(`SELECT cmpp_report_ready_mask('signature',$1,NULL) mask`, [sig.id])).rows[0].mask, 0);
|
||||||
|
checks++;
|
||||||
|
await db.query(`UPDATE "SmsChannel" SET "sendRegion"='全国' WHERE id=$1`, [channel]);
|
||||||
|
|
||||||
|
const t = new Date(Math.floor(Date.now() / 300000) * 300000);
|
||||||
|
const earlier = new Date(t.getTime() - 60000),
|
||||||
|
late = new Date(t.getTime() - 1000);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "SendingMonitorTarget" ("channelId",enabled,version,"effectiveFrom","updatedBy") VALUES($1,true,1,$2,'qa')`,
|
||||||
|
[channel, new Date(t.getTime() - 3600000)],
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "SendingMonitorTargetVersion" SELECT "channelId",version,enabled,"effectiveFrom","updatedBy" FROM "SendingMonitorTarget"`,
|
||||||
|
);
|
||||||
|
const config = { enabled: true, minSamples: 1, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 };
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "SendingMonitorRuleVersion" ("ruleId",version,type,scope,config,"effectiveAt","createdBy") VALUES('qa-rule',1,'industry','{}',$1::jsonb,$2,'qa')`,
|
||||||
|
[JSON.stringify(config), new Date(t.getTime() - 3600000)],
|
||||||
|
);
|
||||||
|
// These are isolated, never-enqueued records, explicitly synthetic, stored and queried by actual PostgreSQL.
|
||||||
|
for (let n = 0; n < 4; n++) {
|
||||||
|
const id = `qa-message-${n}`,
|
||||||
|
submitId = `qa-submit-${n}`,
|
||||||
|
at = n === 3 ? late : earlier;
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "SmsMessageRecord" (id,"tenantId","applicationId","signatureId","messageId","phoneNumber",carrier,content,"cmppRegisteredDelivery","updatedAt") VALUES($1,$2,$3,$4,$1,'13800000000','mobile','【隔离验收】验证码',true,CURRENT_TIMESTAMP)`,
|
||||||
|
[id, sig.tenantId, sig.applicationId, sig.id],
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "SmsSubmitRecord" (id,"messageRecordId","channelId","submitId","firstWireSubmitAt","wireTimeSource","receiptRequested","createdAt","updatedAt") VALUES($1,$2,$3,$1,$4,'gateway_write_complete',true,$4,CURRENT_TIMESTAMP)`,
|
||||||
|
[submitId, id, channel, at],
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "SmsMessageSegmentAudit" (id,"messageRecordId","submitRecordId","channelId","submitId","gatewayMessageId","segmentTotal","segmentIndex","firstWireSubmitAt","wireTimeSource","updatedAt") VALUES($1,$2,$3,$4,$3,$1,1,1,$5,'gateway_write_complete',CURRENT_TIMESTAMP)`,
|
||||||
|
[`qa-gateway-${n}`, id, submitId, channel, at],
|
||||||
|
);
|
||||||
|
const receiptAt = new Date(at.getTime() + [4999, 5000, 5001, 50000][n]);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "UpstreamReceiptInbox" (id,"receiptKey","incomingChannelId","upstreamAccount","upstreamHost","upstreamPort",protocol,"protocolVersion","gatewayMessageId","receiptStatus","rawStatus","deliveredAt","gatewayReceivedAt",status,"matchedMessageRecordId","matchedChannelId","updatedAt") VALUES($1,$1,$2,'qa','127.0.0.1',7890,'cmpp','3.0',$3,'delivered','DELIVRD',$4,$4,'matched',$5,$2,CURRENT_TIMESTAMP)`,
|
||||||
|
[`qa-receipt-${n}`, channel, `qa-gateway-${n}`, receiptAt, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ids = [0, 1, 2, 3].map((n) => `qa-message-${n}`);
|
||||||
|
await projectMessages(db, ids);
|
||||||
|
const facts = (await db.query(`SELECT count(*)::int count FROM "SendingMonitorFact"`)).rows[0].count;
|
||||||
|
assert.equal(facts, 8);
|
||||||
|
checks++;
|
||||||
|
await projectMessages(db, ids);
|
||||||
|
assert.equal((await db.query(`SELECT count(*)::int count FROM "SendingMonitorFact"`)).rows[0].count, facts);
|
||||||
|
checks++;
|
||||||
|
await evaluateWindow(db, 'industry', t, false, true);
|
||||||
|
const key = keyOf([channel, 'mobile']);
|
||||||
|
let row = (await db.query(`SELECT * FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0];
|
||||||
|
assert.equal(row.metrics.total, 4);
|
||||||
|
assert.deepEqual(
|
||||||
|
row.metrics.metrics.map((m) => [m.success, m.mature, m.observing]),
|
||||||
|
[
|
||||||
|
[2, 3, 1],
|
||||||
|
[3, 3, 1],
|
||||||
|
[3, 3, 1],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
await evaluateWindow(db, 'industry', t, true, true);
|
||||||
|
row = (await db.query(`SELECT * FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0];
|
||||||
|
assert.equal(row.revision, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
row.metrics.metrics.map((m) => [m.success, m.mature, m.observing]),
|
||||||
|
[
|
||||||
|
[2, 4, 0],
|
||||||
|
[3, 4, 0],
|
||||||
|
[4, 4, 0],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
assert.equal(
|
||||||
|
(await db.query(`SELECT count(*)::int n FROM "SendingMonitorAlert" WHERE state='active'`)).rows[0].n,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
await evaluateWindow(db, 'industry', t, true, true);
|
||||||
|
assert.equal(
|
||||||
|
(await db.query(`SELECT revision FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0].revision,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
await evaluateWindow(db, 'verification', t, true, true);
|
||||||
|
const business = (await db.query(`SELECT metrics,status FROM "SendingMonitorSnapshot" WHERE type='verification'`))
|
||||||
|
.rows[0];
|
||||||
|
assert.equal(business.metrics.total, 4);
|
||||||
|
assert.equal(business.status, 'unconfigured');
|
||||||
|
checks++;
|
||||||
|
|
||||||
|
// Removing enrollment in a later period cannot rewrite a historical window.
|
||||||
|
await db.query(`INSERT INTO "SendingMonitorTargetVersion" VALUES($1,2,false,$2,'qa')`, [
|
||||||
|
channel,
|
||||||
|
new Date(t.getTime() + 300000),
|
||||||
|
]);
|
||||||
|
await evaluateWindow(db, 'industry', t, true, true);
|
||||||
|
assert.equal(
|
||||||
|
(await db.query(`SELECT metrics FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0].metrics
|
||||||
|
.total,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
await db.query(`UPDATE "SmsSubmitRecord" SET "receiptRequested"=false WHERE id='qa-submit-0'`);
|
||||||
|
await projectMessages(db, ['qa-message-0']);
|
||||||
|
assert.equal(
|
||||||
|
(await db.query(`SELECT reason FROM "SendingMonitorFact" WHERE id='attempt:qa-submit-0'`)).rows[0].reason,
|
||||||
|
'receipt_not_requested',
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
await db.query(`UPDATE "SmsSubmitRecord" SET "receiptRequested"=true WHERE id='qa-submit-0'`);
|
||||||
|
await db.query(`UPDATE "UpstreamReceiptInbox" SET "gatewayReceivedAt"=NULL WHERE id='qa-receipt-0'`);
|
||||||
|
await projectMessages(db, ['qa-message-0']);
|
||||||
|
assert.equal(
|
||||||
|
(await db.query(`SELECT reason FROM "SendingMonitorFact" WHERE id='attempt:qa-submit-0'`)).rows[0].reason,
|
||||||
|
'missing_receipt_time',
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
const created = (await db.query(`SELECT min("createdAt") at FROM "ReportReadinessEvent"`)).rows[0].at;
|
||||||
|
assert(
|
||||||
|
Math.abs(created.getTime() - Date.now()) < 60000,
|
||||||
|
'Notification timestamps must remain UTC under Asia/Shanghai session',
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
const drainage = (
|
||||||
|
await db.query(
|
||||||
|
`SELECT id,"signatureId","tenantId" FROM "SmsDrainageInfo" WHERE "signatureId" IS NOT NULL LIMIT 1`,
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (drainage) {
|
||||||
|
const before = (
|
||||||
|
await db.query(`SELECT count(*)::int n FROM "ReportReadinessEvent" WHERE "reportType"='drainage'`)
|
||||||
|
).rows[0].n;
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "ChannelSignatureReportTask" (id,"tenantId","signatureId","drainageItemId","reportType","channelId",carrier,"approvalScope",status,"updatedAt") VALUES($1,$2,$3,$4,'drainage',$5,NULL,'legacy_channel','approved',CURRENT_TIMESTAMP)`,
|
||||||
|
['qa-drainage', drainage.tenantId, drainage.signatureId, drainage.id, channel],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await db.query(`SELECT count(*)::int n FROM "ReportReadinessEvent" WHERE "reportType"='drainage'`)).rows[0].n,
|
||||||
|
before + 1,
|
||||||
|
);
|
||||||
|
checks++;
|
||||||
|
}
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
const leftover = (await db.query(`SELECT count(*)::int n FROM pg_namespace WHERE nspname=$1`, [schema])).rows[0].n;
|
||||||
|
assert.equal(leftover, 0);
|
||||||
|
checks++;
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
passed: checks,
|
||||||
|
database: 'real PostgreSQL',
|
||||||
|
isolation: 'transaction schema rolled back',
|
||||||
|
smsSent: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await db.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||