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
+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;