fix: harden channel retry attribution and operations UI
This commit is contained in:
@@ -545,6 +545,50 @@ describe('ChannelsService', () => {
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not request a reconnect when only non-connection channel fields change', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.updateChannel('channel-1', {
|
||||
name: '主通道-新名称',
|
||||
carrier: 'unicom',
|
||||
sendRegion: '上海',
|
||||
rateLimitPerSecond: 200,
|
||||
unitPrice: 5,
|
||||
});
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalled();
|
||||
expect(prisma.cmppConnectionState.create).not.toHaveBeenCalled();
|
||||
expect(prisma.cmppConnectionState.update).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not request a reconnect when a full edit payload keeps connection settings unchanged', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.updateChannel('channel-1', {
|
||||
name: '主通道-完整保存',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
cmppVersion: '2.0',
|
||||
desiredConnections: 1,
|
||||
windowSize: 16,
|
||||
heartbeatIntervalSeconds: 30,
|
||||
heartbeatMissThreshold: 3,
|
||||
rateLimitPerSecond: 300,
|
||||
config: { serviceId: 'SMS' },
|
||||
});
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists an arbitrary integer extension digit count within the supported range', async () => {
|
||||
@@ -663,8 +707,22 @@ describe('ChannelsService', () => {
|
||||
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'group-1',
|
||||
code: 'G-MOBILE',
|
||||
name: '移动组更新',
|
||||
carrier: 'mobile',
|
||||
description: null,
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitMinutes: 750,
|
||||
items: [
|
||||
{ channelId: 'channel-national', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { code: 'CMPP-N', name: '全国通道' } },
|
||||
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10, weight: 1, isBackup: false, channel: { code: 'CMPP-SD', name: '山东通道' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
operationLog: { create: jest.fn() },
|
||||
};
|
||||
await transactionCallback(tx);
|
||||
expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({
|
||||
@@ -674,6 +732,22 @@ describe('ChannelsService', () => {
|
||||
for (const item of tx.smsChannelGroupItem.createMany.mock.calls[0][0].data) {
|
||||
expect(item).not.toHaveProperty('rateLimitPerSecond');
|
||||
}
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'sms_channel_group.update',
|
||||
resource: 'sms_channel_group',
|
||||
resourceId: 'group-1',
|
||||
detail: expect.objectContaining({
|
||||
before: expect.objectContaining({ name: '移动组' }),
|
||||
after: expect.objectContaining({
|
||||
name: '移动组更新',
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ channelId: 'channel-national', priority: 1, channelName: '全国通道' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.updateGroup('group-1', {
|
||||
carrier: 'mobile',
|
||||
|
||||
@@ -331,6 +331,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||
? undefined
|
||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||
account: data.account ?? channel.account,
|
||||
passwordCipher: data.passwordCipher ?? channel.passwordCipher,
|
||||
cmppVersion: cmppVersion ?? channel.cmppVersion,
|
||||
config: config ?? channel.config,
|
||||
});
|
||||
const updated = await this.prisma.smsChannel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
@@ -374,20 +382,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const connectionConfigChanged = [
|
||||
'gatewayHost',
|
||||
'gatewayPort',
|
||||
'account',
|
||||
'passwordCipher',
|
||||
'cmppVersion',
|
||||
'rateLimitPerSecond',
|
||||
'desiredConnections',
|
||||
'windowSize',
|
||||
'heartbeatIntervalSeconds',
|
||||
'heartbeatMissThreshold',
|
||||
].some((key) => data[key as keyof UpdateChannelDto] !== undefined)
|
||||
|| Boolean(data.config && ['desiredConnections', 'windowSize', 'heartbeatIntervalSeconds', 'heartbeatMissThreshold']
|
||||
.some((key) => key in data.config!));
|
||||
const updatedStatus = data.status ?? channel.status;
|
||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||
await this.requestChannelConnection(updated, 'channel_updated');
|
||||
@@ -915,7 +909,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
|
||||
const current = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
const current = await this.prisma.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
@@ -959,10 +956,22 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
})),
|
||||
});
|
||||
}
|
||||
return tx.smsChannelGroup.findUnique({
|
||||
const updated = await tx.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel_group.update',
|
||||
resource: 'sms_channel_group',
|
||||
resourceId: groupId,
|
||||
detail: {
|
||||
before: channelGroupAuditSnapshot(current),
|
||||
after: updated ? channelGroupAuditSnapshot(updated) : null,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1866,6 +1875,83 @@ function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
type ChannelConnectionSettings = {
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
cmppVersion: string;
|
||||
config?: Prisma.JsonValue | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function getRuntimeConfigInteger(
|
||||
config: Prisma.JsonValue | Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback;
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function channelConnectionSettingsChanged(
|
||||
before: ChannelConnectionSettings,
|
||||
after: ChannelConnectionSettings,
|
||||
) {
|
||||
return before.gatewayHost !== after.gatewayHost
|
||||
|| before.gatewayPort !== after.gatewayPort
|
||||
|| before.account !== after.account
|
||||
|| before.passwordCipher !== after.passwordCipher
|
||||
|| before.cmppVersion !== after.cmppVersion
|
||||
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
|
||||
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|
||||
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
|
||||
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
|
||||
}
|
||||
|
||||
function channelGroupAuditSnapshot(group: {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
retryEnabled: boolean;
|
||||
retryTimeLimitMinutes: number;
|
||||
items?: Array<{
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
priority: number;
|
||||
weight: number;
|
||||
isBackup: boolean;
|
||||
channel?: { code?: string; name?: string } | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
code: group.code,
|
||||
name: group.name,
|
||||
carrier: group.carrier,
|
||||
description: group.description ?? null,
|
||||
status: group.status,
|
||||
retryEnabled: group.retryEnabled,
|
||||
retryTimeLimitMinutes: group.retryTimeLimitMinutes,
|
||||
items: (group.items ?? []).map((item) => ({
|
||||
channelId: item.channelId,
|
||||
channelCode: item.channel?.code ?? null,
|
||||
channelName: item.channel?.name ?? null,
|
||||
carrier: item.carrier ?? null,
|
||||
province: item.province ?? null,
|
||||
priority: item.priority,
|
||||
weight: item.weight,
|
||||
isBackup: item.isBackup,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChannelRuntimeConfig(
|
||||
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
|
||||
incomingConfig?: Record<string, unknown> | null,
|
||||
|
||||
@@ -226,8 +226,8 @@ describe('OperationsService', () => {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: true,
|
||||
receiptRecords: true,
|
||||
submitRecords: { include: { channel: true } },
|
||||
receiptRecords: { include: { channel: true } },
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
|
||||
@@ -102,8 +102,8 @@ export class OperationsService {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: true,
|
||||
receiptRecords: true,
|
||||
submitRecords: { include: { channel: true } },
|
||||
receiptRecords: { include: { channel: true } },
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
|
||||
@@ -1863,7 +1863,7 @@ describe('SendChainService', () => {
|
||||
});
|
||||
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { submitId: 'SUB-1' },
|
||||
where: { id: 'submit-1' },
|
||||
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
@@ -1937,6 +1937,69 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a legacy aggregate SubmitResult when it cannot match one submit attempt uniquely', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{ id: 'submit-1', submitId: 'SUB-1', channelId: 'channel-1', gatewayMessageId: null },
|
||||
{ id: 'submit-2', submitId: 'SUB-2', channelId: 'channel-1', gatewayMessageId: null },
|
||||
]);
|
||||
|
||||
await expect(service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-LEGACY',
|
||||
submitStatus: 'accepted',
|
||||
})).rejects.toThrow('cannot be matched uniquely');
|
||||
|
||||
expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a direct-signature CMPP message through the next approved channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const route = await prisma.channelRouteRule.findFirst();
|
||||
const primary = route.group.items[0].channel;
|
||||
const backup = { ...primary, id: 'channel-backup', code: 'CMPP-B' };
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
...route,
|
||||
group: {
|
||||
...route.group,
|
||||
items: [
|
||||
{ ...route.group.items[0], channelId: primary.id, priority: 1, channel: primary },
|
||||
{ ...route.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup },
|
||||
],
|
||||
},
|
||||
});
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{ id: 'submit-1', submitId: 'SUB-1', channelId: primary.id, createdAt: new Date() },
|
||||
]);
|
||||
const submitMessageToGateway = jest.spyOn(service as any, 'submitMessageToGateway')
|
||||
.mockResolvedValue({ submitted: true, messageRecordId: 'record-1', channelId: backup.id, attempt: 1 });
|
||||
|
||||
await expect((service as any).retryMessageIfAllowed({
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: null,
|
||||
signatureId: 'sig-direct',
|
||||
messageId: 'MSG-DIRECT-SIGNATURE',
|
||||
phoneNumber: '13800000001',
|
||||
content: '【签名】无模板内容',
|
||||
billingUnits: 1,
|
||||
queuedAt: new Date(),
|
||||
}, '回执失败补发')).resolves.toEqual(expect.objectContaining({ channelId: backup.id }));
|
||||
|
||||
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ signatureId: 'sig-direct' }),
|
||||
}));
|
||||
expect(submitMessageToGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signatureId: 'sig-direct' }),
|
||||
expect.objectContaining({ channel: expect.objectContaining({ id: backup.id }) }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBillingRecord.findFirst
|
||||
@@ -1955,6 +2018,7 @@ describe('SendChainService', () => {
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
submitId: 'SUB-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'rejected',
|
||||
});
|
||||
@@ -2858,11 +2922,32 @@ describe('SendChainService', () => {
|
||||
}),
|
||||
}));
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { submitId: 'SUB-1', gatewayMessageId: null },
|
||||
where: { id: 'submit-1', gatewayMessageId: null },
|
||||
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects a legacy segment result when multiple channel attempts could match', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
{ id: 'submit-2', submitId: 'SUB-2', messageRecordId: 'record-1', channelId: 'channel-1' },
|
||||
{ id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: 'channel-1' },
|
||||
]);
|
||||
|
||||
await expect(service.handleSubmitSegmentResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
segmentTotal: 2,
|
||||
segmentIndex: 1,
|
||||
sequenceId: 72,
|
||||
gatewayMessageId: 'GW-SEG-2',
|
||||
submitStatus: 'accepted',
|
||||
})).rejects.toThrow('cannot be matched uniquely');
|
||||
|
||||
expect(prisma.smsMessageSegmentAudit.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('durably intakes an upstream receipt before asynchronous business matching', async () => {
|
||||
const { service, prisma } = createService();
|
||||
jest.spyOn(service as any, 'processUpstreamReceiptInboxRecord').mockResolvedValue(false);
|
||||
|
||||
@@ -977,11 +977,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
const message = await this.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||
const effectiveSubmitId = submitRecord.submitId;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.recordSubmitSegments(message, {
|
||||
messageId: data.messageId,
|
||||
channelId: data.channelId,
|
||||
submitId: data.submitId,
|
||||
submitId: effectiveSubmitId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId ?? '',
|
||||
submitStatus: normalizeSubmitStatus(data.submitStatus),
|
||||
@@ -1002,7 +1004,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (data.gatewayMessageId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
...(data.submitId ? { submitId: data.submitId } : { messageRecordId: message.id }),
|
||||
id: submitRecord.id,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
@@ -1015,14 +1017,73 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
private async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { submitId: data.submitId },
|
||||
});
|
||||
if (
|
||||
!exact ||
|
||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||
(exact.channelId && exact.channelId !== data.channelId)
|
||||
) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
submitId: data.submitId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
||||
);
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult without submitId cannot be matched uniquely',
|
||||
);
|
||||
}
|
||||
this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
const message = await this.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.resolveSubmitRecordForGatewayResult(message.id, data);
|
||||
const effectiveData = { ...data, submitId: submitRecord.submitId };
|
||||
const batchTask = message.batchTaskId
|
||||
? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } })
|
||||
: null;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: data.submitId ? { submitId: data.submitId } : { messageRecordId: message.id },
|
||||
where: { id: submitRecord.id },
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
@@ -1032,9 +1093,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
await this.recordSubmitSegments(message, data, submittedAt);
|
||||
await this.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (data.submitId && message.submitId && data.submitId !== message.submitId) {
|
||||
if (message.submitId && effectiveData.submitId !== message.submitId) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
@@ -1089,7 +1150,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
where: {
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [
|
||||
data.submitId ? { submitId: data.submitId } : undefined,
|
||||
{ submitId: effectiveData.submitId },
|
||||
data.messageId ? { messageId: data.messageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>,
|
||||
},
|
||||
@@ -1105,6 +1166,48 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
private async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||
if (!exact
|
||||
|| (exact.messageRecordId && exact.messageRecordId !== messageRecordId)
|
||||
|| (exact.channelId && exact.channelId !== data.channelId)) {
|
||||
throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel');
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
OR: [
|
||||
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
|
||||
{ gatewayMessageId: null },
|
||||
].filter(Boolean) as Array<{ gatewayMessageId?: string | null }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: data.channelId },
|
||||
@@ -3198,6 +3301,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
@@ -3327,12 +3431,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
|
||||
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
|
||||
this.logger.log(`sms_retry_route_started ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
if (ageMinutes >= 72 * 60) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason: 'maximum_message_age_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
|
||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: route.groupId,
|
||||
reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
retryTimeLimitMinutes,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@@ -3344,14 +3469,29 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
where: { id: message.id },
|
||||
data: { errorMessage: reason },
|
||||
});
|
||||
return await this.submitMessageToGateway(message, routed, attempts.length);
|
||||
} catch {
|
||||
const retried = await this.submitMessageToGateway(message, routed, attempts.length);
|
||||
this.logger.log(`sms_retry_route_selected ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: routed.groupId,
|
||||
channelId: routed.channel.id,
|
||||
attempt: attempts.length,
|
||||
})}`);
|
||||
return retried;
|
||||
} catch (error) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async selectChannelForMessage(
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; phoneNumber: string; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||
): Promise<RoutedChannel> {
|
||||
if (!message.applicationId) {
|
||||
@@ -3955,8 +4095,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveMessageSignatureId(message: { templateId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
const direct = message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
if (direct || !message.templateId) return direct;
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } });
|
||||
return template?.signature?.id ?? null;
|
||||
|
||||
Reference in New Issue
Block a user