fix: decouple channel test sms records
This commit is contained in:
@@ -596,20 +596,12 @@ describe('ChannelsService', () => {
|
||||
channelId: 'channel-1',
|
||||
status: 'submit_queued',
|
||||
submitted: 1,
|
||||
batchTaskId: 'batch-1',
|
||||
testNo: expect.stringMatching(/^CHTEST-/),
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
sourceType: 'admin_channel_test',
|
||||
phoneTotal: 1,
|
||||
status: 'submit_queued',
|
||||
}),
|
||||
});
|
||||
expect(prisma.tenant.findFirst).not.toHaveBeenCalled();
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'batch-1',
|
||||
phoneNumber: '18821203795',
|
||||
channelId: 'channel-1',
|
||||
status: 'submit_queued',
|
||||
@@ -618,8 +610,6 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'batch-1',
|
||||
channelId: 'channel-1',
|
||||
sessionId: 'session-1',
|
||||
submitStatus: 'queued',
|
||||
@@ -645,6 +635,7 @@ describe('ChannelsService', () => {
|
||||
action: 'sms_channel.test_submit',
|
||||
resource: 'sms_channel',
|
||||
resourceId: 'channel-1',
|
||||
detail: expect.objectContaining({ testNo: expect.stringMatching(/^CHTEST-/) }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,29 +432,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
|
||||
}
|
||||
|
||||
const tenant = await this.prisma.tenant.findFirst({ orderBy: { createdAt: 'asc' } });
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('未找到可归属测试短信的客户租户');
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const taskNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const batchTask = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
taskNo,
|
||||
sourceType: 'admin_channel_test',
|
||||
content,
|
||||
category: 'channel_test',
|
||||
phoneTotal: phoneNumbers.length,
|
||||
status: 'submit_queued',
|
||||
auditStatus: 'approved',
|
||||
progressTotal: phoneNumbers.length,
|
||||
submittedTotal: 0,
|
||||
createdById: data.operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const results = [];
|
||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
@@ -466,14 +445,12 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
const messageRecord = await this.prisma.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
batchTaskId: batchTask.id,
|
||||
messageId,
|
||||
phoneNumber,
|
||||
content,
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
unitPrice: channel.unitPrice,
|
||||
amountCents: channel.unitPrice * calculateBillingUnits(content),
|
||||
unitPrice: 0,
|
||||
amountCents: 0,
|
||||
queuePriority: 'normal',
|
||||
channelId: channel.id,
|
||||
submitId,
|
||||
@@ -484,8 +461,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
await this.prisma.smsSubmitRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
batchTaskId: batchTask.id,
|
||||
messageRecordId: messageRecord.id,
|
||||
channelId: channel.id,
|
||||
sessionId: session.id,
|
||||
@@ -499,8 +474,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
batchTaskId: batchTask.id,
|
||||
tenantId: tenant.id,
|
||||
testNo,
|
||||
attempt: index,
|
||||
accessNo: data.accessNo,
|
||||
});
|
||||
@@ -514,10 +488,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: batchTask.id },
|
||||
data: { submittedTotal: phoneNumbers.length },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
@@ -525,7 +495,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
resource: 'sms_channel',
|
||||
resourceId: channel.id,
|
||||
detail: {
|
||||
batchTaskId: batchTask.id,
|
||||
testNo,
|
||||
phoneTotal: phoneNumbers.length,
|
||||
messageRecordIds: results.map((item) => item.messageRecordId),
|
||||
connectionId: connectedState.connectionId,
|
||||
@@ -536,8 +506,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
return {
|
||||
channelId,
|
||||
status: 'submit_queued',
|
||||
batchTaskId: batchTask.id,
|
||||
taskNo: batchTask.taskNo,
|
||||
testNo,
|
||||
submitted: results.length,
|
||||
messages: results,
|
||||
queuedAt: createdAt,
|
||||
@@ -1265,8 +1234,7 @@ function buildChannelTestSubmitCommand({
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
batchTaskId,
|
||||
tenantId,
|
||||
testNo,
|
||||
attempt,
|
||||
accessNo,
|
||||
}: {
|
||||
@@ -1286,8 +1254,7 @@ function buildChannelTestSubmitCommand({
|
||||
phoneNumber: string;
|
||||
messageId: string;
|
||||
submitId: string;
|
||||
batchTaskId: string;
|
||||
tenantId: string;
|
||||
testNo: string;
|
||||
attempt: number;
|
||||
accessNo?: string;
|
||||
}) {
|
||||
@@ -1299,9 +1266,9 @@ function buildChannelTestSubmitCommand({
|
||||
messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId,
|
||||
tenantId: 'platform-channel-test',
|
||||
applicationId: 'admin-channel-test',
|
||||
taskId: batchTaskId,
|
||||
taskId: testNo,
|
||||
submitId,
|
||||
queuePriority: 'normal',
|
||||
phoneNumber,
|
||||
|
||||
@@ -13,8 +13,15 @@ export class AdminSendChainController {
|
||||
}
|
||||
|
||||
@Get('messages')
|
||||
listMessages(@Query('taskId') taskId?: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
return this.sendChain.listMessages(taskId, phoneNumber);
|
||||
listMessages(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('taskId') taskId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.sendChain.listMessages({ tenantId, applicationId, channelId, taskId, phoneNumber, status });
|
||||
}
|
||||
|
||||
@Get('submit-records')
|
||||
|
||||
@@ -35,7 +35,7 @@ export class ClientSendChainController {
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listTaskMessages(@Param('id') taskId: string) {
|
||||
return this.sendChain.listMessages(taskId);
|
||||
return this.sendChain.listMessages({ taskId });
|
||||
}
|
||||
|
||||
@Post('batch-tasks/:id/cancel')
|
||||
|
||||
@@ -556,7 +556,24 @@ describe('SendChainService', () => {
|
||||
|
||||
it('updates admin channel test message status without business retry routing', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', tenantId: 'tenant-1', sourceType: 'admin_channel_test' });
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
tenantId: null,
|
||||
batchTaskId: null,
|
||||
applicationId: null,
|
||||
templateId: null,
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
billingUnits: 1,
|
||||
unitPrice: 0,
|
||||
amountCents: 0,
|
||||
status: 'submit_queued',
|
||||
queuePriority: 'normal',
|
||||
submitId: 'SUB-1',
|
||||
gatewayMessageId: null,
|
||||
channelId: 'channel-1',
|
||||
});
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
@@ -572,6 +589,7 @@ describe('SendChainService', () => {
|
||||
|
||||
expect(prisma.channelRouteRule.findFirst).not.toHaveBeenCalled();
|
||||
expect(billing.release).not.toHaveBeenCalled();
|
||||
expect(prisma.smsBatchTask.update).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({
|
||||
|
||||
@@ -352,11 +352,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
listMessages(taskId?: string, phoneNumber?: string) {
|
||||
listMessages(query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
taskId?: string;
|
||||
phoneNumber?: string;
|
||||
status?: string;
|
||||
} = {}) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: taskId, phoneNumber },
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
batchTaskId: query.taskId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
status: query.status,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } },
|
||||
receiptRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 200,
|
||||
take: 500,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -589,17 +610,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!message || message.status !== 'queued') {
|
||||
return { skipped: true };
|
||||
}
|
||||
if (!message.tenantId || !message.batchTaskId) {
|
||||
return { skipped: true, reason: 'standalone channel test message' };
|
||||
}
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
try {
|
||||
const routed = await this.selectChannelForMessage(message);
|
||||
return await this.submitMessageToGateway(message, routed, 0);
|
||||
const routed = await this.selectChannelForMessage(businessMessage);
|
||||
return await this.submitMessageToGateway(businessMessage, routed, 0);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', errorMessage: reason },
|
||||
});
|
||||
await this.releaseMessageReservation(message, reason);
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
await this.releaseMessageReservation(businessMessage, reason);
|
||||
await this.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||
}
|
||||
}
|
||||
@@ -619,24 +644,22 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
});
|
||||
await this.recordSubmitSegments(message, data, submittedAt);
|
||||
const batchTask = await this.prisma.smsBatchTask.findUnique({
|
||||
where: { id: message.batchTaskId },
|
||||
select: { sourceType: true },
|
||||
});
|
||||
const isAdminChannelTest = batchTask?.sourceType === 'admin_channel_test';
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (data.submitId && message.submitId && data.submitId !== message.submitId) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted') {
|
||||
await this.chargeAcceptedMessage(message);
|
||||
} else if (!isAdminChannelTest) {
|
||||
const retried = await this.retryMessageIfAllowed(message, data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发');
|
||||
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
await this.chargeAcceptedMessage(businessMessage);
|
||||
} else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.retryMessageIfAllowed(businessMessage, data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发');
|
||||
if (retried) {
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
await this.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.releaseMessageReservation(message, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
await this.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
@@ -664,7 +687,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
resolvedStatus: data.submitStatus,
|
||||
},
|
||||
});
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
if (message.batchTaskId) {
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
@@ -708,13 +733,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
if (status === 'failed') {
|
||||
const retried = await this.retryMessageIfAllowed(message, '回执失败补发');
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.retryMessageIfAllowed(businessMessage, '回执失败补发');
|
||||
if (retried) {
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
await this.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.refundMessage(message, '最终失败退款');
|
||||
await this.refundMessage(businessMessage, '最终失败退款');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
@@ -725,23 +752,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
await this.queueAndTryDownstreamDelivery({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||
await this.queueAndTryDownstreamDelivery({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
deliveredAt: deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
deliveredAt: deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (message.batchTaskId) {
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
@@ -1265,7 +1296,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}> {
|
||||
if (data.messageId) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
|
||||
if (message) {
|
||||
if (message?.tenantId) {
|
||||
return {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
@@ -1324,30 +1355,31 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const recentMessages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
phoneNumber: data.phoneNumber,
|
||||
tenantId: { not: null },
|
||||
applicationId: { not: null },
|
||||
submittedAt: { gte: since },
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (recentMessages.length === 1) {
|
||||
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
|
||||
if (matchableRecentMessages.length === 1) {
|
||||
return {
|
||||
tenantId: recentMessages[0].tenantId,
|
||||
applicationId: recentMessages[0].applicationId ?? undefined,
|
||||
messageRecordId: recentMessages[0].id,
|
||||
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
|
||||
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
|
||||
messageRecordId: matchableRecentMessages[0].id,
|
||||
matchStatus: 'matched',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
if (recentMessages.length > 1) {
|
||||
if (matchableRecentMessages.length > 1) {
|
||||
return {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
|
||||
candidates: recentMessages
|
||||
.filter((message) => message.applicationId)
|
||||
candidates: matchableRecentMessages
|
||||
.map((message) => ({
|
||||
tenantId: message.tenantId,
|
||||
tenantId: String(message.tenantId),
|
||||
applicationId: String(message.applicationId),
|
||||
messageRecordId: message.id,
|
||||
matchSource: 'phone_window',
|
||||
@@ -1433,11 +1465,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({ where: { id: candidate.id } });
|
||||
if (message) {
|
||||
await this.refundMessage(message, '72小时未收到明确回执,自动超时退款');
|
||||
if (message?.tenantId) {
|
||||
await this.refundMessage(message as typeof message & { tenantId: string }, '72小时未收到明确回执,自动超时退款');
|
||||
}
|
||||
}
|
||||
for (const batchTaskId of new Set(candidates.map((candidate) => candidate.batchTaskId))) {
|
||||
for (const batchTaskId of new Set(candidates.map((candidate) => candidate.batchTaskId).filter((value): value is string => Boolean(value)))) {
|
||||
await this.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
return { timeout: candidates.length };
|
||||
@@ -1959,8 +1991,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private async recordSubmitSegments(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
@@ -2048,8 +2080,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private async recordReceiptSegment(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user