fix: implement channel group routing failover

This commit is contained in:
hectorzhao
2026-07-03 11:07:43 +08:00
parent 131f344ac4
commit a890b03163
16 changed files with 853 additions and 121 deletions
@@ -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");
+16
View File
@@ -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
+18 -2
View File
@@ -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);
+32 -3
View File
@@ -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',
},
@@ -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 });
@@ -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: {
+36 -3
View File
@@ -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',
+301 -73
View File
@@ -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<RoutedChannel> {
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 {
+29 -9
View File
@@ -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 条/秒实现建议
+146 -5
View File
@@ -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
+2 -2
View File
@@ -16,8 +16,8 @@
- 当前重点:
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
- 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录。
- 发送链路:批量任务创建、手机号拆分、发送入队、submit result 更新、receipt 更新、uplink 记录、72 小时未知转超时。
- 通道与报备:通道创建、路由规则、签名报备任务、导出、回执导入、签名状态同步。
- 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。
- 通道与报备:通道创建、通道发送地区、三网通道通配、通道组路由规则、企业应用通道组保存校验、禁止单通道发送规则、签名报备任务、导出、回执导入、签名状态同步。
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
- GatewaySEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
+48 -1
View File
@@ -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 <browser-and-api-smoke>
| 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 被占用后切到 5174Vite 依赖 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-004submit 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 Jest10 个 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 真实后端缺口修复
+9 -1
View File
@@ -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<Record<string, unknown>>;
};
@@ -523,10 +526,12 @@ export const adminApi = {
body: JSON.stringify({ reason }),
}),
listChannelGroups: () => request<ChannelGroup[]>('/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<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/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<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
@@ -562,6 +567,9 @@ export const adminApi = {
listPhoneSegments: () => request<DictionaryItem[]>('/admin/dictionaries/phone-segments'),
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
listPhoneCarrierRules: () => request<DictionaryItem[]>('/admin/dictionaries/phone-carrier-rules'),
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
+39 -14
View File
@@ -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<Carrier, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
all: '三网',
};
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success'> = {
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
mobile: 'info',
unicom: 'danger',
telecom: 'success',
all: 'neutral',
};
const statusLabelMap: Record<ChannelStatus, string> = {
@@ -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<Carrier>(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({
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
<div className="sms-channel-radio-row">
<span>* </span>
{(['mobile', 'unicom', 'telecom'] as const).map((item) => (
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
<label key={item}>
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
{carrierLabelMap[item]}
@@ -350,12 +354,33 @@ export function AdminChannelsPage() {
[carrier, channels, keyword, status],
);
function upsertChannel(nextChannel: SmsChannel) {
setChannels((items) => {
const exists = items.some((item) => item.id === nextChannel.id);
return exists ? items.map((item) => (item.id === nextChannel.id ? nextChannel : item)) : [nextChannel, ...items];
});
setModal(null);
async function upsertChannel(nextChannel: SmsChannel) {
if (modal?.mode === 'edit') {
setError('短信通道编辑接口待补,当前不做本地模拟保存');
return;
}
try {
const created = await adminApi.createChannel({
code: `CH-${Date.now()}`,
name: nextChannel.name,
carrier: nextChannel.carrier,
sendRegion: nextChannel.sendRegion,
gatewayHost: nextChannel.gatewayHost,
gatewayPort: Number(nextChannel.gatewayPort),
enterpriseCode: nextChannel.corpCode,
account: nextChannel.account,
passwordCipher: 'secret',
srcId: nextChannel.accessNo,
rateLimitPerSecond: 100,
unitPrice: Math.round(nextChannel.unitPrice),
status: 'active',
});
setChannels((items) => [mapApiChannel(created), ...items]);
setModal(null);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '通道创建失败');
}
}
async function toggleChannel(channel: SmsChannel) {
+74 -5
View File
@@ -10,20 +10,35 @@ type PhoneSegment = DictionaryItem & {
city?: string | null;
};
type CarrierRule = DictionaryItem & {
carrier?: string;
pattern?: string;
priority?: number;
remark?: string | null;
};
export function AdminPhoneSegmentsPage() {
const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [rules, setRules] = useState<CarrierRule[]>([]);
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
const [keyword, setKeyword] = useState('');
const [creating, setCreating] = useState(false);
const [creatingRule, setCreatingRule] = useState(false);
const [prefix, setPrefix] = useState('');
const [carrier, setCarrier] = useState('中国移动');
const [province, setProvince] = useState('');
const [city, setCity] = useState('');
const [ruleCarrier, setRuleCarrier] = useState('mobile');
const [rulePattern, setRulePattern] = useState('');
const [rulePriority, setRulePriority] = useState('100');
const [ruleRemark, setRuleRemark] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listPhoneSegments()
.then((items) => {
setSegments(items as PhoneSegment[]);
Promise.all([adminApi.listPhoneSegments(), adminApi.listPhoneCarrierRules()])
.then(([segmentItems, ruleItems]) => {
setSegments(segmentItems as PhoneSegment[]);
setRules(ruleItems as CarrierRule[]);
setError('');
})
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
@@ -50,6 +65,17 @@ export function AdminPhoneSegmentsPage() {
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
}
function createCarrierRule() {
adminApi.createPhoneCarrierRule({ carrier: ruleCarrier, pattern: rulePattern, priority: Number(rulePriority) || 100, remark: ruleRemark })
.then(() => {
setRulePattern('');
setRuleRemark('');
setCreatingRule(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
@@ -58,6 +84,13 @@ export function AdminPhoneSegmentsPage() {
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => record.carrier ?? '-' },
{ key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> },
{ key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 },
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
], []);
return (
<section className="page-stack admin-system-page">
<div className="page-heading">
@@ -70,11 +103,19 @@ export function AdminPhoneSegmentsPage() {
<div className="surface admin-system-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}></Button>
<div className="segmented-control">
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button"></button>
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button"></button>
</div>
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
{activeTab === 'segments' ? '新增号段' : '新增规则'}
</Button>
</div>
<div className="surface admin-system-table-card">
<Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
{activeTab === 'segments'
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
: <Table columns={ruleColumns} data={rules} emptyText="暂无运营商区分规则" rowKey="id" />}
</div>
<Modal
@@ -104,6 +145,34 @@ export function AdminPhoneSegmentsPage() {
<Input label="城市" onChange={(event) => setCity(event.target.value)} value={city} />
</div>
</Modal>
<Modal
footer={(
<>
<Button onClick={() => setCreatingRule(false)} variant="ghost"></Button>
<Button disabled={!rulePattern} onClick={createCarrierRule}></Button>
</>
)}
onClose={() => setCreatingRule(false)}
open={creatingRule}
title="新增运营商区分规则"
>
<div className="admin-system-modal-form">
<Select
label="运营商"
onChange={(event) => setRuleCarrier(event.target.value)}
options={[
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={ruleCarrier}
/>
<Input label="号码前缀正则" onChange={(event) => setRulePattern(event.target.value)} placeholder="例如 ^13[4-9]" value={rulePattern} />
<Input label="优先级" onChange={(event) => setRulePriority(event.target.value)} value={rulePriority} />
<Input label="备注" onChange={(event) => setRuleRemark(event.target.value)} value={ruleRemark} />
</div>
</Modal>
</section>
);
}
+40 -3
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { adminApi } from '@/api/adminApi';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
export function AdminSmsApplicationFormPage() {
@@ -14,8 +14,18 @@ export function AdminSmsApplicationFormPage() {
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [mobileGroupId, setMobileGroupId] = useState('');
const [unicomGroupId, setUnicomGroupId] = useState('');
const [telecomGroupId, setTelecomGroupId] = useState('');
const [error, setError] = useState('');
useEffect(() => {
adminApi.listChannelGroups()
.then((items) => setGroups(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted')))
.catch((failure: Error) => setError(failure.message || '通道组加载失败'));
}, []);
function goBack() {
navigate('/admin/enterprise-applications');
}
@@ -29,6 +39,15 @@ export function AdminSmsApplicationFormPage() {
setError('短信应用编辑接口待补,当前不做本地模拟保存');
return;
}
const selectedGroups = [
{ carrier: 'mobile', groupId: mobileGroupId },
{ carrier: 'unicom', groupId: unicomGroupId },
{ carrier: 'telecom', groupId: telecomGroupId },
].filter((item) => item.groupId);
if (selectedGroups.length === 0) {
setError('请至少配置一个运营商通道组');
return;
}
adminApi.createEnterpriseApplication({
tenantId: enterpriseId,
name: appName,
@@ -38,10 +57,25 @@ export function AdminSmsApplicationFormPage() {
templateMismatchMode: mismatchPolicy,
ipAllowlist: ipAddress ? [ipAddress] : [],
})
.then(goBack)
.then(async (application) => {
await Promise.all(selectedGroups.map((item, index) => adminApi.createChannelRouteRule({
tenantId: enterpriseId,
applicationId: application.id,
groupId: item.groupId,
carrier: item.carrier,
priority: (index + 1) * 10,
status: 'active',
})));
goBack();
})
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
}
const groupOptions = [
{ label: '不配置', value: '' },
...groups.map((group) => ({ label: group.name, value: group.id })),
];
return (
<section className="page-stack admin-app-form-page">
<div className="page-heading">
@@ -73,6 +107,9 @@ export function AdminSmsApplicationFormPage() {
value={mismatchPolicy}
/>
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptions} value={mobileGroupId} />
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptions} value={unicomGroupId} />
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptions} value={telecomGroupId} />
</div>
</section>