diff --git a/api/prisma/migrations/20260906170000_report_readiness_notifications/migration.sql b/api/prisma/migrations/20260906170000_report_readiness_notifications/migration.sql new file mode 100644 index 0000000..59726d5 --- /dev/null +++ b/api/prisma/migrations/20260906170000_report_readiness_notifications/migration.sql @@ -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(); diff --git a/api/prisma/migrations/20260906171000_sending_monitor/migration.sql b/api/prisma/migrations/20260906171000_sending_monitor/migration.sql new file mode 100644 index 0000000..7fd114c --- /dev/null +++ b/api/prisma/migrations/20260906171000_sending_monitor/migration.sql @@ -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') +); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 2fe8558..6c3b65d 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -696,6 +696,56 @@ model HttpWebhookAttempt { @@unique([deliveryId, attemptNo]) } +model ReportReadinessState { + objectKey String @id + mask Int + armed Boolean + cycle Int @default(0) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) +} + +model ReportNotificationHour { + id String @id + tenantId String + tenantName String + hour DateTime + revision Int @default(1) + signatureCount Int @default(0) + drainageCount Int @default(0) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + events ReportReadinessEvent[] + reads ReportNotificationRead[] + @@unique([tenantId, hour]) + @@index([hour(sort: Desc)], map: "ReportNotificationHour_hour_idx") +} + +model ReportReadinessEvent { + id String @id + hourId String + objectKey String + cycle Int + tenantId String + reportType String + signatureId String + drainageItemId String? + applicationId String? + applicationName String? + signatureName String + targetName String + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + hour ReportNotificationHour @relation(fields: [hourId], references: [id], onDelete: NoAction, onUpdate: NoAction) + @@unique([objectKey, cycle]) + @@index([hourId, createdAt, id], map: "ReportReadinessEvent_hour_idx") +} + +model ReportNotificationRead { + userId String + hourId String + revision Int + hour ReportNotificationHour @relation(fields: [hourId], references: [id], onDelete: NoAction, onUpdate: NoAction) + @@id([userId, hourId]) +} + model SmsSignature { id String @id @default(cuid()) tenantId String @@ -1705,6 +1755,7 @@ model SmsApiRequest { } model SmsMessageRecord { + monitorFacts SendingMonitorFact[] id String @id @default(cuid()) tenantId String? batchTaskId String? @@ -1813,6 +1864,9 @@ model SmsSubmitRecord { errorCode String? errorMessage String? submittedAt DateTime? + firstWireSubmitAt DateTime? + wireTimeSource String? + receiptRequested Boolean? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1832,6 +1886,8 @@ model SmsSubmitRecord { @@index([gatewayMessageId]) @@index([channelId, gatewayMessageId]) @@index([channelGroupId]) + @@index([updatedAt,id], map: "SmsSubmitRecord_monitor_updated_idx") + @@index([createdAt,id], map: "SmsSubmitRecord_monitor_created_idx") } model GatewaySubmitOutbox { @@ -1958,6 +2014,9 @@ model SmsMessageSegmentAudit { errorCode String? errorMessage String? submittedAt DateTime? + firstWireSubmitAt DateTime? + wireTimeSource String? + receiptRequested Boolean? deliveredAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -2339,6 +2398,7 @@ model UpstreamReceiptInbox { errorCode String? errorMessage String? deliveredAt DateTime + gatewayReceivedAt DateTime? receivedAt DateTime @default(now()) status String @default("pending") matchedMessageRecordId String? @@ -2355,6 +2415,8 @@ model UpstreamReceiptInbox { @@index([gatewayMessageId, phoneNumber]) @@index([incomingChannelId, receivedAt]) @@index([matchedMessageRecordId]) + @@index([updatedAt,id], map: "UpstreamReceiptInbox_monitor_updated_idx") + @@index([matchedMessageRecordId,gatewayMessageId], map: "UpstreamReceiptInbox_monitor_message_idx") } model GatewaySubmitDeadLetter { @@ -2563,3 +2625,127 @@ model InfrastructureAlertRead { @@unique([fingerprint, userId]) @@index([userId, readAt]) } + +model SendingMonitorTarget { + channelId String @id + enabled Boolean + version Int + effectiveFrom DateTime + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + updatedBy String +} + +model SendingMonitorRule { + id String @id + type String + scopeKey String + scope Json + config Json + version Int + effectiveAt DateTime + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + updatedBy String + @@unique([type,scopeKey]) +} + +model SendingMonitorRuleVersion { + ruleId String + version Int + type String + scope Json + config Json + effectiveAt DateTime + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + createdBy String + @@id([ruleId,version]) + @@index([type,effectiveAt], map: "SendingMonitorRuleVersion_effective_idx") +} + +model SendingMonitorFact { + id String @id + kind String + sourceId String + messageRecordId String? + messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id], onDelete: SetNull) + dimensionKey String + dimensions Json + submittedAt DateTime + successAt DateTime? + verification Boolean + reason String? + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + @@unique([kind,sourceId]) + @@index([messageRecordId], map: "SendingMonitorFact_message_idx") + @@index([kind,submittedAt,dimensionKey], map: "SendingMonitorFact_window_idx") +} + +model SendingMonitorMinute { + kind String + dimensionKey String + minute DateTime + verification Boolean + dimensions Json + metrics Json + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + @@id([kind,dimensionKey,minute,verification]) + @@index([kind,minute], map: "SendingMonitorMinute_window_idx") +} + +model SendingMonitorSnapshot { + id String @id + type String + dimensionKey String + dimensions Json + evaluationAt DateTime + windowFrom DateTime + observedUntil DateTime + stage String + revision Int @default(1) + metrics Json + rule Json? + status String + completeness Json + computedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + @@unique([type,dimensionKey,evaluationAt]) + @@index([type,evaluationAt(sort: Desc),status], map: "SendingMonitorSnapshot_latest_idx") + @@index([type,dimensionKey,evaluationAt], map: "SendingMonitorSnapshot_history_idx") +} + +model SendingMonitorAlert { + id String @id + type String + dimensionKey String + dimensions Json + state String + openedAt DateTime + lastEvaluatedAt DateTime + closedAt DateTime? + closeReason String? + ruleKey String + latest Json + worst Json + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + @@index([state,openedAt(sort: Desc)], map: "SendingMonitorAlert_state_idx") +} + +model SendingMonitorAlertRead { + alertId String + userId String + readAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) + @@id([alertId,userId]) +} + +model SendingMonitorCheckpoint { + id String @id + data Json + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) +} + +model SendingMonitorTargetVersion { + channelId String + version Int + enabled Boolean + effectiveFrom DateTime @db.Timestamp(3) + updatedBy String + @@id([channelId,version]) +} diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 4a54923..3c1287f 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -27,6 +27,8 @@ import { UsersModule } from './users/users.module'; import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module'; import { SecurityDetectionModule } from './security-detection/security-detection.module'; import { MetricsModule } from './metrics/metrics.module'; +import { ReportNotificationsModule } from './report-notifications/report-notifications.module'; +import { SendingMonitorModule } from './sending-monitor/sending-monitor.module'; @Module({ imports: [ @@ -57,12 +59,16 @@ import { MetricsModule } from './metrics/metrics.module'; SignatureRetirementModule, SecurityDetectionModule, MetricsModule, + ReportNotificationsModule, + SendingMonitorModule, ], controllers: [HealthController], providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware).forRoutes('*'); + consumer + .apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware) + .forRoutes('*'); } } diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index fece270..9899cba 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -46,8 +46,10 @@ export class AdminOperationsController { @Query('hasDrainage') hasDrainage?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, + @Query('monitorSnapshotId') monitorSnapshotId?: string, ) { return this.operations.listMessagesPage({ + monitorSnapshotId, tenantId, applicationId, channelId, @@ -80,8 +82,10 @@ export class AdminOperationsController { @Query('status') status: string | undefined, @Query('hasDrainage') hasDrainage: string | undefined, @Res() response: DownloadResponse, + @Query('monitorSnapshotId') monitorSnapshotId?: string, ) { const exported = await this.operations.exportMessages({ + monitorSnapshotId, tenantId, applicationId, channelId, @@ -105,25 +109,37 @@ export class AdminOperationsController { } @Get('message-segment-audits') - messageSegmentAudits( - @Query('messageId') messageId?: string, - @Query('messageRecordId') messageRecordId?: string, - ) { + messageSegmentAudits(@Query('messageId') messageId?: string, @Query('messageRecordId') messageRecordId?: string) { return this.operations.listMessageSegmentAudits({ messageId, messageRecordId }); } @Get('uplink-messages') - listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + listUplinkMessages( + @Query('tenantId') tenantId?: string, + @Query('channelId') channelId?: string, + @Query('phoneNumber') phoneNumber?: string, + @Query('keyword') keyword?: string, + @Query('startTime') startTime?: string, + @Query('endTime') endTime?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { return page || pageSize - ? this.operations.listUplinkMessagesPage({ tenantId, channelId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }) + ? this.operations.listUplinkMessagesPage({ + tenantId, + channelId, + phoneNumber, + keyword, + startTime, + endTime, + page: Number(page), + pageSize: Number(pageSize), + }) : this.operations.listUplinkMessages({ tenantId, channelId, phoneNumber, keyword, startTime, endTime }); } @Post('uplink-messages/:id/claim') - claimUplinkMatchCandidate( - @Param('id') id: string, - @Body() body: { candidateId?: string; operatorId?: string }, - ) { + claimUplinkMatchCandidate(@Param('id') id: string, @Body() body: { candidateId?: string; operatorId?: string }) { return this.sendChain.claimUplinkMatchCandidate(id, String(body.candidateId ?? ''), body.operatorId); } @@ -231,10 +247,7 @@ export class AdminOperationsController { } @Post('gateway-submit-dead-letters/:id/resolve') - resolveGatewaySubmitDeadLetter( - @Param('id') id: string, - @CurrentSessionUserId() operatorId?: string, - ) { + resolveGatewaySubmitDeadLetter(@Param('id') id: string, @CurrentSessionUserId() operatorId?: string) { return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId); } @@ -381,16 +394,23 @@ export class AdminOperationsController { @Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, @CurrentSessionUserId() operatorId?: string, ) { - return this.sendChain.createDownstreamRequeueTask({ - previewToken: body.previewToken ?? '', - reason: body.reason ?? '', - ratePerSecond: body.ratePerSecond, - consecutiveFailureLimit: body.consecutiveFailureLimit, - }, operatorId); + return this.sendChain.createDownstreamRequeueTask( + { + previewToken: body.previewToken ?? '', + reason: body.reason ?? '', + ratePerSecond: body.ratePerSecond, + consecutiveFailureLimit: body.consecutiveFailureLimit, + }, + operatorId, + ); } @Get('downstream-requeue-tasks') - listDownstreamRequeueTasks(@Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + listDownstreamRequeueTasks( + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) }); } @@ -407,7 +427,12 @@ export class AdminOperationsController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.sendChain.listDownstreamRequeueTaskItems(id, { status, keyword, page: Number(page), pageSize: Number(pageSize) }); + return this.sendChain.listDownstreamRequeueTaskItems(id, { + status, + keyword, + page: Number(page), + pageSize: Number(pageSize), + }); } @Post('downstream-requeue-tasks/:id/:action') @@ -441,7 +466,18 @@ export class AdminSystemLogsController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }); + return this.protocolLogs.list({ + protocol, + direction, + eventType, + status, + keyword, + range, + createdAtFrom, + createdAtTo, + page: Number(page), + pageSize: Number(pageSize), + }); } @Get() @@ -457,11 +493,34 @@ export class AdminSystemLogsController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }); + return this.operations.systemLogs({ + tenantId, + userId, + keyword, + level, + module, + range, + createdAtFrom, + createdAtTo, + page: Number(page), + pageSize: Number(pageSize), + }); } @Post('exports') - export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) { + export( + @Body() + body: { + tenantId?: string; + userId?: string; + keyword?: string; + level?: string; + module?: string; + range?: string; + createdAtFrom?: string; + createdAtTo?: string; + }, + ) { return this.operations.exportSystemLogs(body); } } diff --git a/api/src/operations/operations.contracts.ts b/api/src/operations/operations.contracts.ts index 6199232..831de63 100644 --- a/api/src/operations/operations.contracts.ts +++ b/api/src/operations/operations.contracts.ts @@ -1,6 +1,7 @@ // Stable controller/query contracts extracted in R2. export interface MessageQuery { + monitorSnapshotId?: string; tenantId?: string; applicationId?: string; channelId?: string; diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts index add4f59..948c818 100644 --- a/api/src/operations/queries/messages.queries.ts +++ b/api/src/operations/queries/messages.queries.ts @@ -1,27 +1,32 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { randomUUID } from 'node:crypto'; + import { moneyToNumber } from '../../common/money'; import { PrismaService } from '../../prisma/prisma.service'; -import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; -import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; +import type { MessageQuery } from '../operations.contracts'; +import { + messageWhere, + escapeCsvCell, + formatExportTimestamp, + clientMessageView, + clientBatchTaskView, +} from '../operations.helpers'; -// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline. +// Message queries share the same bounded monitor sample filter for lists and exports. export class OperationsMessageQueries { constructor(private readonly prisma: PrismaService) {} -listBatchTasks(query: { tenantId?: string; status?: string }) { + listBatchTasks(query: { tenantId?: string; status?: string }) { return this.prisma.smsBatchTask.findMany({ where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' }, include: { apiRequests: true }, orderBy: { createdAt: 'desc' }, }); } -async listClientBatchTasks(query: { tenantId?: string; status?: string }) { + async listClientBatchTasks(query: { tenantId?: string; status?: string }) { const items = await this.listBatchTasks(query); return items.map(clientBatchTaskView); } -listMessages(query: MessageQuery) { + listMessages(query: MessageQuery) { return this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), include: { @@ -38,10 +43,34 @@ listMessages(query: MessageQuery) { orderBy: { queuedAt: 'desc' }, }); } -async listMessagesPage(query: MessageQuery) { + private async messageFilter(query: MessageQuery) { + const where = messageWhere(query); + if (query.monitorSnapshotId) { + const snapshot = await this.prisma.sendingMonitorSnapshot.findUnique({ where: { id: query.monitorSnapshotId } }); + if ( + !snapshot || + (query.tenantId && + (snapshot.dimensions as { tenantId?: string }).tenantId && + (snapshot.dimensions as { tenantId?: string }).tenantId !== query.tenantId) + ) + throw new NotFoundException('监控快照不存在'); + if (snapshot.windowFrom.getTime() < Date.now() - 72 * 3600000) + throw new BadRequestException('该窗口已超过72小时样本保留期,历史快照仍可查询'); + where.monitorFacts = { + some: { + kind: snapshot.type === 'industry' ? 'attempt' : 'business', + dimensionKey: snapshot.dimensionKey, + submittedAt: { gte: snapshot.windowFrom, lt: snapshot.evaluationAt }, + ...(snapshot.type === 'verification' ? { verification: true } : {}), + }, + }; + } + return where; + } + async listMessagesPage(query: MessageQuery) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25))); - const where = messageWhere(query); + const where = await this.messageFilter(query); const [items, total] = await Promise.all([ this.prisma.smsMessageRecord.findMany({ where, @@ -75,7 +104,7 @@ async listMessagesPage(query: MessageQuery) { return { items, total, page, pageSize }; } -async getMessage(id: string) { + async getMessage(id: string) { const item = await this.prisma.smsMessageRecord.findUnique({ where: { id }, include: { @@ -121,9 +150,10 @@ async getMessage(id: string) { if (!item) throw new NotFoundException('Message record not found'); return item; } -async exportMessages(query: MessageQuery) { + async exportMessages(query: MessageQuery) { + const where = await this.messageFilter(query); const items = await this.prisma.smsMessageRecord.findMany({ - where: messageWhere(query), + where, select: { messageId: true, queuedAt: true, @@ -144,7 +174,22 @@ async exportMessages(query: MessageQuery) { orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], }); const rows = [ - ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'], + [ + '消息编号', + '企业', + '应用', + '提交时间', + '手机号', + '地区', + '运营商', + '计费条数', + '金额', + '通道', + '状态', + '是否含引流', + '回执时间', + '短信内容', + ], ...items.map((item) => [ item.messageId, item.tenant?.name ?? '', @@ -156,7 +201,9 @@ async exportMessages(query: MessageQuery) { String(item.billingUnits), String(moneyToNumber(item.amountCents)), item.channel?.name ?? '', - item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status, + item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') + ? 'submit_failed' + : item.status, item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测', item.deliveredAt?.toISOString() ?? '', item.content, @@ -167,11 +214,11 @@ async exportMessages(query: MessageQuery) { content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'), }; } -async listClientMessages(query: MessageQuery) { + async listClientMessages(query: MessageQuery) { const items = await this.listMessages(query); return items.map(clientMessageView); } -async listClientMessagesPage(query: MessageQuery) { + async listClientMessagesPage(query: MessageQuery) { const result = await this.listMessagesPage(query); return { ...result, items: result.items.map(clientMessageView) }; } diff --git a/api/src/report-notifications/report-notifications.module.ts b/api/src/report-notifications/report-notifications.module.ts new file mode 100644 index 0000000..4fe65d3 --- /dev/null +++ b/api/src/report-notifications/report-notifications.module.ts @@ -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) { + const items = await tx.$queryRaw>>(Prisma.sql` + SELECT h.*,COALESCE(r.revision,0)>(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 {} diff --git a/api/src/report-notifications/report-notifications.spec.ts b/api/src/report-notifications/report-notifications.spec.ts new file mode 100644 index 0000000..4c7f37d --- /dev/null +++ b/api/src/report-notifications/report-notifications.spec.ts @@ -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); + }); +}); diff --git a/api/src/send-chain/gateway-wire-timing.spec.ts b/api/src/send-chain/gateway-wire-timing.spec.ts new file mode 100644 index 0000000..627052a --- /dev/null +++ b/api/src/send-chain/gateway-wire-timing.spec.ts @@ -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(); + }); +}); diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts index 0c163d5..155309d 100644 --- a/api/src/send-chain/send-chain.contracts.ts +++ b/api/src/send-chain/send-chain.contracts.ts @@ -70,6 +70,9 @@ export interface GatewaySubmitResultDto { errorCode?: string; errorMessage?: string; submittedAt?: string; + firstWireSubmitAt?: string; + wireTimeSource?: string; + receiptRequested?: boolean; segments?: Array<{ segmentTotal?: number; segmentIndex?: number; @@ -79,6 +82,9 @@ export interface GatewaySubmitResultDto { errorCode?: string; errorMessage?: string; submittedAt?: string; + firstWireSubmitAt?: string; + wireTimeSource?: string; + receiptRequested?: boolean; }>; } @@ -96,6 +102,9 @@ export interface GatewaySubmitSegmentResultDto { errorCode?: string; errorMessage?: string; submittedAt?: string; + firstWireSubmitAt?: string; + wireTimeSource?: string; + receiptRequested?: boolean; } export interface GatewayReceiptEventDto { diff --git a/api/src/send-chain/send-gateway-result.service.ts b/api/src/send-chain/send-gateway-result.service.ts index c8b9763..e825bb2 100644 --- a/api/src/send-chain/send-gateway-result.service.ts +++ b/api/src/send-chain/send-gateway-result.service.ts @@ -1,20 +1,36 @@ -import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { createHash } from 'node:crypto'; +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; + import { BillingService } from '../billing/billing.service'; -import { moneyToNumber } from '../common/money'; + import type { OpenApiService } from '../open-api/open-api.service'; import { PrismaService } from '../prisma/prisma.service'; -import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; -import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; -import type { SendSubmissionService } from './send-submission.service'; -import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; +import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto } from './send-chain.contracts'; +import { normalizeSubmitStatus } from './send-chain.helpers'; +import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; /** * R10 gatewayResult implementation. * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. */ +export function wireTiming( + data: { firstWireSubmitAt?: string; wireTimeSource?: string; receiptRequested?: boolean }, + previous?: Date | null, +): { firstWireSubmitAt?: Date; wireTimeSource?: string; receiptRequested?: boolean } { + const receipt = typeof data.receiptRequested === 'boolean' ? { receiptRequested: data.receiptRequested } : {}; + if (!data.firstWireSubmitAt) + return data.wireTimeSource ? { ...receipt, wireTimeSource: data.wireTimeSource } : receipt; + const at = new Date(data.firstWireSubmitAt); + if (!Number.isFinite(at.getTime()) || data.wireTimeSource !== 'gateway_write_complete') { + throw new BadRequestException('Invalid Gateway wire timestamp'); + } + return { + ...receipt, + firstWireSubmitAt: previous && previous < at ? previous : at, + wireTimeSource: data.wireTimeSource, + }; +} + export class SendGatewayResultService { private readonly logger = new Logger('SendChainService'); @@ -31,27 +47,39 @@ export class SendGatewayResultService { const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data); const effectiveSubmitId = submitRecord.submitId; const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date(); - await this.facade.recordSubmitSegments(message, { - messageId: data.messageId, - channelId: data.channelId, - submitId: effectiveSubmitId, - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId ?? '', - submitStatus: normalizeSubmitStatus(data.submitStatus), - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt: submittedAt.toISOString(), - segments: [{ - segmentTotal: data.segmentTotal, - segmentIndex: data.segmentIndex, + await this.facade.recordSubmitSegments( + message, + { + messageId: data.messageId, + channelId: data.channelId, + submitId: effectiveSubmitId, sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - submitStatus: data.submitStatus, + gatewayMessageId: data.gatewayMessageId ?? '', + submitStatus: normalizeSubmitStatus(data.submitStatus), errorCode: data.errorCode, errorMessage: data.errorMessage, submittedAt: submittedAt.toISOString(), - }], - }, submittedAt); + firstWireSubmitAt: data.firstWireSubmitAt, + wireTimeSource: data.wireTimeSource, + receiptRequested: data.receiptRequested, + segments: [ + { + segmentTotal: data.segmentTotal, + segmentIndex: data.segmentIndex, + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + submitStatus: data.submitStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt: submittedAt.toISOString(), + firstWireSubmitAt: data.firstWireSubmitAt, + wireTimeSource: data.wireTimeSource, + receiptRequested: data.receiptRequested, + }, + ], + }, + submittedAt, + ); if (data.gatewayMessageId) { await this.prisma.smsSubmitRecord.updateMany({ where: { @@ -68,10 +96,7 @@ export class SendGatewayResultService { return { accepted: true }; } - async resolveSubmitRecordForGatewaySegmentResult( - messageRecordId: string, - data: GatewaySubmitSegmentResultDto, - ) { + async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) { if (data.submitId) { const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId }, @@ -81,13 +106,15 @@ export class SendGatewayResultService { (exact.messageRecordId && exact.messageRecordId !== messageRecordId) || (exact.channelId && exact.channelId !== data.channelId) ) { - this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - submitId: data.submitId, - channelId: data.channelId, - segmentIndex: data.segmentIndex, - })}`); + this.logger.error( + `gateway_submit_segment_result_unmatched ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + submitId: data.submitId, + channelId: data.channelId, + segmentIndex: data.segmentIndex, + })}`, + ); throw new BadRequestException( 'Gateway SubmitSegmentResult submitId does not match the SMS message and channel', ); @@ -104,24 +131,26 @@ export class SendGatewayResultService { take: 2, }); if (candidates.length !== 1) { - this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({ + this.logger.error( + `gateway_submit_segment_result_unmatched ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + channelId: data.channelId, + segmentIndex: data.segmentIndex, + candidateCount: candidates.length, + })}`, + ); + throw new BadRequestException('Gateway SubmitSegmentResult without submitId cannot be matched uniquely'); + } + this.logger.warn( + `gateway_submit_segment_result_legacy_match ${JSON.stringify({ messageId: data.messageId, messageRecordId, channelId: data.channelId, segmentIndex: data.segmentIndex, - candidateCount: candidates.length, - })}`); - throw new BadRequestException( - 'Gateway SubmitSegmentResult without submitId cannot be matched uniquely', - ); - } - this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - channelId: data.channelId, - segmentIndex: data.segmentIndex, - submitId: candidates[0].submitId, - })}`); + submitId: candidates[0].submitId, + })}`, + ); return candidates[0]; } @@ -148,6 +177,7 @@ export class SendGatewayResultService { errorCode: data.errorCode, errorMessage: data.errorMessage, submittedAt, + ...wireTiming(data, submitRecord.firstWireSubmitAt), }, }); await this.facade.recordSubmitSegments(message, effectiveData, submittedAt); @@ -156,7 +186,8 @@ export class SendGatewayResultService { await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt); return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } - const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed'; + const status = + data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed'; if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; await this.facade.chargeAcceptedMessage(businessMessage); @@ -164,7 +195,12 @@ export class SendGatewayResultService { if (latest?.status === 'failed') { await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款'); } - } else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { + } else if ( + data.submitStatus !== 'accepted' && + !isStandaloneChannelTest && + message.tenantId && + message.batchTaskId + ) { const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; const retried = await this.facade.retryMessageIfAllowed( businessMessage, @@ -176,13 +212,17 @@ export class SendGatewayResultService { await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt); return retried; } - await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结'); + await this.facade.releaseMessageReservation( + businessMessage, + data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结', + ); } const protectedTerminalStatuses = ['delivered', 'failed', 'unknown']; const updated = await this.prisma.smsMessageRecord.updateMany({ - where: data.submitStatus === 'accepted' - ? { id: message.id, status: { notIn: protectedTerminalStatuses } } - : { id: message.id, status: { not: 'delivered' } }, + where: + data.submitStatus === 'accepted' + ? { id: message.id, status: { notIn: protectedTerminalStatuses } } + : { id: message.id, status: { not: 'delivered' } }, data: { gatewayMessageId: data.gatewayMessageId, submitStatus: data.submitStatus, @@ -202,7 +242,12 @@ export class SendGatewayResultService { }, }); } - if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) { + if ( + data.submitStatus !== 'accepted' && + batchTask?.sourceType === 'cmpp' && + message.tenantId && + message.applicationId + ) { await this.facade.recordCmppFailureReceipt( message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string }, data.errorCode || 'SUBMIT', @@ -212,10 +257,9 @@ export class SendGatewayResultService { await this.prisma.gatewaySubmitDeadLetter.updateMany({ where: { status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, - OR: [ - { submitId: effectiveData.submitId }, - data.messageId ? { messageId: data.messageId } : undefined, - ].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>, + OR: [{ submitId: effectiveData.submitId }, data.messageId ? { messageId: data.messageId } : undefined].filter( + Boolean, + ) as Array<{ submitId?: string; messageId?: string }>, }, data: { status: 'resolved', @@ -252,9 +296,11 @@ export class SendGatewayResultService { async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) { if (data.submitId) { const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } }); - if (!exact - || (exact.messageRecordId && exact.messageRecordId !== messageRecordId) - || (exact.channelId && exact.channelId !== data.channelId)) { + if ( + !exact || + (exact.messageRecordId && exact.messageRecordId !== messageRecordId) || + (exact.channelId && exact.channelId !== data.channelId) + ) { throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel'); } return exact; @@ -272,34 +318,40 @@ export class SendGatewayResultService { take: 2, }); if (candidates.length !== 1) { - this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({ + this.logger.error( + `gateway_submit_result_unmatched ${JSON.stringify({ + messageId: data.messageId, + messageRecordId, + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + candidateCount: candidates.length, + })}`, + ); + throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely'); + } + this.logger.warn( + `gateway_submit_result_legacy_match ${JSON.stringify({ messageId: data.messageId, messageRecordId, channelId: data.channelId, gatewayMessageId: data.gatewayMessageId, - candidateCount: candidates.length, - })}`); - throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely'); - } - this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({ - messageId: data.messageId, - messageRecordId, - channelId: data.channelId, - gatewayMessageId: data.gatewayMessageId, - submitId: candidates[0].submitId, - })}`); + submitId: candidates[0].submitId, + })}`, + ); return candidates[0]; } smsMessageSegmentAuditDelegate() { - return (this.prisma as PrismaService & { - smsMessageSegmentAudit: { - upsert: (args: Record) => Promise; - updateMany: (args: Record) => Promise<{ count: number }>; - findFirst: (args: Record) => Promise; - findMany: (args: Record) => Promise; - }; - }).smsMessageSegmentAudit; + return ( + this.prisma as PrismaService & { + smsMessageSegmentAudit: { + upsert: (args: Record) => Promise; + updateMany: (args: Record) => Promise<{ count: number }>; + findFirst: (args: Record) => Promise; + findMany: (args: Record) => Promise; + }; + } + ).smsMessageSegmentAudit; } async recordSubmitSegments( @@ -327,74 +379,84 @@ export class SendGatewayResultService { }); const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`; const attempt = submitRecord - ? Math.max(0, await this.prisma.smsSubmitRecord.count({ - where: { - messageRecordId: message.id, - createdAt: { lte: submitRecord.createdAt }, - }, - }) - 1) + ? Math.max( + 0, + (await this.prisma.smsSubmitRecord.count({ + where: { + messageRecordId: message.id, + createdAt: { lte: submitRecord.createdAt }, + }, + })) - 1, + ) : 0; - const fallbackSegments = [{ - segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), - segmentIndex: 1, - sequenceId: data.sequenceId, - gatewayMessageId: data.gatewayMessageId, - submitStatus: data.submitStatus, - errorCode: data.errorCode, - errorMessage: data.errorMessage, - submittedAt: data.submittedAt, - }]; + const fallbackSegments = [ + { + firstWireSubmitAt: data.firstWireSubmitAt, + wireTimeSource: data.wireTimeSource, + receiptRequested: data.receiptRequested, + segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), + segmentIndex: 1, + sequenceId: data.sequenceId, + gatewayMessageId: data.gatewayMessageId, + submitStatus: data.submitStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + submittedAt: data.submittedAt, + }, + ]; const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments; const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1))); - await Promise.all(segments.map((segment, index) => { - const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1)); - const status = segment.submitStatus ?? data.submitStatus; - return segmentAudits.upsert({ - where: { - messageRecordId_submitId_segmentIndex: { - messageRecordId: message.id, - submitId, - segmentIndex, + await Promise.all( + segments.map((segment, index) => { + const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1)); + const status = segment.submitStatus ?? data.submitStatus; + return segmentAudits.upsert({ + where: { + messageRecordId_submitId_segmentIndex: { + messageRecordId: message.id, + submitId, + segmentIndex, + }, }, - }, - update: { - submitRecordId: submitRecord?.id ?? null, - channelId: data.channelId ?? message.channelId ?? null, - attempt, - segmentTotal, - sequenceId: segment.sequenceId ?? data.sequenceId ?? null, - gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, - submitStatus: status, - errorCode: segment.errorCode ?? data.errorCode ?? null, - errorMessage: segment.errorMessage ?? data.errorMessage ?? null, - submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, - }, - create: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - submitRecordId: submitRecord?.id ?? null, - channelId: data.channelId ?? message.channelId ?? null, - submitId, - attempt, - segmentTotal, - segmentIndex, - sequenceId: segment.sequenceId ?? data.sequenceId ?? null, - gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, - submitStatus: status, - compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null, - errorCode: segment.errorCode ?? data.errorCode ?? null, - errorMessage: segment.errorMessage ?? data.errorMessage ?? null, - submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, - }, - }); - })); + update: { + ...wireTiming(segment), + submitRecordId: submitRecord?.id ?? null, + channelId: data.channelId ?? message.channelId ?? null, + attempt, + segmentTotal, + sequenceId: segment.sequenceId ?? data.sequenceId ?? null, + gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, + submitStatus: status, + errorCode: segment.errorCode ?? data.errorCode ?? null, + errorMessage: segment.errorMessage ?? data.errorMessage ?? null, + submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, + }, + create: { + ...wireTiming(segment), + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + submitRecordId: submitRecord?.id ?? null, + channelId: data.channelId ?? message.channelId ?? null, + submitId, + attempt, + segmentTotal, + segmentIndex, + sequenceId: segment.sequenceId ?? data.sequenceId ?? null, + gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null, + submitStatus: status, + compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null, + errorCode: segment.errorCode ?? data.errorCode ?? null, + errorMessage: segment.errorMessage ?? data.errorMessage ?? null, + submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt, + }, + }); + }), + ); } async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { - const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter( - Boolean, - ) as Array<{ + const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(Boolean) as Array<{ messageId?: string; gatewayMessageId?: string; }>; diff --git a/api/src/send-chain/send-receipt.service.ts b/api/src/send-chain/send-receipt.service.ts index 104b423..7a4d82e 100644 --- a/api/src/send-chain/send-receipt.service.ts +++ b/api/src/send-chain/send-receipt.service.ts @@ -1,17 +1,26 @@ -import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { createHash } from 'node:crypto'; + import { BillingService } from '../billing/billing.service'; -import { moneyToNumber } from '../common/money'; + import type { OpenApiService } from '../open-api/open-api.service'; import { PrismaService } from '../prisma/prisma.service'; -import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts'; -import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey, longMessageReceiptMode } from './send-chain.helpers'; -import type { SendSubmissionService } from './send-submission.service'; +import type { GatewayReceiptEventDto } from './send-chain.contracts'; +import { + positiveInteger, + normalizeReceiptStatus, + DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, + DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, + aggregateReceiptSegmentState, + isSameUpstreamEndpointIdentity, + receiptEventKey, + longMessageReceiptMode, +} from './send-chain.helpers'; + import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; import { queueFinalReceiptDeliveries } from './downstream-receipt-targets'; - /** * R10 receipt implementation. * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. @@ -68,6 +77,7 @@ export class SendReceiptService { errorCode: data.errorCode, errorMessage: data.errorMessage, deliveredAt, + gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null, status: 'pending', nextRetryAt: new Date(), }, @@ -81,8 +91,8 @@ export class SendReceiptService { async processPendingUpstreamReceiptInbox(limit = 100) { const now = new Date(); const staleBefore = new Date( - now.getTime() - - positiveInteger( + now.getTime() - + positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, ), @@ -110,8 +120,8 @@ export class SendReceiptService { async processUpstreamReceiptInboxRecord(id: string) { const staleBefore = new Date( - Date.now() - - positiveInteger( + Date.now() - + positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, ), @@ -119,10 +129,7 @@ export class SendReceiptService { const claimed = await this.prisma.upstreamReceiptInbox.updateMany({ where: { id, - OR: [ - { status: { in: ['pending', 'retrying'] } }, - { status: 'processing', updatedAt: { lte: staleBefore } }, - ], + OR: [{ status: { in: ['pending', 'retrying'] } }, { status: 'processing', updatedAt: { lte: staleBefore } }], }, data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null }, }); @@ -130,25 +137,28 @@ export class SendReceiptService { const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } }); if (!inbox) return false; try { - const message = await this.facade.handleReceipt({ - messageId: inbox.provisionalMessageId ?? undefined, - channelId: inbox.incomingChannelId, - connectionId: inbox.incomingConnectionId ?? undefined, - sequenceId: inbox.sequenceId ?? undefined, - gatewayMessageId: inbox.gatewayMessageId, - phoneNumber: inbox.phoneNumber ?? undefined, - receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), - rawStatus: inbox.rawStatus, - errorCode: inbox.errorCode ?? undefined, - errorMessage: inbox.errorMessage ?? undefined, - deliveredAt: inbox.deliveredAt.toISOString(), - }, { - account: inbox.upstreamAccount, - gatewayHost: inbox.upstreamHost, - gatewayPort: inbox.upstreamPort, - protocol: inbox.protocol, - cmppVersion: inbox.protocolVersion, - }); + const message = await this.facade.handleReceipt( + { + messageId: inbox.provisionalMessageId ?? undefined, + channelId: inbox.incomingChannelId, + connectionId: inbox.incomingConnectionId ?? undefined, + sequenceId: inbox.sequenceId ?? undefined, + gatewayMessageId: inbox.gatewayMessageId, + phoneNumber: inbox.phoneNumber ?? undefined, + receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), + rawStatus: inbox.rawStatus, + errorCode: inbox.errorCode ?? undefined, + errorMessage: inbox.errorMessage ?? undefined, + deliveredAt: inbox.deliveredAt.toISOString(), + }, + { + account: inbox.upstreamAccount, + gatewayHost: inbox.upstreamHost, + gatewayPort: inbox.upstreamPort, + protocol: inbox.protocol, + cmppVersion: inbox.protocolVersion, + }, + ); const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId; const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined; await this.prisma.upstreamReceiptInbox.update({ @@ -171,8 +181,8 @@ export class SendReceiptService { process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, ); - const exhausted = inbox.attemptCount >= maxAttempts - || inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000; + const exhausted = + inbox.attemptCount >= maxAttempts || inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000; const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8)); await this.prisma.upstreamReceiptInbox.update({ where: { id }, @@ -204,7 +214,13 @@ export class SendReceiptService { async handleReceipt( data: GatewayReceiptEventDto, - incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + incomingIdentity?: { + account: string; + gatewayHost: string; + gatewayPort: number; + protocol: string; + cmppVersion: string; + }, ) { const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity); const logicalChannelId = resolved.channelId ?? data.channelId; @@ -263,9 +279,8 @@ export class SendReceiptService { } const logicalReceipt = { ...data, channelId: logicalChannelId }; await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId); - const receiptMode = Number(message.billingUnits ?? 1) > 1 - ? await this.getLongMessageReceiptMode(logicalChannelId) - : 'per_segment'; + const receiptMode = + Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment'; if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') { await this.applyMessageLevelSuccess( message, @@ -287,12 +302,10 @@ export class SendReceiptService { } const status = aggregate.status; const isCurrentAttempt = - (!message.channelId || message.channelId === logicalChannelId) - && ( - !message.gatewayMessageId - || message.gatewayMessageId === data.gatewayMessageId - || (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId)) - ); + (!message.channelId || message.channelId === logicalChannelId) && + (!message.gatewayMessageId || + message.gatewayMessageId === data.gatewayMessageId || + (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))); if (!isCurrentAttempt) { return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } @@ -314,11 +327,7 @@ export class SendReceiptService { const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; - const retried = await this.facade.retryMessageIfAllowed( - businessMessage, - '回执失败补发', - resolved.submitRecordId, - ); + const retried = await this.facade.retryMessageIfAllowed(businessMessage, '回执失败补发', resolved.submitRecordId); if (retried) { await this.facade.refreshTaskProgress(businessMessage.batchTaskId); return retried; @@ -339,32 +348,31 @@ export class SendReceiptService { }, }); if (!isStandaloneChannelTest && message.tenantId && message.applicationId) { - await queueFinalReceiptDeliveries( - this.prisma, - (request) => this.facade.queueAndTryDownstreamDelivery(request), - { - message, - payload: { - messageId: message.messageId, - gatewayMessageId: data.gatewayMessageId, - phoneNumber: message.phoneNumber, - receiptStatus: aggregate.receiptStatus, - rawStatus: aggregate.rawStatus, - errorCode: aggregate.errorCode, - deliveredAt: aggregate.deliveredAt.toISOString(), - }, - segmentPayloads: Object.fromEntries( - aggregate.segments - .filter((segment) => segment.receiptStatus) - .map((segment) => [segment.segmentIndex, { + await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), { + message, + payload: { + messageId: message.messageId, + gatewayMessageId: data.gatewayMessageId, + phoneNumber: message.phoneNumber, + receiptStatus: aggregate.receiptStatus, + rawStatus: aggregate.rawStatus, + errorCode: aggregate.errorCode, + deliveredAt: aggregate.deliveredAt.toISOString(), + }, + segmentPayloads: Object.fromEntries( + aggregate.segments + .filter((segment) => segment.receiptStatus) + .map((segment) => [ + segment.segmentIndex, + { receiptStatus: segment.receiptStatus, rawStatus: segment.rawStatus, errorCode: segment.errorCode, deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(), - }]), - ), - }, - ); + }, + ]), + ), + }); } if (message.batchTaskId) { await this.facade.refreshTaskProgress(message.batchTaskId); @@ -388,8 +396,9 @@ export class SendReceiptService { submitRecordId?: string, submitId?: string, ) { - const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId) - && (!message.submitId || message.submitId === submitId); + const belongsToCurrentAttempt = + (!message.channelId || message.channelId === data.channelId) && + (!message.submitId || message.submitId === submitId); if (!belongsToCurrentAttempt) return; const attemptWhere = submitRecordId ? { messageRecordId: message.id, submitRecordId } @@ -402,7 +411,9 @@ export class SendReceiptService { select: { id: true, receiptStatus: true }, }); if (segments.length <= 1) return; - if (segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))) { + if ( + segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus)) + ) { return; } // This supplier contract reports one message-level success for a multipart SMS. @@ -506,9 +517,9 @@ export class SendReceiptService { const submitRecord = submitRecordId ? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } }) : await this.prisma.smsSubmitRecord.findFirst({ - where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, - orderBy: { createdAt: 'desc' }, - }); + where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, + orderBy: { createdAt: 'desc' }, + }); await segmentAudits.upsert({ where: { messageRecordId_submitId_segmentIndex: { @@ -575,7 +586,13 @@ export class SendReceiptService { async resolveReceiptMessage( data: GatewayReceiptEventDto, - incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string }, + incomingIdentity?: { + account: string; + gatewayHost: string; + gatewayPort: number; + protocol: string; + cmppVersion: string; + }, ) { const exactMessage = data.messageId ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) @@ -641,8 +658,8 @@ export class SendReceiptService { throw new NotFoundException('SMS message record not found'); } - const incomingChannel = incomingIdentity - ?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); + const incomingChannel = + incomingIdentity ?? (await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } })); if (!incomingChannel) { throw new NotFoundException('SMS message record not found'); } @@ -665,8 +682,9 @@ export class SendReceiptService { channelId: exactSegmentMatches[0].channelId, }; } - const sameSupplierSegments = segmentMatches.filter((candidate) => - candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel)); + const sameSupplierSegments = segmentMatches.filter( + (candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel), + ); if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) { return { message: sameSupplierSegments[0].messageRecord, @@ -685,8 +703,9 @@ export class SendReceiptService { orderBy: { createdAt: 'desc' }, take: 10, }); - const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) => - candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel)); + const sameSupplierSubmits = crossConnectionSubmits.filter( + (candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel), + ); if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) { return { message: sameSupplierSubmits[0].messageRecord, diff --git a/api/src/sending-monitor-worker.ts b/api/src/sending-monitor-worker.ts new file mode 100644 index 0000000..ff5b319 --- /dev/null +++ b/api/src/sending-monitor-worker.ts @@ -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(); + 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( + (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; + }); diff --git a/api/src/sending-monitor/monitor-evaluation.ts b/api/src/sending-monitor/monitor-evaluation.ts new file mode 100644 index 0000000..e1aae82 --- /dev/null +++ b/api/src/sending-monitor/monitor-evaluation.ts @@ -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>'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(); + 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(`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)], + ); +} diff --git a/api/src/sending-monitor/monitor-metrics.spec.ts b/api/src/sending-monitor/monitor-metrics.spec.ts new file mode 100644 index 0000000..3938a38 --- /dev/null +++ b/api/src/sending-monitor/monitor-metrics.spec.ts @@ -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(); + }); +}); diff --git a/api/src/sending-monitor/monitor-metrics.ts b/api/src/sending-monitor/monitor-metrics.ts new file mode 100644 index 0000000..1556183 --- /dev/null +++ b/api/src/sending-monitor/monitor-metrics.ts @@ -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); +} diff --git a/api/src/sending-monitor/monitor-projection.ts b/api/src/sending-monitor/monitor-projection.ts new file mode 100644 index 0000000..e9ee87d --- /dev/null +++ b/api/src/sending-monitor/monitor-projection.ts @@ -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(); + 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; +} diff --git a/api/src/sending-monitor/sending-monitor.module.ts b/api/src/sending-monitor/sending-monitor.module.ts new file mode 100644 index 0000000..8c350c1 --- /dev/null +++ b/api/src/sending-monitor/sending-monitor.module.ts @@ -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; +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>( + `SELECT count(*)::int total FROM "SendingMonitorSnapshot" s WHERE ${filter}`, + ...args, + ), + this.prisma.$queryRawUnsafe>( + `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>( + `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( + `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>( + `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>( + `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( + `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>( + `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 {} diff --git a/docs/contracts/gateway-queue-messages.schema.json b/docs/contracts/gateway-queue-messages.schema.json index 1f9bdee..f9124c1 100644 --- a/docs/contracts/gateway-queue-messages.schema.json +++ b/docs/contracts/gateway-queue-messages.schema.json @@ -130,6 +130,9 @@ "submitStatus": { "enum": ["accepted", "rejected", "timeout"] }, "errorCode": { "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" }, "segments": { "type": "array", @@ -144,7 +147,10 @@ "submitStatus": { "enum": ["accepted", "rejected", "timeout"] }, "errorCode": { "type": "string" }, "errorMessage": { "type": "string" }, - "submittedAt": { "type": "string", "format": "date-time" } + "firstWireSubmitAt": { "type": "string", "format": "date-time" }, + "wireTimeSource": { "enum": ["not_written", "write_uncertain", "gateway_write_complete"] }, + "receiptRequested": { "type": "boolean" }, + "submittedAt": { "type": "string", "format": "date-time" } } } } diff --git a/docs/production-deployment.md b/docs/production-deployment.md index da4594e..6f1f4d5 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -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`;正常验证码登录和退出已验证。该账号仅在测试环境创建,预生产不适用。 安全认证入口:工作站`C:\Users\hectorzhao\.config\cmpp-qa\test-admin.json`,仅当前Windows用户及SYSTEM可访问;测试机备份入口`/home/hector/.config/cmpp-qa/test-admin.json`,目录0700、文件0600、所有者hector。文件保存随机生成密码和登录地址,禁止打印内容、提交Git或将密码写进命令、截图和报告。后续通过既有SSH安全入口读取到内存或使用本机受限文件完成正常登录;账号和凭据按用户要求保留,测试结束退出会话即可。使用前重新核验账号有效性,不覆盖已有账号、不因认证失败重置其他管理员;账号存在不构成发送短信、改余额或通道/客户配置的授权。 + +## 发送质量监控与报备消息发布(2026-09-06) + +本轮仅授权测试环境。候选版本新增20260906170000_report_readiness_notifications及20260906171000_sending_monitor两项兼容迁移;新增表/索引/报备触发器、Gateway可选时间元数据,以及独立cmpp-sending-monitor服务。默认规则和行业纳管均为空,不能套用原型阈值。 + +cmpp-sending-monitor以cmpp-api:cmpp-security运行,TZ=UTC,PostgreSQL池上限2、单实例排他锁、5秒采集;读取已有持久事实,不发送短信、不新增发送链路同步依赖。工作目录api,入口dist/sending-monitor-worker.js,日志logs/sending-monitor,目录0750。标准发布脚本已安装并启用该服务;存量部署按本轮范围建立相同单元,保留已有存储及安全drop-in,不为本功能运行初始化/账号重置/存储配置脚本。 + +发布前分别核对迁移、存储、DB连接余量、近72小时尝试量和磁盘预算,备份新触发器上线前的数据库。事实72小时、分钟7天、快照30天、关闭告警90天;当前测试机零近期发送、24GB可用空间适用于此次测试准入,不证明500TPS或预生产容量达标。后续高峰上线须按方案容量和CPU/IO/延迟预算实测。 + +发布顺序:独立候选归档构建→迁移→切换受保护的原运行目录→callback(已启用时)→Gateway健康→API健康→既有Worker→新监控Worker。日志、队列、对象存储和环境沿用;不重启无关存储、不清理历史恢复点、不手工ACK/补发。验证健康检查点更新、规则为空、真实分页API、双端消息页、三尺寸、匿名/越权拒绝及队列基线。应用回退时先停止新Worker,旧代码忽略新增字段;兼容新增表/触发器可保留,数据回退另行评估,不直接覆盖库。 diff --git a/docs/prototypes/sending-monitor-20260906/01-industry-monitor.png b/docs/prototypes/sending-monitor-20260906/01-industry-monitor.png new file mode 100644 index 0000000..ac77ad9 Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/01-industry-monitor.png differ diff --git a/docs/prototypes/sending-monitor-20260906/01-industry-monitor.svg b/docs/prototypes/sending-monitor-20260906/01-industry-monitor.svg new file mode 100644 index 0000000..386556d --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/01-industry-monitor.svg @@ -0,0 +1,184 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + +聆界短信管理平台 +平台运营工作区 +运营概览 + +运营看板 + + + +发送监控 + +网关异常 + +签名质量检测 +客户管理 + +企业管理 + +企业应用管理 + +企业签名管理 + +企业模板管理 +短信业务 + +短信通道 + +短信记录 + +任务进度 +系统管理 + +用户管理 + +系统监控 +设计原型 · 非真实业务数据 + + +运营概览 / 发送监控 + +预警中心 6 + +待审核任务 + +报备任务提醒 +管理员 +发送监控 +监测时效变化,定位异常通道与应用签名 + +静态示例 · 待实施 +行业通道 + +验证码 +整体兜底 +告警记录 + + +以平台收到成功回执为准;未满观察时长的短信不参与该指标评估。 + +刷新 + +阈值设置 + +监控通道 + +监控维度 +16 +通道 × 运营商 + +异常维度 +2 +点击仅看异常 + +样本不足 +3 +不足最低成熟条数 + +窗口提交量 +3,458 +次发送尝试 + +统计窗口 +12:25 – 12:30 +评估时刻 12:30:00 +初评 · 12:31补齐 +更新于 12:30:18 +查看最近2小时趋势 + +搜索通道 / 编号 + +运营商:全部 + +状态:全部 + + +通道 / 运营商 +窗口提交 +5秒到达率 +20秒到达率 +1分钟到达率 +状态 / 规则 +操作 + +行业通道 A +CH-001 · 移动 +1,280 +83.97% +1,058 / 1,260 成熟 +94.00% +1,128 / 1,200 成熟 +97.00% +1,067 / 1,100 成熟 + +异常 +通用规则 +趋势 / 详情 + +行业通道 A +CH-001 · 联通 +920 +97.11% +874 / 900 成熟 +97.27% +856 / 880 成熟 +99.15% +813 / 820 成熟 + +正常 +通用规则 +趋势 / 详情 + +行业通道 B +CH-002 · 电信 +760 +91.89% +680 / 740 成熟 +95.14% +666 / 700 成熟 +98.46% +640 / 650 成熟 + +正常 +通用规则 +趋势 / 详情 + +行业通道 C +CH-003 · 移动 +68 +93.33% +56 / 60 成熟 +96.36% +53 / 55 成熟 +97.83% +45 / 46 成熟 + +样本不足 +通用规则 +趋势 / 详情 + + +行业通道 D +CH-004 · 联通 +430 +73.81% +310 / 420 成熟 +90.50% +362 / 400 成熟 +96.67% +348 / 360 成熟 + +异常 +通用规则 +趋势 / 详情 + +每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。 +展示5行 ‹ 1 2 3 › +行业通道监控 · 图中所有数值仅用于需求评审 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/02-verification-monitor.png b/docs/prototypes/sending-monitor-20260906/02-verification-monitor.png new file mode 100644 index 0000000..668d046 Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/02-verification-monitor.png differ diff --git a/docs/prototypes/sending-monitor-20260906/02-verification-monitor.svg b/docs/prototypes/sending-monitor-20260906/02-verification-monitor.svg new file mode 100644 index 0000000..0fc0c34 --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/02-verification-monitor.svg @@ -0,0 +1,180 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + +聆界短信管理平台 +平台运营工作区 +运营概览 + +运营看板 + + + +发送监控 + +网关异常 + +签名质量检测 +客户管理 + +企业管理 + +企业应用管理 + +企业签名管理 + +企业模板管理 +短信业务 + +短信通道 + +短信记录 + +任务进度 +系统管理 + +用户管理 + +系统监控 +设计原型 · 非真实业务数据 + + +运营概览 / 发送监控 + +预警中心 6 + +待审核任务 + +报备任务提醒 +管理员 +发送监控 +监测时效变化,定位异常通道与应用签名 + +静态示例 · 待实施 +行业通道 +验证码 + +整体兜底 +告警记录 + + +仅统计最终正文包含“验证码”的业务短信;补发不重复计数,以成功回执为准。 + +刷新 + +阈值设置 + +运行概况 + +监控维度 +48 +企业应用 × 签名 + +异常维度 +1 +点击仅看异常 + +样本不足 +3 +不足最低成熟条数 + +窗口提交量 +3,028 +条业务短信 + +统计窗口 +12:25 – 12:30 +评估时刻 12:30:00 +初评 · 12:31补齐 +更新于 12:30:18 +查看最近2小时趋势 + +企业 / 应用 + +搜索签名 + +状态:全部 + + +企业应用 / 签名 +窗口提交 +5秒到达率 +20秒到达率 +1分钟到达率 +状态 / 规则 +操作 + +示例零售 · 登录应用 +【示例商城】 +1,280 +83.97% +1,058 / 1,260 成熟 +94.00% +1,128 / 1,200 成熟 +97.00% +1,067 / 1,100 成熟 + +异常 +验证码通用 +趋势 / 详情 + +示例服务 · 用户中心 +【示例服务】 +920 +97.11% +874 / 900 成熟 +97.27% +856 / 880 成熟 +99.15% +813 / 820 成熟 + +正常 +验证码通用 +趋势 / 详情 + +示例物流 · 商户应用 +【示例物流】 +760 +91.89% +680 / 740 成熟 +95.14% +666 / 700 成熟 +98.46% +640 / 650 成熟 + +正常 +验证码通用 +趋势 / 详情 + +示例教育 · 学员登录 +【示例学堂】 +68 +93.33% +56 / 60 成熟 +96.36% +53 / 55 成熟 +97.83% +45 / 46 成熟 + +样本不足 +验证码通用 +趋势 / 详情 + +示例平台 · 会员应用 +【示例会员】 + +— 待更新 +— 待更新 +— 待更新 + +数据延迟 +验证码通用 +趋势 / 详情 + +每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。 +展示5行 ‹ 1 2 3 › +验证码监控 · 图中所有数值仅用于需求评审 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/03-overall-monitor.png b/docs/prototypes/sending-monitor-20260906/03-overall-monitor.png new file mode 100644 index 0000000..2138c09 Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/03-overall-monitor.png differ diff --git a/docs/prototypes/sending-monitor-20260906/03-overall-monitor.svg b/docs/prototypes/sending-monitor-20260906/03-overall-monitor.svg new file mode 100644 index 0000000..fef311c --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/03-overall-monitor.svg @@ -0,0 +1,181 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + +聆界短信管理平台 +平台运营工作区 +运营概览 + +运营看板 + + + +发送监控 + +网关异常 + +签名质量检测 +客户管理 + +企业管理 + +企业应用管理 + +企业签名管理 + +企业模板管理 +短信业务 + +短信通道 + +短信记录 + +任务进度 +系统管理 + +用户管理 + +系统监控 +设计原型 · 非真实业务数据 + + +运营概览 / 发送监控 + +预警中心 6 + +待审核任务 + +报备任务提醒 +管理员 +发送监控 +监测时效变化,定位异常通道与应用签名 + +静态示例 · 待实施 +行业通道 +验证码 +整体兜底 + +告警记录 + + +近30分钟整体监控;1 / 5 / 20分钟分别使用成熟分母,不将新提交短信计作失败。 + +刷新 + +阈值设置 + +运行概况 + +监控维度 +48 +企业应用 × 签名 + +异常维度 +1 +点击仅看异常 + +样本不足 +3 +不足最低成熟条数 + +窗口提交量 +5,230 +条业务短信 + +统计窗口 +12:00 – 12:30 +评估时刻 12:30:00 +每10分钟评估 +更新于 12:30:18 +查看最近2小时趋势 + +企业 / 应用 + +搜索签名 + +状态:全部 + + +企业应用 / 签名 +窗口提交 +1分钟到达率 +5分钟到达率 +20分钟到达率 +状态 / 规则 +操作 +示例零售 · 通知应用 +【示例商城】 +300 +88.62% +257 / 290 成熟 +92.17% +212 / 230 成熟 +94.44% +85 / 90 成熟 + +样本不足 +应用×签名 +趋势 / 详情 + + +示例服务 · 业务应用 +【示例服务】 +2,500 +90.00% +2,115 / 2,350 成熟 +97.00% +1,940 / 2,000 成熟 +95.00% +950 / 1,000 成熟 + +异常 +签名规则 +趋势 / 详情 + +示例物流 · 商户应用 +【示例物流】 +1,800 +98.00% +1,666 / 1,700 成熟 +99.00% +1,485 / 1,500 成熟 +99.44% +895 / 900 成熟 + +正常 +应用规则 +趋势 / 详情 + +示例教育 · 通知应用 +【示例学堂】 +630 +93.00% +558 / 600 成熟 +95.60% +478 / 500 成熟 +99.17% +238 / 240 成熟 + +正常 +通用规则 +趋势 / 详情 + +示例平台 · 消息应用 +【示例会员】 + +— 待更新 +— 待更新 +— 待更新 + +数据延迟 +通用规则 +趋势 / 详情 + +每个指标独立校验成熟样本;百分比下方为成功数 / 可评估数。 +展示5行 ‹ 1 2 3 › +整体兜底监控 · 图中所有数值仅用于需求评审 +示例首行:20分钟指标85/90,最低100条,尚不触发告警;另有210条观察中。 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/04-threshold-rules.png b/docs/prototypes/sending-monitor-20260906/04-threshold-rules.png new file mode 100644 index 0000000..6be8746 Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/04-threshold-rules.png differ diff --git a/docs/prototypes/sending-monitor-20260906/04-threshold-rules.svg b/docs/prototypes/sending-monitor-20260906/04-threshold-rules.svg new file mode 100644 index 0000000..f011089 --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/04-threshold-rules.svg @@ -0,0 +1,137 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + +聆界短信管理平台 +平台运营工作区 +运营概览 + +运营看板 + + + +发送监控 + +网关异常 + +签名质量检测 +客户管理 + +企业管理 + +企业应用管理 + +企业签名管理 + +企业模板管理 +短信业务 + +短信通道 + +短信记录 + +任务进度 +系统管理 + +用户管理 + +系统监控 +设计原型 · 非真实业务数据 + + +运营概览 / 发送监控 + +预警中心 6 + +待审核任务 + +报备任务提醒 +管理员 +告警阈值设置 +整体兜底 · 通用规则与个性规则 + +静态示例 · 待实施 + +返回监控 + +生效优先级 + +应用×签名 > 签名 > 应用 > 通用 +命中最高优先级后,整套规则覆盖。 +规则列表 + +新增规则 + + +应用×签名 +通知应用 / 【示例商城】 +100条 · 85% / 90% / 95% + + +签名 +【示例服务】 +100条 · 90% / 95% / 98% + + +应用 +商户应用 +100条 · 90% / 95% / 98% + + +通用 +全部未覆盖对象 +100条 · 90% / 95% / 98% +实际生效示例 +示例零售 / 通知应用 / 【示例商城】 +采用应用×签名规则,覆盖下层通用设置。 + +编辑个性规则 + +草稿 · 未保存 +作用范围 + +企业应用 × 签名 +企业 / 应用 + +示例零售 / 通知应用 +签名 + +【示例商城】 + +最低成熟条数 + +100 +条 / 每项指标 +仅有样本数达到门槛的指标才参与告警。 + + + +1分钟到达率下限 + +85.00 % + + + +5分钟到达率下限 + +90.00 % + + + +20分钟到达率下限 + +95.00 % +连续异常 1 次触发 · 连续恢复 2 次关闭 +预计下周期生效;旧告警记录保留原规则版本。 + + +恢复继承 + +取消 + +保存规则 +交互说明:保存失败保留输入;版本冲突重新加载;未保存离开须确认。 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/05-channel-enrollment.png b/docs/prototypes/sending-monitor-20260906/05-channel-enrollment.png new file mode 100644 index 0000000..b73c0ef Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/05-channel-enrollment.png differ diff --git a/docs/prototypes/sending-monitor-20260906/05-channel-enrollment.svg b/docs/prototypes/sending-monitor-20260906/05-channel-enrollment.svg new file mode 100644 index 0000000..04b6196 --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/05-channel-enrollment.svg @@ -0,0 +1,82 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + +聆界短信管理平台 +平台运营工作区 +运营概览 + +运营看板 + + + +发送监控 + +网关异常 + +签名质量检测 +客户管理 + +企业管理 + +企业应用管理 + +企业签名管理 + +企业模板管理 +短信业务 + +短信通道 + +短信记录 + +任务进度 +系统管理 + +用户管理 + +系统监控 +设计原型 · 非真实业务数据 + + +运营概览 / 发送监控 + +预警中心 6 + +待审核任务 + +报备任务提醒 +管理员 +短信通道 +新建 / 复制通道保存成功之后,独立询问是否纳管 + +静态示例 · 待实施 + + +通道已保存 +行业通道 E · 新通道编号 CH-005 +创建结果已生效。加入监控不会修改通道价格、连接或路由。 + + +是否加入行业通道监控? +× + + +如果是行业短信通道建议加入 +新通道:行业通道 E(CH-005) · 支持:移动 / 联通 / 电信 +将采用行业通道通用规则 +最低100条成熟样本;5秒 ≥90%,20秒 ≥95%,1分钟 ≥98%。 +现在不加入,也可稍后从发送监控 → 监控通道中添加。 + + +暂不加入 + +加入监控 + +加入失败状态:通道已保存,加入监控失败。 +仅重试加入监控,不重复创建通道;关闭弹窗保留原创建结果。 +触发点:服务端返回新ID之后。复制通道不继承原通道监控身份。 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/06-alert-center-detail.png b/docs/prototypes/sending-monitor-20260906/06-alert-center-detail.png new file mode 100644 index 0000000..1c42681 Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/06-alert-center-detail.png differ diff --git a/docs/prototypes/sending-monitor-20260906/06-alert-center-detail.svg b/docs/prototypes/sending-monitor-20260906/06-alert-center-detail.svg new file mode 100644 index 0000000..36e913e --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/06-alert-center-detail.svg @@ -0,0 +1,198 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + +聆界短信管理平台 +平台运营工作区 +运营概览 + +运营看板 + + + +发送监控 + +网关异常 + +签名质量检测 +客户管理 + +企业管理 + +企业应用管理 + +企业签名管理 + +企业模板管理 +短信业务 + +短信通道 + +短信记录 + +任务进度 +系统管理 + +用户管理 + +系统监控 +设计原型 · 非真实业务数据 + + +运营概览 / 发送监控 + +预警中心 6 + +待审核任务 + +报备任务提醒 +管理员 +发送质量告警 +聚合异常指标,记录处理与恢复过程 + +静态示例 · 待实施 + + +异常持续 +行业通道 A × 移动 +事件 AL-EXAMPLE-001 · 开始于12:30 · 最近评估12:35 + +三个时效指标低于阈值,合并为一条活动告警。 +命中指标 +实际值 +最低值 +成熟样本 +5秒到达率 +83.97% +90.00% +1058 / 1260 + +20秒到达率 +94.00% +95.00% +1128 / 1200 + +1分钟到达率 +97.00% +98.00% +1067 / 1100 + +5秒到达率趋势 +实线:实际 / 虚线:阈值 + + + + +100% +90% +80% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +11:45 +12:00 +12:15 +12:35 + + +查看样本 + +标记已读(非恢复) + +预警中心弹层示意 +与既有消息提醒保持相同交互 + +安全检测与封禁 + +0 +暂无待处置告警 + +系统监控告警 + +0 +暂无活动告警 + +发送质量告警 + +6 +行业2 · 验证码1 · 整体兜底3 + +事件时间线 + +12:30 +首次异常 +三项命中,创建1条事件 + +12:35 +持续异常 +更新原事件,不重复刷屏 + +后续 +连续2次正常后恢复 +缺样本或数据延迟不算恢复 +未读状态按用户记录;同一异常多个窗口只计一个活动事件。所有数据均为设计示例。 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/07-mobile-monitor.png b/docs/prototypes/sending-monitor-20260906/07-mobile-monitor.png new file mode 100644 index 0000000..9a01d82 Binary files /dev/null and b/docs/prototypes/sending-monitor-20260906/07-mobile-monitor.png differ diff --git a/docs/prototypes/sending-monitor-20260906/07-mobile-monitor.svg b/docs/prototypes/sending-monitor-20260906/07-mobile-monitor.svg new file mode 100644 index 0000000..d3456ae --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/07-mobile-monitor.svg @@ -0,0 +1,60 @@ + +发送监控页面重做 · 静态设计原型(全部为示例数据) + + + + + + +发送监控 + +预警 6 + + + +整体兜底 + +静态设计示例 +近30分钟 · 每10分钟评估 +行业通道 +验证码 +整体兜底 +告警 + + +以收到成功回执为准 +未满时长仍在观察中,不计作失败。 + +筛选 + +阈值设置 + +刷新 +12:00 – 12:30 / 12:30:18更新 + +示例零售 · 通知应用 +【示例商城】 · 应用×签名规则 + +样本不足 +窗口提交300条业务短信 + +1分钟 +88.62% +257/290成熟 · 10条观察中 + +5分钟 +92.17% +212/230成熟 · 70条观察中 + +20分钟 +94.44% +85/90成熟 · 210条观察中 +20分钟指标最低100条,当前90条。 +可评估项正常;不足项继续观察。 + + + +查看趋势 + +告警记录 + \ No newline at end of file diff --git a/docs/prototypes/sending-monitor-20260906/README.md b/docs/prototypes/sending-monitor-20260906/README.md new file mode 100644 index 0000000..feb0daf --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/README.md @@ -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,未修改应用样式或其他并行任务文件。 diff --git a/docs/prototypes/sending-monitor-20260906/render-prototypes.py b/docs/prototypes/sending-monitor-20260906/render-prototypes.py new file mode 100644 index 0000000..9295a07 --- /dev/null +++ b/docs/prototypes/sending-monitor-20260906/render-prototypes.py @@ -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'', + '发送监控页面重做 · 静态设计原型(全部为示例数据)'] + 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'') + + 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'') + + 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'{escape(s)}') + + 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+['']),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 签名 > 应用 > 通用',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') diff --git a/docs/report-readiness-notifications-20260906.md b/docs/report-readiness-notifications-20260906.md new file mode 100644 index 0000000..7cb9289 --- /dev/null +++ b/docs/report-readiness-notifications-20260906.md @@ -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 验证双端会话、租户隔离、分页、小时聚合与按版本阅读。浏览器覆盖三尺寸、刷新、路由页签、详情与失败状态。部署仅测试环境,保留独立恢复资产。 diff --git a/docs/sending-monitor-redesign-plan-20260906.md b/docs/sending-monitor-redesign-plan-20260906.md new file mode 100644 index 0000000..26198f4 --- /dev/null +++ b/docs/sending-monitor-redesign-plan-20260906.md @@ -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 签名 > 应用 > 通用**。同时匹配签名和应用时采用签名规则,界面明确显示被覆盖来源;列表显示“实际生效规则”,详情能查看完整匹配链。 +- 采用最高优先级的一整套规则,避免不同来源按字段拼接难以解释。创建个性规则时预填继承值,保存为独立完整规则集;“恢复继承”删除该层覆盖并展示将生效的下级策略。 +- 同层同范围唯一,重复保存更新原规则;版本号乐观锁,冲突返回409并让用户重新加载。改规则记录操作者、修改前后值、生效时间;不对历史窗口用新阈值追溯制造告警。 +- 编辑页实时展示一个示例维度的最终生效策略,并提示高优先级覆盖范围。不要让用户保存后仍猜测哪套生效。 + +### 5.4 参数校验与生效 + +- 最低成熟条数为正整数;到达率下限0~100%,保留两位小数;开启的累计下限建议满足短时限≤长时限,不合理顺序提示修正。至少开启一项时效指标。 +- 百分比仅展示时四舍五入,告警比较使用整数基点/精确分子分母;等于下限不告警,低于下限告警。0%下限等于该项不会因低比率触发,界面明确提示。 +- 每类可配置连续异常次数(默认建议1)、连续恢复次数(默认建议2),范围1~5;站内提醒默认新异常一次,持续异常更新原事件,不周期刷屏。 +- 配置成功以真实持久化为准,页面显示版本和生效时间。配置修改后从下一个周期使用新版本;旧活动事件以“规则变更”关闭并重置连续计数,不算业务恢复。 + +## 6. 告警生命周期与预警中心 + +1. 任意开启的指标在成熟样本达标且数据完整时低于下限,累计对应连续异常次数;达到阈值创建一条该维度的发送质量告警,附全部命中指标。 +2. 同一类型+维度只保留一个活动告警事件,多个时效指标合并展示;使用事务唯一约束/锁防止双Worker重复。业务维度键不包含窗口T,避免每个重叠窗口新建。 +3. 活动事件更新最新值、最差值、持续时长和周期记录。用户“标记已读/已知悉”只影响个人未读状态,不等于恢复或关闭统计。 +4. 所有开启指标均有充分成熟样本、数据完整且不再命中下限,连续满足恢复次数后自动恢复。样本不足、无发送、数据延迟均不累计恢复;活动告警显示“暂停评估”原因。 +5. 恢复后再次异常创建新事件并重新未读;配置关闭、范围移除、规则变更分别记录关闭原因,不能冒充恢复。短暂静默仅隐藏个人/授权范围通知,统计仍进行并记录到期时间。 +6. 预警中心新增“发送质量告警”,展示未读活动事件数及异常维度数,点击进入`/admin/monitor?tab=alerts`;弹层样式、键盘/点击外部关闭和窄屏行为复用现有AppShell。 +7. 全局角标只调用独立轻量`notification-summary`,按既有30秒节奏、聚焦及已读事件刷新。返回失败保留上次值并标记不可用,不能清零或清空其他预警域。明细列表和计数使用相同权限、过滤和去重口径。 +8. 发送质量告警属于预警中心;签名清退继续在现有报备任务提醒中,待审核任务不增加这些计数。首期只做站内告警,不发短信、邮件或第三方通知,不触发自动停通道、换路由、补发。 + +## 7. 页面与交互 + +### 7.1 监控主页面 + +- 路由保持`/admin/monitor`。一级页签:行业通道、验证码、整体兜底、告警记录;标题操作区为刷新、阈值设置、行业页特有的监控通道。 +- 顶部持续显示口径、当前窗口、计算周期、最新成功计算时间/数据延迟;30秒页面取数不等同于5/10分钟重新评估。长时间挂后台暂停轮询,恢复焦点再拉取,手动刷新节流。 +- 摘要卡:监控维度数、异常维度数、样本不足维度数、窗口提交量(行业用“提交尝试”,其他用“业务短信”)。无数据时显示—/0并区别未配置、无发送、计算中与接口失败。 +- 表格以异常优先:维度、窗口提交量、三个到达率、实际阈值/规则来源、状态、趋势/详情。各指标格包含百分比、成功/成熟数;低于阈值的格红色强调,观察中/样本不足中性显示,不只依赖颜色。 +- 用户能筛选全部/异常/正常/样本不足/数据延迟;应用/签名支持带企业名的远程搜索、分页。筛选保存在URL,首次进入、刷新和告警深链接能还原。 +- 保留“运行概况”入口显示连接、积压及最近消息/回执/上行,不把业务启用标记等同实际连接健康。详情链接保持权限过滤。 + +### 7.2 趋势与告警详情 + +- 详情页/抽屉显示异常对象、类型、状态、开始/最近评估时间、规则快照及每项命中指标。 +- 展示最近2小时/24小时曲线,点的时间含义为评估T;悬停显示窗口、分子分母、观察中条数和revision。阈值变更用分段线和注释,不能用今天阈值覆盖昨天曲线。 +- “查看样本”跳转已有短信记录,带精确应用/签名/通道/时间/结果过滤;需有详情权限,手机号脱敏,不在告警摘要暴露正文或验证码。 +- 异常、无数据、待计算、采集延迟、失败重试、权限不足、删除对象历史查看均有明确状态。配置弹窗保存失败保留输入,未保存离开有确认。 + +### 7.3 响应式与原型覆盖 + +- 原型采用项目白底侧栏、浅灰内容区、蓝色选中、紧凑表格和8px圆角;复用Breadcrumb/Button/Select/Table/Tag/Modal。 +- 1600×1000完整展开;1366×768保持正文14px,表格自身横向滚动、弹窗仅Body滚动;390×844用折叠导航、横滑页签、每维度一张指标卡和全屏配置抽屉,不缩小字体挤宽表。 +- 配套图覆盖三个主视图、阈值/个性规则、通道纳管提示、预警中心/详情、窄屏;均为静态示意,不含真实API、数据库或用户操作。 + +## 8. 数据流与性能方案 + +### 8.1 推荐架构 + +```text +已有Submit结果 / 分片审计 / 回执Inbox(耐久业务事实) + → 独立监控Worker:增量投影、完整性校验、幂等归并 + → 短期MonitorFact + 分钟汇总桶 + → 5/10分钟调度:生成窗口快照、评估规则、维护告警事件 + → 轻量分页API / 角标摘要 → 页面 +``` + +原则是发送热路径不等待统计查询、规则匹配或通知,也不每条短信执行多次聚合SQL。精确时间需要扩展既有耐久事件,但尽量不新增独立同步消息或额外发送事务。监控读取失败只造成监控降级,不能阻断发送;基础事实持久化本身仍遵循原发送可靠性契约,不为性能跳过必要写入。 + +推荐一期由独立Worker读取**既有已落库事实**做短批增量投影,避免再造一套逐短信业务Outbox;如果无法在既有事实中证明完整性,必须在技术验证阶段调整采集契约,不宣称纯查询就能准确算5秒。不能直接消费现有短信Stream的同一个consumer group分走消息,也不能依赖已ACK/XDEL的Stream作为历史账本。 + +### 8.2 增量、桶与精度 + +- 数据提取针对提交事实与回执处理结果两条游标分别维护检查点。`createdAt/id`或`updatedAt/id`排序只提供扫描位置,不能假设数据库事务提交顺序与时间戳一致;采用重叠回读+按ID幂等覆盖,并定期有界对账。扫描到未完成关联的Inbox保留待匹配集合,直到已匹配或明确异常,不越过后遗忘。 +- 监控事实保持稳定source key、源版本、t0、首次完整成功时间、类型标记和维度快照;同一源重复摄取以替换贡献/脏桶重建处理,不能直接重复INCR。事实、桶版本和检查点在监控侧短事务内一致提交,崩溃可回放。 +- 以提交分钟建立稀疏桶,记录计数及5秒/20秒/1分钟/5分钟/20分钟累计按时成功数。长时限成熟边界可整分钟读取;5秒/20秒成熟边界落在分钟内部,必须从索引事实补算边界秒区间,不能把整分钟全算成熟,更不能把时间四舍五入到分钟。 +- 每次评估读5或30个分钟桶及最多一分钟的边界事实,不对每个通道/签名单独发SQL循环。批量处理活跃维度,监控列表只读已保存快照。 +- 三类监控共享一次业务消息/正文解析投影,验证码布尔只识别一次;行业通道尝试汇总与应用业务消息汇总分别维护。禁止常态运行`content LIKE '%验证码%'`扫描业务大表,禁止反复JSON反序列化正文。 +- 固定维度ID;仅有流量或配置的组合建桶,不生成“企业×应用×签名×通道×运营商”笛卡尔积。应用签名不是Prometheus高基数标签;Prometheus仅观测Worker延迟、批量大小、错误率等低基数运行指标。 +- 迟到事件只将相应提交分钟标脏并合并修正其相关快照;兜底每条样本最多影响3个滚动提交窗口,再处理有必要的历史revision,禁止重算所有维度全部历史。 + +### 8.3 表与索引候选(设计项,不直接执行迁移) + +| 候选实体 | 内容及约束 | +|---|---| +| SendingMonitorTarget | 行业通道成员、enabled、effectiveFrom、version;channelId唯一 | +| SendingMonitorRule | 类型、作用域与ID、完整阈值JSON/结构字段、版本、生效时间、操作者;同类型同作用域唯一 | +| SendingMonitorFact | sourceKind/sourceId唯一、tenant/app/signature/channel/carrier快照、t0、successAt、时间源、验证码标记、完整性与源版本;不复制手机号和正文 | +| SendingMonitorMinute | type/dimensionKey/minute/口径版本唯一,累计计数、revision;稀疏存储 | +| SendingMonitorSnapshot | type/dimensionKey/T/口径版本唯一,三个S/N、观察中、规则快照、completeAt/revision;保留历史追溯 | +| SendingMonitorAlert / AlertRead | 活动事件唯一约束、规则版本、首次/最新/最差值、状态/原因;已读按eventId+userId唯一 | +| SendingMonitorCheckpoint | 按来源/分片的扫描位置、租约、扫描上下界、对账进度、未处理缺口 | + +索引按实际谓词和EXPLAIN选:事实`(dimensionKey,t0)`支持边界范围;快照`(type,T,status)`及维度历史时间索引;源表新增`(updatedAt,id)`/相关状态时间索引前先验证现有查询计划。不能仅因表很大就堆宽索引,源表新增索引会增加发送/回执写放大;DDL锁与索引创建窗口需部署评审。 + +参考:[PostgreSQL复合索引说明](https://www.postgresql.org/docs/current/indexes-multicolumn.html)强调前导列约束对扫描范围的作用;[Prometheus标签规范](https://prometheus.io/docs/practices/naming/)提示避免无界高基数。应用版本能力以目标数据库为准,不将文档最新版优化当作当前服务器已具备。 + +### 8.4 调度与资源预算 + +- 一期建议1个独立监控Worker,连接池上限2、并行任务1,按哈希/时间分批;每批500~2000条是压测候选值,受statement_timeout和短事务约束,自适应退避,禁止Promise.all按全部签名并发。 +- 以数据库租约/唯一任务键确保每个边界T只被一个执行者持有;过期可接管,重跑幂等。不要只使用进程内setInterval防重。调度边界固定,批次可错峰,不漂移窗口定义。 +- 轻量摘要API缓存15~30秒,按权限作用域及版本隔离;缓存丢失只回源汇总表,禁止回源重扫业务表。使用Redis只缓存结果,不做每条短信一个TTL键/定时器。 +- 建议热事实先按2小时估算,分钟桶7天、窗口快照30天、告警及规则审计90天,再按实际合规与磁盘容量评审。72小时是候选纠错范围,不要求保留等长的全量热明细;热事实过期后的纠错从既有事实按ID有界恢复,恢复成本超预算则明确标记未回算。清理仅针对独立监控数据,按分区/小批执行,不清业务消息、回执或短信队列。 +- 新增实际发包时间字段、源表索引、监控投影和回算均有成本;不能承诺“零影响”。性能验证不通过时先缩小纳管范围、降低摄取批量或部署独立读副本(需确认复制延迟),不能以提高发送并发或削弱耐久性掩盖。 + +### 8.5 可解释的容量估算与验收预算 + +令业务短信速率为λ、平均尝试数a、平均分片数s,则已有提交/分片/回执事实处理量约按`λ×a×s`增长;重复回执另计,不能仅看业务TPS。 + +以假设500条业务短信/秒、a=1.1、s=1为例:每5分钟15万业务短信、约16.5万次尝试;每30分钟90万业务短信。每次对这些数据重新Join三遍会产生持续负载。监控分钟汇总将周期读取转为与活跃维度和窗口长度相关,而事实摄取仍与事件量线性相关。 + +该假设下业务+尝试投影若约1050行/秒,72小时约2.72亿行;即使每行连索引按粗估200字节,也约54GB且未计WAL/膨胀。**因此72小时明细不能不经容量测算直接上线**;可优先缩短热事实至2小时(约756万行、粗估1.5GB),更长回算在既有事实中按ID有界恢复,或评估分区/独立存储。这是容量风险示例,不代表现环境有500TPS、该磁盘余量或该行大小。 + +实现验收建议预算(待基线实测校准): + +- 同负载对照开启/关闭监控,发送受理/提交及回执落库P95相对恶化不超过5%,无业务错误增加,发送Stream/Inbox无持续新增积压。 +- 监控新增连接不超分配池,数据库总体CPU增幅目标不超过5个百分点,磁盘I/O和WAL写放大单独记录;不以单次低峰截图证明高峰达标。 +- 周期任务P95在30秒内完成、必须小于调度周期;快照/摘要API P95≤300ms(不含公网延迟);不足则标记性能验收未通过,禁止藏掉延迟指标。 +- 数据超过一个采集SLA(初值30秒)未完整时标为延迟;两个评估周期无新完整快照时告警“监控计算异常”。按真实依赖延迟校准,不误关正常短信发送。 +- 先用脱敏历史快照/隔离数据集验证1倍及峰值2倍规模,覆盖高签名基数、长短信、重试与回执突发。真实发送压力测试另需授权;本设计阶段未运行压测。 + +## 9. API与权限边界(拟定) + +统一使用`/api/admin/sending-monitor`,不重用系统监控或旧monitor的全量聚合接口: + +| 接口 | 用途 | +|---|---| +| GET /overview?type=... | 最新快照摘要、T、nextEvaluationAt、数据完整性 | +| GET /rows?type=...&page=... | 权限范围内分页维度、S/N/观察中、状态与生效规则 | +| GET /history?dimensionId=...&range=... | 有界历史快照及规则变更 | +| GET /targets;PUT /targets/:channelId | 行业纳管列表与幂等加入/移除,携带version | +| GET /rules;PUT /rules/:id;POST /rules | 通用与个性规则,完整校验与乐观锁 | +| GET /effective-rule?... | 返回生效规则及被覆盖层,用于编辑预览 | +| GET /alerts;GET /alerts/:id | 历史、活动及详情、过滤、分页 | +| GET /notification-summary | 轻量未读活动事件/异常维度数、更新时间,不聚合原始消息 | +| POST /alerts/:id/read | 个人幂等标记已读,不改变业务恢复状态 | + +响应必须区分`rate=null`、`sample_insufficient`、`observing`、`stale`、`error`,不能统一0。越权资源返回一致404/403策略;后端从会话确定访问范围,不能相信前端tenantId。监控查看、阈值修改、通道纳管、消息详情分别校验权限,配置写入与版本审计在事务中完成。分页上限建议100,历史时间范围有限,错误明确返回而非静默成功。 + +## 10. 实施拆分与验收清单 + +### 10.1 分阶段建议 + +1. 先确认第2节口径与阈值,对真实代码/数据核验提交时间、回执匹配、分片合并和高峰规模。交付统计样本独立对账,不先接告警。 +2. 实现兼容时间字段、独立投影/桶/快照,影子运行并与独立SQL按ID比对;监控关闭时不改变发送行为。完成容量和故障演练后才开启有限纳管。 +3. 实现三页签、通用与个性规则、通道新建/复制提示,接预警中心轻量摘要和事件生命周期;先灰度一个测试范围再扩展。 +4. 同步主需求、系统用例、技术设计及testing-progress;经授权提交、推送及部署,分别记录代码级与真实环境证据。 + +### 10.2 可直接转入系统用例的验收条目 + +| 编号 | 场景 | 预期 | +|---|---|---| +| SMR-001 | 5分钟边界、10分钟边界、跨天和时区 | 提交窗口左闭右开、UTC计算一致,无跨周期漏重 | +| SMR-002 | 三网通道含移动/联通/电信/未知号码 | 按消息运营商拆分;未知单列,不重复三次计数 | +| SMR-003 | 同业务短信换通道补发 | 通道按各尝试,应用签名按1条;t0不因补发重置 | +| SMR-004 | 长短信、重复/乱序/冲突回执 | 全必要分片同一有效尝试完整成功才计成功;幂等,负时延异常可追溯 | +| SMR-005 | 4.999/5.000/5.001秒以及20秒/1分/5分/20分边界 | 精确分类,不由显示四舍五入决定 | +| SMR-006 | 尚未成熟但已成功/已失败的样本 | 两者都观察中;不能只纳入提前成功者;N_h独立 | +| SMR-007 | N_h为0、99、100且最低100 | null、样本不足、可评估,零数据不显示100%或自动恢复 | +| SMR-008 | 最新30分钟,20分钟成熟段只有前10分钟 | 分子分母/观察中符合第4.3例子,不把新提交直接计为未到达 | +| SMR-009 | 正文含验证码、模板分类验证码但正文不含、不同租户同名签名 | 字面正文匹配;稳定ID归属和租户隔离,不混淆 | +| SMR-010 | 全部规则层同时匹配、重复修改、恢复继承 | 应用×签名>签名>应用>通用;整套规则覆盖、409冲突、来源可解释 | +| SMR-011 | 新建/复制成功后加入监控成功或失败 | 主创建只一次、加入幂等;失败保留新通道,复制不继承成员资格 | +| SMR-012 | 新建失败/无权限/关闭纳管弹窗 | 不误弹成功、不越权加入、后续可手动纳管 | +| SMR-013 | 一个维度三项低于阈值、连续重叠周期 | 一条活动事件、多命中指标;无重复角标刷屏 | +| SMR-014 | 已读、持续异常、恢复、再异常、样本不足 | 已读非恢复;充分数据连续恢复;再异常新未读;不足暂停评估 | +| SMR-015 | 回执Gateway及时但API处理迟到、规则变更 | 延迟/回算有revision;保留原告警与修正原因,不伪称终端时延 | +| SMR-016 | 投影Worker重启、多实例抢占、数据库/Redis故障 | 检查点可恢复、幂等;监控显示降级,业务发送链无新增依赖阻断 | +| SMR-017 | 长事务晚提交、游标跨越、已扫描Inbox后匹配 | 重叠读取+待匹配集合+有界对账恢复,不永久漏统 | +| SMR-018 | 预警中心接口失败、权限变化、点告警深链接 | 其他域不清零,来源状态明确;同作用域计数与列表一致 | +| SMR-019 | 三尺寸、首次进入、刷新、筛选、弹窗失败与未保存离开 | 无页面级意外溢出,正文可读、Footer可见,表单状态保留 | +| SMR-020 | 高峰/高基数/长短信与监控开启关闭对照 | 按第8.5预算出真实CPU/IO/延迟/队列/存储报告,不用构建成功代替 | +| SMR-021 | 旧数据缺时间、不确定发包、不支持回执 | 单列不可评估和完整性,不伪造指标或混入健康样本 | +| SMR-022 | 旧功能入口与跨业务提醒分组 | 保留运行概况/最近事件入口,新告警只进预警中心,不污染报备和待审核 | +| SMR-023 | 固定窗口尾部与补齐任务 | 12:29:59提交的行业/验证码短信必须进入12:30窗口的12:31定稿;初评/定稿不重复计告警次数,旧定稿不覆盖新状态 | + +## 11. 原型文件与本轮验证边界 + +参见[原型目录](prototypes/sending-monitor-20260906/README.md),PNG便于预览,SVG用于后续修改。所有表格为示例数据;正文未展示短信、手机号或验证码。原型是需求交流材料,不接后端,不可作为业务验收证据。 + +本轮已核验文档相对链接、Markdown代码块、空白、统计公式示例、绘制脚本语法、七组PNG/SVG尺寸及可解析性,并逐图检查文字与布局;文件范围仅本文及专属原型目录。没有真实API/数据库/Redis功能测试或性能压测。阈值、兜底范围、提交计时起点及容量预算必须在实施前评审,不能依据示例图直接启用告警。 + +## 12. 实施决策与验收边界(2026-09-06) + +用户已确认默认阈值留空,由运营后续配置;任何原型阈值均不自动启用。前三节旧记录保留为设计阶段证据,不代表当前实施状态。 + +新增 Gateway 实际写出时间、时间来源及真实上游回执请求标记,随已有结果和分片耐久事件传递;API 持久化到尝试/分片,回执新增 gatewayReceivedAt 区分真实 Gateway 接收时间与 API 缺省时间。客户端是否要求下游回执不能代替上游实际请求标记。现有 Inbox 的 matchedSubmitRecordId 尚未写入,投影使用已匹配业务消息、通道和网关消息号关联,遇到尝试重号拒绝混合。 + +独立 Worker 使用最多2个 PostgreSQL 连接、单执行槽、数据库排他租约;提交及回执各自保留重叠游标,另有72小时有界对账,脏分钟只重算所属固定窗口/最多3个兜底窗口,调度队列与投影原子提交。指标读取成熟分钟桶加毫秒边界事实,不按页面刷新扫描业务大表。 + +事实保留期本次采用72小时(第8节2小时是原建议),以保证迟到修改能替换原贡献、不会在删除事实后把旧桶重建成局部样本;分钟7天、快照30天、关闭告警90天。新增表不存短信正文/手机号。需要以真实峰值容量预算再优化事实压缩/冷热分层;当前不得据此宣称500TPS下预算达标。真实短信压测未获授权,只使用隔离数据库样本验证统计和查询成本。 + +纳管范围使用不可变版本,按窗口评估时刻解析;以后移除/重新加入不改写旧统计。当前API规则按type+scope通过POST完整保存(version乐观锁),不另外提供重复的PUT更新入口。新Worker健康异常通过发送监控数据延迟和预警中心不可用说明展示;监控性能指标接Prometheus和真实峰值对照尚未验收。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 3857685..16335f1 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5145,3 +5145,22 @@ npm run verify:phase8 | 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不会自动重入池;不把调查写成修复 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index e29a3f6..5feb8df 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4605,3 +4605,16 @@ git diff --check - 动态环境:浏览器验收期间其他操作新增了一个真实停用通道,旧候选快照因此不再匹配;改为用当前页面实际收到的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确认处理;不把脚本超时当作功能通过。图片、响应报告不含密码,认证文件沿用部署文档受限入口。 - 验收边界:未真实保存现有通道组、未执行短信补发;参数映射/保存失败用组件隔离测试,补发顺序用真实代码、当前PostgreSQL/API顺序及无副作用策略测试交叉核对。无真实省网可选夹具,地区过滤和历史缺失项由组件测试覆盖;不冒充端到端投递或真实权限异常验收。仅本地提交,不推送、不部署测试或预生产。 + +## 2026-09-06 发送监控与报备状态消息实施(发布前) + +- 范围:按用户授权修改、提交、推送、部署测试环境;本轮不访问或发布预生产。阈值及最低样本量默认留空、不告警,后续由用户配置。 +- 起点:main/HEAD=69e3d73,2026-09-06 19:15回读origin/main=442dda711d5c9f778f3f76fd6d8fd69f14414ce6,差异0/2。原有AGENTS、开发/测试/部署规范、用例/进度及未跟踪方案受保护,仅暂存本轮文件或追加节。 +- 实现:三种周期监控、真实Gateway写出和回执接收时间、尝试/业务去重、独立有界投影Worker、成熟桶/边界统计、规则与纳管版本、告警生命周期、历史快照和短信样本范围;顶部预警中心及运行概况保留。通知采用事务触发器、零网过程记忆、企业小时聚合、个人版本已读,双端页面和运营提醒入口。 +- 根因证据:现有submittedAt是等待供应商响应后的时间,不能冒充发包时间;matchedSubmitRecordId实际未写入,使用已匹配消息/通道/网关号,重号不可评估;上游实际RegisteredDelivery与客户要求的下游回执不同,分别保留。测试库时区Asia/Shanghai,新表/SQL显式UTC。 +- 资料池只读结论:pending-query要求pendingReport=true;生成完成置false;组添加通道没有重新置true。实际41个pendingReport=false签名存在有效路由和通道组,不会仅因新增通道自动重新进入待生成池;尚待生成对象可以动态计算新目标,但不会自动创建资料文件。本轮未修改此业务规则。 +- 验证:前端20套99项、API59套638项、Gateway全量Go测试及vet通过;真实PostgreSQL隔离事务20项通过(含5秒边界、初评/定稿、重复、缺时间/回执请求、历史纳管版本、签名和引流通知、UTC、回滚)。数据库夹具明确为从不入队的隔离样本,全部回滚;测试替身不作为真实业务验收。新增API单测初次类型声明失败,修正后全量通过;引流夹具初次误用按运营商三条,与真实唯一约束冲突,改为既有全通道报备模型后通过。 +- 本地TypeScript、前后端构建通过;格式、Stylelint、CSS所有权/级联治理及15项门禁测试通过,CSS全部由页面所有者导入,未修改历史模块或加载顺序。按当前门禁机械格式化了本轮涉及的旧代码文件,未变更其其他业务逻辑。ESLint无错误,有既有/关联Hook和any警告;入口gzip108.71KiB低于250KiB,图表chunk保留既有提示。 +- 测试机19:05~19:08基线:旧部署442dda7;短信119509、近72小时尝试0、待匹配回执0;提交命令/结果Stream pending/lag均0;数据库约2363MB、连接13/100、根盘可用24GB。Gateway desired=6、connected=0是发布前既有状态,不宣称供应商链路通过。当前没有真实高峰/长短信负载;500TPS性能预算及故障演练尚未执行,不作容量承诺。 +- 本次独立恢复点:/var/backups/cmpp-platform/20260906-sending-monitor-191302,含PostgreSQL custom dump、应用、环境/systemd/Nginx/Prometheus/fstab、Redis RDB、旧版本;pg_restore列表、tar可读性及SHA-256均通过。未执行实际恢复演练。 +- 此节为发布前证据;提交、推送、测试发布、真实双端浏览器结果在后续发布节记录,不预先标记完成。 diff --git a/gateway/internal/queue/messages.go b/gateway/internal/queue/messages.go index 2765de6..1692b6d 100644 --- a/gateway/internal/queue/messages.go +++ b/gateway/internal/queue/messages.go @@ -83,25 +83,31 @@ type Retry struct { type SubmitResult struct { Envelope - SubmitID string `json:"submitId"` - SequenceID uint32 `json:"sequenceId"` - GatewayMessageID string `json:"gatewayMessageId"` - SubmitStatus string `json:"submitStatus"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - SubmittedAt time.Time `json:"submittedAt"` - Segments []SubmitSegmentResult `json:"segments,omitempty"` + FirstWireSubmitAt *time.Time `json:"firstWireSubmitAt,omitempty"` + WireTimeSource string `json:"wireTimeSource,omitempty"` + ReceiptRequested bool `json:"receiptRequested"` + SubmitID string `json:"submitId"` + SequenceID uint32 `json:"sequenceId"` + GatewayMessageID string `json:"gatewayMessageId"` + SubmitStatus string `json:"submitStatus"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + SubmittedAt time.Time `json:"submittedAt"` + Segments []SubmitSegmentResult `json:"segments,omitempty"` } type SubmitSegmentResult struct { - SegmentTotal int `json:"segmentTotal"` - SegmentIndex int `json:"segmentIndex"` - SequenceID uint32 `json:"sequenceId"` - GatewayMessageID string `json:"gatewayMessageId"` - SubmitStatus string `json:"submitStatus"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - SubmittedAt time.Time `json:"submittedAt"` + FirstWireSubmitAt *time.Time `json:"firstWireSubmitAt,omitempty"` + WireTimeSource string `json:"wireTimeSource,omitempty"` + ReceiptRequested bool `json:"receiptRequested"` + SegmentTotal int `json:"segmentTotal"` + SegmentIndex int `json:"segmentIndex"` + SequenceID uint32 `json:"sequenceId"` + GatewayMessageID string `json:"gatewayMessageId"` + SubmitStatus string `json:"submitStatus"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + SubmittedAt time.Time `json:"submittedAt"` } type ReceiptEvent struct { diff --git a/gateway/internal/upstream/submit.go b/gateway/internal/upstream/submit.go index 1383ae0..7e49c6f 100644 --- a/gateway/internal/upstream/submit.go +++ b/gateway/internal/upstream/submit.go @@ -40,7 +40,15 @@ func (p *connectionPool) submit( ctx context.Context, cmd queue.SubmitCommand, 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) if err != nil { result := submitResult(cmd, 0, "", "rejected", "ENCODE_FAILED", err.Error()) @@ -96,7 +104,10 @@ func (p *connectionPool) submit( 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() defer func() { c.mu.Lock() @@ -116,7 +127,13 @@ func (c *connection) submitPart(ctx context.Context, cmd queue.SubmitCommand, pa return 0, "", result, err } c.sendMu.Lock() + wireSource = "write_uncertain" seq, err := client.SendReqPkt(pkt) + if err == nil { + at := time.Now().UTC() + wireAt = &at + wireSource = "gateway_write_complete" + } c.sendMu.Unlock() if err != nil { c.emitProtocolLog(protocolLogEvent{ @@ -326,6 +343,8 @@ func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID s ErrorCode: code, ErrorMessage: message, SubmittedAt: time.Now().UTC(), + WireTimeSource: "not_written", + ReceiptRequested: cmd.CMPP.RegisteredDelivery != 0, } } @@ -334,14 +353,17 @@ func submitSegmentResult(part submitPart, sequenceID uint32, gatewayMessageID st gatewayMessageID = result.GatewayMessageID } return queue.SubmitSegmentResult{ - SegmentTotal: int(part.PkTotal), - SegmentIndex: int(part.PkNumber), - SequenceID: sequenceID, - GatewayMessageID: gatewayMessageID, - SubmitStatus: result.SubmitStatus, - ErrorCode: result.ErrorCode, - ErrorMessage: result.ErrorMessage, - SubmittedAt: result.SubmittedAt, + FirstWireSubmitAt: result.FirstWireSubmitAt, + WireTimeSource: result.WireTimeSource, + ReceiptRequested: result.ReceiptRequested, + SegmentTotal: int(part.PkTotal), + SegmentIndex: int(part.PkNumber), + SequenceID: sequenceID, + GatewayMessageID: gatewayMessageID, + SubmitStatus: result.SubmitStatus, + ErrorCode: result.ErrorCode, + ErrorMessage: result.ErrorMessage, + SubmittedAt: result.SubmittedAt, } } diff --git a/gateway/internal/upstream/wire_timing_test.go b/gateway/internal/upstream/wire_timing_test.go new file mode 100644 index 0000000..da66f7d --- /dev/null +++ b/gateway/internal/upstream/wire_timing_test.go @@ -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") + } +} diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts index 0b3b49c..e2b6220 100644 --- a/src/api/admin/operations.api.ts +++ b/src/api/admin/operations.api.ts @@ -1,87 +1,349 @@ -import { request, requestBlob, requestForm, 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 { 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'; // Read-heavy operations endpoints are isolated from configuration mutations. export const adminOperationsApi = { - getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), - getPendingAudits: (tenantId?: string) => request(withQuery('/admin/operations/pending-audits', { tenantId })), - getSendQuality: (date?: string) => request(withQuery('/admin/operations/send-quality', { date })), + getDashboard: (tenantId?: string) => + request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), + getPendingAudits: (tenantId?: string) => + request(withQuery('/admin/operations/pending-audits', { tenantId })), + getSendQuality: (date?: string) => + request(withQuery('/admin/operations/send-quality', { date })), getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => request(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 }) => - request(withQuery('/admin/system-logs', query)), - listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) => - request(withQuery('/admin/system-logs/protocol-interactions', query)), - exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) => - request('/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 & { 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 & { 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 & { 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)), + listSystemLogs: (query: { + tenantId?: string; + keyword?: string; + level?: string; + module?: string; + range?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) => request(withQuery('/admin/system-logs', query)), + listProtocolInteractionLogs: (query: { + protocol?: string; + direction?: string; + eventType?: string; + status?: string; + keyword?: string; + range?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) => request(withQuery('/admin/system-logs/protocol-interactions', query)), + exportSystemLogs: (query: { + tenantId?: string; + keyword?: string; + level?: string; + module?: string; + range?: string; + createdAtFrom?: string; + createdAtTo?: string; + }) => request('/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 & { 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 & { 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 & { + 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 } = {}) => request(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 }) => - request>(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; + }) => request>(withQuery('/admin/send/batch-tasks', query)), listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => request(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)), terminateAdminBatchTask: (id: string) => request(`/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 } = {}) => - request(withQuery('/admin/send/messages', query)), + listAdminMessages: ( + query: { + tenantId?: string; + applicationId?: string; + channelId?: string; + taskId?: string; + phoneNumber?: string; + status?: string; + } = {}, + ) => request(withQuery('/admin/send/messages', query)), listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => request(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 } = {}) => - request>(withQuery('/admin/operations/messages', query)), + listOperationMessages: ( + 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>(withQuery('/admin/operations/messages', query)), getOperationMessage: (id: string) => request(`/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 } = {}) => - requestBlob(withQuery('/admin/operations/messages/export', query)), + exportOperationMessages: ( + 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 } = {}) => request(withQuery('/admin/operations/uplink-messages', query)), - listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) => - request>(withQuery('/admin/operations/uplink-messages', query)), + listAdminUplinkMessagesPage: (query: { + tenantId?: string; + channelId?: string; + phoneNumber?: string; + keyword?: string; + startTime?: string; + endTime?: string; + page: number; + pageSize: number; + }) => request>(withQuery('/admin/operations/uplink-messages', query)), claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) => - request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }), - listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), - listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => - request(withQuery('/admin/operations/gateway-submit-dead-letters', query)), + request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { + method: 'POST', + body: JSON.stringify(body), + }), + listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => + request>(withQuery('/admin/operations/monitor', query)), + listGatewaySubmitExceptions: ( + query: { + tenantId?: string; + applicationId?: string; + channelId?: string; + status?: string; + keyword?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request(withQuery('/admin/operations/gateway-submit-dead-letters', query)), requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) => - request(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }), + request(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { + method: 'POST', + body: JSON.stringify(body), + }), resolveGatewaySubmitException: (id: string) => - request(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, { method: 'POST', body: JSON.stringify({}) }), - listReceiptAnomalies: (query: { tenantId?: string; applicationId?: string; channelId?: string; anomalyType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => - request(withQuery('/admin/operations/receipt-anomalies', query)), - listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), - getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) => - request(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(withQuery('/admin/operations/downstream-recovery-statuses', query)), + request(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, { + method: 'POST', + body: JSON.stringify({}), + }), + listReceiptAnomalies: ( + query: { + tenantId?: string; + applicationId?: string; + channelId?: string; + anomalyType?: string; + status?: string; + keyword?: string; + page?: number; + pageSize?: number; + } = {}, + ) => request(withQuery('/admin/operations/receipt-anomalies', query)), + listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => + request>>(withQuery('/admin/operations/statistics', query)), + getDownstreamDeliveryDashboard: ( + query: { + tenantId?: string; + applicationId?: string; + deliveryType?: string; + createdAtFrom?: string; + createdAtTo?: string; + } = {}, + ) => request(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(withQuery('/admin/operations/downstream-recovery-statuses', query)), getDownstreamRecoveryStatus: (id: string) => request(`/admin/operations/downstream-recovery-statuses/${id}`), exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) => 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 } = {}) => - request>(withQuery('/admin/operations/downstream-deliveries', query)), + listDownstreamDeliveries: ( + query: { + tenantId?: string; + applicationId?: string; + deliveryType?: string; + status?: string; + keyword?: string; + page?: number; + pageSize?: number; + createdAtFrom?: string; + createdAtTo?: string; + } = {}, + ) => request>(withQuery('/admin/operations/downstream-deliveries', query)), requeueDownstreamDelivery: (id: string) => - request(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }), + request(`/admin/operations/downstream-deliveries/${id}/requeue`, { + method: 'POST', + body: JSON.stringify({}), + }), batchRequeueDownstreamDeliveries: (ids: string[]) => - request('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }), + request('/admin/operations/downstream-deliveries/requeue', { + method: 'POST', + body: JSON.stringify({ ids }), + }), previewDownstreamRequeueTask: (filter: DownstreamRequeueFilter) => - request('/admin/operations/downstream-requeue-tasks/preview', { method: 'POST', body: JSON.stringify({ filter }) }), - createDownstreamRequeueTask: (body: { previewToken: string; reason: string; ratePerSecond: number; consecutiveFailureLimit: number }) => - request('/admin/operations/downstream-requeue-tasks', { method: 'POST', body: JSON.stringify(body) }), + request('/admin/operations/downstream-requeue-tasks/preview', { + method: 'POST', + body: JSON.stringify({ filter }), + }), + createDownstreamRequeueTask: (body: { + previewToken: string; + reason: string; + ratePerSecond: number; + consecutiveFailureLimit: number; + }) => + request('/admin/operations/downstream-requeue-tasks', { + method: 'POST', + body: JSON.stringify(body), + }), listDownstreamRequeueTasks: (query: { status?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/operations/downstream-requeue-tasks', query)), - getDownstreamRequeueTask: (id: string) => request(`/admin/operations/downstream-requeue-tasks/${id}`), - listDownstreamRequeueTaskItems: (id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query)), + getDownstreamRequeueTask: (id: string) => + request(`/admin/operations/downstream-requeue-tasks/${id}`), + listDownstreamRequeueTaskItems: ( + id: string, + query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}, + ) => + request>( + withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query), + ), changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') => - request(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { method: 'POST', body: JSON.stringify({}) }), + request(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { + method: 'POST', + body: JSON.stringify({}), + }), }; diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 5f25f04..562a8a2 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -7,14 +7,22 @@ import { ChannelFormModal } from './channels/ChannelFormModal'; import { ChannelLogModal } from './channels/ChannelLogModal'; import { ChannelTable } from './channels/ChannelTable'; 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 './channels/AdminChannelsPage.css'; +import { ChannelEnrollmentPrompt } from './sending-monitor/MonitorConfiguration'; export function AdminChannelsPage() { const navigate = useNavigate(); const [channels, setChannels] = useState([]); const [error, setError] = useState(''); + const [enrollment, setEnrollment] = useState<{ id: string; name: string } | null>(null); const [keyword, setKeyword] = useState(''); const [carrier, setCarrier] = useState('all'); const [status, setStatus] = useState('all'); @@ -29,13 +37,23 @@ export function AdminChannelsPage() { function loadChannels(targetPage = page, filters = { keyword, carrier, status }) { 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(), ]) .then(([result, quality]) => { const visibleChannels = result.items; 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); setError(''); }) @@ -54,11 +72,12 @@ export function AdminChannelsPage() { if (modal?.mode === 'edit' && modal.channel) { await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher)); } else { - await adminApi.createChannel({ + const created = await adminApi.createChannel({ code: `CH-${Date.now()}`, ...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'), status: 'active', }); + setEnrollment({ id: created.id, name: created.name }); } loadChannels(); setModal(null); @@ -74,7 +93,8 @@ export function AdminChannelsPage() { } 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(); } @@ -103,34 +123,72 @@ export function AdminChannelsPage() { setConfirmAction(null); } - const confirmTitle = confirmAction?.type === 'copy' - ? '确认复制通道' - : confirmAction?.channel.status === 'stopped' - ? '确认启用通道' - : '确认停用通道'; + const confirmTitle = + confirmAction?.type === 'copy' + ? '确认复制通道' + : confirmAction?.channel.status === 'stopped' + ? '确认启用通道' + : '确认停用通道'; - const confirmDescription = confirmAction?.type === 'copy' - ? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。' - : confirmAction?.channel.status === 'stopped' - ? '启用后通道会进入连接中状态,后续可继续观察网关连接。' - : '停用后该通道将不再承接新的发送任务。'; + const confirmDescription = + confirmAction?.type === 'copy' + ? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。' + : confirmAction?.channel.status === 'stopped' + ? '启用后通道会进入连接中状态,后续可继续观察网关连接。' + : '停用后该通道将不再承接新的发送任务。'; return (
- +
{error ?

{error}

: null}
- setKeyword(event.target.value)} placeholder="请输入通道名称" value={keyword} /> - setStatus(event.target.value)} options={statusOptions} value={status} /> + setKeyword(event.target.value)} + placeholder="请输入通道名称" + value={keyword} + /> + setStatus(event.target.value)} + options={statusOptions} + value={status} + />
- - + +
@@ -150,6 +208,7 @@ export function AdminChannelsPage() { /> {modal ? setModal(null)} onSubmit={upsertChannel} /> : null} + {enrollment && setEnrollment(null)} />} {testChannel ? ( - + - )} + } onClose={() => setConfirmAction(null)} open title={confirmTitle} diff --git a/src/apps/admin/AdminMonitorPage.css b/src/apps/admin/AdminMonitorPage.css new file mode 100644 index 0000000..3c1ad9b --- /dev/null +++ b/src/apps/admin/AdminMonitorPage.css @@ -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; + } +} diff --git a/src/apps/admin/AdminMonitorPage.tsx b/src/apps/admin/AdminMonitorPage.tsx index 6093e99..f0e801a 100644 --- a/src/apps/admin/AdminMonitorPage.tsx +++ b/src/apps/admin/AdminMonitorPage.tsx @@ -1,82 +1,308 @@ -import { useEffect, useMemo, useState } from 'react'; -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> = [ - { 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 ? {carriers.map((carrier) => )} : '-'; - } }, - { key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` }, - { key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` }, - { - key: 'status', - title: '状态', - render: (record) => {record.status === 'active' ? '运行中' : '已停用'}, - }, -]; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Breadcrumb, Button, Input, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; +import { MonitorRuntimeOverview } from './MonitorRuntimeOverview'; +import { MonitorRulesModal, MonitorScopePicker, MonitorTargetsModal } from './sending-monitor/MonitorConfiguration'; +import { MonitorAlerts, MonitorHistory, MonitorPager, Rate } from './sending-monitor/MonitorDetails'; +import { + monitorApi, + names, + ruleSource, + states, + time, + title, + type MonitorType, + type Snapshot, +} from './sending-monitor/monitorApi'; +import './AdminMonitorPage.css'; export function AdminMonitorPage() { - const [channels, setChannels] = useState([]); - const [monitor, setMonitor] = useState>({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - function loadData() { - setLoading(true); - 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)); - } - + const [params, setParams] = useSearchParams(); + const tab = params.get('tab') ?? 'industry'; + const type: MonitorType = tab === 'overall' || tab === 'verification' ? tab : 'industry'; + const [data, setData] = useState> | null>(null); + const [summary, setSummary] = useState> | null>(null); + const [error, setError] = useState(''), + [loading, setLoading] = useState(false), + [dialog, setDialog] = useState<'rules' | 'targets' | null>(null), + [detail, setDetail] = useState(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) => { + 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); + const q = new URLSearchParams(queryKey); + try { + const [rows, overview] = await Promise.all([ + 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(''); + } + } catch (e) { + if (!signal?.aborted) setError(e instanceof Error ? e.message : '监控加载失败'); + } finally { + if (!signal?.aborted) { + setLoading(false); + busy.current = false; + } + } + }, + [queryKey, tab, type], + ); 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 = useMemo(() => statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]); - const deliveredMessages = useMemo(() => statusGroups.filter((item) => item.status === 'delivered').reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]); - const successRate = totalMessages > 0 ? ((deliveredMessages / totalMessages) * 100).toFixed(1) : '0.0'; - - return ( -
-
+ const controller = new AbortController(); + void load(true, controller.signal); + const refresh = () => void load(false, controller.signal); + const timer = setInterval(refresh, 30000); + window.addEventListener('focus', refresh); + document.addEventListener('visibilitychange', refresh); + return () => { + controller.abort(); + clearInterval(timer); + window.removeEventListener('focus', refresh); + document.removeEventListener('visibilitychange', refresh); + }; + }, [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[] = [ + { + key: 'name', + title: type === 'industry' ? '通道 / 运营商' : '企业 / 应用 / 签名', + width: '260px', + render: (row) => ( +
+ {title(row.dimensions)} +

{row.dimensions.channelId ?? row.dimensions.signatureId}

+
+ ), + }, + { key: 'total', title: '窗口提交量', width: '110px', render: (row) => row.metrics.total.toLocaleString() }, + ...(type === 'overall' ? [60, 300, 1200] : [5, 20, 60]).map((seconds, i): TableColumn => ({ + key: `rate${seconds}`, + title: `${seconds < 60 ? `${seconds}秒` : `${seconds / 60}分钟`}到达率`, + width: '165px', + render: (row) => , + })), + { + key: 'state', + title: '状态 / 规则', + width: '140px', + render: (row) => ( + <> + + {stale ? '数据延迟' : states[row.status]} + +

+ {ruleSource(row.rule)} + {row.rule ? ` v${row.rule.version}` : ''} +

+ + ), + }, + { + key: 'detail', + title: '操作', + width: '110px', + render: (row) => ( + + ), + }, + ]; + 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 = ( +
+
+ {cards.map(([label, value]) => ( +
+ {label} + {data ? Number(value).toLocaleString() : '—'} + {type === 'industry' ? '按通道发送尝试' : '按唯一业务短信'} +
+ ))} +
+
- + 评估时刻:{time(counts[0]?.evaluationAt)} +

+ 每{type === 'overall' ? '10' : '5'}分钟评估,提交窗口最近{type === 'overall' ? '30' : '5'}分钟 +

+ + 采集检查:{time(health?.checkedAt)} + {stale ? ' · 数据延迟或等待首次计算,暂停告警判断' : ' · 已完成采集'} +
- + update({ keyword: e.target.value, page: '1' })} + /> + { + setKind(e.target.value as typeof kind); + setPage(1); + setKeyword(''); + }} + /> + { + setKeyword(e.target.value); + setPage(1); + }} + /> + { + changeScope({}, e.target.checked); + }} + />{' '} + 配置个性规则 + + {custom && } + + )} + {error && ( +

+ {error} +

+ )} + {!loaded &&

正在加载规则…

} +
{ + dirtyRef.current = true; + setDirty(true); + }} + > + + setMin(e.target.value)} /> + {(type === 'overall' ? ['1分钟', '5分钟', '20分钟'] : ['5秒', '20秒', '1分钟']).map((label, i) => ( + setThresholds((values) => values.map((v, index) => (index === i ? e.target.value : v)))} + /> + ))} + setBad(e.target.value)} + /> + setGood(e.target.value)} + /> +
+

当前编辑版本:{version || '新规则'}。生效顺序:应用×签名 → 签名 → 应用 → 通用,整套覆盖。

+

+ 当前生效: + {matched.length + ? matched.map((r) => `${ruleSource(r)} v${r.version}(${time(r.effectiveAt)})`).join(' → ') + : '尚未配置'} +

+ {custom && version > 0 && ( + + )} + {type === 'overall' && + rules + .filter((r) => r.type === type && Object.keys(r.scope).length && !r.config.deleted) + .map((r) => ( +
+ + {ruleSource(r)} · {Object.values(r.scope).join(' / ')} · v{r.version} + + +
+ ))} +
+ + ); +} + +export function MonitorTargetsModal({ onClose }: { onClose: () => void }) { + const [targets, setTargets] = useState([]), + [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 ( + 关闭}> +
+

加入和移除仅影响行业质量监控,不改变通道路由或发送配置。下一周期生效。

+ setKeyword(e.target.value)} /> + {error && ( +

+ {error} +

+ )} + {targets + .filter((t) => t.name.includes(keyword)) + .map((t) => ( +
+
+ {t.name} +

+ {t.status === 'active' ? '业务启用' : '业务停用'} · {time(t.effectiveFrom)} +

+
+ {t.enabled ? '已纳管' : '未纳管'} + +
+ ))} +
+
+ ); +} + +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 ( + + + + + } + > +

{channel.name}已保存。是否加入行业通道监控?

+

关闭后仍可在发送监控中加入。复制通道不会继承原通道的监控设置。

+ {error && ( +

+ {error},可重试加入,无需重新创建通道。 +

+ )} +
+ ); +} diff --git a/src/apps/admin/sending-monitor/MonitorDetails.tsx b/src/apps/admin/sending-monitor/MonitorDetails.tsx new file mode 100644 index 0000000..e1abf0f --- /dev/null +++ b/src/apps/admin/sending-monitor/MonitorDetails.tsx @@ -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 = { + recovered: '已连续恢复', + data_corrected: '迟到数据修正', + disabled: '规则已停用', + rule_changed: '规则版本变化', + enrollment_removed: '已移出监控', +}; +export function Rate({ value }: { value: Metric }) { + return ( +
+ {value.rate === null ? '—' : `${value.rate.toFixed(2)}%`} + + {value.success.toLocaleString()} / {value.mature.toLocaleString()} 成熟 + + + {value.observing} 条观察中{value.insufficient ? ' · 样本不足' : ''} + {value.bad ? ' · 低于下限' : ''} + +
+ ); +} +export function MonitorHistory({ row, onClose, alert }: { row: Snapshot; onClose: () => void; alert?: Alert }) { + const [range, setRange] = useState('2h'), + [items, setItems] = useState([]), + [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( + () => ({ + 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 ( + 关闭}> +
+

{title(row.dimensions)}

+ {alert && ( +
+

+ 告警开始:{time(alert.openedAt)} · 最近评估:{time(alert.lastEvaluatedAt)} · {states[alert.state]}{' '} + {closeReasons[alert.closeReason ?? ''] ?? ''} +

+

+ 最差快照:{time(alert.worst.evaluationAt)} ·{' '} + {alert.worst.metrics.metrics + .map( + (m) => + `${m.seconds}秒 ${m.rate === null ? '—' : m.rate.toFixed(2) + '%'}(${m.success}/${m.mature})`, + ) + .join(';')} +

+
+ )} + + 查看该窗口样本(沿用短信详情权限,样本保留72小时) + +

+ 统计窗口:{time(row.windowFrom)} 至 {time(row.evaluationAt)}(不含结束时刻) +

+

+ 观察截止:{time(row.observedUntil)} · {row.stage === 'final' ? '定稿' : '初评'} · 修订 {row.revision} +

+

+ {ruleSource(row.rule)}{' '} + {row.rule + ? `v${row.rule.version},最低成熟 ${row.rule.config.minSamples} 条,下限 ${row.rule.config.thresholds.map((v) => (v === null ? '未启用' : `${v}%`)).join(' / ')}` + : '等待配置阈值'} +

+

+ 不可评估 {row.metrics.unassessable} 条。{row.completeness.reason} +

+ ({ value, label: states[value] })), + ]} + onChange={(e) => { + setState(e.target.value); + setPage(1); + }} + /> + {error && ( +

+ {error} +

+ )} + + + {detail && setDetail(null)} />} + + ); +} diff --git a/src/apps/admin/sending-monitor/monitorApi.ts b/src/apps/admin/sending-monitor/monitorApi.ts new file mode 100644 index 0000000..462780d --- /dev/null +++ b/src/apps/admin/sending-monitor/monitorApi.ts @@ -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 = { 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 = { + 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, signal?: AbortSignal) => + request< + Page & { 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( + withQuery('/admin/sending-monitor/history', { type: row.type, dimensionId: row.dimensionKey, range }), + ), + targets: () => request('/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('/admin/sending-monitor/rules'), + saveRule: (r: Pick) => + request('/admin/sending-monitor/rules', { method: 'POST', body: JSON.stringify(r) }), + effective: (type: MonitorType, scope: Scope) => + request(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>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }), + read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }), +}; diff --git a/src/apps/report-notifications/ReportNotificationsPage.test.tsx b/src/apps/report-notifications/ReportNotificationsPage.test.tsx new file mode 100644 index 0000000..0b5ec0b --- /dev/null +++ b/src/apps/report-notifications/ReportNotificationsPage.test.tsx @@ -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(); + 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(); + 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(); +}); diff --git a/src/apps/report-notifications/ReportNotificationsPage.tsx b/src/apps/report-notifications/ReportNotificationsPage.tsx new file mode 100644 index 0000000..1c4f129 --- /dev/null +++ b/src/apps/report-notifications/ReportNotificationsPage.tsx @@ -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 = { 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>({ 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>(`${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>(`${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[] = [ + { + key: 'read', + title: '状态', + width: '90px', + render: (row) => {row.unread ? '未读' : '已读'}, + }, + { 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) => ( +
+ {row.signatureCount} 个签名、{row.drainageCount} 条引流信息已具备三网全国通道报备成功记录 +
+ ), + }, + { + key: 'actions', + title: '操作', + width: '110px', + render: (row) => ( + + ), + }, + ]; + return ( +
+
+ + +
+

+ 从三网均无全国通道报备成功,变为移动、联通、电信均有全国通道报备成功时通知。按企业与北京时间小时汇总。 +

+ + {error && ( +

+ {error} +

+ )} + {loading ? ( +

正在加载通知…

+ ) : ( +
+
+ {data.total === 0 &&

暂无报备状态变化通知

} + + )} +
+ + 共 {data.total} 条 · 第 {page} 页 + + + +
+ {detail && ( + setDetail(null)} + footer={ + <> + + + + } + > +
+

+ {detail.hour.tenantName} · {time(detail.hour.hour)} · 版本 {detail.hour.revision} +

+ {detailError && ( +

+ {detailError} +

+ )} + {detail.items.map((item) => ( +
+ 三网报备成功 + + {item.reportType === 'signature' ? '签名' : '引流信息'}:{item.targetName} + + + 应用:{item.applicationName ?? '未关联应用'} · 签名:{item.signatureName} + + +
+ ))} +
+ 共 {detail.total} 项 + + +
+
+
+ )} + + ); +} diff --git a/src/apps/report-notifications/report-notifications.css b/src/apps/report-notifications/report-notifications.css new file mode 100644 index 0000000..5ee31a3 --- /dev/null +++ b/src/apps/report-notifications/report-notifications.css @@ -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; +} diff --git a/src/layouts/AdminLayout.test.tsx b/src/layouts/AdminLayout.test.tsx index 415379f..487ed83 100644 --- a/src/layouts/AdminLayout.test.tsx +++ b/src/layouts/AdminLayout.test.tsx @@ -67,11 +67,19 @@ it('separates retirement counts from security/monitoring and leaves audit counts await user.click(screen.getByRole('button', { name: '预警通知' })); const alerts = screen.getByRole('menu', { name: '预警中心' }); 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(); await user.click(screen.getByRole('button', { name: '报备任务提醒' })); expect(screen.queryByRole('menu', { name: '预警中心' })).not.toBeInTheDocument(); 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( 'href', '/admin/signature-retirement', diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index f06f0e1..1374e42 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -34,6 +34,7 @@ import { adminApi, type PendingAuditCounts } from '@/api/adminApi'; import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session'; import { AppShell } from '@/layouts/AppShell'; import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary'; +import { request } from '@/api/core/httpClient'; const EMPTY_PENDING_AUDITS: Omit = { enterpriseCertifications: 0, @@ -54,6 +55,8 @@ export function AdminLayout() { function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS); 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 [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 }); const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked)); @@ -72,8 +75,20 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { adminApi.getSignatureRetirementUnreadCount(), adminApi.getSecurityNotificationSummary(), 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); setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 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('cmpp-audit-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-infrastructure-alert-count-refresh', onAuditRefresh); return () => { @@ -107,6 +124,8 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { window.removeEventListener('focus', onFocus); window.removeEventListener('cmpp-audit-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-infrastructure-alert-count-refresh', onAuditRefresh); }; @@ -123,6 +142,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { userRole="平台管理员" onSessionLockedChange={setSessionLocked} reportingNotifications={[ + { + label: '报备状态变化通知', + count: reportSummary.count, + description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息', + to: '/admin/report-records?tab=readiness', + }, { label: '签名清退预警', count: retirementUnreadCount, @@ -136,6 +161,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { }, ]} alertNotifications={[ + { + label: '发送质量告警', + count: monitorSummary.count, + description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警', + to: '/admin/monitor?tab=alerts', + }, { label: '安全检测与封禁', count: securityAlertSummary.count, diff --git a/src/layouts/AppShell.tsx b/src/layouts/AppShell.tsx index 8ec8959..42d38f6 100644 --- a/src/layouts/AppShell.tsx +++ b/src/layouts/AppShell.tsx @@ -86,6 +86,21 @@ export function AppShell({ const [closedSections, setClosedSections] = useState>({}); const [userMenuOpen, setUserMenuOpen] = useState(false); 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 [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); diff --git a/src/layouts/ClientLayout.tsx b/src/layouts/ClientLayout.tsx index c48be06..dd9bcb0 100644 --- a/src/layouts/ClientLayout.tsx +++ b/src/layouts/ClientLayout.tsx @@ -5,7 +5,6 @@ import { Home, MessageSquareText, PenLine, - ReceiptText, Cable, ShieldCheck, Users, @@ -14,7 +13,11 @@ import { AppShell } from '@/layouts/AppShell'; import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary'; export function ClientLayout() { - return {(session) => }; + return ( + + {(session) => } + + ); } function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) { @@ -30,7 +33,10 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session navSections={[ { title: '概览', - items: [{ label: '工作台', to: '/client', icon: Home }], + items: [ + { label: '工作台', to: '/client', icon: Home }, + { label: '消息通知', to: '/client/notifications', icon: MessageSquareText }, + ], }, { title: '短信业务', @@ -52,9 +58,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session }, { title: '账户', - items: [ - { label: '账户余额', to: '/client/billing', icon: BadgeDollarSign }, - ], + items: [{ label: '账户余额', to: '/client/billing', icon: BadgeDollarSign }], }, { title: '系统管理', diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 8c1966a..8b9a335 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -71,6 +71,10 @@ const AdminGatewaySubmitExceptionsPage = lazyNamed( ); const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome'); 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 AdminRechargeRecordsPage = lazyNamed( () => import('@/apps/admin/AdminRechargeRecordsPage'), @@ -173,6 +177,7 @@ export function AppRoutes() { } /> } /> }> + } /> } /> } /> } /> diff --git a/tools/deploy/production-deploy.sh b/tools/deploy/production-deploy.sh index 57244d7..3a04cc9 100644 --- a/tools/deploy/production-deploy.sh +++ b/tools/deploy/production-deploy.sh @@ -129,7 +129,7 @@ PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-pro chmod 600 "$ADMIN_CREDENTIAL_FILE" || true 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" node_bin="$(command -v node)" @@ -175,6 +175,31 @@ RestartSec=5 StandardOutput=append:$APP_DIR/logs/report-material-worker/stdout.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 < `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; +});