release: prepare RealeseV2.3
This commit is contained in:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' } }) },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user