From a890b031630662559a44898e00634fd5666709c7 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 3 Jul 2026 11:07:43 +0800 Subject: [PATCH] fix: implement channel group routing failover --- .../migration.sql | 20 + api/prisma/schema.prisma | 16 + api/src/channels/channels.service.spec.ts | 20 +- api/src/channels/channels.service.ts | 35 +- .../dictionaries/dictionaries.controller.ts | 11 + api/src/dictionaries/dictionaries.service.ts | 32 ++ api/src/send-chain/send-chain.service.spec.ts | 39 +- api/src/send-chain/send-chain.service.ts | 374 ++++++++++++++---- .../first-version-development-requirements.md | 38 +- docs/system-functional-test-cases.md | 151 ++++++- docs/testing-plan.md | 4 +- docs/testing-progress.md | 49 ++- src/api/adminApi.ts | 10 +- src/apps/admin/AdminChannelsPage.tsx | 53 ++- src/apps/admin/AdminPhoneSegmentsPage.tsx | 79 +++- .../admin/AdminSmsApplicationFormPage.tsx | 43 +- 16 files changed, 853 insertions(+), 121 deletions(-) create mode 100644 api/prisma/migrations/20260703110000_add_channel_group_routing_rules/migration.sql diff --git a/api/prisma/migrations/20260703110000_add_channel_group_routing_rules/migration.sql b/api/prisma/migrations/20260703110000_add_channel_group_routing_rules/migration.sql new file mode 100644 index 0000000..6b55f28 --- /dev/null +++ b/api/prisma/migrations/20260703110000_add_channel_group_routing_rules/migration.sql @@ -0,0 +1,20 @@ +-- Channel-group routing rules for first-version send-chain failover. +ALTER TABLE "SmsChannel" ADD COLUMN "sendRegion" TEXT NOT NULL DEFAULT '全国'; + +ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryEnabled" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryTimeLimitHours" INTEGER NOT NULL DEFAULT 72; + +CREATE TABLE "PhoneCarrierRule" ( + "id" TEXT NOT NULL, + "carrier" TEXT NOT NULL, + "pattern" TEXT NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 100, + "status" TEXT NOT NULL DEFAULT 'active', + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PhoneCarrierRule_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "PhoneCarrierRule_status_priority_idx" ON "PhoneCarrierRule"("status", "priority"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index c5cc6ef..2801d3d 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -173,6 +173,19 @@ model PhoneSegment { updatedAt DateTime @updatedAt } +model PhoneCarrierRule { + id String @id @default(cuid()) + carrier String + pattern String + priority Int @default(100) + status String @default("active") + remark String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, priority]) +} + model SensitiveWord { id String @id @default(cuid()) word String @unique @@ -453,6 +466,7 @@ model SmsChannel { code String @unique name String carrier String? + sendRegion String @default("全国") protocol String @default("CMPP") gatewayHost String gatewayPort Int @@ -514,6 +528,8 @@ model SmsChannelGroup { name String description String? status String @default("active") + retryEnabled Boolean @default(true) + retryTimeLimitHours Int @default(72) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index a07a8b3..5d00cae 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -46,6 +46,7 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), }, smsChannelGroupItem: { + findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })), }, channelRouteRule: { @@ -113,28 +114,43 @@ describe('ChannelsService', () => { passwordCipher: 'secret', srcId: '10690000', }); - await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', channelId: 'channel-1' }); + await service.createGroup({ code: 'G-MOBILE', name: '移动组', retryEnabled: true, retryTimeLimitHours: 24 }); + await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ data: expect.objectContaining({ protocol: 'CMPP', cmppVersion: '3.0', rateLimitPerSecond: 100, + sendRegion: '全国', status: 'active', }), }); + expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ retryEnabled: true, retryTimeLimitHours: 24 }), + }); expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', - channelId: 'channel-1', + channelId: undefined, + carrier: 'mobile', priority: 100, status: 'active', }), }); }); + it('rejects direct single-channel route rules', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + expect(() => service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' })) + .toThrow('Route rules can only bind channel groups'); + expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); + }); + it('upserts signature report material per channel field', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index f98be71..d79d9dc 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -6,6 +6,7 @@ export interface CreateChannelDto { code: string; name: string; carrier?: string; + sendRegion?: string; protocol?: string; gatewayHost: string; gatewayPort: number; @@ -25,6 +26,8 @@ export interface CreateChannelGroupDto { name: string; description?: string; status?: string; + retryEnabled?: boolean; + retryTimeLimitHours?: number; } export interface CreateChannelGroupItemDto { @@ -143,6 +146,7 @@ export class ChannelsService { code: data.code, name: data.name, carrier: data.carrier, + sendRegion: data.sendRegion ?? '全国', protocol: data.protocol ?? 'CMPP', gatewayHost: data.gatewayHost, gatewayPort, @@ -207,6 +211,7 @@ export class ChannelsService { account: source.account, passwordCipher: source.passwordCipher, srcId: source.srcId, + sendRegion: source.sendRegion, cmppVersion: source.cmppVersion, rateLimitPerSecond: source.rateLimitPerSecond, unitPrice: source.unitPrice, @@ -382,17 +387,29 @@ export class ChannelsService { } createGroup(data: CreateChannelGroupDto) { + const retryTimeLimitHours = data.retryTimeLimitHours ?? 72; + if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { + throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); + } return this.prisma.smsChannelGroup.create({ data: { code: data.code, name: data.name, description: data.description, status: data.status ?? 'active', + retryEnabled: data.retryEnabled ?? true, + retryTimeLimitHours, }, }); } - addGroupItem(data: CreateChannelGroupItemDto) { + async addGroupItem(data: CreateChannelGroupItemDto) { + const existing = await this.prisma.smsChannelGroupItem.findFirst({ + where: { groupId: data.groupId, channelId: data.channelId }, + }); + if (existing) { + throw new BadRequestException('通道组内不能重复配置同一通道'); + } return this.prisma.smsChannelGroupItem.create({ data: { groupId: data.groupId, @@ -416,14 +433,26 @@ export class ChannelsService { } createRouteRule(data: CreateRouteRuleDto) { + if (!data.applicationId) { + throw new BadRequestException('applicationId is required for channel group routing'); + } + if (!data.carrier) { + throw new BadRequestException('carrier is required for application channel group routing'); + } + if (data.channelId) { + throw new BadRequestException('Route rules can only bind channel groups, not single channels'); + } + if (data.province) { + throw new BadRequestException('Province routing must be configured inside the channel group'); + } return this.prisma.channelRouteRule.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, groupId: data.groupId, - channelId: data.channelId, + channelId: undefined, carrier: data.carrier, - province: data.province, + province: undefined, priority: data.priority ?? 100, status: data.status ?? 'active', }, diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index 2ed254c..bf974d2 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -4,6 +4,7 @@ import { TenantId } from '../common/tenant-id.decorator'; import { CreateBlacklistDto, CreateDrainageFieldDto, + CreatePhoneCarrierRuleDto, CreatePhoneSegmentDto, CreateSensitiveWordDto, DictionariesService, @@ -25,6 +26,16 @@ export class DictionariesController { return this.dictionaries.createPhoneSegment(body); } + @Get('phone-carrier-rules') + listPhoneCarrierRules() { + return this.dictionaries.listPhoneCarrierRules(); + } + + @Post('phone-carrier-rules') + createPhoneCarrierRule(@Body() body: CreatePhoneCarrierRuleDto) { + return this.dictionaries.createPhoneCarrierRule(body); + } + @Get('sensitive-words') listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) { return this.dictionaries.listSensitiveWords({ keyword, status }); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 27dcb05..3f7caf0 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -9,6 +9,14 @@ export interface CreatePhoneSegmentDto { city?: string; } +export interface CreatePhoneCarrierRuleDto { + carrier: string; + pattern: string; + priority?: number; + status?: string; + remark?: string; +} + export interface CreateSensitiveWordDto { word: string; level?: string; @@ -56,6 +64,30 @@ export class DictionariesService { return this.prisma.phoneSegment.create({ data }); } + listPhoneCarrierRules() { + return this.prisma.phoneCarrierRule.findMany({ orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], take: 200 }); + } + + createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) { + if (!data.carrier || !data.pattern) { + throw new BadRequestException('carrier and pattern are required'); + } + try { + new RegExp(data.pattern); + } catch { + throw new BadRequestException('pattern must be a valid regular expression'); + } + return this.prisma.phoneCarrierRule.create({ + data: { + carrier: data.carrier, + pattern: data.pattern, + priority: data.priority ?? 100, + status: data.status ?? 'active', + remark: data.remark, + }, + }); + } + listSensitiveWords(query: DictionaryListQuery = {}) { return this.prisma.sensitiveWord.findMany({ where: { diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index ba6e538..66392d3 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -27,7 +27,26 @@ function createPrismaMock() { rateLimitPerSecond: 100, unitPrice: 3, status: 'active', + carrier: 'mobile', + sendRegion: '全国', config: { serviceId: 'SMS' }, + connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }], + }; + const route = { + id: 'route-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + groupId: 'group-1', + carrier: 'mobile', + priority: 100, + status: 'active', + group: { + id: 'group-1', + status: 'active', + retryEnabled: true, + retryTimeLimitHours: 72, + items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', priority: 1, province: null, channel }], + }, }; return { tenant: { @@ -65,7 +84,13 @@ function createPrismaMock() { groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]), }, channelRouteRule: { - findFirst: jest.fn().mockResolvedValue(null), + findFirst: jest.fn().mockResolvedValue(route), + }, + phoneCarrierRule: { + findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]), + }, + phoneSegment: { + findUnique: jest.fn().mockResolvedValue({ prefix: '1380000', province: '山东', city: '济南' }), }, smsChannel: { findFirst: jest.fn().mockResolvedValue(channel), @@ -77,7 +102,7 @@ function createPrismaMock() { smsSubmitRecord: { create: jest.fn().mockResolvedValue({ id: 'submit-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), - findMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), @@ -316,7 +341,15 @@ describe('SendChainService', () => { }); it('releases reservation for rejected submit result and refunds failed receipts', async () => { - const { service, billing } = createService(); + const { service, prisma, billing } = createService(); + prisma.channelRouteRule.findFirst.mockResolvedValue({ + id: 'route-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + groupId: 'group-1', + carrier: 'mobile', + group: { id: 'group-1', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] }, + }); await service.handleSubmitResult({ messageId: 'MSG-1', diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 0bcabcb..b08af13 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -80,6 +80,25 @@ interface SendJob { messageRecordId: string; } +type RoutedChannel = { + channel: { + id: string; + code: string; + account: string; + srcId: string; + rateLimitPerSecond: number; + unitPrice: number; + status: string; + carrier?: string | null; + sendRegion: string; + config?: unknown; + }; + carrier: string; + province?: string | null; + groupId: string; + routeScope: 'province' | 'national'; +}; + const SEND_QUEUE = 'sms.send.queue'; const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue'; @@ -443,63 +462,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!message || message.status !== 'queued') { return { skipped: true }; } - const channel = await this.selectChannel(message.tenantId, message.applicationId ?? undefined); - await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); - const submitId = `SUB-${randomUUID()}`; - const session = await this.prisma.cmppSubmitSession.upsert({ - where: { sessionNo: `OPEN-${channel.id}` }, - update: { submitTotal: { increment: 1 } }, - create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, - }); - await this.prisma.smsSubmitRecord.create({ - data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - channelId: channel.id, - sessionId: session.id, - submitId, - submitStatus: 'queued', - }, - }); - await this.prisma.smsMessageRecord.update({ - where: { id: message.id }, - data: { channelId: channel.id, submitId, status: 'submit_queued', submitStatus: 'queued' }, - }); - await this.getGatewayQueue().add('submit-command', { - schemaVersion: 'v1', - messageType: 'SubmitCommand', - traceId: randomUUID(), - messageId: message.messageId, - channelId: channel.id, - createdAt: new Date().toISOString(), - tenantId: message.tenantId, - applicationId: message.applicationId ?? 'unknown', - taskId: message.batchTaskId, - submitId, - phoneNumber: message.phoneNumber, - content: message.content, - signature: message.template?.signature?.name ?? 'SMS', - templateId: message.templateId ?? 'unknown', - billingUnits: message.billingUnits, - route: { - channelCode: channel.code, - cmppAccountCode: channel.account, - priority: 0, - rateLimitPerSecond: channel.rateLimitPerSecond, - }, - cmpp: { - serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config - ? String(channel.config.serviceId) - : 'SMS', - srcId: channel.srcId, - registeredDelivery: 1, - msgFmt: 8, - }, - retry: { attempt: 0, maxAttempts: 3 }, - }); - await this.refreshTaskProgress(message.batchTaskId); - return { submitted: true, messageRecordId: message.id, channelId: channel.id }; + try { + const routed = await this.selectChannelForMessage(message); + return this.submitMessageToGateway(message, 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); + return { submitted: false, messageRecordId: message.id, status: 'failed', reason }; + } } async handleSubmitResult(data: GatewaySubmitResultDto) { @@ -520,6 +495,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (data.submitStatus === 'accepted') { await this.chargeAcceptedMessage(message); } else { + const retried = await this.retryMessageIfAllowed(message, data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发'); + if (retried) { + await this.refreshTaskProgress(message.batchTaskId); + return retried; + } await this.releaseMessageReservation(message, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结'); } await this.prisma.smsMessageRecord.update({ @@ -543,9 +523,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); const status = data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed'; - if (status === 'failed') { - await this.refundMessage(message, '最终失败退款'); - } await this.prisma.smsReceiptRecord.create({ data: { tenantId: message.tenantId, @@ -561,6 +538,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { deliveredAt, }, }); + if (status === 'failed') { + const retried = await this.retryMessageIfAllowed(message, '回执失败补发'); + if (retried) { + await this.refreshTaskProgress(message.batchTaskId); + return retried; + } + await this.refundMessage(message, '最终失败退款'); + } await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { @@ -623,30 +608,232 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return { timeout: candidates.length }; } - private async selectChannel(tenantId: string, applicationId?: string) { + private async submitMessageToGateway( + message: { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + template?: { signature?: { name?: string | null } | null } | null; + }, + routed: RoutedChannel, + attempt: number, + ) { + const channel = routed.channel; + await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); + const submitId = `SUB-${randomUUID()}`; + const session = await this.prisma.cmppSubmitSession.upsert({ + where: { sessionNo: `OPEN-${channel.id}` }, + update: { submitTotal: { increment: 1 } }, + create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 }, + }); + await this.prisma.smsSubmitRecord.create({ + data: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + channelId: channel.id, + sessionId: session.id, + submitId, + submitStatus: 'queued', + }, + }); + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { + channelId: channel.id, + submitId, + status: 'submit_queued', + submitStatus: 'queued', + receiptStatus: null, + errorCode: null, + errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined, + }, + }); + await this.getGatewayQueue().add('submit-command', { + schemaVersion: 'v1', + messageType: 'SubmitCommand', + traceId: randomUUID(), + messageId: message.messageId, + channelId: channel.id, + createdAt: new Date().toISOString(), + tenantId: message.tenantId, + applicationId: message.applicationId ?? 'unknown', + taskId: message.batchTaskId, + submitId, + phoneNumber: message.phoneNumber, + content: message.content, + signature: message.template?.signature?.name ?? 'SMS', + templateId: message.templateId ?? 'unknown', + billingUnits: message.billingUnits, + route: { + channelCode: channel.code, + cmppAccountCode: channel.account, + priority: attempt, + rateLimitPerSecond: channel.rateLimitPerSecond, + carrier: routed.carrier, + province: routed.province ?? undefined, + scope: routed.routeScope, + groupId: routed.groupId, + }, + cmpp: { + serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config + ? String(channel.config.serviceId) + : 'SMS', + srcId: channel.srcId, + registeredDelivery: 1, + msgFmt: 8, + }, + retry: { attempt, maxAttempts: 0 }, + }); + await this.refreshTaskProgress(message.batchTaskId); + return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt }; + } + + private async retryMessageIfAllowed( + message: { + id: string; + tenantId: string; + batchTaskId: string; + applicationId?: string | null; + templateId?: string | null; + messageId: string; + phoneNumber: string; + content: string; + billingUnits: number; + queuedAt?: Date; + }, + reason: string, + ) { + const attempts = await this.prisma.smsSubmitRecord.findMany({ + where: { messageRecordId: message.id }, + orderBy: { createdAt: 'asc' }, + take: 200, + }); + const attemptedChannelIds = attempts.map((attempt) => attempt.channelId); + const ageHours = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 3_600_000; + if (ageHours >= 72) { + return null; + } + const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber)); + if (!route.group.retryEnabled || ageHours >= Math.min(route.group.retryTimeLimitHours, 72)) { + return null; + } + try { + const routed = await this.selectChannelForMessage(message, { + forceNational: true, + excludeChannelIds: attemptedChannelIds, + }); + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { errorMessage: reason }, + }); + return this.submitMessageToGateway(message, routed, attempts.length); + } catch { + return null; + } + } + + private async selectChannelForMessage( + message: { tenantId: string; applicationId?: string | null; phoneNumber: string }, + options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, + ): Promise { + if (!message.applicationId) { + throw new BadRequestException('短信应用未配置,无法选择通道组'); + } + const carrier = await this.identifyCarrier(message.phoneNumber); + const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier); + const province = await this.identifyProvince(message.phoneNumber); + const excluded = new Set(options.excludeChannelIds ?? []); + const items = route.group.items.filter((item) => !excluded.has(item.channelId) && isCarrierCompatible(item.channel.carrier, carrier)); + const provinceCandidates = options.forceNational ? [] : items.filter((item) => isProvinceChannel(item, province)); + const nationalCandidates = items.filter((item) => isNationalChannel(item)); + const selected = [...provinceCandidates, ...nationalCandidates].find((item) => this.isChannelSendAvailable(item.channel)); + if (!selected) { + throw new NotFoundException('无可用在线通道'); + } + return { + channel: selected.channel, + carrier, + province, + groupId: route.groupId, + routeScope: isNationalChannel(selected) ? 'national' : 'province', + }; + } + + private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) { const route = await this.prisma.channelRouteRule.findFirst({ where: { status: 'active', - OR: [{ tenantId, applicationId }, { tenantId, applicationId: null }, { tenantId: null, applicationId: null }], + tenantId, + applicationId, + carrier, + channelId: null, + province: null, }, - include: { channel: true, group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, + include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } }, orderBy: { priority: 'asc' }, }); - const routedChannel = route?.channel ?? route?.group.items.find((item) => item.channel.status === 'active')?.channel; - if (routedChannel) { - return routedChannel; + if (!route) { + throw new NotFoundException('企业应用未配置对应运营商通道组'); } - const channel = await this.prisma.smsChannel.findFirst({ where: { status: 'active' }, orderBy: { createdAt: 'asc' } }); - if (!channel) { - throw new NotFoundException('No active SMS channel available'); + if (route.group.status !== 'active') { + throw new BadRequestException('企业应用绑定的通道组已停用'); } - return channel; + return route; + } + + private async identifyCarrier(phoneNumber: string) { + const rules = await this.prisma.phoneCarrierRule.findMany({ + where: { status: 'active' }, + orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], + take: 100, + }); + for (const rule of rules) { + try { + if (new RegExp(rule.pattern).test(phoneNumber)) { + return normalizeCarrier(rule.carrier); + } + } catch { + continue; + } + } + return 'mobile'; + } + + private async identifyProvince(phoneNumber: string) { + for (let length = Math.min(7, phoneNumber.length); length >= 3; length -= 1) { + const segment = await this.prisma.phoneSegment.findUnique({ where: { prefix: phoneNumber.slice(0, length) } }); + if (segment?.province) { + return segment.province; + } + } + return null; + } + + private isChannelSendAvailable(channel: { status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }> }) { + if (channel.status !== 'active') { + return false; + } + return (channel.connectionStates ?? []).some((connection) => + connection.desiredConnections > 0 && connection.currentConnections > 0 && ['online', 'connected'].includes(connection.status), + ); } private async resolveUnitPrice(tenantId: string, applicationId?: string) { try { - const channel = await this.selectChannel(tenantId, applicationId); - return channel.unitPrice ?? 0; + const route = await this.prisma.channelRouteRule.findFirst({ + where: { tenantId, applicationId, status: 'active', channelId: null }, + include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, + orderBy: { priority: 'asc' }, + }); + const channel = route?.group.items.find((item) => item.channel.status === 'active')?.channel; + return channel?.unitPrice ?? 0; } catch { return 0; } @@ -695,6 +882,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }) { const amountCents = message.amountCents ?? 0; const smsUnits = message.billingUnits ?? 0; + const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); + if (exists?.billingStatus === 'charged') { + return; + } if (amountCents + smsUnits > 0) { await this.billing.release({ tenantId: message.tenantId, @@ -713,7 +904,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { relatedId: message.messageId, remark: '提交成功扣费', }); - const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); const data = { tenantId: message.tenantId, applicationId: message.applicationId ?? undefined, @@ -741,6 +931,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) { return; } + const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); + if (charged) { + return; + } await this.billing.release({ tenantId: message.tenantId, amountCents: message.amountCents, @@ -914,6 +1108,40 @@ function cellByHeader(headers: string[], cells: string[], candidates: string[]) return index >= 0 ? cells[index] : undefined; } +function normalizeCarrier(carrier?: string | null) { + const value = String(carrier ?? '').trim().toLowerCase(); + if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; + if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; + if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; + if (['all', 'tri', '三网', '全网'].includes(value)) return 'all'; + return value || 'mobile'; +} + +function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) { + const normalized = normalizeCarrier(channelCarrier); + return normalized === 'all' || normalized === targetCarrier; +} + +function normalizeRegion(region?: string | null) { + return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); +} + +function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) { + const itemProvince = normalizeRegion(item.province); + const sendRegion = normalizeRegion(item.channel.sendRegion); + return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国'; +} + +function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) { + if (!province) { + return false; + } + const target = normalizeRegion(province); + const itemProvince = normalizeRegion(item.province); + const sendRegion = normalizeRegion(item.channel.sendRegion); + return itemProvince === target || sendRegion === target; +} + function bullmqConnection() { const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); return { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 2ab868a..7880549 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -134,9 +134,24 @@ ### 4.6 通道配置与路由 1. 运营端配置短信通道,包括通道名称、运营商、单价、网关地址、端口、企业代码、账号、密码、接入号、协议参数、启停状态。 -2. 运营端配置短信通道组,定义通道优先级、运营商适配、区域适配、权重和故障切换。 -3. 发送时根据企业、应用、签名、模板、号码归属、通道状态和通道组策略选择通道。 -4. 通道异常时应支持熔断、降级、切换备用通道和失败重试。 +2. 通道运营商支持移动、联通、电信和三网;三网通道可作为移动、联通、电信的通配通道。 +3. 通道必须配置发送地区,发送地区为全国或 34 个省级地区之一;单个通道只能选择一个发送地区。 +4. 运营端配置短信通道组,定义通道优先级、运营商适配、省网路由、全国路由、权重、失败补发开关和补发时间上限。 +5. 企业不配置默认通道组;企业应用必须单独配置至少一个运营商通道组,否则页面不可保存,发送时也必须直接失败。 +6. 一个企业应用可以分别绑定移动、联通、电信通道组;可以只绑定其中一类或两类,但不能一个都不绑定。 +7. 发送时先按运营商识别结果分流:移动短信走移动通道组,联通短信走联通通道组,电信短信走电信通道组;识别不出的号码走移动通道组。 +8. 运营商识别优先使用可配置的号码前缀正则表达式,通常匹配手机号前 3 到 4 位;如当前没有配置能力,手机号段库需增加“运营商区分规则”配置。 +9. 路由规则只能表达应用到通道组的绑定关系,不能直接绑定单个通道,也不在规则层配置省份;省份和全国路由在通道组内部处理。 +10. 发送服务必须使用手机号段库识别手机号省份和城市;无法识别省份时走对应运营商的全国通道组路由。 +11. 通道组内必须先匹配省网路由;省网未匹配时走同一运营商通道组内的全国通道。省网发送失败后,当前版本立即跳到该通道组第一个全国通道补发,不再尝试同省第二省网通道。 +12. 省网配置当前版本一省只能配置一个通道;全国通道可配置多个,并按优先级依次补发。 +13. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节。 +14. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 online、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected 或当前连接数为 0 的通道均不可被选中。 +15. 多连接通道只要至少 1 条连接 online 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。 +16. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并保证计费、退款、幂等和 trace 可追踪。 +17. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。 +18. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔或失败类型白名单。 +19. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;本期客户计费不使用通道成本价。 ### 4.7 通道签名报备 @@ -462,12 +477,17 @@ 2. 拆分号码和变量,生成 message_record。 3. 写入数据库并投递 send.queue。 4. Send Worker 消费消息,校验黑名单、敏感词、限额。 -5. 路由选择通道。 -6. 通过通道限速器控制 TPS。 -7. 调用 Gateway Adapter 提交短信。 -8. 写入 submit 状态。 -9. Receipt Worker 接收回执并更新最终状态。 -10. 统计任务进度。 +5. 使用可配置号码前缀正则识别运营商;识别不出时按移动处理。 +6. 使用手机号段库识别号码省份和城市;识别不出省份时按全国路由处理。 +7. 按企业应用绑定的对应运营商通道组执行路由:先匹配省网通道,再匹配全国通道;不得直接绑定或 fallback 到非授权单通道。 +8. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。 +9. 通过通道限速器控制 TPS。 +10. 调用 Gateway Adapter 提交短信。 +11. 写入 submit 状态。 +12. Submit rejected、submit timeout、Gateway 连接断开或未提交成功、receipt failed 等场景按通道组策略补发到下一可用全国通道;unknown、超过 72 小时、超过通道组补发时间上限或关闭补发时不再补发。 +13. Receipt Worker 接收回执并更新最终状态。 +14. 补发过程必须保持幂等、计费一致和 trace 可查,最终成功只扣一次客户费率。 +15. 统计任务进度。 ### 8.3 500 条/秒实现建议 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index b5c62ed..e64bcff 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -664,12 +664,139 @@ ### TC-SEND-003 通道路由和 Gateway SubmitCommand - 优先级:P0 -- 前置条件:存在 active 通道和路由规则。 +- 前置条件:企业应用已绑定对应运营商的 active 通道组,通道组内存在 active 且 online 的通道。 - 步骤:处理一条发送 job。 - 预期结果: - - 选择正确通道。 + - 先按号码前缀正则识别运营商,再只在企业应用绑定的对应运营商通道组内选择正确通道。 - 创建 SmsSubmitRecord。 - 投递 Gateway SubmitCommand,包含 traceId、messageId、channelId、submitId、phoneNumber、content、cmpp 参数。 + - 未配置对应运营商通道组或未命中可用通道时不发送,并记录可读失败原因。 + +### TC-SEND-010 禁止路由规则直接绑定单通道 + +- 优先级:P0 +- 前置条件:存在通道 A、通道组 G、企业应用 App。 +- 步骤: + 1. 尝试创建或启用直接绑定通道 A 的路由规则。 + 2. 创建 App 的发送任务。 + 3. 查看 submit record、trace 和系统日志。 +- 预期结果: + - 系统不允许直接绑定单个通道作为发送规则。 + - 发送链路只接受通道组路由。 + - 如存在历史单通道规则,发送链路不得使用该规则,并记录配置错误。 + +### TC-SEND-011 未命中企业应用通道组时不发送 + +- 优先级:P0 +- 前置条件:企业应用 App 未配置通道组,系统存在其他 active 通道。 +- 步骤: + 1. 创建 App 的发送任务。 + 2. 触发送 worker。 + 3. 查看发送明细、submit record、trace 和系统日志。 +- 预期结果: + - 系统不 fallback 到其他 active 通道。 + - 不投递 Gateway SubmitCommand。 + - 发送明细进入 failed。 + - 运营端 trace 和系统日志可看到“未配置可用通道组”原因;客户端本期只展示失败状态,不展示通道细节。 + +### TC-SEND-012 手机号段识别归属地参与路由 + +- 优先级:P0 +- 前置条件:运营商区分规则可识别移动号码;手机号段库存在 `prefix/province/city` 数据;企业应用绑定移动通道组,组内配置山东省网通道 A、全国通道 B。 +- 步骤: + 1. 使用山东手机号创建发送任务。 + 2. 触发送 worker。 + 3. 查询 submit record、trace 和路由日志。 +- 预期结果: + - 系统按可配置前缀正则识别运营商,并按手机号段库查询省份和城市。 + - trace 记录识别出的运营商、省份和城市。 + - 路由优先选择山东省网通道 A。 + - Gateway SubmitCommand 的 channelId 为 A。 + +### TC-SEND-013 省网未匹配时走全国路由 + +- 优先级:P0 +- 前置条件:手机号段库可识别四川手机号;企业应用绑定对应运营商通道组,组内无四川省网通道,但存在全国通道 B。 +- 步骤: + 1. 使用四川手机号创建发送任务。 + 2. 触发送 worker。 + 3. 查询 submit record 和 trace。 +- 预期结果: + - 系统识别手机号归属地为四川。 + - 省网未匹配时选择同一通道组内全国通道 B。 + - 不选择其他通道组或全局 active 通道。 + +### TC-SEND-014 省网失败后补发全国通道 + +- 优先级:P0 +- 前置条件:通道组内配置山东省网通道 A 和全国通道 B/C;A、B、C 均属同一企业应用授权通道组;A 可模拟 submit 失败、连接断开或最终回执失败。 +- 步骤: + 1. 使用山东手机号创建发送任务。 + 2. 首次路由命中 A。 + 3. 模拟 A submit rejected、submit timeout、连接断开、运营商失败回执或其他 receipt failed。 + 4. 查看补发记录、submit record、trace、计费流水。 +- 预期结果: + - 系统立即切换到全国通道 B 补发,不再尝试同省第二省网通道。 + - 如果 B 失败,继续按全国通道优先级补发到 C。 + - 补发不跨出企业应用授权通道组。 + - 原失败原因、补发次数、前后 channelId 和状态流转在 trace 中可查。 + - 最终成功只按企业应用客户费率扣一次,不按通道成本价重复扣费。 + +### TC-SEND-015 企业应用通道组保存校验 + +- 优先级:P0 +- 前置条件:存在移动、联通、电信通道组。 +- 步骤: + 1. 新建或编辑企业应用,不选择任何通道组并保存。 + 2. 选择移动通道组后保存。 + 3. 再补充联通、电信通道组保存。 +- 预期结果: + - 不选择任何通道组时页面不可保存,并提示至少配置一个运营商通道组。 + - 允许只配置移动、联通或电信中的一类或两类。 + - 已配置的运营商通道组会影响对应运营商短信分流。 + +### TC-SEND-016 运营商正则分流和识别失败默认移动 + +- 优先级:P0 +- 前置条件:手机号段库的“运营商区分规则”已配置移动、联通、电信号码前缀正则;企业应用分别绑定移动、联通、电信通道组。 +- 步骤: + 1. 分别提交移动、联通、电信号码。 + 2. 提交一个正则无法识别运营商但号码格式合法的号码。 + 3. 查询 submit record 和 trace。 +- 预期结果: + - 移动号码进入移动通道组。 + - 联通号码进入联通通道组。 + - 电信号码进入电信通道组。 + - 运营商识别失败时进入移动通道组。 + +### TC-SEND-017 三网通道作为运营商通配 + +- 优先级:P0 +- 前置条件:企业应用绑定移动通道组;移动通道组内无可用移动全国通道,但有可用三网全国通道。 +- 步骤: + 1. 提交移动号码。 + 2. 触发送 worker。 + 3. 查询 submit record 和 trace。 +- 预期结果: + - 三网通道可作为移动、联通、电信的通配通道参与路由。 + - route/trace 标明实际命中的三网通道。 + +### TC-SEND-018 失败补发停止条件 + +- 优先级:P0 +- 前置条件:通道组开启失败补发并配置补发时间上限;全国通道可连续模拟失败。 +- 步骤: + 1. 模拟普通 failed 回执,确认触发补发。 + 2. 模拟 unknown 状态,执行 72 小时超时补偿。 + 3. 将消息提交时间调整为超过 72 小时。 + 4. 将消息提交时间调整为超过通道组补发时间上限。 + 5. 关闭通道组失败补发后再次模拟 failed。 +- 预期结果: + - 普通 failed 在限制内触发补发。 + - unknown 不触发补发。 + - 超过 72 小时不触发补发。 + - 超过通道组补发时间上限不触发补发。 + - 通道组关闭失败补发时不触发补发。 ### TC-SEND-004 SubmitResult accepted @@ -2084,7 +2211,7 @@ - 前置条件:通道组包含主通道 A 和备用通道 B,A active 但连接离线,B active 且 online,签名在 B 报备通过。 - 步骤: 1. 确认 A 业务状态 active、连接状态 disconnected。 - 2. 确认 B 业务状态 active、连接状态 online。 + 2. 确认 B 业务状态 active、连接状态 online、currentConnections 大于 0。 3. 创建发送任务。 4. 查询 submit record 和 trace。 - 预期结果: @@ -2103,8 +2230,8 @@ 3. 查看任务状态、发送明细、系统日志。 - 预期结果: - 系统不向离线连接 submit。 - - 按设计进入 delayed/retry、failed 或 pending_channel 状态。 - - 客户端和运营端展示“无可用在线通道”原因。 + - 发送明细进入 failed。 + - 运营端 trace 和系统日志展示“无可用在线通道”原因;客户端本期只展示失败状态,不展示通道细节。 - 不产生 submit accepted 记录,不错误扣费。 ### TC-CMPP-STATUS-008 连接状态与通道启停状态组合 @@ -2122,6 +2249,20 @@ - 启用后如连接仍 online,可恢复路由;如连接已断开,需要等待重连。 - 系统日志记录启停操作。 +### TC-CMPP-STATUS-008A 通道连接可用性标准 + +- 优先级:P0 +- 前置条件:同一通道组内准备多个通道,分别设置为 online/currentConnections=1、online/currentConnections=0、auth_failed、heartbeat_timeout、reconnecting、disconnected。 +- 步骤: + 1. 创建发送任务。 + 2. 触发送 worker。 + 3. 查询 submit record、trace 和连接状态日志。 +- 预期结果: + - 只有业务 active、连接 online、currentConnections 大于 0 且心跳未失败的通道可被选中。 + - auth_failed、heartbeat_timeout、reconnecting、disconnected 和 currentConnections=0 的通道均被跳过。 + - 多连接通道只要至少 1 条连接 online 且可用即可参与路由。 + - 心跳连续 3 次失败后进入重连,重连成功前不可发送。 + ### TC-CMPP-STATUS-009 慢响应导致窗口占满和状态告警 - 优先级:P1 diff --git a/docs/testing-plan.md b/docs/testing-plan.md index d4add65..9719557 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -16,8 +16,8 @@ - 当前重点: - 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。 - 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录。 - - 发送链路:批量任务创建、手机号拆分、发送入队、submit result 更新、receipt 更新、uplink 记录、72 小时未知转超时。 - - 通道与报备:通道创建、路由规则、签名报备任务、导出、回执导入、签名状态同步。 + - 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。 + - 通道与报备:通道创建、通道发送地区、三网通道通配、通道组路由规则、企业应用通道组保存校验、禁止单通道发送规则、签名报备任务、导出、回执导入、签名状态同步。 - 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。 - Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 759aae0..c5c9aab 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -81,7 +81,8 @@ npm run test:gateway - 记录两个接口健壮性问题:不存在的 `reviewerId`、不存在的 `createdById` 会触发数据库外键 500。 - 客户侧导入发送、非法字符展示、敏感词接入发送前风控当前缺少完整入口,已在 `docs/testing-execution-step-4.md` 记录为阻塞缺口。 - 第 5 步发送链路 smoke 通过: - - `TC-SEND-001 / TC-SEND-002`、`TC-SEND-003 / TC-SEND-004`、`TC-SEND-005`、`TC-SEND-006`、`TC-SEND-007`、`TC-SEND-008`、`TC-SEND-009 / TC-SEND-010` 均通过。 + - `TC-SEND-001 / TC-SEND-002`、旧版 `TC-SEND-003 / TC-SEND-004`、`TC-SEND-005`、`TC-SEND-006`、`TC-SEND-007`、`TC-SEND-008`、旧版 `TC-SEND-009 / TC-SEND-010` 均通过。 + - 2026-07-03 新增的通道组真实路由用例 `TC-SEND-010` 到 `TC-SEND-018` 尚未开发和执行,不能沿用旧 smoke 通过结论。 - 使用真实 Redis/BullMQ 和 `API_ENABLE_SEND_WORKER=true` 验证了任务创建、号码去重拆分、自动入队、Worker 消费、submit record、submit result、receipt、uplink、72 小时 unknown 转 timeout、客户端/运营端任务查看。 - 定时发送探测发现 `scheduledAt/sendMode=scheduled` 会被接口忽略,任务直接变为 `queued`,已在 `docs/testing-execution-step-5.md` 记录为功能缺口。 - 第 6 步计费和对账 smoke 通过: @@ -271,6 +272,52 @@ node | BUG-FE-005 | P0 | 企业应用 CMPP 状态和连接详情页面仍使用本地初始数据,未读取真实连接状态 API。 | `src/apps/admin/AdminEnterpriseApplicationsPage.tsx` 使用 `initialSmsApps`、`setSmsApps`,连接删除也是本地状态变更。 | 接入企业应用、连接状态、连接详情、连接删除/断开真实 API 或 Gateway 回写接口。 | | BUG-API-001 | P1 | 通道创建参数缺失时返回 Prisma 500,而不是业务 400。 | 浏览器 smoke 第一轮 `POST /api/admin/channels` 缺少 `code/gatewayHost/gatewayPort/account/passwordCipher/srcId`,API 返回 Internal server error。 | 为通道创建 DTO 增加校验,缺失必填字段返回 400 和可读错误,并写失败日志。 | | BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174,Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 | +| BUG-SEND-001 | P0 | 发送路由规则允许直接绑定单个通道,违反“规则只能绑定通道组”的业务约束。 | `SendChainService.selectChannel()` 当前存在 `route?.channel ?? route?.group...` 路径;`ChannelRouteRule` 模型也保留 `channelId` 字段。 | 路由规则只能表达应用到通道组的绑定关系;发送链路必须从企业应用绑定的运营商通道组内选路,不允许规则直接指定单个通道。 | +| BUG-SEND-002 | P0 | 未命中路由规则时会 fallback 到全局第一个 `active` 通道,可能把短信发到未配置给该企业/应用的通道。 | `SendChainService.selectChannel()` 未找到 route 后执行 `smsChannel.findFirst({ where: { status: 'active' } })`。 | 企业应用没有配置对应运营商通道组或无可用通道时,短信直接 failed;不得进入 pending/delayed,不得 fallback 到其他 active 通道,需记录 trace/日志。 | +| BUG-SEND-003 | P0 | 发送选路只判断通道业务状态 `active`,不判断 CMPP 真实连接状态。 | `selectChannel()` 只检查 `SmsChannel.status`,未查询 `CmppConnectionState.status/currentConnections/lastHeartbeatAt/lastError`。 | 选路必须跳过离线、认证失败、心跳超时、重连中或 `currentConnections=0` 的通道;至少 1 条连接 online 且心跳正常才可发送,连续 3 次心跳失败进入重连且不可选。 | +| BUG-SEND-004 | P0 | 通道组主通道提交失败、超时或回执失败后不会切换到下一个通道补发。 | `handleSubmitResult()` 和 `handleReceipt()` 只更新状态、释放/退款和刷新进度,没有重新选路或创建补发记录;`retry.maxAttempts` 目前未形成业务补发闭环。 | 除 unknown、超过 72 小时、超过通道组补发时间上限或通道组关闭补发外,submit rejected/timeout、连接断开、未提交成功、receipt failed 均需补发;省网失败后立即走全国通道,全国通道按优先级继续补发,最终成功只按企业应用客户费率扣一次。 | +| BUG-SEND-005 | P0 | 通道组省网/全国路由没有接入真实发送链路,手机号段库也未参与归属地识别。 | `SmsChannelGroupItem` 和 `ChannelRouteRule` 虽有 `carrier/province` 字段,`PhoneSegment` 有 `prefix/carrier/province/city`,但 `SendChainService.selectChannel()` 未读取 message.phoneNumber、未查询 `phoneSegment`,只按优先级取第一个 active 通道;前端 `AdminChannelGroupFormPage` 的省网/全国配置仍为本地 `useState`。 | 发送前按可配置号码前缀正则识别运营商,识别失败走移动通道组;按手机号段库识别省份和城市,省份识别失败走对应运营商全国通道;通道需支持移动/联通/电信/三网和全国/单省发送地区,三网作为通配。 | +| BUG-SEND-006 | P0 | 企业应用缺少按运营商绑定多个通道组和保存校验的真实闭环。 | 当前发送链路只按 `tenantId/applicationId` 查询单一路由规则;未体现一个应用分别绑定移动、联通、电信通道组,也未强制至少绑定一个通道组后才能保存。 | 企业应用可分别绑定移动、联通、电信通道组;一个都不绑定时 UI 不允许保存,发送时直接 failed;移动、联通、电信短信按识别结果进入对应通道组。 | + +## 2026-07-03 通道组真实路由和补发修复 + +### 本轮修复范围 + +- BUG-SEND-001:后端 `createRouteRule` 禁止直接绑定单通道,路由规则只能绑定应用、运营商和通道组;发送链路不再读取 `route.channel`。 +- BUG-SEND-002:发送链路未找到企业应用对应运营商通道组或无可用在线通道时,短信直接标记 `failed`,不再 fallback 到全局第一个 active 通道。 +- BUG-SEND-003:发送选路加入 CMPP 连接状态过滤,通道必须业务 `active`、连接 `online/connected`、`desiredConnections > 0` 且 `currentConnections > 0` 才可选。 +- BUG-SEND-004:submit rejected、submit timeout、回执 failed 等失败场景会在补发开启且未超过时间限制时,排除已尝试通道并切换到同一通道组全国通道继续提交;unknown、超过 72 小时、超过通道组补发上限或关闭补发时不补发。 +- BUG-SEND-005:新增 `PhoneCarrierRule` 运营商前缀正则配置,发送前先识别运营商,识别失败默认移动;手机号段库用于识别省份,省份识别失败走对应运营商全国通道;通道新增 `sendRegion`,支持全国或单省。 +- BUG-SEND-006:企业应用创建页面可分别选择移动、联通、电信通道组,一个都不选时 UI 阻止保存;创建应用成功后写入真实通道组路由规则。 +- 前端配置补充:通道创建支持移动、联通、电信、三网和发送地区;手机号段库新增“运营商区分规则”tab。 + +### 新增/更新测试 + +| 测试文件 | 新增覆盖 | +| --- | --- | +| `api/src/send-chain/send-chain.service.spec.ts` | 应用运营商通道组路由、在线连接过滤、失败不 fallback、补发关闭时释放/退款。 | +| `api/src/channels/channels.service.spec.ts` | 通道发送地区默认值、通道组补发配置、禁止单通道路由规则。 | + +### 已执行命令 + +```bash +npm --prefix api run prisma:generate +npm --prefix api test -- send-chain.service.spec.ts channels.service.spec.ts --runInBand +npm --prefix api test +npm --prefix api run build +npm run build +npm run verify:phase8 # 阻塞:BullMQ spike endToEndTps 未达到 500 +npm run spike:bullmq # 复跑仍未达到 500 +``` + +### 当前结果 + +- Prisma Client generate:通过。 +- API Jest:10 个 test suite 通过,50 个测试通过。 +- API build:通过。 +- 前端 build:通过,仍存在既有大 chunk warning。 +- `npm run verify:phase8`:未通过,阻塞在 `spike:bullmq` 性能阈值;第一次 endToEndTps=464.58,复跑 `npm run spike:bullmq` endToEndTps=495.97,第三次 endToEndTps=477.17,均低于 500 TPS 阈值。 +- 尚未执行真实 PostgreSQL/Redis/Gateway 端到端 smoke;需在生产验证或本地真实服务环境中覆盖 `TC-SEND-010` 到 `TC-SEND-018`、`TC-CMPP-STATUS-008A`。 ## 2026-07-02 真实后端缺口修复 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 79b3e7c..ad54f34 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -29,6 +29,7 @@ export type AdminChannel = { code: string; name: string; carrier?: string | null; + sendRegion?: string | null; gatewayHost: string; gatewayPort: number; enterpriseCode?: string | null; @@ -264,6 +265,8 @@ export type ChannelGroup = DictionaryItem & { code: string; name: string; description?: string | null; + retryEnabled?: boolean; + retryTimeLimitHours?: number; items?: Array>; }; @@ -523,10 +526,12 @@ export const adminApi = { body: JSON.stringify({ reason }), }), listChannelGroups: () => request('/admin/channel-groups'), - createChannelGroup: (body: { code: string; name: string; description?: string; status?: string }) => + createChannelGroup: (body: { code: string; name: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) => request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), addChannelGroupItem: (body: Record) => request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), + createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => + request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), @@ -562,6 +567,9 @@ export const adminApi = { listPhoneSegments: () => request('/admin/dictionaries/phone-segments'), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), + listPhoneCarrierRules: () => request('/admin/dictionaries/phone-carrier-rules'), + createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => + request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index e132ab4..0142d73 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -4,13 +4,14 @@ import { useNavigate } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; -type Carrier = 'mobile' | 'unicom' | 'telecom'; +type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed'; type SmsChannel = { id: string; name: string; carrier: Carrier; + sendRegion: string; unitPrice: number; status: ChannelStatus; total: number; @@ -47,6 +48,7 @@ const carrierOptions = [ { label: '移动', value: 'mobile' }, { label: '联通', value: 'unicom' }, { label: '电信', value: 'telecom' }, + { label: '三网', value: 'all' }, ]; const statusOptions = [ @@ -65,9 +67,7 @@ const protocolOptions = [ const regionOptions = [ { label: '全国', value: '全国' }, - { label: '华东', value: '华东' }, - { label: '华南', value: '华南' }, - { label: '华北', value: '华北' }, + ...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })), ]; const extensionOptions = [ @@ -81,12 +81,14 @@ const carrierLabelMap: Record = { mobile: '移动', unicom: '联通', telecom: '电信', + all: '三网', }; -const carrierToneMap: Record = { +const carrierToneMap: Record = { mobile: 'info', unicom: 'danger', telecom: 'success', + all: 'neutral', }; const statusLabelMap: Record = { @@ -114,7 +116,8 @@ function mapApiChannel(channel: AdminChannel): SmsChannel { return { id: channel.id, name: channel.name, - carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' ? channel.carrier : 'mobile', + carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile', + sendRegion: channel.sendRegion ?? '全国', unitPrice: channel.unitPrice, status: statusMap[channel.status] ?? 'normal', total: 0, @@ -159,7 +162,7 @@ function ChannelFormModal({ const [name, setName] = useState(channel?.name ?? ''); const [carrier, setCarrier] = useState(channel?.carrier ?? 'mobile'); const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300'); - const [region, setRegion] = useState('全国'); + const [region, setRegion] = useState(channel?.sendRegion ?? '全国'); const [protocol, setProtocol] = useState('CMPP'); const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? ''); const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890'); @@ -175,6 +178,7 @@ function ChannelFormModal({ id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)), name: name || '新建短信通道', carrier, + sendRegion: region, unitPrice: Number(unitPrice || 0) * 100, status: channel?.status ?? 'connecting', total: channel?.total ?? 0, @@ -212,7 +216,7 @@ function ChannelFormModal({ setName(event.target.value)} placeholder="请输入通道名称" value={name} />
* 运营商 - {(['mobile', 'unicom', 'telecom'] as const).map((item) => ( + {(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (