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
|
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 {
|
model SensitiveWord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
word String @unique
|
word String @unique
|
||||||
@@ -453,6 +466,7 @@ model SmsChannel {
|
|||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
carrier String?
|
carrier String?
|
||||||
|
sendRegion String @default("全国")
|
||||||
protocol String @default("CMPP")
|
protocol String @default("CMPP")
|
||||||
gatewayHost String
|
gatewayHost String
|
||||||
gatewayPort Int
|
gatewayPort Int
|
||||||
@@ -514,6 +528,8 @@ model SmsChannelGroup {
|
|||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
|
retryEnabled Boolean @default(true)
|
||||||
|
retryTimeLimitHours Int @default(72)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function createPrismaMock() {
|
|||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||||
},
|
},
|
||||||
smsChannelGroupItem: {
|
smsChannelGroupItem: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
||||||
},
|
},
|
||||||
channelRouteRule: {
|
channelRouteRule: {
|
||||||
@@ -113,28 +114,43 @@ describe('ChannelsService', () => {
|
|||||||
passwordCipher: 'secret',
|
passwordCipher: 'secret',
|
||||||
srcId: '10690000',
|
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({
|
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
cmppVersion: '3.0',
|
cmppVersion: '3.0',
|
||||||
rateLimitPerSecond: 100,
|
rateLimitPerSecond: 100,
|
||||||
|
sendRegion: '全国',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ retryEnabled: true, retryTimeLimitHours: 24 }),
|
||||||
|
});
|
||||||
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
|
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
applicationId: 'app-1',
|
applicationId: 'app-1',
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
channelId: 'channel-1',
|
channelId: undefined,
|
||||||
|
carrier: 'mobile',
|
||||||
priority: 100,
|
priority: 100,
|
||||||
status: 'active',
|
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 () => {
|
it('upserts signature report material per channel field', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface CreateChannelDto {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
carrier?: string;
|
carrier?: string;
|
||||||
|
sendRegion?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
gatewayHost: string;
|
gatewayHost: string;
|
||||||
gatewayPort: number;
|
gatewayPort: number;
|
||||||
@@ -25,6 +26,8 @@ export interface CreateChannelGroupDto {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
retryEnabled?: boolean;
|
||||||
|
retryTimeLimitHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateChannelGroupItemDto {
|
export interface CreateChannelGroupItemDto {
|
||||||
@@ -143,6 +146,7 @@ export class ChannelsService {
|
|||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
carrier: data.carrier,
|
carrier: data.carrier,
|
||||||
|
sendRegion: data.sendRegion ?? '全国',
|
||||||
protocol: data.protocol ?? 'CMPP',
|
protocol: data.protocol ?? 'CMPP',
|
||||||
gatewayHost: data.gatewayHost,
|
gatewayHost: data.gatewayHost,
|
||||||
gatewayPort,
|
gatewayPort,
|
||||||
@@ -207,6 +211,7 @@ export class ChannelsService {
|
|||||||
account: source.account,
|
account: source.account,
|
||||||
passwordCipher: source.passwordCipher,
|
passwordCipher: source.passwordCipher,
|
||||||
srcId: source.srcId,
|
srcId: source.srcId,
|
||||||
|
sendRegion: source.sendRegion,
|
||||||
cmppVersion: source.cmppVersion,
|
cmppVersion: source.cmppVersion,
|
||||||
rateLimitPerSecond: source.rateLimitPerSecond,
|
rateLimitPerSecond: source.rateLimitPerSecond,
|
||||||
unitPrice: source.unitPrice,
|
unitPrice: source.unitPrice,
|
||||||
@@ -382,17 +387,29 @@ export class ChannelsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createGroup(data: CreateChannelGroupDto) {
|
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({
|
return this.prisma.smsChannelGroup.create({
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
status: data.status ?? 'active',
|
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({
|
return this.prisma.smsChannelGroupItem.create({
|
||||||
data: {
|
data: {
|
||||||
groupId: data.groupId,
|
groupId: data.groupId,
|
||||||
@@ -416,14 +433,26 @@ export class ChannelsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRouteRule(data: CreateRouteRuleDto) {
|
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({
|
return this.prisma.channelRouteRule.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
groupId: data.groupId,
|
groupId: data.groupId,
|
||||||
channelId: data.channelId,
|
channelId: undefined,
|
||||||
carrier: data.carrier,
|
carrier: data.carrier,
|
||||||
province: data.province,
|
province: undefined,
|
||||||
priority: data.priority ?? 100,
|
priority: data.priority ?? 100,
|
||||||
status: data.status ?? 'active',
|
status: data.status ?? 'active',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TenantId } from '../common/tenant-id.decorator';
|
|||||||
import {
|
import {
|
||||||
CreateBlacklistDto,
|
CreateBlacklistDto,
|
||||||
CreateDrainageFieldDto,
|
CreateDrainageFieldDto,
|
||||||
|
CreatePhoneCarrierRuleDto,
|
||||||
CreatePhoneSegmentDto,
|
CreatePhoneSegmentDto,
|
||||||
CreateSensitiveWordDto,
|
CreateSensitiveWordDto,
|
||||||
DictionariesService,
|
DictionariesService,
|
||||||
@@ -25,6 +26,16 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createPhoneSegment(body);
|
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')
|
@Get('sensitive-words')
|
||||||
listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||||
return this.dictionaries.listSensitiveWords({ keyword, status });
|
return this.dictionaries.listSensitiveWords({ keyword, status });
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export interface CreatePhoneSegmentDto {
|
|||||||
city?: string;
|
city?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatePhoneCarrierRuleDto {
|
||||||
|
carrier: string;
|
||||||
|
pattern: string;
|
||||||
|
priority?: number;
|
||||||
|
status?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSensitiveWordDto {
|
export interface CreateSensitiveWordDto {
|
||||||
word: string;
|
word: string;
|
||||||
level?: string;
|
level?: string;
|
||||||
@@ -56,6 +64,30 @@ export class DictionariesService {
|
|||||||
return this.prisma.phoneSegment.create({ data });
|
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 = {}) {
|
listSensitiveWords(query: DictionaryListQuery = {}) {
|
||||||
return this.prisma.sensitiveWord.findMany({
|
return this.prisma.sensitiveWord.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -27,7 +27,26 @@ function createPrismaMock() {
|
|||||||
rateLimitPerSecond: 100,
|
rateLimitPerSecond: 100,
|
||||||
unitPrice: 3,
|
unitPrice: 3,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
carrier: 'mobile',
|
||||||
|
sendRegion: '全国',
|
||||||
config: { serviceId: 'SMS' },
|
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 {
|
return {
|
||||||
tenant: {
|
tenant: {
|
||||||
@@ -65,7 +84,13 @@ function createPrismaMock() {
|
|||||||
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]),
|
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]),
|
||||||
},
|
},
|
||||||
channelRouteRule: {
|
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: {
|
smsChannel: {
|
||||||
findFirst: jest.fn().mockResolvedValue(channel),
|
findFirst: jest.fn().mockResolvedValue(channel),
|
||||||
@@ -77,7 +102,7 @@ function createPrismaMock() {
|
|||||||
smsSubmitRecord: {
|
smsSubmitRecord: {
|
||||||
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
||||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
smsReceiptRecord: {
|
smsReceiptRecord: {
|
||||||
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
|
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 () => {
|
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({
|
await service.handleSubmitResult({
|
||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
|
|||||||
@@ -80,6 +80,25 @@ interface SendJob {
|
|||||||
messageRecordId: string;
|
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 SEND_QUEUE = 'sms.send.queue';
|
||||||
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||||
|
|
||||||
@@ -443,63 +462,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!message || message.status !== 'queued') {
|
if (!message || message.status !== 'queued') {
|
||||||
return { skipped: true };
|
return { skipped: true };
|
||||||
}
|
}
|
||||||
const channel = await this.selectChannel(message.tenantId, message.applicationId ?? undefined);
|
try {
|
||||||
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
const routed = await this.selectChannelForMessage(message);
|
||||||
const submitId = `SUB-${randomUUID()}`;
|
return this.submitMessageToGateway(message, routed, 0);
|
||||||
const session = await this.prisma.cmppSubmitSession.upsert({
|
} catch (error) {
|
||||||
where: { sessionNo: `OPEN-${channel.id}` },
|
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||||
update: { submitTotal: { increment: 1 } },
|
await this.prisma.smsMessageRecord.update({
|
||||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
where: { id: message.id },
|
||||||
});
|
data: { status: 'failed', errorMessage: reason },
|
||||||
await this.prisma.smsSubmitRecord.create({
|
});
|
||||||
data: {
|
await this.releaseMessageReservation(message, reason);
|
||||||
tenantId: message.tenantId,
|
await this.refreshTaskProgress(message.batchTaskId);
|
||||||
batchTaskId: message.batchTaskId,
|
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||||
@@ -520,6 +495,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (data.submitStatus === 'accepted') {
|
if (data.submitStatus === 'accepted') {
|
||||||
await this.chargeAcceptedMessage(message);
|
await this.chargeAcceptedMessage(message);
|
||||||
} else {
|
} 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.releaseMessageReservation(message, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||||
}
|
}
|
||||||
await this.prisma.smsMessageRecord.update({
|
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 deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||||
const status =
|
const status =
|
||||||
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
|
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
|
||||||
if (status === 'failed') {
|
|
||||||
await this.refundMessage(message, '最终失败退款');
|
|
||||||
}
|
|
||||||
await this.prisma.smsReceiptRecord.create({
|
await this.prisma.smsReceiptRecord.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: message.tenantId,
|
tenantId: message.tenantId,
|
||||||
@@ -561,6 +538,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
deliveredAt,
|
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({
|
await this.prisma.smsMessageRecord.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -623,30 +608,232 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return { timeout: candidates.length };
|
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({
|
const route = await this.prisma.channelRouteRule.findFirst({
|
||||||
where: {
|
where: {
|
||||||
status: 'active',
|
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' },
|
orderBy: { priority: 'asc' },
|
||||||
});
|
});
|
||||||
const routedChannel = route?.channel ?? route?.group.items.find((item) => item.channel.status === 'active')?.channel;
|
if (!route) {
|
||||||
if (routedChannel) {
|
throw new NotFoundException('企业应用未配置对应运营商通道组');
|
||||||
return routedChannel;
|
|
||||||
}
|
}
|
||||||
const channel = await this.prisma.smsChannel.findFirst({ where: { status: 'active' }, orderBy: { createdAt: 'asc' } });
|
if (route.group.status !== 'active') {
|
||||||
if (!channel) {
|
throw new BadRequestException('企业应用绑定的通道组已停用');
|
||||||
throw new NotFoundException('No active SMS channel available');
|
|
||||||
}
|
}
|
||||||
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) {
|
private async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||||
try {
|
try {
|
||||||
const channel = await this.selectChannel(tenantId, applicationId);
|
const route = await this.prisma.channelRouteRule.findFirst({
|
||||||
return channel.unitPrice ?? 0;
|
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 {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -695,6 +882,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}) {
|
}) {
|
||||||
const amountCents = message.amountCents ?? 0;
|
const amountCents = message.amountCents ?? 0;
|
||||||
const smsUnits = message.billingUnits ?? 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) {
|
if (amountCents + smsUnits > 0) {
|
||||||
await this.billing.release({
|
await this.billing.release({
|
||||||
tenantId: message.tenantId,
|
tenantId: message.tenantId,
|
||||||
@@ -713,7 +904,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
relatedId: message.messageId,
|
relatedId: message.messageId,
|
||||||
remark: '提交成功扣费',
|
remark: '提交成功扣费',
|
||||||
});
|
});
|
||||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
|
||||||
const data = {
|
const data = {
|
||||||
tenantId: message.tenantId,
|
tenantId: message.tenantId,
|
||||||
applicationId: message.applicationId ?? undefined,
|
applicationId: message.applicationId ?? undefined,
|
||||||
@@ -741,6 +931,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
|
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||||
|
if (charged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.billing.release({
|
await this.billing.release({
|
||||||
tenantId: message.tenantId,
|
tenantId: message.tenantId,
|
||||||
amountCents: message.amountCents,
|
amountCents: message.amountCents,
|
||||||
@@ -914,6 +1108,40 @@ function cellByHeader(headers: string[], cells: string[], candidates: string[])
|
|||||||
return index >= 0 ? cells[index] : undefined;
|
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() {
|
function bullmqConnection() {
|
||||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -134,9 +134,24 @@
|
|||||||
### 4.6 通道配置与路由
|
### 4.6 通道配置与路由
|
||||||
|
|
||||||
1. 运营端配置短信通道,包括通道名称、运营商、单价、网关地址、端口、企业代码、账号、密码、接入号、协议参数、启停状态。
|
1. 运营端配置短信通道,包括通道名称、运营商、单价、网关地址、端口、企业代码、账号、密码、接入号、协议参数、启停状态。
|
||||||
2. 运营端配置短信通道组,定义通道优先级、运营商适配、区域适配、权重和故障切换。
|
2. 通道运营商支持移动、联通、电信和三网;三网通道可作为移动、联通、电信的通配通道。
|
||||||
3. 发送时根据企业、应用、签名、模板、号码归属、通道状态和通道组策略选择通道。
|
3. 通道必须配置发送地区,发送地区为全国或 34 个省级地区之一;单个通道只能选择一个发送地区。
|
||||||
4. 通道异常时应支持熔断、降级、切换备用通道和失败重试。
|
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 通道签名报备
|
### 4.7 通道签名报备
|
||||||
|
|
||||||
@@ -462,12 +477,17 @@
|
|||||||
2. 拆分号码和变量,生成 message_record。
|
2. 拆分号码和变量,生成 message_record。
|
||||||
3. 写入数据库并投递 send.queue。
|
3. 写入数据库并投递 send.queue。
|
||||||
4. Send Worker 消费消息,校验黑名单、敏感词、限额。
|
4. Send Worker 消费消息,校验黑名单、敏感词、限额。
|
||||||
5. 路由选择通道。
|
5. 使用可配置号码前缀正则识别运营商;识别不出时按移动处理。
|
||||||
6. 通过通道限速器控制 TPS。
|
6. 使用手机号段库识别号码省份和城市;识别不出省份时按全国路由处理。
|
||||||
7. 调用 Gateway Adapter 提交短信。
|
7. 按企业应用绑定的对应运营商通道组执行路由:先匹配省网通道,再匹配全国通道;不得直接绑定或 fallback 到非授权单通道。
|
||||||
8. 写入 submit 状态。
|
8. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。
|
||||||
9. Receipt Worker 接收回执并更新最终状态。
|
9. 通过通道限速器控制 TPS。
|
||||||
10. 统计任务进度。
|
10. 调用 Gateway Adapter 提交短信。
|
||||||
|
11. 写入 submit 状态。
|
||||||
|
12. Submit rejected、submit timeout、Gateway 连接断开或未提交成功、receipt failed 等场景按通道组策略补发到下一可用全国通道;unknown、超过 72 小时、超过通道组补发时间上限或关闭补发时不再补发。
|
||||||
|
13. Receipt Worker 接收回执并更新最终状态。
|
||||||
|
14. 补发过程必须保持幂等、计费一致和 trace 可查,最终成功只扣一次客户费率。
|
||||||
|
15. 统计任务进度。
|
||||||
|
|
||||||
### 8.3 500 条/秒实现建议
|
### 8.3 500 条/秒实现建议
|
||||||
|
|
||||||
|
|||||||
@@ -664,12 +664,139 @@
|
|||||||
### TC-SEND-003 通道路由和 Gateway SubmitCommand
|
### TC-SEND-003 通道路由和 Gateway SubmitCommand
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
- 前置条件:存在 active 通道和路由规则。
|
- 前置条件:企业应用已绑定对应运营商的 active 通道组,通道组内存在 active 且 online 的通道。
|
||||||
- 步骤:处理一条发送 job。
|
- 步骤:处理一条发送 job。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- 选择正确通道。
|
- 先按号码前缀正则识别运营商,再只在企业应用绑定的对应运营商通道组内选择正确通道。
|
||||||
- 创建 SmsSubmitRecord。
|
- 创建 SmsSubmitRecord。
|
||||||
- 投递 Gateway SubmitCommand,包含 traceId、messageId、channelId、submitId、phoneNumber、content、cmpp 参数。
|
- 投递 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
|
### TC-SEND-004 SubmitResult accepted
|
||||||
|
|
||||||
@@ -2084,7 +2211,7 @@
|
|||||||
- 前置条件:通道组包含主通道 A 和备用通道 B,A active 但连接离线,B active 且 online,签名在 B 报备通过。
|
- 前置条件:通道组包含主通道 A 和备用通道 B,A active 但连接离线,B active 且 online,签名在 B 报备通过。
|
||||||
- 步骤:
|
- 步骤:
|
||||||
1. 确认 A 业务状态 active、连接状态 disconnected。
|
1. 确认 A 业务状态 active、连接状态 disconnected。
|
||||||
2. 确认 B 业务状态 active、连接状态 online。
|
2. 确认 B 业务状态 active、连接状态 online、currentConnections 大于 0。
|
||||||
3. 创建发送任务。
|
3. 创建发送任务。
|
||||||
4. 查询 submit record 和 trace。
|
4. 查询 submit record 和 trace。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
@@ -2103,8 +2230,8 @@
|
|||||||
3. 查看任务状态、发送明细、系统日志。
|
3. 查看任务状态、发送明细、系统日志。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- 系统不向离线连接 submit。
|
- 系统不向离线连接 submit。
|
||||||
- 按设计进入 delayed/retry、failed 或 pending_channel 状态。
|
- 发送明细进入 failed。
|
||||||
- 客户端和运营端展示“无可用在线通道”原因。
|
- 运营端 trace 和系统日志展示“无可用在线通道”原因;客户端本期只展示失败状态,不展示通道细节。
|
||||||
- 不产生 submit accepted 记录,不错误扣费。
|
- 不产生 submit accepted 记录,不错误扣费。
|
||||||
|
|
||||||
### TC-CMPP-STATUS-008 连接状态与通道启停状态组合
|
### TC-CMPP-STATUS-008 连接状态与通道启停状态组合
|
||||||
@@ -2122,6 +2249,20 @@
|
|||||||
- 启用后如连接仍 online,可恢复路由;如连接已断开,需要等待重连。
|
- 启用后如连接仍 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 慢响应导致窗口占满和状态告警
|
### TC-CMPP-STATUS-009 慢响应导致窗口占满和状态告警
|
||||||
|
|
||||||
- 优先级:P1
|
- 优先级:P1
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
- 当前重点:
|
- 当前重点:
|
||||||
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
|
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
|
||||||
- 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录。
|
- 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录。
|
||||||
- 发送链路:批量任务创建、手机号拆分、发送入队、submit result 更新、receipt 更新、uplink 记录、72 小时未知转超时。
|
- 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。
|
||||||
- 通道与报备:通道创建、路由规则、签名报备任务、导出、回执导入、签名状态同步。
|
- 通道与报备:通道创建、通道发送地区、三网通道通配、通道组路由规则、企业应用通道组保存校验、禁止单通道发送规则、签名报备任务、导出、回执导入、签名状态同步。
|
||||||
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
|
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
|
||||||
- Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
|
- Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ npm run test:gateway
|
|||||||
- 记录两个接口健壮性问题:不存在的 `reviewerId`、不存在的 `createdById` 会触发数据库外键 500。
|
- 记录两个接口健壮性问题:不存在的 `reviewerId`、不存在的 `createdById` 会触发数据库外键 500。
|
||||||
- 客户侧导入发送、非法字符展示、敏感词接入发送前风控当前缺少完整入口,已在 `docs/testing-execution-step-4.md` 记录为阻塞缺口。
|
- 客户侧导入发送、非法字符展示、敏感词接入发送前风控当前缺少完整入口,已在 `docs/testing-execution-step-4.md` 记录为阻塞缺口。
|
||||||
- 第 5 步发送链路 smoke 通过:
|
- 第 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、客户端/运营端任务查看。
|
- 使用真实 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` 记录为功能缺口。
|
- 定时发送探测发现 `scheduledAt/sendMode=scheduled` 会被接口忽略,任务直接变为 `queued`,已在 `docs/testing-execution-step-5.md` 记录为功能缺口。
|
||||||
- 第 6 步计费和对账 smoke 通过:
|
- 第 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-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-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-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 真实后端缺口修复
|
## 2026-07-02 真实后端缺口修复
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -29,6 +29,7 @@ export type AdminChannel = {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
carrier?: string | null;
|
carrier?: string | null;
|
||||||
|
sendRegion?: string | null;
|
||||||
gatewayHost: string;
|
gatewayHost: string;
|
||||||
gatewayPort: number;
|
gatewayPort: number;
|
||||||
enterpriseCode?: string | null;
|
enterpriseCode?: string | null;
|
||||||
@@ -264,6 +265,8 @@ export type ChannelGroup = DictionaryItem & {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
retryEnabled?: boolean;
|
||||||
|
retryTimeLimitHours?: number;
|
||||||
items?: Array<Record<string, unknown>>;
|
items?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -523,10 +526,12 @@ export const adminApi = {
|
|||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
}),
|
}),
|
||||||
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
|
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) }),
|
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
addChannelGroupItem: (body: Record<string, unknown>) =>
|
addChannelGroupItem: (body: Record<string, unknown>) =>
|
||||||
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
|
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 })),
|
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
|
||||||
createChannelReportField: (body: Record<string, unknown>) =>
|
createChannelReportField: (body: Record<string, unknown>) =>
|
||||||
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
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'),
|
listPhoneSegments: () => request<DictionaryItem[]>('/admin/dictionaries/phone-segments'),
|
||||||
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
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'),
|
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
||||||
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) =>
|
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) }),
|
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 { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
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 ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||||
|
|
||||||
type SmsChannel = {
|
type SmsChannel = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
carrier: Carrier;
|
carrier: Carrier;
|
||||||
|
sendRegion: string;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
status: ChannelStatus;
|
status: ChannelStatus;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -47,6 +48,7 @@ const carrierOptions = [
|
|||||||
{ label: '移动', value: 'mobile' },
|
{ label: '移动', value: 'mobile' },
|
||||||
{ label: '联通', value: 'unicom' },
|
{ label: '联通', value: 'unicom' },
|
||||||
{ label: '电信', value: 'telecom' },
|
{ label: '电信', value: 'telecom' },
|
||||||
|
{ label: '三网', value: 'all' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@@ -65,9 +67,7 @@ const protocolOptions = [
|
|||||||
|
|
||||||
const regionOptions = [
|
const regionOptions = [
|
||||||
{ label: '全国', value: '全国' },
|
{ label: '全国', value: '全国' },
|
||||||
{ label: '华东', value: '华东' },
|
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
|
||||||
{ label: '华南', value: '华南' },
|
|
||||||
{ label: '华北', value: '华北' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const extensionOptions = [
|
const extensionOptions = [
|
||||||
@@ -81,12 +81,14 @@ const carrierLabelMap: Record<Carrier, string> = {
|
|||||||
mobile: '移动',
|
mobile: '移动',
|
||||||
unicom: '联通',
|
unicom: '联通',
|
||||||
telecom: '电信',
|
telecom: '电信',
|
||||||
|
all: '三网',
|
||||||
};
|
};
|
||||||
|
|
||||||
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success'> = {
|
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
|
||||||
mobile: 'info',
|
mobile: 'info',
|
||||||
unicom: 'danger',
|
unicom: 'danger',
|
||||||
telecom: 'success',
|
telecom: 'success',
|
||||||
|
all: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabelMap: Record<ChannelStatus, string> = {
|
const statusLabelMap: Record<ChannelStatus, string> = {
|
||||||
@@ -114,7 +116,8 @@ function mapApiChannel(channel: AdminChannel): SmsChannel {
|
|||||||
return {
|
return {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
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,
|
unitPrice: channel.unitPrice,
|
||||||
status: statusMap[channel.status] ?? 'normal',
|
status: statusMap[channel.status] ?? 'normal',
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -159,7 +162,7 @@ function ChannelFormModal({
|
|||||||
const [name, setName] = useState(channel?.name ?? '');
|
const [name, setName] = useState(channel?.name ?? '');
|
||||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||||
const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300');
|
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 [protocol, setProtocol] = useState('CMPP');
|
||||||
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890');
|
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890');
|
||||||
@@ -175,6 +178,7 @@ function ChannelFormModal({
|
|||||||
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
|
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
|
||||||
name: name || '新建短信通道',
|
name: name || '新建短信通道',
|
||||||
carrier,
|
carrier,
|
||||||
|
sendRegion: region,
|
||||||
unitPrice: Number(unitPrice || 0) * 100,
|
unitPrice: Number(unitPrice || 0) * 100,
|
||||||
status: channel?.status ?? 'connecting',
|
status: channel?.status ?? 'connecting',
|
||||||
total: channel?.total ?? 0,
|
total: channel?.total ?? 0,
|
||||||
@@ -212,7 +216,7 @@ function ChannelFormModal({
|
|||||||
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
||||||
<div className="sms-channel-radio-row">
|
<div className="sms-channel-radio-row">
|
||||||
<span>* 运营商</span>
|
<span>* 运营商</span>
|
||||||
{(['mobile', 'unicom', 'telecom'] as const).map((item) => (
|
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
|
||||||
<label key={item}>
|
<label key={item}>
|
||||||
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
|
||||||
{carrierLabelMap[item]}
|
{carrierLabelMap[item]}
|
||||||
@@ -350,12 +354,33 @@ export function AdminChannelsPage() {
|
|||||||
[carrier, channels, keyword, status],
|
[carrier, channels, keyword, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
function upsertChannel(nextChannel: SmsChannel) {
|
async function upsertChannel(nextChannel: SmsChannel) {
|
||||||
setChannels((items) => {
|
if (modal?.mode === 'edit') {
|
||||||
const exists = items.some((item) => item.id === nextChannel.id);
|
setError('短信通道编辑接口待补,当前不做本地模拟保存');
|
||||||
return exists ? items.map((item) => (item.id === nextChannel.id ? nextChannel : item)) : [nextChannel, ...items];
|
return;
|
||||||
});
|
}
|
||||||
setModal(null);
|
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) {
|
async function toggleChannel(channel: SmsChannel) {
|
||||||
|
|||||||
@@ -10,20 +10,35 @@ type PhoneSegment = DictionaryItem & {
|
|||||||
city?: string | null;
|
city?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CarrierRule = DictionaryItem & {
|
||||||
|
carrier?: string;
|
||||||
|
pattern?: string;
|
||||||
|
priority?: number;
|
||||||
|
remark?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function AdminPhoneSegmentsPage() {
|
export function AdminPhoneSegmentsPage() {
|
||||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||||
|
const [rules, setRules] = useState<CarrierRule[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [creatingRule, setCreatingRule] = useState(false);
|
||||||
const [prefix, setPrefix] = useState('');
|
const [prefix, setPrefix] = useState('');
|
||||||
const [carrier, setCarrier] = useState('中国移动');
|
const [carrier, setCarrier] = useState('中国移动');
|
||||||
const [province, setProvince] = useState('');
|
const [province, setProvince] = useState('');
|
||||||
const [city, setCity] = 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('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
adminApi.listPhoneSegments()
|
Promise.all([adminApi.listPhoneSegments(), adminApi.listPhoneCarrierRules()])
|
||||||
.then((items) => {
|
.then(([segmentItems, ruleItems]) => {
|
||||||
setSegments(items as PhoneSegment[]);
|
setSegments(segmentItems as PhoneSegment[]);
|
||||||
|
setRules(ruleItems as CarrierRule[]);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
|
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
|
||||||
@@ -50,6 +65,17 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
|
.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>>>(() => [
|
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
|
||||||
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
|
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
|
||||||
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
|
{ 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 ?? '-' },
|
{ 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 (
|
return (
|
||||||
<section className="page-stack admin-system-page">
|
<section className="page-stack admin-system-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
@@ -70,11 +103,19 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
|
|
||||||
<div className="surface admin-system-toolbar">
|
<div className="surface admin-system-toolbar">
|
||||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
|
<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>
|
||||||
|
|
||||||
<div className="surface admin-system-table-card">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -104,6 +145,34 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
<Input label="城市" onChange={(event) => setCity(event.target.value)} value={city} />
|
<Input label="城市" onChange={(event) => setCity(event.target.value)} value={city} />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
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';
|
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
|
||||||
|
|
||||||
export function AdminSmsApplicationFormPage() {
|
export function AdminSmsApplicationFormPage() {
|
||||||
@@ -14,8 +14,18 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||||
const [ipAddress, setIpAddress] = useState('');
|
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('');
|
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() {
|
function goBack() {
|
||||||
navigate('/admin/enterprise-applications');
|
navigate('/admin/enterprise-applications');
|
||||||
}
|
}
|
||||||
@@ -29,6 +39,15 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
setError('短信应用编辑接口待补,当前不做本地模拟保存');
|
setError('短信应用编辑接口待补,当前不做本地模拟保存');
|
||||||
return;
|
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({
|
adminApi.createEnterpriseApplication({
|
||||||
tenantId: enterpriseId,
|
tenantId: enterpriseId,
|
||||||
name: appName,
|
name: appName,
|
||||||
@@ -38,10 +57,25 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
templateMismatchMode: mismatchPolicy,
|
templateMismatchMode: mismatchPolicy,
|
||||||
ipAllowlist: ipAddress ? [ipAddress] : [],
|
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 || '短信应用保存失败'));
|
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupOptions = [
|
||||||
|
{ label: '不配置', value: '' },
|
||||||
|
...groups.map((group) => ({ label: group.name, value: group.id })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-app-form-page">
|
<section className="page-stack admin-app-form-page">
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
@@ -73,6 +107,9 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
value={mismatchPolicy}
|
value={mismatchPolicy}
|
||||||
/>
|
/>
|
||||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user