fix: enforce carrier-specific channel group routing

This commit is contained in:
hectorzhao
2026-07-03 12:48:16 +08:00
parent a890b03163
commit dd09d91c1e
17 changed files with 630 additions and 78 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "SmsChannelGroup" ADD COLUMN "carrier" TEXT NOT NULL DEFAULT 'mobile';
CREATE UNIQUE INDEX "SmsChannelGroupItem_groupId_province_key" ON "SmsChannelGroupItem"("groupId", "province");
@@ -0,0 +1 @@
ALTER TABLE "SmsApplication" ADD COLUMN "customerUnitPrice" INTEGER NOT NULL DEFAULT 0;
+3
View File
@@ -338,6 +338,7 @@ model SmsApplication {
callbackUrl String?
secretHash String
dailyLimit Int?
customerUnitPrice Int @default(0)
maxPhonesPerTask Int @default(1000000)
templateMismatchMode String @default("reject")
status String @default("active")
@@ -527,6 +528,7 @@ model SmsChannelGroup {
code String @unique
name String
description String?
carrier String @default("mobile")
status String @default("active")
retryEnabled Boolean @default(true)
retryTimeLimitHours Int @default(72)
@@ -553,6 +555,7 @@ model SmsChannelGroupItem {
channel SmsChannel @relation(fields: [channelId], references: [id])
@@unique([groupId, channelId, carrier, province])
@@unique([groupId, province])
@@index([channelId])
}
+76 -4
View File
@@ -19,6 +19,7 @@ function createPrismaMock() {
unitPrice: 3,
status: 'active',
config: { serviceId: 'SMS' },
sendRegion: '山东',
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
};
return {
@@ -43,6 +44,7 @@ function createPrismaMock() {
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active' }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
},
smsChannelGroupItem: {
@@ -114,7 +116,7 @@ describe('ChannelsService', () => {
passwordCipher: 'secret',
srcId: '10690000',
});
await service.createGroup({ code: 'G-MOBILE', name: '移动组', retryEnabled: true, retryTimeLimitHours: 24 });
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 });
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
@@ -127,7 +129,7 @@ describe('ChannelsService', () => {
}),
});
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
data: expect.objectContaining({ retryEnabled: true, retryTimeLimitHours: 24 }),
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
});
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -146,8 +148,78 @@ describe('ChannelsService', () => {
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');
await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' }))
.rejects.toThrow('Route rules can only bind channel groups');
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('enforces single-carrier channel groups and compatible group items', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
expect(() => service.createGroup({ code: 'G-ALL', name: '三网组', carrier: 'all' })).toThrow('carrier must be mobile, unicom, or telecom');
await service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东', priority: 10 });
expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东' }),
});
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' }))
.rejects.toThrow('Channel group items must use the same carrier');
const compatibleChannel = {
id: 'channel-1',
code: 'CMPP-A',
name: '主通道',
carrier: 'mobile',
protocol: 'CMPP',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
enterpriseCode: 'EC',
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
config: { serviceId: 'SMS' },
sendRegion: '山东',
};
prisma.smsChannel.findUnique.mockResolvedValueOnce({ ...compatibleChannel, carrier: 'telecom' });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-x', carrier: 'mobile' }))
.rejects.toThrow('Channel carrier is not compatible');
prisma.smsChannel.findUnique.mockResolvedValue(compatibleChannel);
prisma.smsChannelGroupItem.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'province-item', province: '山东' });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-2', carrier: 'mobile', province: '山东' }))
.rejects.toThrow('同一通道组内同一省份只能配置一个通道');
});
it('rejects province routes with mismatched channel sendRegion and duplicate national priorities', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-henan', carrier: 'all', sendRegion: '河南' });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-henan', carrier: 'mobile', province: '山东' }))
.rejects.toThrow('Province route must use a channel with the same sendRegion');
prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-national', carrier: 'all', sendRegion: '全国' });
prisma.smsChannelGroupItem.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'national-priority-1', province: null, priority: 1 });
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-national', carrier: 'mobile', priority: 1 }))
.rejects.toThrow('同一通道组内全国通道优先级不能重复');
});
it('requires route rule carrier to match the channel group carrier', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'unicom' }))
.rejects.toThrow('Route rule carrier must match the channel group carrier');
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
+78 -3
View File
@@ -24,6 +24,7 @@ export interface CreateChannelDto {
export interface CreateChannelGroupDto {
code: string;
name: string;
carrier: string;
description?: string;
status?: string;
retryEnabled?: boolean;
@@ -391,10 +392,12 @@ export class ChannelsService {
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
}
const carrier = normalizeBusinessCarrier(data.carrier);
return this.prisma.smsChannelGroup.create({
data: {
code: data.code,
name: data.name,
carrier,
description: data.description,
status: data.status ?? 'active',
retryEnabled: data.retryEnabled ?? true,
@@ -404,17 +407,51 @@ export class ChannelsService {
}
async addGroupItem(data: CreateChannelGroupItemDto) {
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
if (!group) {
throw new NotFoundException('Channel group not found');
}
const groupCarrier = normalizeBusinessCarrier(group.carrier);
const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier;
if (itemCarrier !== groupCarrier) {
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
}
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
}
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
throw new BadRequestException('Province route must use a channel with the same sendRegion');
}
const existing = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, channelId: data.channelId },
});
if (existing) {
throw new BadRequestException('通道组内不能重复配置同一通道');
}
if (data.province) {
const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, province: data.province },
});
if (existingProvince) {
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
}
} else {
const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 },
});
if (existingPriority) {
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
}
}
return this.prisma.smsChannelGroupItem.create({
data: {
groupId: data.groupId,
channelId: data.channelId,
carrier: data.carrier,
carrier: itemCarrier,
province: data.province,
priority: data.priority ?? 100,
weight: data.weight ?? 1,
@@ -432,26 +469,34 @@ export class ChannelsService {
});
}
createRouteRule(data: CreateRouteRuleDto) {
async 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');
}
const carrier = normalizeBusinessCarrier(data.carrier);
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');
}
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
if (!group) {
throw new NotFoundException('Channel group not found');
}
if (normalizeBusinessCarrier(group.carrier) !== carrier) {
throw new BadRequestException('Route rule carrier must match the channel group carrier');
}
return this.prisma.channelRouteRule.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
groupId: data.groupId,
channelId: undefined,
carrier: data.carrier,
carrier,
province: undefined,
priority: data.priority ?? 100,
status: data.status ?? 'active',
@@ -645,6 +690,36 @@ function normalizeConnectionAction(status: string) {
return 'updated';
}
function normalizeBusinessCarrier(carrier?: string | null) {
const normalized = normalizeChannelCarrier(carrier);
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
throw new BadRequestException('carrier must be mobile, unicom, or telecom');
}
return normalized;
}
function normalizeChannelCarrier(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;
}
function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
const normalized = normalizeChannelCarrier(channelCarrier);
return normalized === 'all' || normalized === groupCarrier;
}
function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
}
function normalizeLinkEvent(action: string) {
if (action.includes('connected')) {
return '新建';
+139 -4
View File
@@ -17,7 +17,10 @@ function createPrismaMock() {
unitPrice: 3,
amountCents: 3,
status: 'queued',
template: { signature: { name: '签名' } },
submitId: 'SUB-1',
gatewayMessageId: 'GW-1',
channelId: 'channel-1',
template: { signature: { id: 'sig-1', name: '签名' } },
};
const channel = {
id: 'channel-1',
@@ -42,10 +45,11 @@ function createPrismaMock() {
status: 'active',
group: {
id: 'group-1',
carrier: 'mobile',
status: 'active',
retryEnabled: true,
retryTimeLimitHours: 72,
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', priority: 1, province: null, channel }],
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
},
};
return {
@@ -53,7 +57,7 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active' }),
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3 }),
},
smsTemplate: {
findUnique: jest.fn().mockResolvedValue({
@@ -104,6 +108,9 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findMany: jest.fn().mockResolvedValue([]),
},
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
},
smsReceiptRecord: {
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
findMany: jest.fn(),
@@ -118,6 +125,9 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'bill-1' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
accountTransaction: {
findFirst: jest.fn().mockResolvedValue(null),
},
enterpriseBlacklist: {
findMany: jest.fn().mockResolvedValue([]),
},
@@ -342,13 +352,17 @@ describe('SendChainService', () => {
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
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: [] },
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] },
});
await service.handleSubmitResult({
@@ -369,6 +383,127 @@ describe('SendChainService', () => {
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
});
it('does not let stale failed receipts overwrite a later delivered message', async () => {
const { service, prisma, billing } = createService();
prisma.smsMessageRecord.findFirst.mockResolvedValue({
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
templateId: 'tpl-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
content: 'hello',
channelId: 'channel-new',
gatewayMessageId: 'GW-NEW',
status: 'delivered',
amountCents: 3,
billingUnits: 1,
unitPrice: 3,
});
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-old',
gatewayMessageId: 'GW-OLD',
receiptStatus: 'undelivered',
rawStatus: 'UNDELIV',
});
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ channelId: 'channel-old', gatewayMessageId: 'GW-OLD', receiptStatus: 'undelivered' }),
});
expect(billing.refund).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith({
where: { id: 'record-1' },
data: expect.objectContaining({ status: 'failed' }),
});
});
it('blocks submit when signature is not approved on the selected channel', async () => {
const { service, prisma } = createService();
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
prisma.channelSignatureReportTask.findFirst.mockResolvedValue(null);
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
expect.objectContaining({ submitted: false, status: 'failed', reason: '短信签名未在最终通道报备通过' }),
);
expect(gatewayAdd).not.toHaveBeenCalled();
});
it('only selects channel group items allocated to the matched carrier', async () => {
const { service, prisma } = createService();
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
prisma.channelRouteRule.findFirst.mockResolvedValue({
id: 'route-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
carrier: 'mobile',
group: {
id: 'group-1',
carrier: 'mobile',
status: 'active',
retryEnabled: true,
retryTimeLimitHours: 72,
items: [
{
id: 'wrong-item',
groupId: 'group-1',
channelId: 'channel-unicom',
carrier: 'unicom',
priority: 1,
province: null,
channel: {
id: 'channel-unicom',
code: 'CMPP-U',
account: 'u',
srcId: '1061',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
carrier: 'all',
sendRegion: '全国',
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
},
},
{
id: 'mobile-item',
groupId: 'group-1',
channelId: 'channel-all',
carrier: 'mobile',
priority: 2,
province: null,
channel: {
id: 'channel-all',
code: 'CMPP-ALL',
account: 'all',
srcId: '1062',
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
carrier: 'all',
sendRegion: '全国',
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
},
},
],
},
});
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
expect.objectContaining({ channelId: 'channel-all' }),
);
expect(gatewayAdd).toHaveBeenCalledWith(
'submit-command',
expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }),
);
});
it('records receipts and uplink messages from gateway events', async () => {
const { service, prisma } = createService();
+74 -15
View File
@@ -464,7 +464,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
try {
const routed = await this.selectChannelForMessage(message);
return this.submitMessageToGateway(message, routed, 0);
return await this.submitMessageToGateway(message, routed, 0);
} catch (error) {
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
@@ -491,6 +491,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
submittedAt,
},
});
if (data.submitId && message.submitId && data.submitId !== message.submitId) {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
if (data.submitStatus === 'accepted') {
await this.chargeAcceptedMessage(message);
@@ -538,6 +541,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
deliveredAt,
},
});
const isCurrentAttempt =
(!message.channelId || message.channelId === data.channelId)
&& (!message.gatewayMessageId || message.gatewayMessageId === data.gatewayMessageId);
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
if (status === 'failed') {
const retried = await this.retryMessageIfAllowed(message, '回执失败补发');
if (retried) {
@@ -619,12 +628,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
phoneNumber: string;
content: string;
billingUnits: number;
template?: { signature?: { name?: string | null } | null } | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
},
routed: RoutedChannel,
attempt: number,
) {
const channel = routed.channel;
await this.ensureSignatureReportedForChannel(message, channel.id);
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
const submitId = `SUB-${randomUUID()}`;
const session = await this.prisma.cmppSubmitSession.upsert({
@@ -733,7 +743,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
where: { id: message.id },
data: { errorMessage: reason },
});
return this.submitMessageToGateway(message, routed, attempts.length);
return await this.submitMessageToGateway(message, routed, attempts.length);
} catch {
return null;
}
@@ -750,7 +760,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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 items = route.group.items.filter((item) =>
!excluded.has(item.channelId)
&& normalizeCarrier(item.carrier) === carrier
&& 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));
@@ -785,6 +799,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (route.group.status !== 'active') {
throw new BadRequestException('企业应用绑定的通道组已停用');
}
if (normalizeCarrier(route.group.carrier) !== carrier) {
throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致');
}
return route;
}
@@ -826,17 +843,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async resolveUnitPrice(tenantId: string, applicationId?: string) {
try {
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 {
if (!applicationId) {
return 0;
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: { tenantId: true, customerUnitPrice: true },
});
if (!application || application.tenantId !== tenantId) {
return 0;
}
return application.customerUnitPrice ?? 0;
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
@@ -935,12 +952,18 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (charged) {
return;
}
const released = await this.prisma.accountTransaction.findFirst({
where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' },
});
if (released) {
return;
}
await this.billing.release({
tenantId: message.tenantId,
amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_batch_task',
relatedId: message.batchTaskId,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark: `${remark}: ${message.messageId}`,
});
}
@@ -952,6 +975,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
return;
}
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
if (refunded) {
return;
}
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
if (!charged) {
return;
}
const transaction = await this.billing.refund({
tenantId: message.tenantId,
amountCents: message.amountCents,
@@ -966,6 +997,34 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
private async ensureSignatureReportedForChannel(
message: {
id: string;
templateId?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
},
channelId: string,
) {
let signatureId = message.template?.signature?.id ?? null;
if (!signatureId && message.templateId) {
const template = await this.prisma.smsTemplate.findUnique({
where: { id: message.templateId },
include: { signature: true },
});
signatureId = template?.signature?.id ?? null;
}
if (!signatureId) {
throw new BadRequestException('短信签名未配置,不能提交到通道');
}
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId, status: 'approved' },
select: { id: true },
});
if (!reportTask) {
throw new BadRequestException('短信签名未在最终通道报备通过');
}
}
private async waitForChannelRateLimit(channelId: string, tps: number) {
const redis = this.getRedis();
for (;;) {
+2
View File
@@ -9,6 +9,7 @@ export interface CreateSmsApplicationDto {
scene?: string;
callbackUrl?: string;
dailyLimit?: number;
customerUnitPrice?: number;
maxPhonesPerTask?: number;
templateMismatchMode?: string;
ipAllowlist?: string[];
@@ -120,6 +121,7 @@ export class SmsConfigService {
callbackUrl: data.callbackUrl,
secretHash: hashSecret(secret),
dailyLimit: data.dailyLimit,
customerUnitPrice: data.customerUnitPrice ?? 0,
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
templateMismatchMode: data.templateMismatchMode ?? 'reject',
ipAllowlist: {
+26 -19
View File
@@ -136,22 +136,25 @@
1. 运营端配置短信通道,包括通道名称、运营商、单价、网关地址、端口、企业代码、账号、密码、接入号、协议参数、启停状态。
2. 通道运营商支持移动、联通、电信和三网;三网通道可作为移动、联通、电信的通配通道。
3. 通道必须配置发送地区,发送地区为全国或 34 个省级地区之一;单个通道只能选择一个发送地区。
4. 运营端配置短信通道组,定义通道优先级、运营商配、省网路由、全国路由、权重、失败补发开关和补发时间上限。
4. 运营端配置短信通道组,通道组必须选择且只能选择一个运营商:移动、联通或电信;通道组不允许选择三网。通道组定义通道优先级、运营商配、省网路由、全国路由、权重、失败补发开关和补发时间上限。
5. 企业不配置默认通道组;企业应用必须单独配置至少一个运营商通道组,否则页面不可保存,发送时也必须直接失败。
6. 一个企业应用可以分别绑定移动、联通、电信通道组;可以只绑定其中一类或两类,但不能一个都不绑定。
7. 发送时先按运营商识别结果分流:移动短信走移动通道组,联通短信走联通通道组,电信短信走电信通道组;识别不出的号码走移动通道组。
8. 运营商识别优先使用可配置的号码前缀正则表达式,通常匹配手机号前 3 到 4 位;如当前没有配置能力,手机号段库需增加“运营商区分规则”配置
8. 运营商识别可配置的号码前缀正则表达式为准,通常匹配手机号前 3 到 4 位;手机号段库只提供省份/城市识别,其 carrier 字段仅作后台校验或提示,不参与发送运营商判定
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 未真正发出时释放冻结且不扣费;本期客户计费不使用通道成本价
12. 省网配置当前版本按通道组内省份唯一:同一个通道组内山东只能选择一个通道、河南只能选择一个通道,依此类推;该校验针对通道组明细里的省份配置,不是校验通道本体属性。省网明细的省份必须与引用通道的发送地区一致,例如山东省网不能引用发送地区为河南的通道
13. 全国通道可配置多个,并按优先级依次补发;同一通道组内全国通道优先级禁止重复,本期不支持权重分流
14. 通道组明细 `carrier` 必须保留并参与发送逻辑,且必须等于通道组运营商。三网只允许作为通道本体能力 `SmsChannel.carrier=all`,表示该通道可被运营分配到移动、联通或电信通道组;一旦放入某个通道组,只能服务该通道组所属运营商
15. 通道组明细引用通道时必须校验通道能力:移动组只能引用移动通道或三网通道,联通组只能引用联通通道或三网通道,电信组只能引用电信通道或三网通道
16. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节
17. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 online、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected 或当前连接数为 0 的通道均不可被选中
18. 多连接通道只要至少 1 条连接 online 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送
19. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪
20. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。
21. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。
22. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。
### 4.7 通道签名报备
@@ -163,6 +166,7 @@
6. 运营端在报备任务或通道报备详情页导入通道回执。
7. 系统根据回执同步签名在各通道的报备状态。
8. 报备记录保留每次导出、导入、状态变更和操作人。
9. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
### 4.8 回执与上行
@@ -170,20 +174,23 @@
2. 回执状态至少包括提交成功、提交失败、发送成功、发送失败、未知、超时。
3. 提交后 72 小时仍为未知的短信转超时,返回客户失败。
4. 超时前允许人工同步、重新拉取回执或接收供应商二次回执;所有二次回执必须记录历史。
5. 上行短信接入后优先按手机号、接入号、企业/应用、时间窗口匹配下发记录
6. 接入号匹配不到企业/应用时,展示近期平台对此手机号下发的短信供人工判断
7. 完全匹配不到的上行短信仍需入库,并展示为未匹配
8. 客户端可查看本企业上行短信,运营端可查看全平台上行短信
5. 旧通道迟到的 failed receipt 如果对应短信已经由新通道 delivered,不得覆盖短信记录最终 delivered 状态;短信详情弹窗必须能看到该历史 failed receipt
6. 重复提交结果或重复回执必须幂等处理,不得重复扣费、重复释放冻结或重复退款
7. 上行短信接入后优先按手机号、接入号、企业/应用、时间窗口匹配下发记录
8. 接入号匹配不到企业/应用时,展示近期平台对此手机号下发的短信供人工判断
9. 完全匹配不到的上行短信仍需入库,并展示为未匹配。
10. 客户端可查看本企业上行短信,运营端可查看全平台上行短信。
### 4.9 账户计费
1. 客户端可查看充值套餐、购买或申请充值套餐、查看账单流水。
2. 发送创建时按短信内容计费条数、企业单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分。
2. 发送创建时按短信内容计费条数、企业应用客户单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价
3. 平台需在发送前检查企业账户余额、套餐余量或授信额度。
4. 发送链路需记录计费条数、计费单价、计费金额、账务状态。
5. 账单流水与短信记录可追溯关联,支持按企业、应用、任务、手机号、时间对账。
6. 最终失败、超时失败需要退费。
7. 计费口径可配置为按提交成功计费或按回执成功计费
7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额
8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。
## 5. 功能需求
@@ -479,7 +486,7 @@
4. Send Worker 消费消息,校验黑名单、敏感词、限额。
5. 使用可配置号码前缀正则识别运营商;识别不出时按移动处理。
6. 使用手机号段库识别号码省份和城市;识别不出省份时按全国路由处理。
7. 按企业应用绑定的对应运营商通道组执行路由:先匹配省网通道,再匹配全国通道不得直接绑定或 fallback 到非授权单通道。
7. 按企业应用绑定的对应运营商通道组执行路由:通道组只能是移动、联通、电信之一,发送时必须同时满足路由规则运营商、通道组运营商、通道组明细 carrier 与号码识别运营商一致;再校验通道本体 carrier 为对应运营商或三网;最后先匹配省网通道,再匹配全国通道不得直接绑定或 fallback 到非授权单通道。
8. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。
9. 通过通道限速器控制 TPS。
10. 调用 Gateway Adapter 提交短信。
@@ -521,8 +528,8 @@
### 9.3 通道与路由
- sms_channel:短信通道。
- sms_channel_group:通道组。
- sms_channel_group_item:通道组通道明细。
- sms_channel_group:通道组,必须配置单一运营商 `mobile/unicom/telecom`,不支持三网通道组
- sms_channel_group_item:通道组通道明细`carrier` 必须等于所属通道组运营商;省网明细按 `groupId + province` 唯一,全国明细可多条
- channel_route_rule:路由规则。
- channel_health_metric:通道健康指标。
+55 -8
View File
@@ -248,16 +248,27 @@
### TC-ADMIN-004 通道组与路由规则
- 优先级:P0
- 前置条件:存在主通道和备用通道。
- 前置条件:存在移动、联通、电信专属通道和三网通道,存在主通道和备用通道。
- 步骤:
1. 创建通道组。
2. 添加主通道优先级 10、备用通道优先级 20
3. 创建租户/应用维度路由规则
4. 创建发送任务触发送链路
1. 创建移动通道组、联通通道组、电信通道组。
2. 尝试创建三网通道组
3. 在移动通道组中添加 mobile item,引用移动通道或三网通道
4. 尝试在移动通道组中添加 unicom item 或引用电信专属通道
5. 尝试在移动通道组中添加山东省网 item,但引用发送地区为河南的通道。
6. 在同一通道组中为山东省重复添加第二个省网通道。
7. 添加主通道优先级 10、备用全国通道优先级 20,并尝试添加另一个优先级 20 的全国通道。
8. 创建租户/应用维度路由规则。
9. 创建发送任务触发送链路。
- 预期结果:
- 只能创建 mobile/unicom/telecom 通道组,三网通道组被拒绝。
- 通道组明细 carrier 必须等于通道组运营商。
- 通道组明细引用通道时必须满足通道本体能力兼容:移动组只允许 mobile/all,联通组只允许 unicom/all,电信组只允许 telecom/all。
- 省网 item 的省份必须与通道发送地区一致。
- 同一通道组内同一省份只能配置一个通道,全国通道可配置多个但优先级不能重复。
- 路由优先命中租户/应用规则。
- 主通道 active 时选择主通道。
- 主通道 disabled 时选择备用 active 通道。
- 通道组不支持权重分流,同优先级全国通道配置被拒绝。
### TC-ADMIN-005 签名报备任务生成与导出
@@ -286,6 +297,7 @@
- 报备任务状态变为 approved。
- 签名 `reportStatus` 同步为 approved。
- 报备记录包含 receipt_import。
- 发送前仅允许使用最终选中通道上报备状态为 approved 的签名。
### TC-ADMIN-007 报备回执导入失败
@@ -613,11 +625,12 @@
### TC-BILLING-003 submit 成功扣费
- 优先级:P0
- 前置条件:计费口径为 submit_success
- 前置条件:企业应用已配置客户单价,发送任务已冻结预算
- 步骤:模拟 Gateway 返回 submit accepted。
- 预期结果:
- 生成 charged 流水。
- 短信计费记录状态更新为 charged。
- 扣费金额按企业应用客户单价计算,不按通道成本价或运营商差异价计算。
- 可按 messageId 对账。
### TC-BILLING-004 最终失败退款
@@ -629,6 +642,7 @@
- 短信状态 failed/undelivered。
- 生成 refunded 流水。
- 退款金额与原扣费一致。
- 重复 failed receipt 不重复退款。
### TC-BILLING-005 72 小时超时退款
@@ -709,6 +723,7 @@
3. 查询 submit record、trace 和路由日志。
- 预期结果:
- 系统按可配置前缀正则识别运营商,并按手机号段库查询省份和城市。
- 手机号段 carrier 仅作为后台校验或提示,不改变正则识别出的发送运营商。
- trace 记录识别出的运营商、省份和城市。
- 路由优先选择山东省网通道 A。
- Gateway SubmitCommand 的 channelId 为 A。
@@ -741,6 +756,7 @@
- 补发不跨出企业应用授权通道组。
- 原失败原因、补发次数、前后 channelId 和状态流转在 trace 中可查。
- 最终成功只按企业应用客户费率扣一次,不按通道成本价重复扣费。
- 补发选择通道时使用当前通道组配置,并重新校验新通道上的签名报备状态。
### TC-SEND-015 企业应用通道组保存校验
@@ -768,17 +784,21 @@
- 联通号码进入联通通道组。
- 电信号码进入电信通道组。
- 运营商识别失败时进入移动通道组。
- 若手机号段库 carrier 与正则识别结果冲突,以正则识别结果为准,号段 carrier 仅记录为提示信息。
### TC-SEND-017 三网通道作为运营商通配
- 优先级:P0
- 前置条件:企业应用绑定移动通道组;移动通道组内无可用移动全国通道,但有可用三网全国通道。
- 前置条件:企业应用绑定移动通道组;移动通道组 carrier 为 mobile;组内明细 carrier 为 mobile;组内无可用移动全国通道,但有可用三网全国通道。
- 步骤:
1. 提交移动号码。
2. 触发送 worker。
3. 查询 submit record 和 trace。
4. 将同一三网通道放入联通通道组,再提交移动号码。
- 预期结果:
- 三网通道可作为移动、联通电信的通配通道参与路由
- 三网通道本体 `channel.carrier=all` 只表示通道能力,可被运营分配到移动、联通电信通道组
- 移动号码只会选择移动通道组内 `item.carrier=mobile` 且通道本体为 mobile/all 的通道。
- 放入联通通道组的三网通道不能被移动号码选中。
- route/trace 标明实际命中的三网通道。
### TC-SEND-018 失败补发停止条件
@@ -798,6 +818,33 @@
- 超过通道组补发时间上限不触发补发。
- 通道组关闭失败补发时不触发补发。
### TC-SEND-019 迟到旧通道回执不覆盖最终成功
- 优先级:P0
- 前置条件:短信首次通过通道 A 发送失败并补发到通道 B,通道 B 已返回 delivered。
- 步骤:
1. 查询短信记录列表,确认该短信当前状态为 deliveredchannelId/gatewayMessageId 为通道 B。
2. 模拟通道 A 迟到的 failed receipt。
3. 查询短信记录列表、短信详情弹窗、回执历史和账单流水。
- 预期结果:
- 短信记录列表仍展示最终 delivered 状态,不被通道 A 的 failed receipt 覆盖。
- 短信详情弹窗可见通道 A 的历史 failed receipt。
- 不产生重复退款,也不改变已成功短信的最终计费状态。
### TC-SEND-020 重复回调幂等
- 优先级:P0
- 前置条件:存在一条已 submit accepted 并完成账务扣费的短信。
- 步骤:
1. 连续发送两次相同 submit accepted 回调。
2. 连续发送两次相同 failed receipt 回调。
3. 查询短信记录、提交记录、回执记录、短信计费记录和账户流水。
- 预期结果:
- 提交/回执历史可追踪,但短信最终状态按最新有效尝试处理。
- accepted 不重复扣费。
- failed receipt 不重复退款。
- 账户流水、短信计费记录和 reconciliation 无重复金额。
### TC-SEND-004 SubmitResult accepted
- 优先级:P0
+3 -3
View File
@@ -15,9 +15,9 @@
- 目标:覆盖纯业务规则、状态流转、查询条件、Gateway tracker/reconnector/health 等无需真实外部服务的逻辑。
- 当前重点:
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
- 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录
- 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。
- 通道与报备:通道创建、通道发送地区、三网通道通配、通道组路由规则、企业应用通道组保存校验、禁止单通道发送规则、签名报备任务、导出、回执导入、签名状态同步。
- 计费:费用预估、余额检查、冻结、提交 accepted 扣费、失败回执退款、短信计费记录、应用级客户费率、重复回调幂等
- 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、迟到旧回执不覆盖最终成功、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。
- 通道与报备:通道创建、通道发送地区、三网通道通配、单运营商通道组、通道组明细 carrier 参与发送、省份与通道发送地区一致性、全国通道优先级唯一、企业应用通道组保存校验、禁止单通道发送规则、最终选中通道签名报备校验、签名报备任务、导出、回执导入、签名状态同步。
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
- GatewaySEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
+72
View File
@@ -437,3 +437,75 @@ npm run verify:phase8
### 待复测
- 浏览器 smoke 和真实文件上传 smoke 需要在生产验证环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。
## 2026-07-03 单运营商通道组、应用级费率和回执幂等
### 本轮修复范围
- 通道组规则:
- `SmsChannelGroup.carrier` 固化为移动、联通、电信三选一,禁止三网通道组。
- `SmsChannelGroupItem.carrier` 保留并参与发送,必须等于通道组运营商。
- 三网只作为通道本体能力 `SmsChannel.carrier=all`,放入某个通道组后只服务该组运营商。
- 同一通道组内同一省份只能配置一个通道;省份 item 必须引用发送地区一致的通道。
- 全国通道允许多个,但同一通道组内全国通道优先级禁止重复;本期不做权重分流。
- 路由规则必须绑定应用、运营商、通道组,且 route carrier 必须等于 group carrier。
- 发送和计费规则:
- 运营商以号码前缀正则为准;手机号段库只提供省份/城市,carrier 仅作后台提示或校验。
- 发送前校验最终选中通道的签名报备任务为 approved,补发切换通道时重新校验。
- 企业应用新增 `customerUnitPrice`,客户扣费按应用级客户费率;通道成本只作内部成本。
- 迟到旧通道 failed receipt 不覆盖新通道 delivered 最终状态;历史回执仍入库可查。
- 重复 submit/receipt 回调不得重复扣费、释放冻结或退款。
- 补发使用触发时当前通道组配置;本期不考虑人工重发。
- 前端和真实 smoke
- 企业应用表单增加客户单价输入,保存时写入真实应用 API。
- 企业应用按移动、联通、电信分别选择通道组,选项按通道组 carrier 过滤。
- 真实 smoke seed 补充应用客户单价、三网通道放入移动组、签名-通道 approved 报备。
### 新增/更新测试
| 测试文件 | 新增覆盖 |
| --- | --- |
| `api/src/channels/channels.service.spec.ts` | 单运营商通道组、通道组 item carrier 校验、通道 carrier 兼容、省份与发送地区一致、同省唯一、全国优先级唯一、route carrier 与 group carrier 一致。 |
| `api/src/send-chain/send-chain.service.spec.ts` | 通道组 item carrier 参与发送、最终通道签名报备校验、应用级客户费率、迟到旧 failed receipt 不覆盖 delivered、重复账务动作幂等。 |
| `api/src/sms-config/sms-config.service.spec.ts` | 应用配置与列表在新增客户费率字段后继续通过。 |
### 已执行命令
```bash
npm --prefix api run prisma:generate
npm --prefix api test -- channels.service.spec.ts send-chain.service.spec.ts sms-config.service.spec.ts --runInBand
npm --prefix api test
npm --prefix api run build
npm run build
npm --prefix api run prisma:migrate:deploy
$env:API_PORT='3101'; $env:API_ENABLE_SEND_WORKER='true'; npm --prefix api run start:dev
node tools/smoke/real-env-smoke.mjs
node <inline channel-group rule HTTP smoke>
npm run spike:contracts
npm run test:gateway
npm run verify:phase8
```
### 当前结果
- Prisma Client 生成通过。
- 新增迁移已应用到真实 PostgreSQL:
- `20260703143000_add_channel_group_carrier`
- `20260703152000_add_application_customer_rate`
- API Jest10 个 test suite 通过,56 个测试通过。
- API build 通过。
- 前端 build 通过,仍存在既有 Vite chunk size warning。
- 真实 API smoke 通过:
- `tools/smoke/real-env-smoke.mjs` 通过,验证真实 API、Prisma/PostgreSQL、Redis/BullMQ、登录、充值、发送任务、worker 入队、文件上传元数据和操作日志。
- inline 通道组规则 HTTP smoke 通过,覆盖三网组拒绝、item carrier 不匹配拒绝、通道 carrier 不兼容拒绝、省份/发送地区不匹配拒绝、同省重复拒绝、全国优先级重复拒绝、route carrier/group carrier 不匹配拒绝。
- Gateway 队列契约通过,4 个示例均验证通过。
- `npm run test:gateway` 通过,Go Gateway health、connection、tracker、cmpp、spike 测试全部通过。
- `npm run verify:phase8` 未通过,仍阻塞在已知 BullMQ spike 性能阈值:
- 15000 条消息、并发 500。
- enqueue TPS 3139.43。
- end-to-end TPS 479.02,低于 500 TPS。
### 剩余说明
- `verify:phase8` 当前失败点是独立 BullMQ 性能阈值,不是本轮通道组、计费、报备、回执业务逻辑测试失败。
- 浏览器端完整手工回归仍建议补跑企业应用创建、通道组配置、短信记录详情弹窗中的历史回执展示。
+4 -2
View File
@@ -182,6 +182,7 @@ export type ClientSmsApplication = {
tenantId: string;
name: string;
scene?: string | null;
customerUnitPrice?: number | null;
status: string;
};
@@ -264,6 +265,7 @@ export type DictionaryItem = Record<string, unknown> & {
export type ChannelGroup = DictionaryItem & {
code: string;
name: string;
carrier: 'mobile' | 'unicom' | 'telecom';
description?: string | null;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
@@ -461,7 +463,7 @@ export const adminApi = {
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
@@ -526,7 +528,7 @@ export const adminApi = {
body: JSON.stringify({ reason }),
}),
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) =>
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; 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) }),
+1 -1
View File
@@ -149,7 +149,7 @@ export function AdminChannelGroupFormPage() {
const navigate = useNavigate();
const { groupId } = useParams();
const editing = Boolean(groupId && groupId !== 'new');
const [groupName, setGroupName] = useState(editing ? '学医三网专用' : '');
const [groupName, setGroupName] = useState(editing ? '学医移动专用' : '');
const [carrier, setCarrier] = useState<Carrier>('mobile');
const [retryEnabled, setRetryEnabled] = useState(false);
const [provinceRoutes, setProvinceRoutes] = useState(defaultProvinceRoutes);
+27 -1
View File
@@ -3,12 +3,27 @@ import { Layers3, Plus, Search, UsersRound } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
const carrierOptions: Array<{ label: string; value: GroupCarrier }> = [
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
];
const carrierLabels: Record<GroupCarrier, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
};
export function AdminChannelGroupsPage() {
const [groupName, setGroupName] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [carrier, setCarrier] = useState<GroupCarrier>('mobile');
const [error, setError] = useState('');
function loadData() {
@@ -27,11 +42,12 @@ export function AdminChannelGroupsPage() {
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
function createGroup() {
adminApi.createChannelGroup({ code, name, status: 'active' })
adminApi.createChannelGroup({ code, name, carrier, status: 'active' })
.then(() => {
setModalOpen(false);
setName('');
setCode('');
setCarrier('mobile');
loadData();
})
.catch((failure: Error) => setError(failure.message || '通道组创建失败'));
@@ -66,6 +82,7 @@ export function AdminChannelGroupsPage() {
<Layers3 size={18} />
<strong>{group.name}</strong>
</div>
<span title="运营商">{carrierLabels[group.carrier] ?? group.carrier}</span>
<span title="包含通道数"><UsersRound size={16} />{group.items?.length ?? 0}</span>
</header>
<div className="channel-group-card__body">
@@ -95,6 +112,15 @@ export function AdminChannelGroupsPage() {
<div className="admin-system-modal-form">
<Input label="通道组编码" onChange={(event) => setCode(event.target.value)} value={code} />
<Input label="通道组名称" onChange={(event) => setName(event.target.value)} value={name} />
<div className="channel-group-radio-row">
<span></span>
{carrierOptions.map((item) => (
<label key={item.value}>
<input checked={carrier === item.value} onChange={() => setCarrier(item.value)} type="radio" />
{item.label}
</label>
))}
</div>
</div>
</Modal>
</div>
@@ -11,6 +11,7 @@ export function AdminSmsApplicationFormPage() {
const [appName, setAppName] = useState('');
const [scene, setScene] = useState('行业通知');
const [dailyLimit, setDailyLimit] = useState('100000');
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
@@ -53,6 +54,7 @@ export function AdminSmsApplicationFormPage() {
name: appName,
scene,
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
ipAllowlist: ipAddress ? [ipAddress] : [],
@@ -71,9 +73,9 @@ export function AdminSmsApplicationFormPage() {
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
}
const groupOptions = [
const groupOptionsByCarrier = (carrier: ChannelGroup['carrier']) => [
{ label: '不配置', value: '' },
...groups.map((group) => ({ label: group.name, value: group.id })),
...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })),
];
return (
@@ -94,6 +96,7 @@ export function AdminSmsApplicationFormPage() {
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
<Select
label="不符合模板的短信"
@@ -107,9 +110,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} />
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptionsByCarrier('mobile')} value={mobileGroupId} />
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptionsByCarrier('unicom')} value={unicomGroupId} />
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptionsByCarrier('telecom')} value={telecomGroupId} />
</div>
</section>
+58 -13
View File
@@ -70,6 +70,8 @@ async function ensureSmokeData() {
where: { code: 'SMOKE_CMPP' },
update: {
name: 'Smoke CMPP Channel',
carrier: 'all',
sendRegion: '全国',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'smoke-account',
@@ -97,8 +99,8 @@ async function ensureSmokeData() {
const group = await prisma.smsChannelGroup.upsert({
where: { code: 'SMOKE_GROUP' },
update: { name: 'Smoke Channel Group', status: 'active' },
create: { code: 'SMOKE_GROUP', name: 'Smoke Channel Group', status: 'active' },
update: { name: 'Smoke Channel Group', carrier: 'mobile', status: 'active' },
create: { code: 'SMOKE_GROUP', name: 'Smoke Channel Group', carrier: 'mobile', status: 'active' },
});
const groupItem = await prisma.smsChannelGroupItem.findFirst({
@@ -106,20 +108,16 @@ async function ensureSmokeData() {
});
if (!groupItem) {
await prisma.smsChannelGroupItem.create({
data: { groupId: group.id, channelId: channel.id, priority: 1, weight: 1, rateLimitPerSecond: 500 },
data: { groupId: group.id, channelId: channel.id, carrier: 'mobile', priority: 1, weight: 1, rateLimitPerSecond: 500 },
});
} else {
await prisma.smsChannelGroupItem.update({
where: { id: groupItem.id },
data: { carrier: 'mobile', province: null, priority: 1, weight: 1, rateLimitPerSecond: 500 },
});
}
const routeRule = await prisma.channelRouteRule.findFirst({
where: { tenantId: tenant.id, groupId: group.id, status: 'active' },
});
if (!routeRule) {
await prisma.channelRouteRule.create({
data: { tenantId: tenant.id, groupId: group.id, priority: 1, status: 'active' },
});
}
const application =
let application =
(await prisma.smsApplication.findFirst({ where: { tenantId: tenant.id, name: 'Smoke SMS App' } })) ??
(await prisma.smsApplication.create({
data: {
@@ -128,11 +126,47 @@ async function ensureSmokeData() {
scene: 'smoke',
secretHash: hashPassword('smoke-secret'),
dailyLimit: 100000,
customerUnitPrice: 5,
maxPhonesPerTask: 100000,
templateMismatchMode: 'reject',
status: 'active',
},
}));
application = await prisma.smsApplication.update({
where: { id: application.id },
data: { customerUnitPrice: 5, status: 'active' },
});
const routeRule = await prisma.channelRouteRule.findFirst({
where: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile', status: 'active' },
});
if (!routeRule) {
await prisma.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile', priority: 1, status: 'active' },
});
}
await prisma.cmppConnectionState.upsert({
where: { channelId_connectionId: { channelId: channel.id, connectionId: 'smoke-conn-1' } },
update: {
tenantId: tenant.id,
status: 'online',
desiredConnections: 1,
currentConnections: 1,
lastHeartbeatAt: new Date(),
lastError: null,
},
create: {
tenantId: tenant.id,
channelId: channel.id,
connectionId: 'smoke-conn-1',
status: 'online',
desiredConnections: 1,
currentConnections: 1,
lastConnectedAt: new Date(),
lastHeartbeatAt: new Date(),
},
});
const signature =
(await prisma.smsSignature.findFirst({ where: { tenantId: tenant.id, applicationId: application.id, name: '烟测签名' } })) ??
@@ -163,6 +197,17 @@ async function ensureSmokeData() {
},
}));
const reportTask = await prisma.channelSignatureReportTask.findFirst({
where: { tenantId: tenant.id, signatureId: signature.id, channelId: channel.id },
});
if (!reportTask) {
await prisma.channelSignatureReportTask.create({
data: { tenantId: tenant.id, signatureId: signature.id, channelId: channel.id, status: 'approved' },
});
} else if (reportTask.status !== 'approved') {
await prisma.channelSignatureReportTask.update({ where: { id: reportTask.id }, data: { status: 'approved', reason: null } });
}
return { tenant, user, channel, application, signature, template };
}