fix: enforce carrier-specific channel group routing
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 '新建';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 (;;) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user