diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index ae5fc75..eb68660 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -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', diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 32b271a..d310fa3 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -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 | null; +}; + +function getRuntimeConfigInteger( + config: Prisma.JsonValue | Record | null | undefined, + key: string, + fallback: number, +) { + if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback; + const value = Number((config as Record)[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 | null, incomingConfig?: Record | null, diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 54aa4d3..8eb7500 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -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 }, diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 1a3dfd2..6bc5cb2 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -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 }, diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 8c6b0b7..03cec1c 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 9805ce3..8b737b6 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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 { 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; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 4c3bd56..bff0383 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1721,3 +1721,32 @@ 7. 模板变量异常指本次发送缺少模板必填变量或传入模板未定义变量。该校验保留为不可配置的确定性拒绝,返回明确的缺失/多传变量原因,不进入人工审核;模板创建时的人工审核不能替代每次发送的变量完整性校验。 8. 短信审核页面只展示待人工审核和人工审核记录;自动放行和自动拒绝不得混入“人工通过/人工驳回”。号码数量可点击查看真实号码明细,字段仅为手机号码、号码归属地、运营商和短信记录状态,并提供服务端搜索与分页。 9. 创建待审核批次时,每条待审`SmsMessageRecord.reviewTaskId`必须同步保存。人工通过或驳回应同时兼容短信直连审核任务和`SmsBatchTask.riskTaskId`关联路径,保证审核任务、批次、短信状态及入队/拒绝动作一致;本次不修复或补发升级前历史异常数据。 +## 2026-07-26 编号名称统一补充要求 + +- `SmsBatchTask.taskNo`在运营端短信任务进度、客户端批量任务、客户端首页及发送成功提示中统一显示为“发送批次号”,不得再显示为笼统的“任务编号”或“批次编号”。 +- `SmsSendTask.taskNo`在短信审核列表、筛选、详情及号码明细标题中统一显示为“审核任务号”。 +- 报备任务及其状态记录中的任务标识统一显示为“报备任务号”。 +- 数据库内部主键、发送批次号、审核任务号、报备任务号和协议`Msg_Id`保持原有数据结构与编号格式,本次只统一用户可见名称,不做字段迁移。 +## 2026-07-26 企业删除拦截提示补充要求 + +- 删除企业或变更企业状态被后端业务规则拦截时,错误必须显示在当前确认弹窗内,弹窗保持打开;不得只写入被遮挡的页面级错误区域。 +- 请求处理中禁用确认、取消及弹窗关闭操作,避免重复提交;仅在操作成功后关闭弹窗并刷新企业列表。 +- 企业仍有启用或停用中的应用时,必须展示后端返回的应用数量和“先完成应用停用”提示。 + +## 2026-07-26 运营页面细节与通道重连补充要求 + +1. 企业签名和引流信息报备状态中,通道运营商必须显示为移动、联通、电信或全网等中文名称;目标通道使用真实通道名称展示,不得用内部通道编号替代。 +2. 短信上行列表为上行内容保留足够列宽,列表可展示最多三行并在表格容器内横向滚动;完整内容继续以详情为准。 +3. 编辑启用中的通道时,仅当网关地址、端口、账号、密码、CMPP版本、连接数、窗口或心跳参数的实际值发生变化才请求重连。名称、运营商、地区、单价、服务号、扩展位、企业代码及TPS限速等业务参数不得触发重连;启用和停用状态变更仍按原规则连接或断开。 +4. 运营端短信记录首次进入及点击重置后,默认查询北京时间昨天和今天两天,仍允许用户选择其他日期。 +5. 下游投递详情按时间线卡片展示每次投递,分别呈现中文状态、发送/ACK/截止时间、连接ID、Sequence_Id、Msg_Id、ACK Result和错误,不使用需要横向滚动的宽表。 +6. Gateway提交异常列表标题区域必须与容器边框、表格留出清晰间距,并展示当前结果总数;分页区域具有独立分隔。 +7. 原提议的报表“T-4未知转失败”本轮明确取消,不改变既有日报未知状态、重算逻辑或历史数据。 + +## 2026-07-26 通道补发归因与发送详情补充要求 + +1. 模板短信和直接签名短信必须使用短信记录保存的真实`signatureId`进行通道路由及失败补发,不得要求直接签名短信必须存在模板;无法选出备用通道时必须写结构化原因、已尝试通道和通道组信息,禁止静默吞掉异常。 +2. Gateway返回的每条聚合和逐分片提交结果必须携带连接命令原始`submitId`。API必须按`submitId`精确更新一次`SmsSubmitRecord`;滚动升级期间收到不含`submitId`的旧结果时,只能在短信记录、通道和提交尝试构成唯一候选时兼容,零个或多个候选必须拒绝并记录日志,禁止批量覆盖历史尝试。 +3. 迟到的旧尝试结果只允许更新其对应提交尝试和分片审计,不得覆盖短信主记录当前尝试的通道、上游消息号或终态。 +4. 发送详情中的“通道发送与回执”必须优先按`SmsMessageSegmentAudit.submitId`重建逐次尝试,显示每次真实通道、发送时间、提交结果及各分片回执;不得用短信主记录最终通道回填所有历史尝试。 +5. 通道组成员顺序、优先级、权重或主备关系变更必须写操作审计,保存修改前后有序成员及真实通道名称,便于解释某条短信发送当时使用的路由配置。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index f07b2cd..50c06e2 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3853,3 +3853,45 @@ npm run verify:phase8 - `SMS-REVIEW-010`:点击号码数量后,通过真实后端分页查看手机号码、归属地、运营商和短信状态;号码搜索与10/20/50条分页正确,接口同时兼容`reviewTaskId`和批次`riskTaskId`关联。 - `SMS-REVIEW-011`:客户端和CMPP待审核任务创建时短信记录保存`reviewTaskId`;人工通过后短信由`pending_review`转为`queued`并入队,人工驳回后转拒绝且执行既有资金释放,不能只更新审核任务。 - `SMS-REVIEW-012`:升级前历史`pending_review`异常记录保持原样,不执行数据修复或短信补发;升级后新任务不再产生审核任务与短信状态不一致。 +## 2026-07-26 发送批次号与任务号命名用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-ID-NAME-001 | 查看运营端短信任务进度 | 列表、查询条件和详情均使用“发送批次号”,显示真实`SmsBatchTask.taskNo` | +| TC-ID-NAME-002 | 查看客户端批量任务、首页和发送成功提示 | 统一使用“发送批次号”,不再出现“任务编号”或“批次编号” | +| TC-ID-NAME-003 | 查看短信审核列表、详情和号码明细 | 显示真实`SmsSendTask.taskNo`并统一命名为“审核任务号”,可按该编号筛选 | +| TC-ID-NAME-004 | 查看报备任务及报备记录 | 列表、筛选和详情统一使用“报备任务号” | +| TC-ID-NAME-005 | 验证接口和数据库兼容性 | 仅修改展示文案,不改变现有ID、`taskNo`、关联关系或协议`Msg_Id` | +## 2026-07-26 企业删除拦截提示用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-TENANT-DELETE-001 | 删除仍有启用或停用中应用的企业 | 后端拒绝删除,确认弹窗保持打开,并在弹窗内显示应用数量及先停用应用的原因 | +| TC-TENANT-DELETE-002 | 删除请求处理中重复点击或关闭弹窗 | 确认、取消和关闭均被禁用,不产生重复请求 | +| TC-TENANT-DELETE-003 | 删除无阻塞依赖的企业 | 删除成功后才关闭弹窗,并刷新企业列表 | + +## 2026-07-26 运营页面细节与通道重连用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-OPS-UI-001 | 查看企业签名及引流信息报备状态 | 运营商以中文显示,目标通道显示真实名称,不出现内部通道编号替代名称 | +| TC-OPS-UI-002 | 查看较长的短信上行内容 | 内容列宽不被其他列挤窄,可展示最多三行,完整内容可在详情查看 | +| TC-CHANNEL-RECONNECT-003 | 仅修改启用中通道的名称、单价、运营商或TPS | 保存成功且不创建连接中状态、不发送Gateway连接控制请求 | +| TC-CHANNEL-RECONNECT-004 | 前端提交包含未变化连接参数的完整通道表单 | 按修改前后实际值判断,不发送无效重连请求 | +| TC-CHANNEL-RECONNECT-005 | 修改网关地址、账号、连接数、窗口或心跳参数 | 保存后发送连接控制请求;停用/启用仍正确断开/连接 | +| TC-SMS-RECORD-006 | 首次进入短信记录或点击重置 | 日期默认覆盖北京时间昨天和今天,并以该范围请求真实后端 | +| TC-DOWNSTREAM-UI-007 | 查看包含多次投递的下游投递详情 | 每次投递按纵向时间线展示中文状态、时间、连接和ACK证据,窄屏无需横向滚动 | +| TC-GATEWAY-UI-008 | 查看Gateway提交异常列表 | 标题、说明、总数、表格和分页层次清晰,不紧贴容器边框 | +| TC-REPORT-SCOPE-009 | 检查本轮报表变更范围 | T-4未知转失败未实现,日报未知口径和历史数据保持不变 | + +## 2026-07-26 通道补发归因与发送详情用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-RETRY-ROUTE-001 | 直接签名短信在首通道失败,签名在同组备用通道已报备通过 | 使用短信记录`signatureId`选中备用通道;无需模板;日志记录开始、选择结果和尝试通道 | +| TC-RETRY-ROUTE-002 | 模板短信在首通道失败,备用通道不可用或未报备 | 不创建伪补发;结构化日志记录失败原因、通道组和已尝试通道,不静默吞错 | +| TC-SUBMIT-ATTR-003 | 同一短信先后经两个通道提交,旧尝试的聚合结果迟到 | Gateway携带原始`submitId`;API只更新对应`SmsSubmitRecord`,不覆盖当前尝试主记录 | +| TC-SUBMIT-ATTR-004 | 滚动升级期间收到不含`submitId`的聚合或分片结果 | 唯一候选时兼容并告警;零个或多个候选时返回失败、写歧义日志且不批量更新 | +| TC-SUBMIT-ATTR-005 | 两分片长短信在通道A失败后由通道B补发 | 每个分片结果归属正确`submitId`和通道;任一迟到结果不污染另一尝试 | +| TC-SMS-DETAIL-006 | 查看先经富泷失败、再经铁布衫失败的历史短信详情 | “通道发送与回执”显示两次真实通道及各自分片回执,不把两行都显示为最终通道 | +| TC-CHANNEL-GROUP-007 | 调整通道组成员顺序、优先级、权重或主备 | 操作日志保存修改前后有序成员、通道编号和名称,可还原短信发送时配置 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 0175bad..b7c0d4a 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2490,3 +2490,37 @@ git diff --check - 新待审核短信同步保存`reviewTaskId`,审核决定同时按短信直连和批次`riskTaskId`查找,修复审核任务已通过而短信仍`pending_review`。遵照要求不改写现存历史异常数据、不补发历史短信。 - 用户登录标识改为“仅未删除记录唯一”后,部署管理员初始化与真实环境冒烟脚本同步从`username`唯一`upsert`改为先查询未删除用户、再按主键更新或创建,避免迁移后Prisma拒绝旧的唯一查询。 - 发布前门禁阶段结果:Prisma format/generate/validate通过;风险审核+发送链定向2 suites / 108 tests通过,随后补充应用覆盖、号码分页与逐号码拦截用例;API全量26 suites / 338 tests、API TypeScript build、前端TypeScript/Vite生产构建、Gateway `go test ./...`/`go vet ./...`、依赖安全门禁通过。API保留既有Redis不可用容错告警与`--forceExit`提示,前端保留约1.95MB单chunk/584.70KB gzip提示。 +## 2026-07-26 发送批次号与任务号命名统一(本地未提交、未部署) + +- 运营端短信任务进度、客户端批量任务、客户端首页及发送成功提示统一把`SmsBatchTask.taskNo`展示为“发送批次号”;同步修改查询标签、占位提示、详情标题和终止失败提示。 +- 短信审核列表新增“审核任务号”列,筛选、详情和号码明细标题统一展示`SmsSendTask.taskNo`;不新增接口请求或浏览器派生数据。 +- 报备任务和报备记录的列表、筛选及详情统一使用“报备任务号”。本次不修改数据库字段、编号格式、关联关系或协议消息ID。 +- Node.js v24.14.0下前端TypeScript检查、Vite生产构建和`git diff --check`通过;构建保留既有约1.95MB单chunk/584.75KB gzip提示。应用内浏览器访问本地短信任务进度路由时,未登录会话由真实鉴权守卫引导到运营登录页,标题、表单交互和控制台0条error/warn通过;图形验证码阻止登录后页面验收,未绕过认证或把登录页冒充目标页面。 +- 本轮按要求保持本地未提交、未推送、未部署;`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`及未跟踪空文件`=`继续作为既有构建/临时产物隔离。 +## 2026-07-26 企业删除拦截提示修复(本地未提交、未部署) + +- 后端原本已在企业仍有`active/disabling`应用时返回具体数量和“先完成应用停用”提示;运营端此前仅把失败写入弹窗背后的页面级错误,用户几乎不可见。 +- 企业删除/状态变更确认弹窗新增独立错误和请求中状态:失败保持弹窗并就地显示原因;请求期间禁用确认、取消及关闭;成功后才关闭并刷新列表。实现复用用户管理最后管理员拦截的交互口径。 +- Node.js v24.14.0下前端TypeScript检查、Vite生产构建和`git diff --check`通过,保留既有约1.95MB单chunk/584.92KB gzip提示。应用内浏览器访问本地企业管理路由时由真实鉴权守卫引导到图形验证码登录页,页面完整、输入交互正常且控制台0条error/warn;未绕过认证,登录后的拦截弹窗仍需有效运营会话补充可见验收。 +- 本节仅记录本地修改;未提交、未推送、未部署。 + +## 2026-07-26 运营页面细节与通道重连收敛(本地未提交、未部署) + +- 企业签名及引流信息报备状态将运营商映射为中文,并使用目标通道真实名称替代内部通道编号;展示忠实反映实际通道关联,不根据签名名称猜测运营商。 +- 短信上行内容列扩大到360px并最多展示三行,表格最小宽度同步增加;短信记录首次加载及重置默认查询北京时间昨天和今天。 +- 下游投递详情把逐次投递宽表改为纵向时间线卡片,中文展示等待确认、已确认、拒绝和失败状态,并分组展示发送/ACK/截止时间、连接、Sequence_Id、Msg_Id、ACK Result和错误。 +- Gateway提交异常列表新增带间距和分隔的标题区、结果总数及分页分隔,解决标题和边框紧贴的问题。 +- 通道更新改为比较修改前后实际连接参数;仅网关地址、端口、账号/密码、CMPP版本、连接数、窗口和心跳参数变化时请求重连。名称、价格、运营商、地区、服务号、扩展位及TPS等非连接参数不触发连接控制,状态启停逻辑不变。 +- 按最新要求取消日报T-4未知转失败,本轮未修改报表重算、未知口径或历史数据。 +- Node.js v24.14.0下通道服务定向1 suite / 40 tests、API TypeScript build、前端TypeScript检查、Vite生产构建及`git diff --check`通过。Jest仅保留既有Redis不可用容错告警和`--forceExit`异步句柄提示,前端保留既有约1.95MB单chunk/585.35KB gzip提示。 +- 应用内浏览器分别以默认桌面和390×844视口访问本地短信记录路由,真实认证守卫均引导至运营登录页;页面非空、无框架错误覆盖、控制台0条error/warn,输入框交互正常,移动端`scrollWidth=clientWidth=390`。未绕过图形验证码,因此六个登录后目标页面的最终视觉和真实数据验收仍需有效运营会话复核。 +- 本轮按要求保持未提交、未推送、未部署;工作区原有用户管理、编号命名、依赖安全整改、构建产物及临时文件保持原样。 + +## 2026-07-26 通道补发归因与发送详情修复(发布前) + +- 预生产只读核对`13901860234 / 17:53:46`:首轮富泷物业-移动两个分片均提交成功后返回`FLBLACK`,随后会员营销-铁布衫补发并返回`WL:FSNM`。详情把两次尝试都显示为铁布衫,是因为接口未返回提交/回执关联通道且前端使用短信主记录最终通道兜底;后续仅显示一个通道,是直接签名短信没有模板时补发签名解析失败并被空`catch`吞掉,与18:01通道顺序调整只是时间相关而非真实原因。 +- 直接签名和模板短信统一从短信记录解析真实`signatureId`;补发开始、跳过、选中和失败均输出结构化日志,包含消息、通道组、尝试通道、限制时间和异常原因。 +- Gateway聚合及逐分片提交结果携带原始`submitId`,API按提交记录主键精确更新。旧Gateway缺少`submitId`时只允许严格唯一候选兼容;歧义结果拒绝并记录,不再按短信批量覆盖多个尝试。迟到旧尝试结果只更新对应提交审计,不倒写当前短信尝试。 +- 运营短信接口返回提交及回执关联通道;详情优先按分片审计的`submitId`重建逐次通道、发送时间、提交状态和分片回执,因此既有17:53记录可还原为富泷到铁布衫两次真实尝试。 +- 通道组更新新增修改前后有序成员操作日志,包含通道编号、名称、运营商、地区、优先级、权重和主备,可审计顺序变化。 +- 定向验证已通过发送链1 suite / 98 tests(含直接签名备用通道、聚合及分片歧义拒绝)、通道和运营接口专项以及Gateway消息透传专项。最终发布门禁通过:API全量26 suites / 343 tests、API TypeScript build、Prisma format/validate、前端TypeScript/Vite生产构建、Gateway `go test ./...`/`go vet ./...`、依赖安全门禁及`git diff --check`;前端仅保留既有约1.95MB单chunk提示。提交、推送和预生产部署结果将在发布完成后补记。 diff --git a/gateway/internal/queue/messages.go b/gateway/internal/queue/messages.go index a3cb0ba..62d48dd 100644 --- a/gateway/internal/queue/messages.go +++ b/gateway/internal/queue/messages.go @@ -79,6 +79,7 @@ type Retry struct { type SubmitResult struct { Envelope + SubmitID string `json:"submitId"` SequenceID uint32 `json:"sequenceId"` GatewayMessageID string `json:"gatewayMessageId"` SubmitStatus string `json:"submitStatus"` diff --git a/gateway/internal/queue/messages_test.go b/gateway/internal/queue/messages_test.go index 777200a..e54d7a8 100644 --- a/gateway/internal/queue/messages_test.go +++ b/gateway/internal/queue/messages_test.go @@ -67,3 +67,20 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) { t.Fatalf("unexpected extension digits: %d", command.CMPP.ExtensionDigits) } } + +func TestSubmitResultMarshalsSubmitID(t *testing.T) { + payload, err := json.Marshal(SubmitResult{SubmitID: "submit-1"}) + if err != nil { + t.Fatalf("marshal submit result: %v", err) + } + if string(payload) == "" || !json.Valid(payload) { + t.Fatalf("invalid submit result JSON: %s", payload) + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal submit result: %v", err) + } + if decoded["submitId"] != "submit-1" { + t.Fatalf("submitId = %v, want submit-1", decoded["submitId"]) + } +} diff --git a/gateway/internal/upstream/manager.go b/gateway/internal/upstream/manager.go index c230290..9f79dc3 100644 --- a/gateway/internal/upstream/manager.go +++ b/gateway/internal/upstream/manager.go @@ -1284,6 +1284,7 @@ func submitResult(cmd queue.SubmitCommand, sequenceID uint32, gatewayMessageID s ChannelID: cmd.ChannelID, CreatedAt: time.Now().UTC(), }, + SubmitID: cmd.SubmitID, SequenceID: sequenceID, GatewayMessageID: gatewayMessageID, SubmitStatus: status, diff --git a/gateway/internal/upstream/submit_packet_test.go b/gateway/internal/upstream/submit_packet_test.go index e06fdb4..254d444 100644 --- a/gateway/internal/upstream/submit_packet_test.go +++ b/gateway/internal/upstream/submit_packet_test.go @@ -41,6 +41,20 @@ func TestSubmitRequestPacketUsesCMPP2PacketForCMPP20Channel(t *testing.T) { } } +func TestSubmitResultKeepsCommandSubmitID(t *testing.T) { + cmd := submitCommandForPacketTest("2.0") + cmd.SubmitID = "submit-attempt-2" + + result := submitResult(cmd, 42, "9001", "accepted", "", "") + + if result.SubmitID != cmd.SubmitID { + t.Fatalf("SubmitID = %q, want %q", result.SubmitID, cmd.SubmitID) + } + if result.ChannelID != cmd.ChannelID || result.MessageID != cmd.MessageID { + t.Fatalf("unexpected result envelope: %+v", result.Envelope) + } +} + func TestSubmitRequestPacketUsesCMPP3PacketForCMPP30Channel(t *testing.T) { conn := &connection{config: queue.UpstreamConfig{CMPPVersion: "3.0", Account: "ljcs02"}} cmd := submitCommandForPacketTest("3.0") diff --git a/src/apps/admin/AdminCustomersPage.tsx b/src/apps/admin/AdminCustomersPage.tsx index f6b9540..c07c818 100644 --- a/src/apps/admin/AdminCustomersPage.tsx +++ b/src/apps/admin/AdminCustomersPage.tsx @@ -11,10 +11,30 @@ type AdminCustomersPageProps = { type CustomerRow = TenantManagementRow; -function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) { +function ConfirmModal({ + error, + isSubmitting, + message, + onCancel, + onConfirm, + type, +}: { + error: string; + isSubmitting: boolean; + message: string; + onCancel: () => void; + onConfirm: () => void; + type: 'toggle' | 'delete'; +}) { return ( - } onClose={onCancel} open title="操作确认"> + } + onClose={() => { if (!isSubmitting) onCancel(); }} + open + title={type === 'delete' ? '删除企业' : '变更企业状态'} + >

{message}

+ {error ?

{error}

: null}
); } @@ -26,6 +46,8 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto const [queryStatus, setQueryStatus] = useState('all'); const [filters, setFilters] = useState({ name: '', status: 'all' }); const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null); + const [confirmError, setConfirmError] = useState(''); + const [confirming, setConfirming] = useState(false); const [rechargeTarget, setRechargeTarget] = useState(null); const [error, setError] = useState(''); @@ -82,10 +104,10 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
- - +
), }, @@ -95,15 +117,35 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto setRechargeTarget(record); } - function submitConfirmAction() { + function openConfirmAction(action: { type: 'toggle' | 'delete'; record: CustomerRow }) { + setConfirmError(''); + setConfirmAction(action); + } + + async function submitConfirmAction() { if (!confirmAction) return; - const action = confirmAction.type === 'delete' - ? adminApi.deleteTenant(confirmAction.record.id) - : adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active'); - action.then(() => { - setConfirmAction(null); - loadData(); - }).catch((failure: Error) => setError(failure.message || '企业状态更新失败')); + setConfirming(true); + setConfirmError(''); + try { + if (confirmAction.type === 'delete') { + await adminApi.deleteTenant(confirmAction.record.id); + } else { + await adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active'); + } + } catch (failure) { + const detail = failure instanceof Error ? failure.message : '企业操作失败'; + setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`); + setConfirming(false); + return; + } + setConfirmAction(null); + try { + await loadData(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '企业列表刷新失败'); + } finally { + setConfirming(false); + } } return ( @@ -140,9 +182,12 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto {confirmAction ? ( setConfirmAction(null)} - onConfirm={submitConfirmAction} + onCancel={() => { setConfirmAction(null); setConfirmError(''); }} + onConfirm={() => void submitConfirmAction()} + type={confirmAction.type} /> ) : null} = { uplink: '上行短信', }; +const attemptStatusLabel: Record = { + awaiting_ack: '等待客户端确认', + acknowledged: '客户端已确认', + rejected: '客户端拒绝', + failed: '投递失败', +}; + +function attemptStatusTone(status: string) { + if (status === 'acknowledged') return 'success' as const; + if (status === 'rejected' || status === 'failed') return 'danger' as const; + if (status === 'awaiting_ack') return 'info' as const; + return 'neutral' as const; +} + function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) { const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]); @@ -68,29 +82,33 @@ function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRe

逐次投递记录

-
-
- 次数 / 状态发送 / ACK 时间连接 / Sequence / Msg_Id结果 -
+
{(record.attempts ?? []).map((attempt) => ( -
- 第 {attempt.attemptNo} 次
{attempt.status}
- - {attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'} -
- ACK:{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'} -
- - {attempt.connectionId ?? '-'} -
- Seq:{attempt.sequenceId ?? '-'} / Msg:{attempt.messageId ?? '-'} -
- - ACK Result:{attempt.ackResult ?? '-'} -
- {attempt.errorMessage ?? attempt.failureType ?? '-'} -
-
+
+
+ {attempt.attemptNo} +
+
+
+ 第 {attempt.attemptNo} 次投递 + {attemptStatusLabel[attempt.status] ?? attempt.status} +
+
+
发送时间
{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}
+
ACK 时间
{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}
+
ACK 截止
{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}
+
+
+
连接 ID
{attempt.connectionId ?? '-'}
+
Sequence_Id
{attempt.sequenceId ?? '-'}
+
Msg_Id
{attempt.messageId ?? '-'}
+
+
+ ACK Result:{attempt.ackResult ?? '-'} + {attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'} +
+
+
))} {(record.attempts ?? []).length === 0 ?

暂无逐次投递记录

: null}
diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index 132a312..61fd657 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -49,6 +49,18 @@ type SignatureFormState = { type CarrierReportSummary = { status: string; approved: number; total: number }; type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray'; +const carrierLabels: Record = { + mobile: '移动', + unicom: '联通', + telecom: '电信', + all: '全网', +}; + +function carrierLabel(carrier?: string | null) { + if (!carrier) return '未标注运营商'; + return carrierLabels[carrier] ?? carrier; +} + function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) { if (!summary || summary.status === 'not_applicable' || summary.total === 0) return 不适用; let label = '未报备'; @@ -477,7 +489,7 @@ function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onC
-
{(item.reportTargets ?? []).map((target) =>
{target.channel.name}({target.channel.carrier})
)}
+
{(item.reportTargets ?? []).map((target) =>
{target.channel.name}({carrierLabel(target.channel.carrier)})
)}
); @@ -505,7 +517,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS return } onClose={onClose} open size="xl" title="按通道修改签名报备状态">
企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。
{error ?

{error}

: null} - {targets.length ? targets.map((target) =>
{target.channel.name}
{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}
setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} />
) :
该企业应用当前没有配置目标通道。
}