diff --git a/api/prisma/migrations/20260709122000_decouple_channel_test_sms/migration.sql b/api/prisma/migrations/20260709122000_decouple_channel_test_sms/migration.sql new file mode 100644 index 0000000..39fa13e --- /dev/null +++ b/api/prisma/migrations/20260709122000_decouple_channel_test_sms/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "SmsMessageRecord" ALTER COLUMN "tenantId" DROP NOT NULL; +ALTER TABLE "SmsMessageRecord" ALTER COLUMN "batchTaskId" DROP NOT NULL; + +ALTER TABLE "SmsSubmitRecord" ALTER COLUMN "tenantId" DROP NOT NULL; +ALTER TABLE "SmsSubmitRecord" ALTER COLUMN "batchTaskId" DROP NOT NULL; + +ALTER TABLE "SmsMessageSegmentAudit" ALTER COLUMN "tenantId" DROP NOT NULL; + +ALTER TABLE "SmsReceiptRecord" ALTER COLUMN "tenantId" DROP NOT NULL; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e35832b..54c3320 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -859,8 +859,8 @@ model SmsApiRequest { model SmsMessageRecord { id String @id @default(cuid()) - tenantId String - batchTaskId String + tenantId String? + batchTaskId String? applicationId String? templateId String? messageId String @unique @@ -884,8 +884,8 @@ model SmsMessageRecord { timeoutAt DateTime? updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id]) - batchTask SmsBatchTask @relation(fields: [batchTaskId], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenantId], references: [id]) + batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id], onDelete: Cascade) application SmsApplication? @relation(fields: [applicationId], references: [id]) template SmsTemplate? @relation(fields: [templateId], references: [id]) channel SmsChannel? @relation(fields: [channelId], references: [id]) @@ -921,8 +921,8 @@ model CmppSubmitSession { model SmsSubmitRecord { id String @id @default(cuid()) - tenantId String - batchTaskId String + tenantId String? + batchTaskId String? messageRecordId String channelId String sessionId String? @@ -936,8 +936,8 @@ model SmsSubmitRecord { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id]) - batchTask SmsBatchTask @relation(fields: [batchTaskId], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenantId], references: [id]) + batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id], onDelete: Cascade) messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id], onDelete: Cascade) channel SmsChannel @relation(fields: [channelId], references: [id]) session CmppSubmitSession? @relation(fields: [sessionId], references: [id]) @@ -950,7 +950,7 @@ model SmsSubmitRecord { model SmsMessageSegmentAudit { id String @id @default(cuid()) - tenantId String + tenantId String? batchTaskId String? messageRecordId String submitRecordId String? @@ -972,7 +972,7 @@ model SmsMessageSegmentAudit { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id]) + tenant Tenant? @relation(fields: [tenantId], references: [id]) batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id]) messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id], onDelete: Cascade) submitRecord SmsSubmitRecord? @relation(fields: [submitRecordId], references: [id]) @@ -988,7 +988,7 @@ model SmsMessageSegmentAudit { model SmsReceiptRecord { id String @id @default(cuid()) - tenantId String + tenantId String? batchTaskId String? messageRecordId String? channelId String? @@ -1001,7 +1001,7 @@ model SmsReceiptRecord { deliveredAt DateTime createdAt DateTime @default(now()) - tenant Tenant @relation(fields: [tenantId], 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]) diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index c8625c0..1228a29 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -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-/) }), }), }); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index a6c79be..42df7d5 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -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, diff --git a/api/src/send-chain/admin-send-chain.controller.ts b/api/src/send-chain/admin-send-chain.controller.ts index 5d1a633..7e72d6e 100644 --- a/api/src/send-chain/admin-send-chain.controller.ts +++ b/api/src/send-chain/admin-send-chain.controller.ts @@ -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') diff --git a/api/src/send-chain/client-send-chain.controller.ts b/api/src/send-chain/client-send-chain.controller.ts index e0b83f4..9207143 100644 --- a/api/src/send-chain/client-send-chain.controller.ts +++ b/api/src/send-chain/client-send-chain.controller.ts @@ -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') diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 2efc785..b7a43b8 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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({ diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index bfcf685..56df3c7 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 92c9f62..1515838 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -188,7 +188,8 @@ 1. Gateway 必须按运营端通道配置连接上游 SMSC,使用通道的 `gatewayHost/gatewayPort/account/passwordCipher/srcId/cmppVersion` 完成 CMPP 2.0/3.0 connect/login。 - 运营端通道创建/编辑必须提供 CMPP 2.0/3.0 版本选项,默认 CMPP 2.0;保存后 Gateway 连接与 SubmitCommand 均必须使用真实保存的 `cmppVersion`。 - - 运营端通道“发送测试”必须走真实闭环:前端提交手机号和短信内容到 NestJS API,后端校验通道在线后创建 `SmsBatchTask`、`SmsMessageRecord`、`SmsSubmitRecord`,并向 Redis Stream `gateway.submit.commands` 写入真实 `SubmitCommand`;短信记录页面必须能查询到测试短信,不允许只返回占位成功或只关闭弹窗。 + - 运营端通道“发送测试”必须走真实闭环:前端提交手机号和短信内容到 NestJS API,后端校验通道在线后创建独立 `SmsMessageRecord`、`SmsSubmitRecord`,并向 Redis Stream `gateway.submit.commands` 写入真实 `SubmitCommand`;短信记录页面必须能查询到测试短信,不允许只返回占位成功或只关闭弹窗。 + - 通道测试短信是运营侧上游通道连通性验证,不绑定企业、企业应用或 `SmsBatchTask` 发送任务;submit result 和 receipt 只记录在短信记录、提交记录、回执记录和分片审计中,不触发客户侧 Deliver 推送、企业账务、任务进度或业务补发。 2. Gateway 必须校验上游 connect/login 返回码,区分 connected、auth_failed、connect_timeout、network_error、protocol_error 等状态,并回写 NestJS 真实连接状态。 3. Gateway 必须支持每个通道配置期望连接数,建立多条长连接,并按连接维度维护 currentConnections、lastConnectedAt、lastHeartbeatAt、lastError、reconnectCount。 - `desiredConnections`、`windowSize` 是平台对上游通道连接池和提交窗口的运行配置,必须通过运营端通道配置页面保存到真实后端;它们不是 CMPP 标准 PDU 字段,也不是 gocmpp 的原生配置字段。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index cc94f58..b77cf18 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -247,7 +247,8 @@ - 通道协议默认为 CMPP,CMPP 版本默认 2.0,且可选择 2.0 或 3.0。 - 通道限速保存正确。 - 通道真实保存 `desiredConnections/windowSize`,后续 Gateway `ConnectChannel` 与 `SubmitCommand.upstream` 使用该配置。 - - 通道测试短信必须调用真实 NestJS API,创建 `SmsBatchTask`、`SmsMessageRecord`、`SmsSubmitRecord`,并向 Redis Stream `gateway.submit.commands` 写入 `SubmitCommand`;短信记录页面能查询到该测试短信。 + - 通道测试短信必须调用真实 NestJS API,创建独立 `SmsMessageRecord` 和 `SmsSubmitRecord`,并向 Redis Stream `gateway.submit.commands` 写入 `SubmitCommand`;短信记录页面能查询到该测试短信。 + - 通道测试短信不得绑定企业、企业应用或 `SmsBatchTask` 发送任务;submit result 和 receipt 只更新短信记录、提交记录、回执记录和分片审计,不向客户侧推送 Deliver。 - 若通道未启用或没有在线 CMPP 连接,测试短信 API 返回明确错误,不能只在前端假提示成功。 - 停用通道不会被路由选中。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 5529688..2769b4d 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3,9 +3,10 @@ ## 2026-07-09 运营端通道测试短信闭环修复 - 生产验证发现运营端通道“短信测试”弹窗仅关闭页面,未调用后端;`POST /api/admin/channels/:id/test` 仍返回 phase-4 placeholder,不创建 `SmsMessageRecord/SmsSubmitRecord`,也不写入 Gateway SubmitCommand,因此短信记录页面无记录。 -- 已修复为真实链路:前端提交手机号、内容和可选接入号;NestJS 校验通道 active 且存在在线 CMPP 连接后,创建 `SmsBatchTask`、`SmsMessageRecord`、`SmsSubmitRecord` 和操作日志,并向 BullMQ `gateway.submit.queue` 与 Redis Stream `gateway.submit.commands` 写入真实 `SubmitCommand`。 +- 已修复为真实链路:前端提交手机号、内容和可选接入号;NestJS 校验通道 active 且存在在线 CMPP 连接后,创建独立 `SmsMessageRecord`、`SmsSubmitRecord` 和操作日志,并向 BullMQ `gateway.submit.queue` 与 Redis Stream `gateway.submit.commands` 写入真实 `SubmitCommand`。通道测试不绑定企业、企业应用或 `SmsBatchTask` 发送任务。 - 测试口径同步:`TC-ADMIN-003` 增加通道测试短信闭环要求,必须能从页面/API 发起真实测试短信,短信记录页面可查询到对应记录,Gateway submit worker 按通道真实 CMPP 配置消费发送。 - 已执行:`npm --prefix api test -- channels.service.spec.ts --runInBand`、`npm --prefix api run build`、`npm run build`。待生产部署后用指定号码做一次真实发送验证,并回查 DB/短信记录。 +- 2026-07-09 追加:按产品边界收窄通道测试短信,`SmsMessageRecord/SmsSubmitRecord/SmsReceiptRecord/SmsMessageSegmentAudit` 支持 `tenantId/batchTaskId` 为空;通道测试只记录短信、提交结果和回执,不进入企业账务、发送任务进度、客户侧 Deliver 推送或业务补发。 ## 2026-07-09 线上通道 CMPP 版本修复 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 1a140cc..11543d4 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -93,8 +93,7 @@ export type ChannelConnectionLogResponse = { export type ChannelTestResponse = { channelId: string; status: string; - batchTaskId: string; - taskNo: string; + testNo: string; submitted: number; messages: Array<{ phoneNumber: string; @@ -344,6 +343,7 @@ export type SmsMessageRecord = { amountCents: number; status: string; errorMessage?: string | null; + errorCode?: string | null; queuedAt: string; submittedAt?: string | null; deliveredAt?: string | null; @@ -352,8 +352,36 @@ export type SmsMessageRecord = { channel?: AdminChannel | null; tenant?: TenantOption | null; application?: { id: string; name: string }; - submitRecords?: Array>; - receiptRecords?: Array>; + submitRecords?: SmsSubmitRecord[]; + receiptRecords?: SmsReceiptRecord[]; +}; + +export type SmsSubmitRecord = { + id: string; + channelId: string; + submitId: string; + sequenceId?: number | null; + gatewayMessageId?: string | null; + submitStatus: string; + errorCode?: string | null; + errorMessage?: string | null; + submittedAt?: string | null; + createdAt: string; + channel?: AdminChannel | null; +}; + +export type SmsReceiptRecord = { + id: string; + channelId?: string | null; + messageId: string; + gatewayMessageId: string; + sequenceId?: number | null; + receiptStatus: string; + rawStatus: string; + errorCode?: string | null; + deliveredAt: string; + createdAt: string; + channel?: AdminChannel | null; }; export type SmsMessageSegmentAudit = { diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 015435b..b393741 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -335,7 +335,7 @@ function SmsTestModal({ content, accessNo: accessNo.trim() || undefined, }); - setResult(`已提交 ${response.submitted} 条测试短信,任务号 ${response.taskNo}`); + setResult(`已提交 ${response.submitted} 条测试短信,测试流水号 ${response.testNo}`); } catch (failure) { setError(failure instanceof Error ? failure.message : '测试短信发送失败'); } finally {