release: prepare RealeseV2.3

This commit is contained in:
hectorzhao
2026-08-06 10:48:36 +08:00
parent 57b58f1c40
commit 8ad8e61793
37 changed files with 997 additions and 64 deletions
@@ -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;
+51 -4
View File
@@ -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?
+9
View File
@@ -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)) {
+15
View File
@@ -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);
+15
View File
@@ -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' } }) },
+14 -1
View File
@@ -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();
@@ -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,
@@ -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;
@@ -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);
+9 -1
View File
@@ -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);
}
@@ -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' } }),
@@ -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)));
+7
View File
@@ -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<string, unknown>).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;
@@ -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<string, unknown> | null = null;
+134 -3
View File
@@ -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;