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

This commit is contained in:
hectorzhao
2026-09-06 19:22:49 +08:00
parent 69e3d7368d
commit 457319e627
66 changed files with 6992 additions and 489 deletions
@@ -0,0 +1,110 @@
CREATE TABLE "ReportReadinessState" (
"objectKey" TEXT PRIMARY KEY, "mask" INTEGER NOT NULL, "armed" BOOLEAN NOT NULL,
"cycle" INTEGER NOT NULL DEFAULT 0, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
CREATE TABLE "ReportNotificationHour" (
"id" TEXT PRIMARY KEY, "tenantId" TEXT NOT NULL, "tenantName" TEXT NOT NULL,
"hour" TIMESTAMP(3) NOT NULL, "revision" INTEGER NOT NULL DEFAULT 1,
"signatureCount" INTEGER NOT NULL DEFAULT 0, "drainageCount" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE ("tenantId", "hour")
);
CREATE INDEX "ReportNotificationHour_hour_idx" ON "ReportNotificationHour" ("hour" DESC);
CREATE TABLE "ReportReadinessEvent" (
"id" TEXT PRIMARY KEY, "hourId" TEXT NOT NULL REFERENCES "ReportNotificationHour"("id"),
"objectKey" TEXT NOT NULL, "cycle" INTEGER NOT NULL, "tenantId" TEXT NOT NULL,
"reportType" TEXT NOT NULL, "signatureId" TEXT NOT NULL, "drainageItemId" TEXT,
"applicationId" TEXT, "applicationName" TEXT, "signatureName" TEXT NOT NULL,
"targetName" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE ("objectKey", "cycle")
);
CREATE INDEX "ReportReadinessEvent_hour_idx" ON "ReportReadinessEvent" ("hourId", "createdAt", "id");
CREATE TABLE "ReportNotificationRead" (
"userId" TEXT NOT NULL, "hourId" TEXT NOT NULL REFERENCES "ReportNotificationHour"("id"),
"revision" INTEGER NOT NULL, PRIMARY KEY ("userId", "hourId")
);
CREATE FUNCTION cmpp_report_ready_mask(kind TEXT, signature_id TEXT, drainage_id TEXT) RETURNS INTEGER
LANGUAGE sql STABLE AS $$
SELECT COALESCE(sum(bit),0)::integer FROM (VALUES ('mobile',1),('unicom',2),('telecom',4)) AS carriers(name,bit)
WHERE EXISTS (
SELECT 1 FROM "SmsChannel" c
WHERE c.status='active' AND c."sendRegion"='全国'
AND (CASE WHEN cardinality(c.carriers)>0 THEN carriers.name=ANY(c.carriers)
ELSE c.carrier IN (carriers.name,'all') END)
AND (SELECT t.status FROM "ChannelSignatureReportTask" t
WHERE t."channelId"=c.id AND t."signatureId"=signature_id AND t."reportType"=kind
AND (kind='signature' OR t."drainageItemId"=drainage_id)
AND (t.carrier=carriers.name OR (t.carrier IS NULL AND t."approvalScope"='legacy_channel'))
ORDER BY (t.carrier=carriers.name) DESC NULLS LAST, t."updatedAt" DESC, t.id DESC LIMIT 1)='approved'
);
$$;
-- Seed only state. Existing successes must never become historical unread notices.
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed)
SELECT 'signature:'||id, mask, mask=0 FROM "SmsSignature" s
CROSS JOIN LATERAL (SELECT cmpp_report_ready_mask('signature',s.id,NULL) AS mask) m;
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed)
SELECT 'drainage:'||id, mask, mask=0 FROM "SmsDrainageInfo" d
CROSS JOIN LATERAL (SELECT cmpp_report_ready_mask('drainage',d."signatureId",d.id) AS mask) m;
CREATE FUNCTION cmpp_report_readiness_before() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r "ChannelSignatureReportTask"; k TEXT; m INTEGER;
BEGIN
r := CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
IF TG_OP='UPDATE' AND (OLD."signatureId",OLD."drainageItemId",OLD."reportType") IS DISTINCT FROM
(NEW."signatureId",NEW."drainageItemId",NEW."reportType") THEN
RAISE EXCEPTION 'Reporting task identity is immutable';
END IF;
k := r."reportType"||':'||CASE WHEN r."reportType"='drainage' THEN r."drainageItemId" ELSE r."signatureId" END;
IF k IS NULL THEN RETURN r; END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(k, 20260906));
m := cmpp_report_ready_mask(r."reportType",r."signatureId",r."drainageItemId");
INSERT INTO "ReportReadinessState" ("objectKey",mask,armed) VALUES(k,m,m=0) ON CONFLICT DO NOTHING;
-- A configuration change can remove eligibility without changing a reporting task.
UPDATE "ReportReadinessState" SET mask=m, armed=armed OR m=0 WHERE "objectKey"=k;
RETURN r;
END;
$$;
CREATE FUNCTION cmpp_report_readiness_after() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r "ChannelSignatureReportTask"; k TEXT; m INTEGER; st "ReportReadinessState";
sig "SmsSignature"; tenant_name TEXT; app_name TEXT; target_name TEXT;
app_id TEXT; hour_value TIMESTAMP(3); hour_id TEXT; next_cycle INTEGER;
BEGIN
r := CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
k := r."reportType"||':'||CASE WHEN r."reportType"='drainage' THEN r."drainageItemId" ELSE r."signatureId" END;
IF k IS NULL THEN RETURN r; END IF;
SELECT * INTO st FROM "ReportReadinessState" WHERE "objectKey"=k FOR UPDATE;
m := cmpp_report_ready_mask(r."reportType",r."signatureId",r."drainageItemId");
IF m=7 AND st.armed THEN
SELECT * INTO sig FROM "SmsSignature" WHERE id=r."signatureId";
IF sig.id IS NOT NULL THEN
SELECT name INTO tenant_name FROM "Tenant" WHERE id=sig."tenantId";
app_id:=sig."applicationId"; target_name:=sig.name;
IF r."reportType"='drainage' THEN
SELECT COALESCE(d."applicationId",sig."applicationId"), d.url INTO app_id,target_name
FROM "SmsDrainageInfo" d WHERE id=r."drainageItemId";
END IF;
SELECT name INTO app_name FROM "SmsApplication" WHERE id=app_id;
hour_value:=date_trunc('hour',timezone('UTC',statement_timestamp()));
hour_id:=md5(sig."tenantId"||':'||hour_value::text);
next_cycle:=st.cycle+1;
INSERT INTO "ReportNotificationHour" (id,"tenantId","tenantName",hour,"signatureCount","drainageCount")
VALUES(hour_id,sig."tenantId",tenant_name,hour_value,(r."reportType"='signature')::integer,(r."reportType"='drainage')::integer)
ON CONFLICT ("tenantId",hour) DO UPDATE SET revision="ReportNotificationHour".revision+1,
"signatureCount"="ReportNotificationHour"."signatureCount"+EXCLUDED."signatureCount",
"drainageCount"="ReportNotificationHour"."drainageCount"+EXCLUDED."drainageCount",
"updatedAt"=timezone('UTC',statement_timestamp()) RETURNING id INTO hour_id;
INSERT INTO "ReportReadinessEvent" (id,"hourId","objectKey",cycle,"tenantId","reportType","signatureId","drainageItemId","applicationId","applicationName","signatureName","targetName")
VALUES(md5(k||':'||next_cycle),hour_id,k,next_cycle,sig."tenantId",r."reportType",sig.id,r."drainageItemId",app_id,app_name,sig.name,target_name);
UPDATE "ReportReadinessState" SET cycle=next_cycle,armed=false WHERE "objectKey"=k;
END IF;
END IF;
UPDATE "ReportReadinessState" SET mask=m,armed=armed OR m=0,"updatedAt"=timezone('UTC',statement_timestamp()) WHERE "objectKey"=k;
RETURN r;
END;
$$;
CREATE TRIGGER report_readiness_before BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION cmpp_report_readiness_before();
CREATE TRIGGER report_readiness_after AFTER INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION cmpp_report_readiness_after();
@@ -0,0 +1,73 @@
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "firstWireSubmitAt" TIMESTAMP(3), ADD COLUMN "wireTimeSource" TEXT;
ALTER TABLE "SmsMessageSegmentAudit" ADD COLUMN "firstWireSubmitAt" TIMESTAMP(3), ADD COLUMN "wireTimeSource" TEXT;
ALTER TABLE "UpstreamReceiptInbox" ADD COLUMN "gatewayReceivedAt" TIMESTAMP(3);
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "receiptRequested" BOOLEAN;
ALTER TABLE "SmsMessageSegmentAudit" ADD COLUMN "receiptRequested" BOOLEAN;
CREATE INDEX "SmsSubmitRecord_monitor_updated_idx" ON "SmsSubmitRecord" ("updatedAt",id);
CREATE INDEX "SmsSubmitRecord_monitor_created_idx" ON "SmsSubmitRecord" ("createdAt",id);
CREATE INDEX "UpstreamReceiptInbox_monitor_updated_idx" ON "UpstreamReceiptInbox" ("updatedAt",id);
CREATE INDEX "UpstreamReceiptInbox_monitor_message_idx" ON "UpstreamReceiptInbox" ("matchedMessageRecordId","gatewayMessageId");
CREATE TABLE "SendingMonitorTarget" (
"channelId" TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL, version INTEGER NOT NULL,
"effectiveFrom" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
"updatedBy" TEXT NOT NULL
);
CREATE TABLE "SendingMonitorTargetVersion" (
"channelId" TEXT NOT NULL, version INTEGER NOT NULL, enabled BOOLEAN NOT NULL,
"effectiveFrom" TIMESTAMP(3) NOT NULL, "updatedBy" TEXT NOT NULL,
PRIMARY KEY("channelId",version)
);
CREATE TABLE "SendingMonitorRule" (
id TEXT PRIMARY KEY, type TEXT NOT NULL, "scopeKey" TEXT NOT NULL, scope JSONB NOT NULL,
config JSONB NOT NULL, version INTEGER NOT NULL, "effectiveAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedBy" TEXT NOT NULL,
UNIQUE(type,"scopeKey")
);
CREATE TABLE "SendingMonitorRuleVersion" (
"ruleId" TEXT NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, scope JSONB NOT NULL,
config JSONB NOT NULL, "effectiveAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
"createdBy" TEXT NOT NULL, PRIMARY KEY("ruleId",version)
);
CREATE INDEX "SendingMonitorRuleVersion_effective_idx" ON "SendingMonitorRuleVersion" (type,"effectiveAt");
CREATE TABLE "SendingMonitorFact" (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, "sourceId" TEXT NOT NULL, "dimensionKey" TEXT NOT NULL,
"messageRecordId" TEXT REFERENCES "SmsMessageRecord"(id) ON DELETE SET NULL ON UPDATE CASCADE,
dimensions JSONB NOT NULL, "submittedAt" TIMESTAMP(3) NOT NULL, "successAt" TIMESTAMP(3),
verification BOOLEAN NOT NULL, reason TEXT, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE(kind,"sourceId")
);
CREATE INDEX "SendingMonitorFact_window_idx" ON "SendingMonitorFact" (kind,"submittedAt","dimensionKey");
CREATE INDEX "SendingMonitorFact_message_idx" ON "SendingMonitorFact" ("messageRecordId");
CREATE TABLE "SendingMonitorMinute" (
kind TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, minute TIMESTAMP(3) NOT NULL,
verification BOOLEAN NOT NULL, dimensions JSONB NOT NULL, metrics JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
PRIMARY KEY(kind,"dimensionKey",minute,verification)
);
CREATE INDEX "SendingMonitorMinute_window_idx" ON "SendingMonitorMinute" (kind,minute);
CREATE TABLE "SendingMonitorSnapshot" (
id TEXT PRIMARY KEY, type TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, dimensions JSONB NOT NULL,
"evaluationAt" TIMESTAMP(3) NOT NULL, "windowFrom" TIMESTAMP(3) NOT NULL,
"observedUntil" TIMESTAMP(3) NOT NULL, stage TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
metrics JSONB NOT NULL, rule JSONB, status TEXT NOT NULL, completeness JSONB NOT NULL,
"computedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
UNIQUE(type,"dimensionKey","evaluationAt")
);
CREATE INDEX "SendingMonitorSnapshot_latest_idx" ON "SendingMonitorSnapshot" (type,"evaluationAt" DESC,status);
CREATE INDEX "SendingMonitorSnapshot_history_idx" ON "SendingMonitorSnapshot" (type,"dimensionKey","evaluationAt");
CREATE TABLE "SendingMonitorAlert" (
id TEXT PRIMARY KEY, type TEXT NOT NULL, "dimensionKey" TEXT NOT NULL, dimensions JSONB NOT NULL,
state TEXT NOT NULL, "openedAt" TIMESTAMP(3) NOT NULL, "lastEvaluatedAt" TIMESTAMP(3) NOT NULL,
"closedAt" TIMESTAMP(3), "closeReason" TEXT, "ruleKey" TEXT NOT NULL,
latest JSONB NOT NULL, worst JSONB NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
CREATE UNIQUE INDEX "SendingMonitorAlert_one_active_idx" ON "SendingMonitorAlert" (type,"dimensionKey") WHERE state='active';
CREATE INDEX "SendingMonitorAlert_state_idx" ON "SendingMonitorAlert" (state,"openedAt" DESC);
CREATE TABLE "SendingMonitorAlertRead" (
"alertId" TEXT NOT NULL, "userId" TEXT NOT NULL, "readAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
PRIMARY KEY("alertId","userId")
);
CREATE TABLE "SendingMonitorCheckpoint" (
id TEXT PRIMARY KEY, data JSONB NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
+186
View File
@@ -696,6 +696,56 @@ model HttpWebhookAttempt {
@@unique([deliveryId, attemptNo]) @@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])
}
+7 -1
View File
@@ -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;
+64 -17
View File
@@ -1,27 +1,32 @@
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) {}
listBatchTasks(query: { tenantId?: string; status?: string }) { listBatchTasks(query: { tenantId?: string; status?: string }) {
return this.prisma.smsBatchTask.findMany({ return this.prisma.smsBatchTask.findMany({
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' }, where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
include: { apiRequests: true }, include: { apiRequests: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async listClientBatchTasks(query: { tenantId?: string; status?: string }) { async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
const items = await this.listBatchTasks(query); const items = await this.listBatchTasks(query);
return items.map(clientBatchTaskView); return items.map(clientBatchTaskView);
} }
listMessages(query: MessageQuery) { listMessages(query: MessageQuery) {
return this.prisma.smsMessageRecord.findMany({ return this.prisma.smsMessageRecord.findMany({
where: messageWhere(query), where: messageWhere(query),
include: { include: {
@@ -38,10 +43,34 @@ listMessages(query: MessageQuery) {
orderBy: { queuedAt: 'desc' }, orderBy: { queuedAt: 'desc' },
}); });
} }
async listMessagesPage(query: MessageQuery) { private async messageFilter(query: MessageQuery) {
const where = messageWhere(query);
if (query.monitorSnapshotId) {
const snapshot = await this.prisma.sendingMonitorSnapshot.findUnique({ where: { id: query.monitorSnapshotId } });
if (
!snapshot ||
(query.tenantId &&
(snapshot.dimensions as { tenantId?: string }).tenantId &&
(snapshot.dimensions as { tenantId?: string }).tenantId !== query.tenantId)
)
throw new NotFoundException('监控快照不存在');
if (snapshot.windowFrom.getTime() < Date.now() - 72 * 3600000)
throw new BadRequestException('该窗口已超过72小时样本保留期,历史快照仍可查询');
where.monitorFacts = {
some: {
kind: snapshot.type === 'industry' ? 'attempt' : 'business',
dimensionKey: snapshot.dimensionKey,
submittedAt: { gte: snapshot.windowFrom, lt: snapshot.evaluationAt },
...(snapshot.type === 'verification' ? { verification: true } : {}),
},
};
}
return where;
}
async listMessagesPage(query: MessageQuery) {
const page = Math.max(1, Math.floor(Number(query.page) || 1)); const 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,
@@ -75,7 +104,7 @@ async listMessagesPage(query: MessageQuery) {
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
async getMessage(id: string) { async getMessage(id: string) {
const item = await this.prisma.smsMessageRecord.findUnique({ const item = await this.prisma.smsMessageRecord.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -121,9 +150,10 @@ async getMessage(id: string) {
if (!item) throw new NotFoundException('Message record not found'); if (!item) throw new NotFoundException('Message record not found');
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,
@@ -167,11 +214,11 @@ async exportMessages(query: MessageQuery) {
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'), content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
}; };
} }
async listClientMessages(query: MessageQuery) { async listClientMessages(query: MessageQuery) {
const items = await this.listMessages(query); const items = await this.listMessages(query);
return items.map(clientMessageView); return items.map(clientMessageView);
} }
async listClientMessagesPage(query: MessageQuery) { async listClientMessagesPage(query: MessageQuery) {
const result = await this.listMessagesPage(query); const result = await this.listMessagesPage(query);
return { ...result, items: result.items.map(clientMessageView) }; return { ...result, items: result.items.map(clientMessageView) };
} }
@@ -0,0 +1,133 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Injectable,
Module,
NotFoundException,
Param,
Post,
Query,
Req,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PrismaModule } from '../prisma/prisma.module';
import type { SessionRequest } from '../auth/session-validation.middleware';
export function positivePage(value: unknown, fallback: number, max = 100000) {
if (value === undefined) return fallback;
const n = Number(value);
if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new BadRequestException('页码、条数或版本无效');
return n;
}
@Injectable()
export class ReportNotificationsService {
constructor(private readonly prisma: PrismaService) {}
async scope(req: SessionRequest) {
if (!req.sessionUserId) throw new ForbiddenException('请先登录');
if (req.authSession?.portal === 'client' && req.sessionTenantId) return req.sessionTenantId;
const admin =
req.authSession?.portal === 'admin' &&
(await this.prisma.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!admin) throw new ForbiddenException('无消息查看权限');
return null;
}
async list(req: SessionRequest, query: { page?: string; pageSize?: string; unread?: string }) {
const tenant = await this.scope(req);
const page = positivePage(query.page, 1);
const pageSize = positivePage(query.pageSize, 20, 100);
const filter = Prisma.sql`WHERE (${tenant}::text IS NULL OR h."tenantId"=${tenant})
AND (${query.unread === 'true'}=false OR COALESCE(r.revision,0)<h.revision)`;
return this.prisma.$transaction(
async (tx) => {
const items = await tx.$queryRaw<Array<Record<string, unknown>>>(Prisma.sql`
SELECT h.*,COALESCE(r.revision,0)<h.revision AS unread FROM "ReportNotificationHour" h
LEFT JOIN "ReportNotificationRead" r ON r."hourId"=h.id AND r."userId"=${req.sessionUserId!}
${filter} ORDER BY h.hour DESC,h.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`);
const counts = await tx.$queryRaw<Array<{ total: number }>>(Prisma.sql`
SELECT count(*)::integer AS total FROM "ReportNotificationHour" h
LEFT JOIN "ReportNotificationRead" r ON r."hourId"=h.id AND r."userId"=${req.sessionUserId!} ${filter}`);
return { items, total: counts[0].total, page, pageSize };
},
{ isolationLevel: 'RepeatableRead' },
);
}
async hour(req: SessionRequest, id: string) {
const tenantId = await this.scope(req);
const item = await this.prisma.reportNotificationHour.findFirst({
where: { id, ...(tenantId ? { tenantId } : {}) },
});
if (!item) throw new NotFoundException('消息不存在');
return item;
}
async detail(req: SessionRequest, id: string, query: { page?: string; pageSize?: string }) {
const hour = await this.hour(req, id);
const page = positivePage(query.page, 1),
pageSize = positivePage(query.pageSize, 20, 100);
const [items, total] = await this.prisma.$transaction([
this.prisma.reportReadinessEvent.findMany({
where: { hourId: id },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.reportReadinessEvent.count({ where: { hourId: id } }),
]);
return { hour, items, total, page, pageSize };
}
async read(req: SessionRequest, id: string, revision: unknown) {
const hour = await this.hour(req, id);
const version = positivePage(revision, 0, 2147483647);
if (!version || version > hour.revision) throw new BadRequestException('消息版本无效,请刷新');
await this.prisma.$executeRaw`INSERT INTO "ReportNotificationRead" ("userId","hourId",revision)
VALUES (${req.sessionUserId!},${id},${version}) ON CONFLICT ("userId","hourId")
DO UPDATE SET revision=GREATEST("ReportNotificationRead".revision,EXCLUDED.revision)`;
return { success: true };
}
}
@Controller(['admin/report-notifications', 'client/report-notifications'])
class ReportNotificationsController {
constructor(private readonly service: ReportNotificationsService) {}
@Get() list(@Req() req: SessionRequest, @Query() query: { page?: string; pageSize?: string; unread?: string }) {
return this.service.list(req, query);
}
@Get('summary') async summary(@Req() req: SessionRequest) {
const result = await this.service.list(req, { pageSize: '1', unread: 'true' });
return { count: result.total };
}
@Get(':id') detail(
@Req() req: SessionRequest,
@Param('id') id: string,
@Query() query: { page?: string; pageSize?: string },
) {
return this.service.detail(req, id, query);
}
@Post(':id/read') read(@Req() req: SessionRequest, @Param('id') id: string, @Body() body: { revision?: unknown }) {
return this.service.read(req, id, body?.revision);
}
}
@Module({
imports: [PrismaModule],
controllers: [ReportNotificationsController],
providers: [ReportNotificationsService],
})
export class ReportNotificationsModule {}
@@ -0,0 +1,35 @@
import { ReportNotificationsService, positivePage } from './report-notifications.module';
import type { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
describe('report notification access and read versions', () => {
const db = { reportNotificationHour: { findFirst: jest.fn() }, $executeRaw: jest.fn() };
const service = new ReportNotificationsService(db as unknown as PrismaService);
const req = {
sessionUserId: 'reader',
sessionTenantId: 'tenant-a',
authSession: { portal: 'client' },
} as SessionRequest;
beforeEach(() => jest.clearAllMocks());
it('rejects anonymous access', async () => {
await expect(service.scope({} as SessionRequest)).rejects.toThrow('请先登录');
});
it('uses the session tenant and hides inaccessible hours', async () => {
db.reportNotificationHour.findFirst.mockResolvedValue(null);
await expect(service.hour(req, 'tenant-b-hour')).rejects.toThrow('消息不存在');
expect(db.reportNotificationHour.findFirst).toHaveBeenCalledWith({
where: { id: 'tenant-b-hour', tenantId: 'tenant-a' },
});
});
it('rejects future read revisions without writing and accepts a displayed older revision', async () => {
db.reportNotificationHour.findFirst.mockResolvedValue({ id: 'hour', revision: 4 });
await expect(service.read(req, 'hour', 5)).rejects.toThrow('消息版本无效');
expect(db.$executeRaw).not.toHaveBeenCalled();
await expect(service.read(req, 'hour', 3)).resolves.toEqual({ success: true });
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
});
it('bounds pages and rejects fractional or zero values', () => {
for (const value of ['0', '-1', '1.5', '101', 'NaN']) expect(() => positivePage(value, 20, 100)).toThrow();
expect(positivePage(undefined, 20, 100)).toBe(20);
});
});
@@ -0,0 +1,23 @@
import { wireTiming } from './send-gateway-result.service';
describe('Gateway wire timing compatibility', () => {
it('does not invent timestamps for old events or pre-write failures', () => {
expect(wireTiming({})).toEqual({});
expect(wireTiming({ wireTimeSource: 'not_written', receiptRequested: true })).toEqual({
wireTimeSource: 'not_written',
receiptRequested: true,
});
});
it('preserves the earliest actual time when replaying aggregate results', () => {
const prior = new Date('2026-09-06T04:00:00Z');
expect(
wireTiming(
{ firstWireSubmitAt: '2026-09-06T04:00:05Z', wireTimeSource: 'gateway_write_complete', receiptRequested: true },
prior,
).firstWireSubmitAt,
).toEqual(prior);
});
it('rejects invalid dates and unverified sources', () => {
expect(() => wireTiming({ firstWireSubmitAt: 'invalid', wireTimeSource: 'gateway_write_complete' })).toThrow();
expect(() => wireTiming({ firstWireSubmitAt: '2026-09-06T04:00:00Z', wireTimeSource: 'response_time' })).toThrow();
});
});
@@ -70,6 +70,9 @@ export interface GatewaySubmitResultDto {
errorCode?: string; 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 {
+113 -51
View File
@@ -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;
}>; }>;
+72 -53
View File
@@ -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,
+201
View File
@@ -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();
});
});
+147
View File
@@ -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" }
} }
} }
+10
View File
@@ -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=UTCPostgreSQL池上限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,旧代码忽略新增字段;兼容新增表/触发器可保留,数据回退另行评估,不直接覆盖库。
Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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">应用×签名 &gt; 签名 &gt; 应用 &gt; 通用</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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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节。
![行业通道监控](01-industry-monitor.png)
[可编辑SVG](01-industry-monitor.svg)
## 2. 验证码监控
按企业应用×签名展开,只统计最终提交内容含“验证码”的业务短信。复用行业监控的时间口径、筛选和详情布局;数据延迟明确显示为待更新,不填充正常指标。
![验证码监控](02-verification-monitor.png)
[可编辑SVG](02-verification-monitor.svg)
## 3. 整体兜底监控
暂按全部短信理解“兜底”,展示近30分钟、每10分钟评估的1/5/20分钟指标,以及当前生效规则来源。
首行采用应用×签名规则,最低100条、下限85%/90%/95%。前两项正常;20分钟成功85条、成熟90条,94.44%但尚不告警,另有210条观察中。该例用于说明三个指标的分母不同,不能用全窗口300条直接计算20分钟指标。
![整体兜底监控](03-overall-monitor.png)
[可编辑SVG](03-overall-monitor.svg)
## 4. 阈值与个性规则
完整规则优先级为:应用×签名 > 签名 > 企业应用 > 通用。左侧选择范围,右侧编辑最低样本、各指标下限及异常/恢复连续次数;保存前预览生效范围。恢复继承需要清晰说明将采用哪一套规则。保存失败保留输入,版本冲突提示重新加载。
![阈值与个性规则](04-threshold-rules.png)
[可编辑SVG](04-threshold-rules.svg)
## 5. 新建或复制通道后的纳管提示
仅在服务端创建成功、取得新通道ID之后出现。提示“是否加入行业通道监控?”并标注“如果是行业短信通道建议加入”。暂不加入不影响创建结果;加入失败只重试纳管,不重复创建通道。图下方的失败提示用于展示另一种状态,并非成功时同时弹出错误。
![通道纳管提示](05-channel-enrollment.png)
[可编辑SVG](05-channel-enrollment.svg)
## 6. 预警中心与告警详情
右上角预警中心新增“发送质量告警”,汇总行业、验证码、整体兜底三类;保留既有待审核任务和报备任务提醒入口。点击进入告警列表,再进入左侧详情。图中把弹层、详情和时间线并列,以便评审,实际弹层仍由顶部按钮打开。
同一维度多项异常合成一条活动事件,持续异常更新原事件;标记已读不等于恢复。阈值线、当时分母、首次/最近异常时间及规则版本应可追溯。
![预警中心与告警详情](06-alert-center-detail.png)
[可编辑SVG](06-alert-center-detail.svg)
## 7. 窄屏监控
折叠导航,将宽表转为指标卡;每项独立展示百分比、成熟样本、观察中条数。筛选和配置在抽屉中完成,保留刷新、趋势和告警入口。
![窄屏监控](07-mobile-monitor.png)
[可编辑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、versionchannelId唯一 |
| 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 /targetsPUT /targets/:channelId | 行业纳管列表与幂等加入/移除,携带version |
| GET /rulesPUT /rules/:idPOST /rules | 通用与个性规则,完整校验与乐观锁 |
| GET /effective-rule?... | 返回生效规则及被覆盖层,用于编辑预览 |
| GET /alertsGET /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和真实峰值对照尚未验收。
+19
View File
@@ -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不会自动重入池;不把调查写成修复 |
+13
View File
@@ -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=69e3d732026-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:0519: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均通过。未执行实际恢复演练。
- 此节为发布前证据;提交、推送、测试发布、真实双端浏览器结果在后续发布节记录,不预先标记完成。
+6
View File
@@ -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"`
+24 -2
View File
@@ -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")
}
}
+319 -57
View File
@@ -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({}),
}),
}; };
+77 -16
View File
@@ -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}
+123
View File
@@ -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;
}
}
+292 -66
View File
@@ -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>
); );
} }
+62 -4
View File
@@ -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);
+50 -19
View File
@@ -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 ? (
+143
View File
@@ -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;
}
+9 -1
View File
@@ -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',
+32 -1
View File
@@ -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,
+15
View File
@@ -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('');
+10 -6
View File
@@ -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: '系统管理',
+5
View File
@@ -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 />} />
+30 -1
View File
@@ -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
+12
View File
@@ -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"]
} }
] ]
} }
+252
View File
@@ -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;
});