From 8ad8e6179305f7be3046fc761826c6f1c1b9e4c2 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Thu, 6 Aug 2026 10:48:36 +0800 Subject: [PATCH] release: prepare RealeseV2.3 --- .env.example | 2 + .../migration.sql | 43 +++++ api/prisma/schema.prisma | 55 +++++- api/src/channels/channels.helpers.ts | 9 + api/src/channels/channels.service.spec.ts | 15 ++ api/src/open-api/open-api.service.spec.ts | 15 ++ api/src/open-api/open-api.service.ts | 15 +- .../operations/admin-operations.controller.ts | 28 ++++ api/src/operations/operations.contracts.ts | 11 ++ api/src/operations/operations.service.spec.ts | 84 ++++++++++ api/src/operations/operations.service.ts | 10 +- .../operations/queries/dashboard.queries.ts | 4 +- .../operations/queries/downstream.queries.ts | 63 ++++++- api/src/send-chain/send-chain.helpers.ts | 7 + api/src/send-chain/send-chain.service.spec.ts | 122 ++++++++++++++ api/src/send-chain/send-receipt.service.ts | 137 ++++++++++++++- docs/contracts/admin-api-r1-methods.json | 10 +- docs/contracts/channels-r5-methods.json | 6 +- docs/contracts/inbound-r6-declarations.json | 12 +- docs/contracts/operations-r2-methods.json | 28 ++-- docs/contracts/send-chain-r10-completion.json | 2 +- .../first-version-development-requirements.md | 15 +- docs/production-deployment.md | 8 +- docs/system-functional-test-cases.md | 20 ++- docs/testing-progress.md | 42 +++++ src/api/admin/operations.api.ts | 5 +- src/api/types/channels-reports.ts | 2 +- src/api/types/identity-config.ts | 13 +- src/api/types/operations.ts | 32 ++++ .../AdminGatewaySubmitExceptionsPage.tsx | 34 +++- src/apps/admin/channels/ChannelFormModal.tsx | 14 +- src/apps/admin/channels/channelModel.ts | 7 +- src/apps/admin/channels/channelTypes.ts | 2 + .../ReceiptAnomalyPanel.tsx | 158 ++++++++++++++++++ src/apps/client/ClientHttpApiPage.tsx | 7 +- src/layouts/AdminLayout.tsx | 15 +- src/utils/interfaceParams.ts | 9 +- 37 files changed, 997 insertions(+), 64 deletions(-) create mode 100644 api/prisma/migrations/20260806100000_add_receipt_anomalies/migration.sql create mode 100644 src/apps/admin/gateway-exceptions/ReceiptAnomalyPanel.tsx diff --git a/.env.example b/.env.example index 7cebeea..9085d5f 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_URL=redis://127.0.0.1:6379 HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters +# Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters. +HTTP_API_PUBLIC_ORIGIN=https://api.example.com API_ENABLE_SEND_WORKER=true API_SEND_WORKER_CONCURRENCY=50 ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000 diff --git a/api/prisma/migrations/20260806100000_add_receipt_anomalies/migration.sql b/api/prisma/migrations/20260806100000_add_receipt_anomalies/migration.sql new file mode 100644 index 0000000..f7e3a00 --- /dev/null +++ b/api/prisma/migrations/20260806100000_add_receipt_anomalies/migration.sql @@ -0,0 +1,43 @@ +CREATE TABLE "SmsReceiptAnomaly" ( + "id" TEXT NOT NULL, + "anomalyKey" TEXT NOT NULL, + "tenantId" TEXT, + "applicationId" TEXT, + "channelId" TEXT, + "messageRecordId" TEXT, + "submitRecordId" TEXT, + "receiptRecordId" TEXT, + "anomalyType" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "previousStatus" TEXT, + "incomingStatus" TEXT, + "rawStatus" TEXT, + "errorCode" TEXT, + "detail" JSONB, + "occurrenceCount" INTEGER NOT NULL DEFAULT 1, + "firstOccurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastOccurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolvedAt" TIMESTAMP(3), + "resolutionNote" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SmsReceiptAnomaly_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SmsReceiptAnomaly_anomalyKey_key" ON "SmsReceiptAnomaly"("anomalyKey"); +CREATE INDEX "SmsReceiptAnomaly_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("status", "lastOccurredAt"); +CREATE INDEX "SmsReceiptAnomaly_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("lastOccurredAt"); +CREATE INDEX "SmsReceiptAnomaly_anomalyType_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("anomalyType", "lastOccurredAt"); +CREATE INDEX "SmsReceiptAnomaly_tenantId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("tenantId", "status", "lastOccurredAt"); +CREATE INDEX "SmsReceiptAnomaly_applicationId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("applicationId", "status", "lastOccurredAt"); +CREATE INDEX "SmsReceiptAnomaly_channelId_status_lastOccurredAt_idx" ON "SmsReceiptAnomaly"("channelId", "status", "lastOccurredAt"); +CREATE INDEX "SmsReceiptAnomaly_messageRecordId_idx" ON "SmsReceiptAnomaly"("messageRecordId"); +CREATE INDEX "SmsReceiptAnomaly_submitRecordId_idx" ON "SmsReceiptAnomaly"("submitRecordId"); + +ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_submitRecordId_fkey" FOREIGN KEY ("submitRecordId") REFERENCES "SmsSubmitRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SmsReceiptAnomaly" ADD CONSTRAINT "SmsReceiptAnomaly_receiptRecordId_fkey" FOREIGN KEY ("receiptRecordId") REFERENCES "SmsReceiptRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 1a760c9..2685b7c 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -47,6 +47,7 @@ model Tenant { cmppConnectionStates CmppConnectionState[] gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] + smsReceiptAnomalies SmsReceiptAnomaly[] openApiRequests OpenApiRequest[] httpWebhookEvents HttpWebhookEvent[] cmppInboundLongMessages CmppInboundLongMessage[] @@ -453,6 +454,7 @@ model SmsApplication { connectionStates CmppConnectionState[] gatewayDownstreamRecoveryStatuses GatewayDownstreamRecoveryStatus[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] + receiptAnomalies SmsReceiptAnomaly[] httpConfig SmsApplicationHttpConfig? httpIpAllowlist SmsApplicationHttpIpAllowlist[] httpApiCredentials HttpApiCredential[] @@ -834,6 +836,7 @@ model SmsChannel { uplinkMessages SmsUplinkMessage[] connectionStates CmppConnectionState[] gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] + receiptAnomalies SmsReceiptAnomaly[] @@index([status]) @@index([status, createdAt]) @@ -1559,6 +1562,7 @@ model SmsMessageRecord { submitRecords SmsSubmitRecord[] receiptRecords SmsReceiptRecord[] segmentAudits SmsMessageSegmentAudit[] + receiptAnomalies SmsReceiptAnomaly[] matchedUplinks SmsUplinkMessage[] @relation("SmsUplinkMatchedMessage") uplinkMatchCandidates SmsUplinkMatchCandidate[] downstreamDeliveries CmppDownstreamDelivery[] @@ -1622,6 +1626,7 @@ model SmsSubmitRecord { retryOfSubmitRecord SmsSubmitRecord? @relation("SmsSubmitRetry", fields: [retryOfSubmitRecordId], references: [id]) retrySubmit SmsSubmitRecord? @relation("SmsSubmitRetry") segmentAudits SmsMessageSegmentAudit[] + receiptAnomalies SmsReceiptAnomaly[] @@index([tenantId, createdAt]) @@index([messageRecordId]) @@ -1812,10 +1817,11 @@ model SmsReceiptRecord { deliveredAt DateTime createdAt DateTime @default(now()) - tenant Tenant? @relation(fields: [tenantId], references: [id]) - batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id]) - messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id]) - channel SmsChannel? @relation(fields: [channelId], references: [id]) + tenant Tenant? @relation(fields: [tenantId], references: [id]) + batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id]) + messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id]) + channel SmsChannel? @relation(fields: [channelId], references: [id]) + anomalies SmsReceiptAnomaly[] @@index([tenantId, createdAt]) @@index([messageId]) @@ -1824,6 +1830,47 @@ model SmsReceiptRecord { @@index([channelId, gatewayMessageId, phoneNumber]) } +model SmsReceiptAnomaly { + id String @id @default(cuid()) + anomalyKey String @unique + tenantId String? + applicationId String? + channelId String? + messageRecordId String? + submitRecordId String? + receiptRecordId String? + anomalyType String + status String @default("pending") + previousStatus String? + incomingStatus String? + rawStatus String? + errorCode String? + detail Json? + occurrenceCount Int @default(1) + firstOccurredAt DateTime @default(now()) + lastOccurredAt DateTime @default(now()) + resolvedAt DateTime? + resolutionNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull) + application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + channel SmsChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull) + messageRecord SmsMessageRecord? @relation(fields: [messageRecordId], references: [id], onDelete: SetNull) + submitRecord SmsSubmitRecord? @relation(fields: [submitRecordId], references: [id], onDelete: SetNull) + receiptRecord SmsReceiptRecord? @relation(fields: [receiptRecordId], references: [id], onDelete: SetNull) + + @@index([status, lastOccurredAt]) + @@index([lastOccurredAt]) + @@index([anomalyType, lastOccurredAt]) + @@index([tenantId, status, lastOccurredAt]) + @@index([applicationId, status, lastOccurredAt]) + @@index([channelId, status, lastOccurredAt]) + @@index([messageRecordId]) + @@index([submitRecordId]) +} + model SmsUplinkMessage { id String @id @default(cuid()) tenantId String? diff --git a/api/src/channels/channels.helpers.ts b/api/src/channels/channels.helpers.ts index 16575a8..33b8fea 100644 --- a/api/src/channels/channels.helpers.ts +++ b/api/src/channels/channels.helpers.ts @@ -336,9 +336,18 @@ export function normalizeChannelRuntimeConfig( ); base.extensionDigits = normalizeExtensionDigits(base.extensionDigits); base.serviceId = normalizeCmppServiceId(base.serviceId); + base.longMessageReceiptMode = normalizeLongMessageReceiptMode(base.longMessageReceiptMode); return base; } +export function normalizeLongMessageReceiptMode(value: unknown) { + const normalized = String(value ?? 'per_segment').trim() || 'per_segment'; + if (!['per_segment', 'message_level'].includes(normalized)) { + throw new BadRequestException('longMessageReceiptMode must be per_segment or message_level'); + } + return normalized; +} + export function normalizeCmppServiceId(value: unknown) { const normalized = String(value ?? 'SMS').trim() || 'SMS'; if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) { diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index f983644..9f5a943 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -585,6 +585,7 @@ describe('ChannelsService', () => { await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000'); await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20'); await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters'); + await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow('longMessageReceiptMode must be per_segment or message_level'); }); it('updates CMPP channel configuration without requiring password changes', async () => { @@ -686,6 +687,20 @@ describe('ChannelsService', () => { })); }); + it('persists a message-level long-message receipt mode without requesting a reconnect', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.updateChannel('channel-1', { config: { longMessageReceiptMode: 'message_level' } }); + + expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }), + }), + })); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('rejects invalid channel update ports', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts index f06ae54..638642d 100644 --- a/api/src/open-api/open-api.service.spec.ts +++ b/api/src/open-api/open-api.service.spec.ts @@ -11,6 +11,21 @@ describe('OpenApiService', () => { expect(decryptSecret(encrypted)).toBe('customer-secret'); }); + it('returns the configured public HTTPS origin for customer integration parameters', async () => { + const previous = process.env.HTTP_API_PUBLIC_ORIGIN; + process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/'; + const prisma = { + smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) }, + }; + try { + const service = new OpenApiService(prisma as never, {} as never); + await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' })); + } finally { + if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN; + else process.env.HTTP_API_PUBLIC_ORIGIN = previous; + } + }); + it('replays a completed request for the same idempotency key and body', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) }, diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index 52c95b3..64b88e1 100644 --- a/api/src/open-api/open-api.service.ts +++ b/api/src/open-api/open-api.service.ts @@ -68,6 +68,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { return { applicationId, applicationName: application.name, + publicOrigin: httpApiPublicOrigin(), config: application.httpConfig, ipAllowlist: application.httpIpAllowlist.map((item) => item.ipCidr), }; @@ -86,7 +87,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }), ...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []), ]); - return { applicationId, config, ipAllowlist }; + return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist }; } async listCredentials(applicationId: string, tenantId?: string) { @@ -419,6 +420,18 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { } } +function httpApiPublicOrigin() { + const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, ''); + if (!configured) return undefined; + const url = new URL(configured); + if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + // This value is copied into customer integration parameters, so fail closed instead of + // publishing an insecure or path-dependent endpoint when deployment config is wrong. + throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址'); + } + return url.origin; +} + function normalizeOpenApiFailure(error: unknown) { if (error instanceof HttpException) { const value = error.getResponse(); diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 0eee339..a765d41 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -127,6 +127,11 @@ export class AdminOperationsController { return this.operations.dashboard({ tenantId }); } + @Get('pending-audits') + pendingAudits(@Query('tenantId') tenantId?: string) { + return this.operations.pendingAudits(tenantId); + } + @Get('dashboard/statistics') dashboardStatistics(@Query('tenantId') tenantId?: string) { return this.operations.dashboard({ tenantId }); @@ -220,6 +225,29 @@ export class AdminOperationsController { return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId }); } + @Get('receipt-anomalies') + receiptAnomalies( + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('channelId') channelId?: string, + @Query('anomalyType') anomalyType?: string, + @Query('status') status?: string, + @Query('keyword') keyword?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.operations.listReceiptAnomalies({ + tenantId, + applicationId, + channelId, + anomalyType, + status, + keyword, + page: Number(page), + pageSize: Number(pageSize), + }); + } + @Get('downstream-deliveries') downstreamDeliveries( @Query('tenantId') tenantId?: string, diff --git a/api/src/operations/operations.contracts.ts b/api/src/operations/operations.contracts.ts index 250e290..e3859ee 100644 --- a/api/src/operations/operations.contracts.ts +++ b/api/src/operations/operations.contracts.ts @@ -43,6 +43,17 @@ export interface GatewaySubmitDeadLetterQuery { pageSize?: number; } +export interface ReceiptAnomalyQuery { + tenantId?: string; + applicationId?: string; + channelId?: string; + anomalyType?: string; + status?: string; + keyword?: string; + page?: number; + pageSize?: number; +} + export interface DownstreamDeliveryQuery { tenantId?: string; applicationId?: string; diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 48423ba..978a4e6 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -96,6 +96,26 @@ function createPrismaMock() { groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }), }, + smsReceiptAnomaly: { + findMany: jest.fn().mockResolvedValue([{ + id: 'receipt-anomaly-1', + anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1', + anomalyType: 'aggregate_success_then_failure', + status: 'pending', + occurrenceCount: 1, + firstOccurredAt: new Date('2026-08-06T01:00:00.000Z'), + lastOccurredAt: new Date('2026-08-06T01:00:00.000Z'), + tenant: { name: '租户A' }, + application: { name: '应用A' }, + channel: { name: '通道A' }, + messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' }, + submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' }, + receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' }, + }]), + count: jest.fn().mockResolvedValue(1), + groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), + findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }), + }, gatewayDownstreamRecoveryStatus: { findMany: jest.fn().mockResolvedValue([{ id: 'recover-1', @@ -612,6 +632,27 @@ describe('OperationsService', () => { expect(prisma.$queryRaw).toHaveBeenCalledTimes(4); }); + it('returns pending audit counts without running the full dashboard aggregation', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.pendingAudits('tenant-1')).resolves.toEqual({ + enterpriseCertifications: 1, + smsAudits: 2, + templates: 1, + signatures: 1, + drainageInfos: 0, + total: 5, + }); + expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); + expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); + expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); + expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending' } }); + expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending_review' } }); + expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled(); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); + it('rejects invalid send quality dates', async () => { const service = new OperationsService(createPrismaMock() as never); await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效'); @@ -884,6 +925,49 @@ describe('OperationsService', () => { }); }); + it('returns paginated receipt anomalies with status summary', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.listReceiptAnomalies({ + tenantId: 'tenant-1', + channelId: 'channel-1', + status: 'pending', + anomalyType: 'aggregate_success_then_failure', + keyword: 'MSG-1', + page: 1, + pageSize: 10, + })).resolves.toEqual(expect.objectContaining({ + total: 1, + page: 1, + pageSize: 10, + summary: { + pending: 1, + resolved: 0, + ignored: 0, + oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'), + }, + })); + expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + tenantId: 'tenant-1', + channelId: 'channel-1', + status: 'pending', + anomalyType: 'aggregate_success_then_failure', + }), + orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }], + take: 10, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + channel: { select: { id: true, code: true, name: true, status: true } }, + messageRecord: { select: { messageId: true, phoneNumber: true, status: true } }, + submitRecord: { select: { submitId: true, submitStatus: true } }, + receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } }, + }, + })); + }); + it('returns paginated downstream deliveries', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index ec933b0..b6da780 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import type { DownstreamDeliveryDashboardQuery, DownstreamDeliveryQuery, DownstreamRecoveryStatusQuery, GatewaySubmitDeadLetterQuery, MessageQuery, MessageSegmentAuditQuery, OperationLogQuery, SignatureQualityQuery, TraceQuery } from './operations.contracts'; +import type { DownstreamDeliveryDashboardQuery, DownstreamDeliveryQuery, DownstreamRecoveryStatusQuery, GatewaySubmitDeadLetterQuery, MessageQuery, MessageSegmentAuditQuery, OperationLogQuery, ReceiptAnomalyQuery, SignatureQualityQuery, TraceQuery } from './operations.contracts'; import { OperationsMessageQueries } from './queries/messages.queries'; import { OperationsUplinkQueries } from './queries/uplink.queries'; import { OperationsDashboardQueries } from './queries/dashboard.queries'; @@ -80,6 +80,10 @@ export class OperationsService { return this.dashboardQueries.dashboard(query); } + pendingAudits(tenantId?: string) { + return this.dashboardQueries.pendingAudits(tenantId); + } + async clientDashboard(query: { tenantId?: string }) { return this.dashboardQueries.clientDashboard(query); } @@ -112,6 +116,10 @@ export class OperationsService { return this.downstreamQueries.listGatewaySubmitDeadLetters(query); } + async listReceiptAnomalies(query: ReceiptAnomalyQuery) { + return this.downstreamQueries.listReceiptAnomalies(query); + } + async listDownstreamDeliveries(query: DownstreamDeliveryQuery) { return this.downstreamQueries.listDownstreamDeliveries(query); } diff --git a/api/src/operations/queries/dashboard.queries.ts b/api/src/operations/queries/dashboard.queries.ts index 8c2f8a6..bd586a6 100644 --- a/api/src/operations/queries/dashboard.queries.ts +++ b/api/src/operations/queries/dashboard.queries.ts @@ -71,7 +71,7 @@ async dashboard(query: { tenantId?: string }) { _count: { _all: true }, _sum: { currentConnections: true, desiredConnections: true }, }), - this.countPendingAudits(query.tenantId), + this.pendingAudits(query.tenantId), this.prisma.tenantAccount.findMany({ where: query.tenantId ? { tenantId: query.tenantId } : undefined, include: { tenant: true }, @@ -358,7 +358,7 @@ async clientDashboard(query: { tenantId?: string }) { }, }; } -private countPendingAudits(tenantId?: string) { +pendingAudits(tenantId?: string) { return Promise.all([ this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), diff --git a/api/src/operations/queries/downstream.queries.ts b/api/src/operations/queries/downstream.queries.ts index 82872e5..368892a 100644 --- a/api/src/operations/queries/downstream.queries.ts +++ b/api/src/operations/queries/downstream.queries.ts @@ -3,7 +3,7 @@ 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 type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, ReceiptAnomalyQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; import { 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'; // R2 downstream query domain. Method bodies are preserved byte-for-byte from the facade baseline. @@ -73,6 +73,67 @@ async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) { }, }; } +async listReceiptAnomalies(query: ReceiptAnomalyQuery) { + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); + const baseWhere: Prisma.SmsReceiptAnomalyWhereInput = { + tenantId: query.tenantId, + applicationId: query.applicationId, + channelId: query.channelId, + anomalyType: query.anomalyType && query.anomalyType !== 'all' ? query.anomalyType : undefined, + OR: query.keyword ? [ + { anomalyKey: { contains: query.keyword } }, + { rawStatus: { contains: query.keyword } }, + { errorCode: { contains: query.keyword } }, + { messageRecord: { messageId: { contains: query.keyword } } }, + { submitRecord: { submitId: { contains: query.keyword } } }, + ] : undefined, + }; + const where: Prisma.SmsReceiptAnomalyWhereInput = { + ...baseWhere, + status: query.status && query.status !== 'all' ? query.status : undefined, + }; + const [items, total, statusGroups, oldestPending] = await Promise.all([ + this.prisma.smsReceiptAnomaly.findMany({ + where, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + channel: { select: { id: true, code: true, name: true, status: true } }, + messageRecord: { select: { messageId: true, phoneNumber: true, status: true } }, + submitRecord: { select: { submitId: true, submitStatus: true } }, + receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } }, + }, + orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.smsReceiptAnomaly.count({ where }), + this.prisma.smsReceiptAnomaly.groupBy({ + by: ['status'], + where: baseWhere, + _count: { _all: true }, + }), + this.prisma.smsReceiptAnomaly.findFirst({ + where: { ...baseWhere, status: 'pending' }, + orderBy: { firstOccurredAt: 'asc' }, + select: { firstOccurredAt: true }, + }), + ]); + const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all])); + return { + items, + total, + page, + pageSize, + summary: { + pending: statusCounts.get('pending') ?? 0, + resolved: statusCounts.get('resolved') ?? 0, + ignored: statusCounts.get('ignored') ?? 0, + oldestPendingAt: oldestPending?.firstOccurredAt ?? null, + }, + }; + } async listDownstreamDeliveries(query: DownstreamDeliveryQuery) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); diff --git a/api/src/send-chain/send-chain.helpers.ts b/api/src/send-chain/send-chain.helpers.ts index 3fea43e..3915283 100644 --- a/api/src/send-chain/send-chain.helpers.ts +++ b/api/src/send-chain/send-chain.helpers.ts @@ -24,6 +24,13 @@ export const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000; export const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000; +export function longMessageReceiptMode(config: unknown): 'per_segment' | 'message_level' { + if (!config || typeof config !== 'object' || Array.isArray(config)) return 'per_segment'; + return (config as Record).longMessageReceiptMode === 'message_level' + ? 'message_level' + : 'per_segment'; +} + export const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000; export const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000; diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 8ccba0b..628d59f 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -198,6 +198,9 @@ function createPrismaMock() { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn(), }, + smsReceiptAnomaly: { + upsert: jest.fn().mockResolvedValue({ id: 'receipt-anomaly-1', status: 'pending' }), + }, smsUplinkMessage: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })), findMany: jest.fn(), @@ -2654,6 +2657,125 @@ describe('SendChainService', () => { }); }); + it('treats one delivered receipt as the whole long-message success only for a message-level receipt channel', async () => { + const { service, prisma } = createService(); + prisma.smsChannel.findUnique.mockResolvedValue({ + id: 'channel-1', + config: { longMessageReceiptMode: 'message_level' }, + }); + prisma.smsMessageRecord.findUnique.mockResolvedValue({ + id: 'record-message-level', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + messageId: 'MSG-MESSAGE-LEVEL', + submitId: 'SUB-MESSAGE-LEVEL', + phoneNumber: '13127620092', + channelId: 'channel-1', + gatewayMessageId: 'GW-MESSAGE-LEVEL-1', + status: 'submitted', + billingUnits: 2, + }); + prisma.smsSubmitRecord.findFirst.mockResolvedValue({ + id: 'submit-message-level', + submitId: 'SUB-MESSAGE-LEVEL', + channelId: 'channel-1', + gatewayMessageId: 'GW-MESSAGE-LEVEL-1', + }); + prisma.smsMessageSegmentAudit.findMany + .mockResolvedValueOnce([ + { id: 'segment-1', receiptStatus: 'delivered' }, + { id: 'segment-2', receiptStatus: null }, + ]) + .mockResolvedValueOnce([ + { segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, + { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', compensationType: 'supplier_message_level_receipt', deliveredAt: new Date() }, + ]); + + await service.handleReceipt({ + messageId: 'MSG-MESSAGE-LEVEL', + channelId: 'channel-1', + gatewayMessageId: 'GW-MESSAGE-LEVEL-1', + phoneNumber: '13127620092', + receiptStatus: 'delivered', + rawStatus: 'DELIVRD', + }); + + expect(prisma.smsMessageSegmentAudit.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + messageRecordId: 'record-message-level', + submitRecordId: 'submit-message-level', + receiptStatus: null, + }), + data: expect.objectContaining({ + receiptStatus: 'delivered', + compensationType: 'supplier_message_level_receipt', + }), + })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'record-message-level' }, + data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }), + })); + }); + + it('records a receipt anomaly when a message-level success is followed by a failure for the same attempt', async () => { + const { service, prisma, billing } = createService(); + prisma.smsChannel.findUnique.mockResolvedValue({ + id: 'channel-1', + config: { longMessageReceiptMode: 'message_level' }, + }); + prisma.smsMessageRecord.findUnique.mockResolvedValue({ + id: 'record-conflict', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + messageId: 'MSG-CONFLICT', + submitId: 'SUB-CONFLICT', + phoneNumber: '13127620092', + channelId: 'channel-1', + gatewayMessageId: 'GW-CONFLICT-1', + status: 'delivered', + billingUnits: 2, + }); + prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({ + id: 'segment-conflict-2', + messageRecordId: 'record-conflict', + submitRecordId: 'submit-conflict', + submitId: 'SUB-CONFLICT', + channelId: 'channel-1', + gatewayMessageId: 'GW-CONFLICT-2', + segmentIndex: 2, + segmentTotal: 2, + }); + prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([ + { segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() }, + { segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() }, + ]); + + await service.handleReceipt({ + messageId: 'MSG-CONFLICT', + channelId: 'channel-1', + gatewayMessageId: 'GW-CONFLICT-2', + phoneNumber: '13127620092', + receiptStatus: 'undelivered', + rawStatus: 'UNDELIV', + errorCode: 'SP_CONFLICT', + }); + + expect(prisma.smsReceiptAnomaly.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { anomalyKey: 'aggregate-receipt-conflict:record-conflict:SUB-CONFLICT' }, + create: expect.objectContaining({ + anomalyType: 'aggregate_success_then_failure', + previousStatus: 'delivered', + incomingStatus: 'undelivered', + }), + })); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + })); + expect(billing.refund).not.toHaveBeenCalled(); + }); + it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => { const { service, prisma } = createService(); let claimedDelivery: Record | null = null; diff --git a/api/src/send-chain/send-receipt.service.ts b/api/src/send-chain/send-receipt.service.ts index dabf75f..104b423 100644 --- a/api/src/send-chain/send-receipt.service.ts +++ b/api/src/send-chain/send-receipt.service.ts @@ -6,7 +6,7 @@ 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 { 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 { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; import { queueFinalReceiptDeliveries } from './downstream-receipt-targets'; @@ -230,8 +230,9 @@ export class SendReceiptService { }, }); } + let receiptRecordId: string | undefined; try { - await this.prisma.smsReceiptRecord.create({ + const createdReceipt = await this.prisma.smsReceiptRecord.create({ data: { tenantId: message.tenantId, batchTaskId: message.batchTaskId, @@ -249,6 +250,7 @@ export class SendReceiptService { deliveredAt, }, }); + receiptRecordId = createdReceipt.id; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const duplicate = await this.prisma.smsReceiptRecord.findUnique({ @@ -261,6 +263,18 @@ 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'; + if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') { + await this.applyMessageLevelSuccess( + message, + logicalReceipt, + deliveredAt, + resolved.submitRecordId, + resolved.submitId, + ); + } const aggregate = await this.facade.aggregateReceiptSegments( message, logicalReceipt, @@ -279,7 +293,22 @@ export class SendReceiptService { || message.gatewayMessageId === data.gatewayMessageId || (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId)) ); - if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) { + if (!isCurrentAttempt) { + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } + if (status === 'failed' && message.status === 'delivered') { + if (receiptMode === 'message_level') { + // A delivered result may already have been exposed to the customer and settled. + // Preserve that terminal decision; the contradictory late receipt is evidence for operations, not a second state transition. + await this.recordReceiptConflict({ + message, + submitRecordId: resolved.submitRecordId, + submitId: resolved.submitId, + receiptRecordId, + receiptKey, + data: logicalReceipt, + }); + } return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; @@ -343,6 +372,108 @@ export class SendReceiptService { return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } + private async getLongMessageReceiptMode(channelId?: string | null) { + if (!channelId) return 'per_segment' as const; + const channel = await this.prisma.smsChannel.findUnique({ + where: { id: channelId }, + select: { config: true }, + }); + return longMessageReceiptMode(channel?.config); + } + + private async applyMessageLevelSuccess( + message: { id: string; channelId?: string | null; submitId?: string | null }, + data: GatewayReceiptEventDto, + deliveredAt: Date, + submitRecordId?: string, + submitId?: string, + ) { + const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId) + && (!message.submitId || message.submitId === submitId); + if (!belongsToCurrentAttempt) return; + const attemptWhere = submitRecordId + ? { messageRecordId: message.id, submitRecordId } + : submitId + ? { messageRecordId: message.id, submitId } + : null; + if (!attemptWhere) return; + const segments = await this.prisma.smsMessageSegmentAudit.findMany({ + where: attemptWhere, + select: { id: true, receiptStatus: true }, + }); + if (segments.length <= 1) return; + if (segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))) { + return; + } + // This supplier contract reports one message-level success for a multipart SMS. + // Mark only missing segments as inferred so the raw receipt remains singular and auditable. + await this.prisma.smsMessageSegmentAudit.updateMany({ + where: { ...attemptWhere, receiptStatus: null }, + data: { + receiptStatus: 'delivered', + rawStatus: data.rawStatus, + errorCode: data.errorCode ?? null, + errorMessage: data.errorMessage ?? null, + compensationType: 'supplier_message_level_receipt', + deliveredAt, + }, + }); + } + + private async recordReceiptConflict(input: { + message: { id: string; tenantId?: string | null; applicationId?: string | null; status: string; messageId: string }; + submitRecordId?: string; + submitId?: string; + receiptRecordId?: string; + receiptKey: string; + data: GatewayReceiptEventDto; + }) { + // One logical conflict per message attempt keeps repeated supplier packets auditable + // without creating an unbounded queue of operationally identical anomalies. + const anomalyKey = `aggregate-receipt-conflict:${input.message.id}:${input.submitId ?? input.submitRecordId ?? 'unknown'}`; + const occurredAt = new Date(); + const detail = { + messageId: input.message.messageId, + submitId: input.submitId, + receiptKey: input.receiptKey, + gatewayMessageId: input.data.gatewayMessageId, + phoneNumber: input.data.phoneNumber, + reason: 'message_level_success_followed_by_failure', + }; + await this.prisma.smsReceiptAnomaly.upsert({ + where: { anomalyKey }, + update: { + status: 'pending', + receiptRecordId: input.receiptRecordId, + incomingStatus: input.data.receiptStatus, + rawStatus: input.data.rawStatus, + errorCode: input.data.errorCode ?? null, + detail, + occurrenceCount: { increment: 1 }, + lastOccurredAt: occurredAt, + resolvedAt: null, + resolutionNote: null, + }, + create: { + anomalyKey, + tenantId: input.message.tenantId, + applicationId: input.message.applicationId, + channelId: input.data.channelId, + messageRecordId: input.message.id, + submitRecordId: input.submitRecordId, + receiptRecordId: input.receiptRecordId, + anomalyType: 'aggregate_success_then_failure', + previousStatus: input.message.status, + incomingStatus: input.data.receiptStatus, + rawStatus: input.data.rawStatus, + errorCode: input.data.errorCode ?? null, + detail, + firstOccurredAt: occurredAt, + lastOccurredAt: occurredAt, + }, + }); + } + async recordReceiptSegment( message: { id: string; diff --git a/docs/contracts/admin-api-r1-methods.json b/docs/contracts/admin-api-r1-methods.json index de9193c..38bbd04 100644 --- a/docs/contracts/admin-api-r1-methods.json +++ b/docs/contracts/admin-api-r1-methods.json @@ -133,6 +133,10 @@ "name": "getDashboard", "implementationSha256": "4cfc7639a483d91a98b32966ad14fc73f576f6e6ed040e717b73bc2481252c5d" }, + { + "name": "getPendingAudits", + "implementationSha256": "2cf50d9d969fa66fa25603061ccd45963824dd3c043da3855f75a7ffc89bfe30" + }, { "name": "getSendQuality", "implementationSha256": "75b05d980a296badf271e6619c92a2a87471716c3ae8a94ded1e9a835bbfd079" @@ -599,7 +603,11 @@ }, { "name": "requeueGatewaySubmitException", - "implementationSha256": "b85c6125a9ae47075fd755e70cc75d653c33a1ee0ff8e5bc280738ca7a848e3c" + "implementationSha256": "5f5dc7bee19ca3fb16ea2537c92bd83434ae76c3d2d6bf7f03288c59da2b80d0" + }, + { + "name": "listReceiptAnomalies", + "implementationSha256": "7acb3583eb55b332ff229c5376bf228a684c78aa5fbfad3a748462f37a977146" }, { "name": "listStatistics", diff --git a/docs/contracts/channels-r5-methods.json b/docs/contracts/channels-r5-methods.json index 6556456..8f5374c 100644 --- a/docs/contracts/channels-r5-methods.json +++ b/docs/contracts/channels-r5-methods.json @@ -649,7 +649,11 @@ }, { "name": "normalizeChannelRuntimeConfig", - "sha256": "a979b4bcb5e7b8d1acfb0b99196a9c8dec7f834116140af170113b25da7d14d5" + "sha256": "0105469fae58cceb27cfddac83996c05590787429b545ec6d532fcf20db83b5a" + }, + { + "name": "normalizeLongMessageReceiptMode", + "sha256": "42c58d5a3151c16c13a773e140b7c1ce5f200d1c2187d9ae2e59f64d21791e34" }, { "name": "normalizeCmppServiceId", diff --git a/docs/contracts/inbound-r6-declarations.json b/docs/contracts/inbound-r6-declarations.json index bab12b6..8ce1280 100644 --- a/docs/contracts/inbound-r6-declarations.json +++ b/docs/contracts/inbound-r6-declarations.json @@ -204,7 +204,7 @@ "name": "pushReceiptWithResult", "kind": "func", "file": "delivery.go", - "sha256": "ed0be61791a471b59a92601aa34f47b9d72c606096863493bb1dbb89a2d5e0a9" + "sha256": "af51d1192d346c181326f3344680718dd0734185cc4be4c4ec889847ace6ef35" }, { "name": "recoverReceiptSession", @@ -426,7 +426,7 @@ "name": "rememberDownstream", "kind": "func", "file": "sessions.go", - "sha256": "0803f1f2b2c69d47ac4adf0ec3d48fc122b5c994318dda93738b9c4d5ea56f64" + "sha256": "1f533aa5c3bbf325718f9004c22d6f8ba8527323f66bf299989b7b5ea354c069" }, { "name": "removePresence", @@ -474,7 +474,7 @@ "name": "handleSubmit", "kind": "func", "file": "submit.go", - "sha256": "fcb1d5ada61ff28a39976506e6a14f1fc09f840eaa4033ec9ea3fb9b8f0882f0" + "sha256": "61e3b8235ab9121a82651e07ce53e38cb650561d80c5f1e4fe6bf4b101b4240a" }, { "name": "inboundLongMessageFragment", @@ -486,7 +486,7 @@ "name": "inboundSubmitPacket", "kind": "type", "file": "submit.go", - "sha256": "50984034ef07833dcc2f4c4156cfe7f0add8767ae2b4d376001931e4f211e9d5" + "sha256": "adac6aed04068f3811fbcf0530ab54f7384f0ea85a303c9c70326b5a9a46ba78" }, { "name": "messageIDFrom", @@ -498,7 +498,7 @@ "name": "normalizeInboundSubmit", "kind": "func", "file": "submit.go", - "sha256": "e9f6b5bee5fe114d0fefee910d818bafa6834a00709fade7f4e0468f821e69fd" + "sha256": "977d81193fdc2adfd402d6b1ddb142ea2fdf44f7c8f8d2d00ca8cf20d2cf0830" }, { "name": "setInboundSubmitResponse", @@ -516,7 +516,7 @@ "name": "submitRequest", "kind": "type", "file": "submit.go", - "sha256": "8b622eefb6255f762b332a1d161269b116c234018a32cc881b9bde138a8fb1cd" + "sha256": "80791da6ef4e968dbafbea288ef783422b4c089013a05175b6add32bf8919df0" }, { "name": "submitResponse", diff --git a/docs/contracts/operations-r2-methods.json b/docs/contracts/operations-r2-methods.json index 598c111..cfdc737 100644 --- a/docs/contracts/operations-r2-methods.json +++ b/docs/contracts/operations-r2-methods.json @@ -6,7 +6,8 @@ "MessageQuery": "b2713cdb6dedf7d6d9c3aa23235b595a15a8f0c82c045a0bd9b39151a40cf00c", "TraceQuery": "636138d9593d61b2936eb32231d071b4682ca1d36cbf6a2b07d3b71b4f5d8453", "OperationLogQuery": "084d60f6487a2b38264211bf030950baacd2bc9b218e992867fb0e429dae7ece", - "GatewaySubmitDeadLetterQuery": "d40dea1cc7d5ee369ab16a49cada5c6bcfae2b21fc66459c49d9377374aa1445", + "GatewaySubmitDeadLetterQuery": "73bd1597f13ce59cf3137cbdc5a34fef1eccc99bc6b5ef505ba5e8dee8260f7b", + "ReceiptAnomalyQuery": "03c074cf49c7ace5915bb76d4ff573fa538382951d05a63ac335bc04b4e6ccf2", "DownstreamDeliveryQuery": "fd5b570bce0723aa6e848dd74ee326f3fb920dca86d5a60ffb8b1ed743343027", "DownstreamDeliveryDashboardQuery": "62f06aba6ac8bc90b0885a70bcc6038901c1727438e7e9b70c16bfa88b2c7f08", "DownstreamRecoveryStatusQuery": "74f3018a6d582a4ffde9711217fcc32d42131a9e262ae9b39b3025bd03d2b7f3", @@ -96,7 +97,14 @@ "group": "dashboard", "isPrivate": false, "signatureSha256": "7bea6ce2744f19d6a6c7ec2078e986b9f36aa8ac139c83a9bf21f2554550a3b0", - "bodySha256": "50f10c48dcb9a0e7a7fdeae445fcd6a930a51894a76ce9834e306a70a598c574" + "bodySha256": "ff01169684ad08782b34206643205ee1c3be835a4ca42ef88c0b5427847b9f74" + }, + { + "name": "pendingAudits", + "group": "dashboard", + "isPrivate": false, + "signatureSha256": "c0564bca1049f33a3194773ad2e843865008875f2d30c61d28ccaaf521d6ca53", + "bodySha256": "81556ffd8efa6d54d3f0b906fdc2ca640d2f402e7e1fa65a43cfc493f581a57d" }, { "name": "clientDashboard", @@ -161,12 +169,19 @@ "signatureSha256": "fe33722f549a09191d3132c67c2664b5ce9df518aac70aaf3234b5abaef5e0d8", "bodySha256": "c9552eb39c44c863d7d916191245620274275229e3f7246ad2596a46ce28560b" }, + { + "name": "listReceiptAnomalies", + "group": "downstream", + "isPrivate": false, + "signatureSha256": "08bf41f600f7894221642137029fd2935e79df2190ad1749abe36860fea8dbea", + "bodySha256": "97f10224d405154e9977059032c7edf7e79ffc8cdf858f0a7bd2f838858f3499" + }, { "name": "listDownstreamDeliveries", "group": "downstream", "isPrivate": false, "signatureSha256": "5fe6e1847a711fbcf547934513367ba591e6364d630b89a7133f8400cd0a1c84", - "bodySha256": "9f5f8efa141a4efcb057586008c6d942a346ac1b64889e381d4ce23155fe06e9" + "bodySha256": "ab04062579679287ceb59f4c1cc9697bcd4a5d1282c01320f078ed68eb6c76d4" }, { "name": "downstreamDeliveryDashboard", @@ -224,13 +239,6 @@ "signatureSha256": "c6d1db8ff18f46f60f3fa5d3c4a8390469bf1f190d245a8a8b07e7fcf6ae0cbb", "bodySha256": "f1bded76987def7292858bc303485a64403f2c28fe4c2f1a7eed359c30a4f8f6" }, - { - "name": "countPendingAudits", - "group": "dashboard", - "isPrivate": true, - "signatureSha256": "033be4c74aaaf745ad29c524f528e8db97706034dd907860c56b91b6a599aba3", - "bodySha256": "ee021da1d250f65d7956bcab163a310b74cf1530cb1baf5a78052d1aeef8d654" - }, { "name": "gatewayDownstreamRecoveryStatusDelegate", "group": "downstream", diff --git a/docs/contracts/send-chain-r10-completion.json b/docs/contracts/send-chain-r10-completion.json index 3c539b6..e45a4f8 100644 --- a/docs/contracts/send-chain-r10-completion.json +++ b/docs/contracts/send-chain-r10-completion.json @@ -67,7 +67,7 @@ { "name": "handleReceipt", "file": "send-receipt.service.ts", - "bodySha256": "7302c72f2f3f3e374f38c365234f1a9705e80150de750d6fee73c22856efee46" + "bodySha256": "675a1ca5f00127d72d392cd3a60e7f07a6f21a9191891810421743ed99247dcd" }, { "name": "recordReceiptSegment", diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 6d72af3..fde2bc1 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -284,7 +284,7 @@ - 已实现 SubmitCommand 在途恢复第一版:Go Gateway submit worker 在消费新消息前会对 Redis Stream consumer group 中空闲超过阈值的 pending 命令执行 `XAUTOCLAIM`,重新提交并按正常成功路径 ack,避免 Gateway 重启后命令永久滞留在 PEL。 - 已实现上游连接断开时的 pending submit 状态补偿第一版:如果某条上游 CMPP 连接在收到 submit resp 前断开,Gateway 会立即唤醒该连接上等待中的 pending submit,请求返回 `timeout/CONNECTION_LOST`,由 NestJS 进入既有补发或释放冻结逻辑,不再只依赖固定超时。 - 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。 -- 已实现 Gateway 提交异常治理:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表(数据库表名和内部接口保留技术兼容名,页面统一称“Gateway提交异常”)。运营端 `/admin/gateway-submit-exceptions` 提供真实分页、筛选、汇总、脱敏详情和单条重新入队;原始 payload、密码、密钥不得返回浏览器。重新入队必须要求近期认证、填写原因、勾选“已确认上游未受理”,并校验短信尚未 accepted/submitted/delivered/unknown、通道 active 且 connected、人工次数小于 3;服务端以 pending 到 requeueing 的原子状态抢占防止重复点击,成功写回 Redis Stream 后记录操作人、原因、Stream ID 和时间。收到后续 SubmitResult 时必须将对应异常记录闭环为 resolved。 +- 已实现 Gateway 提交异常治理:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表(数据库表名和内部接口保留技术兼容名,页面在“网关异常”的“提交异常”Tab展示)。运营端 `/admin/gateway-submit-exceptions` 提供真实分页、筛选、汇总、脱敏详情和单条重新入队;原始 payload、密码、密钥不得返回浏览器。重新入队必须要求近期认证、填写原因、勾选“已确认上游未受理”,并校验短信尚未 accepted/submitted/delivered/unknown、通道 active 且 connected、人工次数小于 3;服务端以 pending 到 requeueing 的原子状态抢占防止重复点击,成功写回 Redis Stream 后记录操作人、原因、Stream ID 和时间。收到后续 SubmitResult 时必须将对应异常记录闭环为 resolved。 - Gateway 提交异常重新入队必须使用“异常记录 ID + 下一次人工次数”的稳定幂等键,通过 Redis Lua 原子完成“检查幂等键、XADD、保存 Stream ID”;API 在 XADD 成功后宕机或数据库落账失败时,超时恢复扫描必须复用同一幂等键完成落账,不能再次产生 Stream 消息。Gateway 重复上报同一个原始 Stream 异常不得把 `requeued/resolved` 回退成 `pending`。 - 已实现 Gateway 通道级 Redis 限速:NestJS 入队前保留业务层通道限速,Go Gateway 在真正调用上游 Submit 前再次按通道 ID 预约发送时隙;连接命令把权威 TPS 写入 Redis,提交按权威值与消息值的较小者执行。普通 Stream 消息在等待期间不 ACK、不转失败,多实例共同使用同一限速状态;worker 对同批消息并发调度,低 TPS 通道等待不阻塞其他通道。 - 已实现 Gateway 重启后的 active 上游通道恢复:部署先重启 Gateway 再重启 API;API 启动后从 PostgreSQL 读取 active 通道,重新下发连接命令,恢复 Gateway 内存连接池、真实连接状态和 Redis 权威 TPS key,不得继续沿用重启前的 connected 状态。 @@ -451,6 +451,7 @@ - 运营概览一级菜单下只保留运营看板、发送监控、数据统计;客户管理独立作为一级业务域展示,避免重复菜单。 - 右上角消息铃铛展示所有待审核任务总数,并按企业认证、短信审核、短信模板审核、签名审核等分类展示;点击分类跳转到对应审核页面。 - 新审核任务进入时,运营端应触发浏览器通知或站内提醒;提醒数据必须来自真实待审核数量接口,不得只写死前端数字。 +- 全局导航首次加载、每 30 秒轮询、窗口重新获得焦点及审核完成后的角标刷新,必须调用独立轻量待审核数量接口;该接口只统计五类待审核数量,不得调用或复用包含发送、账务、连接、下游投递和趋势查询的完整运营看板聚合。 ### 5.11 运营端客户与企业 @@ -1536,6 +1537,7 @@ - 单发复用现有 `SendChainService`,必须经过真实企业/应用、签名、模板、风控、余额、计费、通道路由和 Redis 队列链路;接收成功返回 202,不代表运营商提交或终端到达成功。 - 上行查询只返回已匹配或人工认领到当前应用的记录,默认最近 24 小时,单次范围和分页上限由应用配置控制;未匹配和歧义上行不得泄露给任一客户。 - 客户错误使用 `application/problem+json` 和稳定业务码。客户 Swagger 只包含四个 `/openapi/v1` 接口,不得包含 admin、client 管理或 gateway 内部接口。 +- 客户 HTTP API 使用独立公网源地址配置;管理页面、参数复制和 Swagger 链接必须从真实后端返回的 `HTTP_API_PUBLIC_ORIGIN` 生成,不得沿用管理页面 `window.location.origin`。预生产固定为 `https://api.lisglo.com`,该灰云域名只暴露 `/api/openapi/v1/*`、客户 Swagger 和健康检查,不得暴露 admin/client 管理接口或前端页面。 ### HTTP Webhook 与客户端页面 @@ -1754,7 +1756,7 @@ 3. 编辑启用中的通道时,仅当网关地址、端口、账号、密码、CMPP版本、连接数、窗口或心跳参数的实际值发生变化才请求重连。名称、运营商、地区、单价、服务号、扩展位、企业代码及TPS限速等业务参数不得触发重连;启用和停用状态变更仍按原规则连接或断开。 4. 运营端短信记录首次进入及点击重置后,默认查询北京时间昨天和今天两天,仍允许用户选择其他日期。 5. 下游投递详情按时间线卡片展示每次投递,分别呈现中文状态、发送/ACK/截止时间、连接ID、Sequence_Id、Msg_Id、ACK Result和错误,不使用需要横向滚动的宽表。 -6. Gateway提交异常列表标题区域必须与容器边框、表格留出清晰间距,并展示当前结果总数;分页区域具有独立分隔。 +6. “网关异常”的“提交异常”Tab列表标题区域必须与容器边框、表格留出清晰间距,并展示当前结果总数;分页区域具有独立分隔。 7. 原提议的报表“T-4未知转失败”本轮明确取消,不改变既有日报未知状态、重算逻辑或历史数据。 ## 2026-07-26 通道补发归因与发送详情补充要求 @@ -1904,3 +1906,12 @@ - `Registered_Delivery=0`的客户分片不生成CMPP状态报告;同一HTTP提交仍只生成一个消息级最终Webhook,不因供应商内部计费分片数而重复回调。 - 提交或未知状态满72小时仍无明确最终回执时,主记录转为`timeout`并写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`。CMPP对每个请求状态报告的原始分片建立失败回执,HTTP建立一个明确失败Webhook;退款保持消息级一次。 - 超时状态变更与下游回执建单之间必须可恢复:仅在HTTP事件及全部应建CMPP分片投递均成功持久化后写`timeoutReceiptQueuedAt`;中途失败保留空标记,由后续定时扫描按稳定幂等键补齐,禁止出现“已转超时但永久没有下游回执”。 + +## 供应商长短信整条级成功回执与网关异常中心(2026-08-06) + +- 通道配置增加“长短信成功回执口径”,默认值为`per_segment`(逐分片)。只有供应商明确约定长短信成功时仅返回一条、且该条代表整条短信全部分片成功,才允许人工配置为`message_level`(整条级);修改该业务口径不触发通道重连。 +- `per_segment`保持既有严格规则:只收到一个成功分片时,其他未回执分片继续等待,主记录不得提前成功。`message_level`收到当前提交尝试的一条明确成功回执时,可将同次提交中尚无回执的分片标记为推断成功,并以`compensationType=supplier_message_level_receipt`保留推断依据;真实`SmsReceiptRecord`仍只保存供应商实际返回的一条回执,不伪造多条原始回执。 +- 整条级成功推断只作用于当前通道、当前提交尝试和真实存在的多分片审计;已有明确失败的分片不得被成功推断覆盖。业务终态、退款、补发及对客户的CMPP/HTTP最终回执继续沿用既有幂等规则。 +- 已按整条级成功形成`delivered`终态后,如果同一提交尝试又收到明确失败回执,平台保留原始回执和分片审计,但不得自动把已送达终态改成失败、重复退款或向客户推送互相矛盾的失败结果;系统按稳定异常键写入`SmsReceiptAnomaly`,重复矛盾回执累加发生次数。 +- 运营菜单“Gateway提交异常”更名为“网关异常”,原路由保持兼容。页面使用“提交异常”和“回执异常”两个Tab,均查询真实后端和PostgreSQL;每个Tab必须在标题区说明其数据来源、业务含义、不能代表的结论及人工处理注意事项,避免运营人员间隔较久后误判。 +- “提交异常”展示Gateway消费提交命令连续失败且没有明确供应商提交结果的死信,可在严格确认未被供应商接收后重新入队;“回执异常”展示供应商回执与平台既有终态冲突的结构化异常判定。回执异常详情必须指明真实回执保存在回执记录、原始CMPP报文在通讯交互日志,不得用异常摘要替代原始证据。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 8b2311d..e5dba81 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -1,8 +1,9 @@ # CMPP 平台部署手册(当前实例为预发布环境) -## 当前预发布环境端口 +## 当前预发布环境端口与域名 -- 运营端、客户端页面:`http://8.160.169.106:12026` +- 运营端、客户端页面:`https://sms.lisglo.com`,Cloudflare 橙云代理,源站使用 Cloudflare Origin CA;旧 `12026` 仅在切换期临时保留。 +- 客户 HTTP API:`https://api.lisglo.com/api/openapi/v1`,Cloudflare 灰云直连,源站必须使用公网信任的 Let’s Encrypt 证书;该虚拟主机不得暴露 admin/client 管理接口或前端页面。 - API:仅本机 `127.0.0.1:3000`,由 Nginx `/api/` 反向代理。 - Redis:仅本机 `127.0.0.1:6379`。 - PostgreSQL:仅本机 `127.0.0.1:5432`。 @@ -30,6 +31,7 @@ BRANCH=main PUBLIC_HTTP_PORT=12026 API_PORT=3000 HTTP_API_MASTER_KEY=<至少32位随机值,用于AES-256-GCM加密HTTP访问凭据和Webhook密钥> +HTTP_API_PUBLIC_ORIGIN=https://api.lisglo.com API_ENABLE_SEND_WORKER=true API_SEND_WORKER_CONCURRENCY=50 ADMIN_SESSION_IDLE_TIMEOUT_MS=3600000 @@ -60,7 +62,7 @@ PROD_ADMIN_USERNAME=prod_admin PROD_ADMIN_PASSWORD='change-me' ``` -安全会话使用 HttpOnly Cookie,正式生产必须先为页面和 `/api` 配置 HTTPS,并保持 `SESSION_COOKIE_SECURE=true`。当前 HTTP 预发布环境经明确授权可临时显式设置 `SESSION_COOKIE_SECURE=false` 维持验证可用性;该例外必须记录在发布验收中,不能替代正式环境 TLS。 +安全会话使用 HttpOnly Cookie,正式生产必须先为页面和管理 API 配置 HTTPS,并保持 `SESSION_COOKIE_SECURE=true`;纯 HTTP 的 `IP:12026` 不作为受支持的登录入口,即使切换期仍保留其监听,也只允许用于非登录的兼容检查并应尽快下线。`sms.lisglo.com` 只允许 Cloudflare 回源,`api.lisglo.com` 通过独立 Nginx SNI 虚拟主机只开放客户接口、客户 Swagger 和健康检查;Let’s Encrypt 使用 DNS-01 自动续期,不依赖开放 80 端口。 系统操作日志默认在线保留 180 天。API 每日以最多 20 个、每批 1000 条的小事务将过期记录搬入 `OperationLogArchive`,并用 `archiveMonth=YYYY-MM` 标记归档月份;归档记录不会自动删除。调整保留期或批量参数前,应先评估数据库、备份窗口和审计要求。归档表达到千万级或清理窗口不能满足要求时,再实施按 `createdAt` 的月度 PostgreSQL 分区,不在当前数据规模下提前改造主表分区。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index a2279b5..2f0f0ab 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1405,7 +1405,7 @@ 1. 让同一条 `SubmitCommand` 连续处理失败,达到 Gateway 配置的异常转存阈值。 2. 检查 Redis PEL 中该消息是否被 ack,不再无限 pending。 3. 检查 NestJS 是否在真实数据库写入一条 `GatewaySubmitDeadLetter`,保存失败原因、尝试次数和原始命令载荷。 - 4. 从运营端“Gateway提交异常”页面查询异常列表并打开脱敏详情。 + 4. 从运营端“网关异常”的“提交异常”Tab查询异常列表并打开脱敏详情。 5. 完成风险确认、原因和状态校验后,将该异常命令重新写回 `gateway.submit.commands`。 - 预期结果: - 达到阈值后,Gateway 会把该消息转存为提交异常,而不是永久卡在 PEL。 @@ -3416,7 +3416,7 @@ npm run verify:phase8 | TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 | | TC-ADMIN-019 | 打开通道连接日志,按事件类型和时间查看。 | 日志包含 connect、active_test、disconnect、reconnect、auth_failed、slow_response;按时间倒序;可定位 channelId/connectionId。 | | TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 企业黑名单必须绑定短信应用且按应用生效;搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 | -| TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 | +| TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数,并观察首次加载、30 秒轮询、窗口重新获得焦点及审核完成后的网络请求。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知;所有全局角标刷新只请求独立待审核数量接口,不请求完整运营看板统计。 | | TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 | | TC-ADMIN-023 | 准备多个企业产生不同的当日真实消费金额后打开企业列表。 | API 结果按 `todaySpendCents` 降序;相同金额按企业名称和 id 稳定排序;金额来自 PostgreSQL 当日短信记录聚合。 | | TC-ADMIN-024 | 准备多个企业应用产生不同的当日真实发送数量后打开企业应用列表。 | API 结果按 `sentToday` 降序;相同数量按应用名称和 id 稳定排序;数量来自 PostgreSQL 当日短信记录聚合。 | @@ -3625,7 +3625,7 @@ npm run verify:phase8 | 用例编号 | 操作 | 预期结果 | | --- | --- | --- | -| TC-GW-SUBMIT-EXCEPTION-001 | 制造一条超过 Gateway 最大处理次数的真实 `SubmitCommand`,打开运营端“Gateway提交异常”,按状态、应用、通道和关键字筛选并查看详情。 | 记录写入 PostgreSQL,页面汇总、分页和详情来自 NestJS API;手机号脱敏,命令中的密码、密钥和原始 payload 不返回浏览器,页面不使用“死信”作为业务名称。 | +| TC-GW-SUBMIT-EXCEPTION-001 | 制造一条超过 Gateway 最大处理次数的真实 `SubmitCommand`,打开运营端“网关异常”的“提交异常”Tab,按状态、应用、通道和关键字筛选并查看详情。 | 记录写入 PostgreSQL,页面汇总、分页和详情来自 NestJS API;手机号脱敏,命令中的密码、密钥和原始 payload 不返回浏览器,页面不使用“死信”作为业务名称。 | | TC-GW-SUBMIT-EXCEPTION-002 | 对短信仍处于 pending/failed、通道 active 且 connected 的异常记录,输入 5~500 字原因,勾选“已确认上游未受理”并重新入队。 | 近期认证通过后服务端原子抢占记录、真实写入 Redis Stream;记录变为 requeued,人工次数、操作人、原因、Stream ID 和时间完整留痕,收到 SubmitResult 后变为 resolved。 | | TC-GW-SUBMIT-EXCEPTION-003 | 不勾选确认、原因过短、重复点击同一记录,或分别把短信置为 accepted/submitted/delivered/unknown、把通道置为停用/断开、人工重试达到 3 次后尝试重新入队。 | API 拒绝危险或重复操作,不产生额外 Stream 命令;页面显示可读原因,操作日志不伪造成功。 | | TC-GW-RATE-001 | 给通道 A 配置 10 TPS,连续投递 20 条;通道 B 同时配置 20 TPS 并投递,另让提交命令携带高于通道配置的数值。 | Gateway A 实际提交节奏不超过 10 TPS,B 独立按自身额度执行;消息值不能放大 A 的权威上限,同一通道跨通道组共享额度。 | @@ -3666,6 +3666,7 @@ npm run verify:phase8 | TC-HTTP-WEBHOOK-002 | 回调依次返回 500、429、408、400、302 和 200,并模拟超时。 | 500/429/408/网络错误按既定退避重试,400 和重定向终结,2xx 成功;每次尝试、状态码、耗时和截断响应写 PostgreSQL,可授权手工重投。 | | TC-HTTP-WEBHOOK-003 | 保存指向 localhost、RFC1918、链路本地、共享地址、云元数据 IP、会解析到私网的域名和发生 DNS 重绑定的 URL。 | 保存或投递前被 SSRF 校验拒绝;不跟随重定向;生产 HTTPS 约束开启时 HTTP URL 被拒绝。 | | TC-HTTP-CLIENT-001 | 客户端打开“接口对接”五个页签,切换应用、创建凭据、配置回调、查看文档与日志;API 断开后重试。 | 所有状态来自真实 API/PostgreSQL/Redis;应用卡片显示 HTTP 状态;API 失败展示错误,不使用 localStorage 或前端静态数据伪造成功。 | +| TC-HTTP-PUBLIC-ORIGIN-001 | 将管理页面部署在 `https://sms.lisglo.com`,设置 `HTTP_API_PUBLIC_ORIGIN=https://api.lisglo.com`,分别在运营端参数弹窗和客户端接口对接页查看并复制参数。 | 页面展示、复制内容和 Swagger 链接均使用 `https://api.lisglo.com`;灰云域名的四个开放接口及文档可访问,admin/client 管理接口和前端页面返回 404;不回退为管理页面域名。 | ### 17.15 手工验收瑕疵回归 @@ -3915,7 +3916,7 @@ npm run verify:phase8 | TC-CHANNEL-RECONNECT-005 | 修改网关地址、账号、连接数、窗口或心跳参数 | 保存后发送连接控制请求;停用/启用仍正确断开/连接 | | TC-SMS-RECORD-006 | 首次进入短信记录或点击重置 | 日期默认覆盖北京时间昨天和今天,并以该范围请求真实后端 | | TC-DOWNSTREAM-UI-007 | 查看包含多次投递的下游投递详情 | 每次投递按纵向时间线展示中文状态、时间、连接和ACK证据,窄屏无需横向滚动 | -| TC-GATEWAY-UI-008 | 查看Gateway提交异常列表 | 标题、说明、总数、表格和分页层次清晰,不紧贴容器边框 | +| TC-GATEWAY-UI-008 | 查看“网关异常”的“提交异常”Tab | 标题、说明、总数、表格和分页层次清晰,不紧贴容器边框 | | TC-REPORT-SCOPE-009 | 检查本轮报表变更范围 | T-4未知转失败未实现,日报未知口径和历史数据保持不变 | ## 2026-07-26 通道补发归因与发送详情用例 @@ -4387,3 +4388,14 @@ npm run verify:phase8 | TC-RECEIPT-FRAGMENT-004 | 客户在线时分别向同一长短信的两个分片推送回执 | Gateway发送的两个`CMPP_DELIVER`回执内容分别携带两个不同的原SubmitResp Msg_Id,不因在线会话按业务消息查找而都复用第一片Msg_Id | | TC-RECEIPT-TIMEOUT-005 | HTTP消息超过72小时无明确回执,首次Webhook建单失败,下一轮扫描恢复 | 主记录只转一次`timeout`并只退款一次;失败时`timeoutReceiptQueuedAt`为空,后续扫描补建`undelivered/EXPIRED/RECEIPT_TIMEOUT`事件后写入标记,HTTP最终事件只有一个 | | TC-RECEIPT-TIMEOUT-006 | CMPP长短信超过72小时无明确回执,其中部分片已有真实回执,其余片缺失 | 已有结果的分片保留自己的最终状态;缺失片收到明确`EXPIRED`失败回执;每片使用各自原SubmitResp Msg_Id并可分别ACK,重复扫描不重复发送 | + +## 2026-08-06 供应商整条级回执与网关异常中心用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-RECEIPT-MODE-001 | 新建或编辑通道,不设置长短信成功回执口径;两分片长短信只收到第一片成功 | 后端按`per_segment`默认值保存;真实回执和第一片审计落库,第二片仍无回执,主记录保持`submitted`,不提前退款、补发或推送最终成功 | +| TC-RECEIPT-MODE-002 | 将测试通道设置为`message_level`,两分片当前提交尝试只收到一条明确成功回执 | 只新增一条真实`SmsReceiptRecord`;缺失分片审计被标记`delivered`且`compensationType=supplier_message_level_receipt`,主记录形成一次成功终态,并按客户原始`Registered_Delivery`逐片幂等建立CMPP回执或建立一个HTTP事件 | +| TC-RECEIPT-MODE-003 | 在`message_level`通道中,对含明确失败分片、历史提交尝试或单分片短信输入成功回执 | 明确失败不被覆盖,历史尝试不改变当前终态,单分片不产生推断分片;不得重复计费、退款、补发或下游投递 | +| TC-RECEIPT-CONFLICT-001 | 整条级成功已经形成`delivered`后,同一提交尝试又收到明确失败回执,并重复输入同一矛盾事件 | 原始失败回执和分片证据保留;主记录仍为`delivered`,不补发、不退款、不向客户推送失败;`SmsReceiptAnomaly`按稳定键只有一条记录并累加发生次数 | +| TC-GATEWAY-EXCEPTION-UI-001 | 打开运营端“网关异常”,切换“提交异常”和“回执异常”Tab并刷新、筛选、翻页 | 菜单新名称和两个Tab正常展示且原路由可访问;两个Tab分别调用真实提交死信API和回执异常API,筛选、汇总、总数和分页与PostgreSQL一致,不使用mock、静态数据或localStorage | +| TC-GATEWAY-EXCEPTION-UI-002 | 阅读两个Tab标题说明并打开两类详情 | “提交异常”明确说明死信不等于供应商拒绝/送达失败及重入队风险;“回执异常”明确说明其为终态冲突摘要,并指向真实回执记录和通讯交互日志,详情不泄露通道密码或鉴权信息 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 9084fa6..410d1d7 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3171,3 +3171,45 @@ git diff --check - 发布后API、Gateway、Nginx、PostgreSQL、Redis、MinIO均active;12026、17890、8090、3000、6379、5432和9000均监听;内网API/Gateway/MinIO health通过,公网首页、运营端、客户端和API health均HTTP 200,公网CMPP 17890 TCP可连接。Redis返回`PONG`,`gateway.submit.commands`消费者1、pending=0、lag=0、entries-read=3270;观察窗口后API/Gateway error级journal仍为0,120秒内活跃下游客户连接为0。 - 供应商通道重启后3条保持connected 1/1:`会员营销-铁布衫`、`赛邮行业-王斯评中转`、`赛邮行业-王斯评中转副本`。`会员营销-富泷`由发布前connected 1/1变为failed 0/1,观察窗口复查仍为`authentication / connect response status: auth failed`;保留系统自动重连,没有修改其账号、密码或启停状态。 - 依赖缓解安全门禁通过。npm audit仍为已知前端2项high(项目未使用的React Router RSC路径)和API 3项moderate(Prisma开发工具链),未执行破坏性自动升级。本次没有发送、重投或补发真实短信,没有修改企业余额、客户连接或真实通道配置。 + +## 2026-08-05 HTTPS 双域名与 HTTP API 公网地址配置(服务器配置完成,业务代码未提交) + +- `sms.lisglo.com` 已使用匹配 `*.lisglo.com` 的 Cloudflare Origin CA 证书和 `SESSION_COOKIE_SECURE=true`;Nginx 只监听 443/旧切换端口 12026,不监听 80。新增基于原始 TCP 对端 `$realip_remote_addr` 的 Cloudflare 网段限制,并启用 Cloudflare 全局 Authenticated Origin Pulls:橙云公网健康接口返回 200,直连源站并指定 `sms.lisglo.com` SNI 因缺少 Cloudflare 客户端证书返回 400,避免绕过 Cloudflare WAF。 +- 新增 `/usr/local/sbin/update-cloudflare-nginx-ranges` 与 systemd timer,每日从 Cloudflare 官方 IPv4/IPv6 列表生成 Real-IP 与源站判定配置;更新前校验格式和最小网段数、执行 `nginx -t`,失败时保留旧配置。当前列表为 IPv4 15 段、IPv6 7 段,下一次计划执行时间由 timer 随机延迟确定。 +- `api.lisglo.com` 灰云 A 记录已真实解析到 `8.160.169.106`。已创建仅限 `lisglo.com` DNS 编辑的 Cloudflare 用户 API Token,使用 Certbot 5.7.0 和 DNS-01 签发独立 ECDSA Let’s Encrypt 证书,有效期至 2026-11-03;凭据文件仅 root 可读(0600),一次性中转文件和本地剪贴板已清理。新增独立 API-only Nginx 虚拟主机,只放行 `/api/health`、`/api/openapi/v1/` 和 `/api/client-docs`,管理页面、客户端页面、运营/客户端私有 API 均返回 404。 +- 本地业务代码增加后端环境变量 `HTTP_API_PUBLIC_ORIGIN`,由真实配置 API 返回 `publicOrigin`;运营端参数复制、客户端接口概览/复制和 Swagger 链接统一使用 `https://api.lisglo.com`,不再错误沿用管理页面 `window.location.origin`。同步更新首版需求、`TC-HTTP-PUBLIC-ORIGIN-001`、环境示例和部署手册。 +- 新增 `cmpp-letsencrypt-renew.timer` 每日检查续期,续期成功后先执行 `nginx -t` 再 reload;Let’s Encrypt staging dry-run 已成功。公网验证结果为 `sms` 健康接口 200、`api` 健康接口和客户 Swagger 200、OpenAPI 未认证请求 401、`api` 根路径/管理与客户端入口/私有 API 404;`api` 直连源站使用可信 Let’s Encrypt 证书并返回 200。 +- 本轮服务器配置备份位于 `/opt/cmpp-platform-backups/config/20260805-100601-cloudflare-origin-restriction`、`20260805-102234-api-letsencrypt`、`20260805-102550-sms-aop` 和 `20260805-102614-http-api-origin-env`。未修改防火墙、安全组、CMPP 17890、短信数据、通道、余额或客户连接;业务代码保持未提交、未推送、未部署,生产环境变量已预置但需代码发布后页面才会展示新的公网 API 地址。 + +## 2026-08-05 旧 IP:12026 登录短时过渡与撤销(已恢复 HTTPS-only,未提交) + +- 真实故障确认为 HTTPS 切换后服务器设置 `SESSION_COOKIE_SECURE=true`,API 发送的 Secure 会话 Cookie 无法被 `http://8.160.169.106:12026` 接收;因此登录接口成功后,受保护页面恢复会话失败并重新跳回登录页。 +- 为保留旧 IP 登录入口,预生产环境临时改为 `SESSION_COOKIE_SECURE=false` 并只重启 `cmpp-api.service`。进程环境已确认读取 `false`,API 服务 active 且重启后无 error 级 journal;IP 首页、运营登录页和健康接口均返回 200,`sms.lisglo.com` 经 Cloudflare/AOP 的健康接口及 `api.lisglo.com` 健康接口也均返回 200。 +- 该短时方案保留了 `HttpOnly`、`SameSite=Lax` 和运营/客户端独立 Cookie,但 HTTPS 入口也会使用非 Secure 的过渡期 Cookie,已有登录用户可能需要重新登录;用户随后决定不接受该长期代价。临时调整前的配置备份位于 `/opt/cmpp-platform-backups/config/20260805-105501-ip-login-cookie-compat`。 +- 用户复核安全代价后决定不再保留 IP 直接登录,也暂不新增灰云管理域名证书。预生产已于 11:04 将 `SESSION_COOKIE_SECURE` 恢复为 `true` 并只重启 API;进程环境确认读取 `true`,`sms.lisglo.com` 经 Cloudflare/AOP 与 `api.lisglo.com` 健康接口均返回 200,API active 且重启后无 error 级日志。12026 当前仍监听并可健康检查,但不再是受支持的登录入口;恢复前配置备份位于 `/opt/cmpp-platform-backups/config/20260805-110427-restore-secure-cookie`。 +- 同步撤销首版需求和系统测试用例中的 IP 登录兼容要求,部署规范明确只支持 HTTPS 登录;本次未新增证书、未修改 Nginx、Cloudflare、防火墙或安全组,也未提交、推送或部署业务代码。 + +## 2026-08-05 运营端待审核角标轻量轮询(本地未提交) + +- 新增真实后端 `GET /api/admin/operations/pending-audits`,仅并发统计企业认证、短信审核、模板、签名和引流信息五类待审数量及总数,复用运营看板原有口径,不执行发送、账务、连接、下游投递和趋势等看板查询。 +- 运营端全局布局的首次加载、30 秒轮询、窗口重新获得焦点和审核刷新事件已改用轻量接口,不再调用 `/api/admin/operations/dashboard/statistics`;运营看板页本身仍保留完整统计接口。 +- 新增服务层单元测试,校验五类数量口径、总数和租户边界,并断言该路径不执行短信状态聚合或看板原始 SQL。 +- 本地验证通过:针对性 `operations.service.spec.ts` 27/27,API 全量 32 suites / 409 tests,API TypeScript 构建、前端 TypeScript 检查、Vite 生产构建和 `git diff --check`均通过。Vite 仍有已知大 chunk 警告,与本次接口替换无关。 +- 本地 `4173` 前端登录页标题、DOM 和主要控件可正常渲染;本地未运行真实 API,图形验证码请求返回 502,因此未伪造登录或进行登录后端到端轮询验收。本次未提交、推送、部署,也未发送或重投短信,未修改通道、余额或客户连接。 + +## 2026-08-06 供应商长短信整条级成功回执与网关异常双Tab(本地未提交) + +- 运营菜单“Gateway提交异常”更名为“网关异常”,保留原`/admin/gateway-submit-exceptions`路由;页面拆为“提交异常”和“回执异常”两个Tab。提交异常继续使用既有真实死信接口,回执异常使用新增`GET /api/admin/operations/receipt-anomalies`,两处均在标题区解释数据来源、业务含义、不能代表的结论和人工处理注意事项。 +- 通道配置新增`longMessageReceiptMode`:默认`per_segment`保持逐分片聚合;仅供应商明确采用整条级成功口径时配置`message_level`。后一模式收到当前提交尝试的一条成功回执后,只将同次提交尚无回执的分片标记为推断成功,并写`compensationType=supplier_message_level_receipt`;供应商真实回执仍只保存实际收到的一条,不伪造原始回执。 +- 新增`SmsReceiptAnomaly`及migration`20260806100000_add_receipt_anomalies`。整条级成功已经形成`delivered`后,同一提交尝试再到明确失败时,保留原始失败回执和分片证据,不改写已送达终态、不重复退款/补发、不向客户推送矛盾失败;异常按消息和提交尝试稳定键upsert,重复事件累加发生次数,并可在回执异常Tab分页、筛选和查看结构化详情。 +- 新增发送链、通道配置和运营查询回归,针对性3 suites / 178 tests通过;API全量32 suites / 413 tests通过。API TypeScript构建、前端TypeScript、Vite生产构建、Prisma schema validate/generate、R2运营查询/R5通道/R10发送完成结构门禁和`git diff --check`均通过。Vite仍有既有大chunk告警;Jest仍需`--forceExit`结束既有开放句柄。 +- `docs/contracts/operations-r2-methods.json`按当前组合工作区同步:`pendingAudits`和dashboard哈希属于此前“待审核角标轻量轮询”会话,本轮仅新增回执异常契约/查询,并为同文件当前行尾结果刷新受影响的既有哈希,未把前一会话业务改动归入本需求。 +- 本轮未应用migration到本地或预生产数据库,未连接预生产、未发送/补发/重投短信,未修改真实通道配置、账号密码、启停状态、企业余额或客户连接;代码按要求保持未提交、未推送、未部署。`*.tsbuildinfo`、`outputs/`和空文件`=`继续作为其他会话/构建产物保留,不删除、不提交、不归因。 + +## 2026-08-06 `RealeseV2.3` 工作区合并与推送前验证 + +- 用户授权将当前工作区全部有效业务代码合并、提交并推送,版本名称按用户给出的精确拼写定为`RealeseV2.3`。合并范围包括:HTTPS双域名与HTTP API公网地址、运营端待审核角标轻量轮询、供应商长短信整条级成功回执及网关异常双Tab;三组需求、测试、部署说明和进度记录均随代码纳入。 +- `git fetch --prune --tags`后本地`HEAD`与`origin/main`均为`57b58f1c4052691531941e0fbda02e43ce2fea87`,分歧为0/0,因此没有远端提交需要合并,也没有源代码文本冲突。既有annotated恢复标签`RealseV2.0`仍有效并指向`c0a4317a7ea641bab39294e596f58f859edfca73`;本轮只提交和推送,不部署、不执行migration。 +- 合并后同步结构契约:R1登记`getPendingAudits/listReceiptAnomalies`及当前重入队实现,R2登记轻量审核和回执异常查询,R5/R10登记长短信回执口径及处理变化;R6清单的5个声明哈希与当前`main`既有Gateway源码重新对齐,Gateway业务源码没有工作区改动。全部结构门禁随后通过。 +- 发布前验证通过:API全量32 suites / 413 tests;API TypeScript构建;前端TypeScript与Vite生产构建;Prisma schema validate/generate;Gateway `go test ./... -count=1`与`go vet ./...`;19个`.mjs`结构门禁、R6/R7 Go结构门禁、依赖缓解安全门禁和`git diff --check`。Vite仍只有既有大chunk警告,Jest仍使用`--forceExit`结束既有开放句柄。 +- 提交范围排除`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`;这些缓存或临时产物继续保留在工作区,不删除、不提交。本轮未连接或修改生产/预生产服务、数据库、通道、余额或客户连接,未发送、补发或重投真实短信。 diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts index 7ee81b3..c6650ef 100644 --- a/src/api/admin/operations.api.ts +++ b/src/api/admin/operations.api.ts @@ -1,9 +1,10 @@ import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, ProtocolInteractionLogResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types'; +import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProtocolInteractionLogResponse, ReceiptAnomalyResponse, 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 })), getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => request(withQuery('/admin/operations/signature-quality', query)), @@ -52,6 +53,8 @@ export const adminOperationsApi = { 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) }), + 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)), diff --git a/src/api/types/channels-reports.ts b/src/api/types/channels-reports.ts index a01f112..6288d93 100644 --- a/src/api/types/channels-reports.ts +++ b/src/api/types/channels-reports.ts @@ -17,7 +17,7 @@ export type AdminChannel = { rateLimitPerSecond: number; unitPrice: number; status: string; - config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null; + config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; longMessageReceiptMode?: 'per_segment' | 'message_level'; [key: string]: unknown } | null; connectionStates?: CmppConnectionState[]; }; diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts index ed13c9f..6c22519 100644 --- a/src/api/types/identity-config.ts +++ b/src/api/types/identity-config.ts @@ -127,6 +127,15 @@ export type UserPayload = { operatorId?: string; }; +export type PendingAuditCounts = { + enterpriseCertifications: number; + smsAudits: number; + templates: number; + signatures: number; + drainageInfos: number; + total: number; +}; + export type DashboardResponse = { taskCount: number; messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; @@ -136,7 +145,7 @@ export type DashboardResponse = { transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } }; gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; pendingAuditCount: number; - pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number }; + pendingAudits: PendingAuditCounts; hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>; auditProcessingSpeed: Array<{ category: string; label: string; count: number; averageProcessingMs: number | null }>; downstreamDeliverySummary?: { @@ -382,7 +391,7 @@ export type HttpApiConfig = { allowClientTest: boolean; }; -export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; config: HttpApiConfig | null; ipAllowlist: string[] }; +export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; publicOrigin?: string; config: HttpApiConfig | null; ipAllowlist: string[] }; export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string }; diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index a037326..60567f0 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -571,6 +571,38 @@ export type GatewaySubmitExceptionResponse = PagedResponse | null; + occurrenceCount: number; + firstOccurredAt: string; + lastOccurredAt: string; + resolvedAt?: string | null; + resolutionNote?: string | null; + tenant?: Pick | null; + application?: Pick | null; + channel?: Pick | null; + messageRecord?: { messageId: string; phoneNumber: string; status: string } | null; + submitRecord?: { submitId: string; submitStatus: string } | null; + receiptRecord?: { gatewayMessageId: string; receiptStatus: string; rawStatus: string; deliveredAt: string } | null; +}; + +export type ReceiptAnomalyResponse = PagedResponse & { + summary: { + pending: number; + resolved: number; + ignored: number; + oldestPendingAt?: string | null; + }; +}; + export type DownstreamRecoveryStatusResponse = PagedResponse & { summary: { total: number; diff --git a/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx b/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx index 0fbb2b0..a450d80 100644 --- a/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx +++ b/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, RotateCcw, Search } from 'lucide-react'; import { adminApi, type AdminChannel, type EnterpriseApplication, type GatewaySubmitException } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui'; +import { ReceiptAnomalyPanel } from './gateway-exceptions/ReceiptAnomalyPanel'; const statusLabel: Record = { pending: '待处理', @@ -45,7 +46,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: { open onClose={onClose} size="xl" - title={

Gateway提交异常详情

{record.messageId ?? record.streamMessageId}

} + title={

提交异常详情

{record.messageId ?? record.streamMessageId}

} footer={(
@@ -139,7 +140,7 @@ function RequeueModal({ record, submitting, onClose, onSubmit }: { ); } -export function AdminGatewaySubmitExceptionsPage() { +function GatewaySubmitExceptionPanel() { const [items, setItems] = useState([]); const [applications, setApplications] = useState([]); const [channels, setChannels] = useState([]); @@ -175,7 +176,7 @@ export function AdminGatewaySubmitExceptionsPage() { .catch((failure: Error) => { setItems([]); setTotal(0); - setError(failure.message || 'Gateway提交异常加载失败'); + setError(failure.message || '提交异常加载失败'); }) .finally(() => setLoading(false)); }, [applicationId, channelId, keyword, page, status]); @@ -215,9 +216,9 @@ export function AdminGatewaySubmitExceptionsPage() { const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( -
+
-

Gateway提交异常

仅处理Gateway连续失败且尚未取得明确上游结果的提交命令。

+

提交异常

展示 Gateway 消费提交命令连续处理失败、且尚未取得明确供应商提交结果的死信数据。这不等同于供应商拒绝或终端送达失败;只有向通道确认原短信未被接收后,才能人工重新入队。

{error ?

{error}

: null} @@ -239,11 +240,30 @@ export function AdminGatewaySubmitExceptionsPage() {

提交异常记录

详情中的 Gateway 命令已由后端脱敏,不返回通道密码。

{total} 条
- +
= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} /> {detail ? setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null} {requeueRecord ? setRequeueRecord(null)} onSubmit={submitRequeue} /> : null} + + ); +} + +export function AdminGatewaySubmitExceptionsPage() { + const [activeTab, setActiveTab] = useState('submit'); + return ( +
+
+

网关异常

集中查看网关提交链路和供应商回执链路的可追踪异常数据。

+
+ }, + { label: '回执异常', value: 'receipt', content: }, + ]} + onChange={setActiveTab} + value={activeTab} + />
); } diff --git a/src/apps/admin/channels/ChannelFormModal.tsx b/src/apps/admin/channels/ChannelFormModal.tsx index f49ded0..2403d00 100644 --- a/src/apps/admin/channels/ChannelFormModal.tsx +++ b/src/apps/admin/channels/ChannelFormModal.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Button, Input, Modal, Select } from '@/components/ui'; import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; import { carrierLabelMap, cmppVersionOptions, regionOptions } from './channelModel'; -import type { Carrier, ChannelModalState, SmsChannel } from './channelTypes'; +import type { Carrier, ChannelModalState, LongMessageReceiptMode, SmsChannel } from './channelTypes'; export function ChannelFormModal({ modal, @@ -33,6 +33,7 @@ export function ChannelFormModal({ const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30)); const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3)); + const [longMessageReceiptMode, setLongMessageReceiptMode] = useState(channel?.longMessageReceiptMode ?? 'per_segment'); function submit() { if (!isValidMoneyInput(unitPrice)) { @@ -67,6 +68,7 @@ export function ChannelFormModal({ heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30, heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3, extensionDigits: Number(extensionDigits), + longMessageReceiptMode, rateLimitPerSecond: Number(flowLimit), passwordCipher: password || undefined, }); @@ -136,6 +138,16 @@ export function ChannelFormModal({ setWindowSize(event.target.value)} placeholder="16" value={windowSize} /> setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} /> setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} /> + { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、原始状态" value={keyword} /> + { setAnomalyType(event.target.value); setPage(1); }} /> + ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => { setChannelId(event.target.value); setPage(1); }} /> +
+ +
+

回执异常记录

详情仅展示结构化判定信息,不用它替代原始通讯报文。

{total} 条
+
+ = totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} /> + + {detail ? setDetail(null)} /> : null} + + ); +} diff --git a/src/apps/client/ClientHttpApiPage.tsx b/src/apps/client/ClientHttpApiPage.tsx index cc345bc..aa8b806 100644 --- a/src/apps/client/ClientHttpApiPage.tsx +++ b/src/apps/client/ClientHttpApiPage.tsx @@ -3,7 +3,7 @@ import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react'; import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi'; import { Button, Input, Select, Tabs, Tag } from '@/components/ui'; import { copyText } from '@/utils/clipboard'; -import { formatHttpApiParams } from '@/utils/interfaceParams'; +import { formatHttpApiParams, httpApiPublicOrigin } from '@/utils/interfaceParams'; export function ClientHttpApiPage() { const [applications, setApplications] = useState([]); @@ -80,9 +80,10 @@ export function ClientHttpApiPage() { } const api = config?.config; + const publicApiOrigin = httpApiPublicOrigin(config, window.location.origin); const overview =
{!api?.enabled ?

当前应用尚未由运营端开通 HTTP 接口。

: null} -

{config?.applicationName ?? '企业应用'}

基础地址:{window.location.origin}/api/openapi/v1

+

{config?.applicationName ?? '企业应用'}

基础地址:{publicApiOrigin}/api/openapi/v1

单条发送 {api?.sendEnabled ? '已开通' : '未开通'} 状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'} 上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'} @@ -105,7 +106,7 @@ export function ClientHttpApiPage() { TIMESTAMP NONCE SHA256(rawBody)`}

使用访问密钥执行 HMAC-SHA256,输出小写十六进制。单发还必须携带 Idempotency-Key

-

接口清单

{`POST /api/openapi/v1/sms/messages\nGET  /api/openapi/v1/sms/messages/{messageId}\nGET  /api/openapi/v1/sms/uplinks\nGET  /api/openapi/v1/sms/uplinks/{uplinkId}`}

完整 OpenAPI 文档:/api/client-docs

+

接口清单

{`POST /api/openapi/v1/sms/messages\nGET  /api/openapi/v1/sms/messages/{messageId}\nGET  /api/openapi/v1/sms/uplinks\nGET  /api/openapi/v1/sms/uplinks/{uplinkId}`}

完整 OpenAPI 文档:{publicApiOrigin}/api/client-docs

回调验签

回调请求头包含 X-Event-Id、X-Event-Type、X-Timestamp、X-Signature。签名原文为 TIMESTAMP + '\\n' + rawBody,同样使用 HMAC-SHA256。客户系统必须按 X-Event-Id 幂等。

; const logsPanel =

最近调用

{requests.map((item) =>

{item.requestId} · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}

)}{requests.length === 0 ?

暂无调用记录。

: null}
diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 83f3af3..c9ff8da 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -38,19 +38,22 @@ import type { LoginSession } from '@/api/session'; import { AppShell } from '@/layouts/AppShell'; import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary'; +const EMPTY_PENDING_AUDITS = { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 }; + export function AdminLayout() { return {(session) => }; } function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { - const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 }); + const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS); const loadPendingAuditCount = useCallback(() => { - adminApi.getDashboard() - .then((dashboard) => { - setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 }); + // This runs globally and on a timer, so it must not fan out through the full dashboard aggregation. + adminApi.getPendingAudits() + .then((counts) => { + setPendingAudits(counts); }) .catch(() => { - setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 }); + setPendingAudits(EMPTY_PENDING_AUDITS); }); }, []); @@ -94,7 +97,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { items: [ { label: '运营看板', to: '/admin', icon: Gauge }, { label: '发送监控', to: '/admin/monitor', icon: Activity }, - { label: 'Gateway提交异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle }, + { label: '网关异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle }, { label: '数据统计', to: '/admin/analytics', icon: BarChart3 }, ], }, diff --git a/src/utils/interfaceParams.ts b/src/utils/interfaceParams.ts index 12522a0..7fdbc2c 100644 --- a/src/utils/interfaceParams.ts +++ b/src/utils/interfaceParams.ts @@ -18,13 +18,14 @@ const deliveryModeLabels: Record = { export function formatHttpApiParams(response: HttpApiConfigResponse, origin: string) { const config = response.config; - const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`; + const publicOrigin = httpApiPublicOrigin(response, origin); + const baseUrl = `${publicOrigin}/api/openapi/v1`; return [ `应用名称: ${response.applicationName ?? response.applicationId}`, `AppID: ${response.applicationId}`, `HTTP接口: ${config?.enabled ? '开通' : '关闭'}`, `基础地址: ${baseUrl}`, - `接口文档: ${origin.replace(/\/$/, '')}/api/client-docs`, + `接口文档: ${publicOrigin}/api/client-docs`, `接口能力: ${capabilityLabels.filter(([key]) => config?.[key]).map(([, label]) => label).join('、') || '无'}`, `QPS限制: ${config?.qpsLimit ?? '-'}`, `签名时间容差: ${config?.timestampToleranceSeconds ?? '-'}秒`, @@ -33,3 +34,7 @@ export function formatHttpApiParams(response: HttpApiConfigResponse, origin: str `上行投递方式: ${config?.uplinkDeliveryMode ? deliveryModeLabels[config.uplinkDeliveryMode] ?? config.uplinkDeliveryMode : '-'}`, ].join('\n'); } + +export function httpApiPublicOrigin(response: HttpApiConfigResponse | null | undefined, fallbackOrigin: string) { + return (response?.publicOrigin ?? fallbackOrigin).replace(/\/$/, ''); +}