fix: implement channel group routing failover
This commit is contained in:
@@ -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");
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
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: { 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 },
|
||||
data: { status: 'failed', errorMessage: reason },
|
||||
});
|
||||
await this.releaseMessageReservation(message, reason);
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
return { submitted: true, messageRecordId: message.id, channelId: channel.id };
|
||||
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 {
|
||||
|
||||
@@ -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 条/秒实现建议
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 模拟器。
|
||||
|
||||
|
||||
@@ -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 被占用后切到 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 真实后端缺口修复
|
||||
|
||||
|
||||
+9
-1
@@ -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) }),
|
||||
|
||||
@@ -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];
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user