fix: implement channel group routing failover

This commit is contained in:
hectorzhao
2026-07-03 11:07:43 +08:00
parent 131f344ac4
commit a890b03163
16 changed files with 853 additions and 121 deletions
@@ -0,0 +1,20 @@
-- Channel-group routing rules for first-version send-chain failover.
ALTER TABLE "SmsChannel" ADD COLUMN "sendRegion" TEXT NOT NULL DEFAULT '全国';
ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryEnabled" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryTimeLimitHours" INTEGER NOT NULL DEFAULT 72;
CREATE TABLE "PhoneCarrierRule" (
"id" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"pattern" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"status" TEXT NOT NULL DEFAULT 'active',
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PhoneCarrierRule_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "PhoneCarrierRule_status_priority_idx" ON "PhoneCarrierRule"("status", "priority");
+16
View File
@@ -173,6 +173,19 @@ model PhoneSegment {
updatedAt DateTime @updatedAt
}
model PhoneCarrierRule {
id String @id @default(cuid())
carrier String
pattern String
priority Int @default(100)
status String @default("active")
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, priority])
}
model SensitiveWord {
id String @id @default(cuid())
word String @unique
@@ -453,6 +466,7 @@ model SmsChannel {
code String @unique
name String
carrier String?
sendRegion String @default("全国")
protocol String @default("CMPP")
gatewayHost String
gatewayPort Int
@@ -514,6 +528,8 @@ model SmsChannelGroup {
name String
description String?
status String @default("active")
retryEnabled Boolean @default(true)
retryTimeLimitHours Int @default(72)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+18 -2
View File
@@ -46,6 +46,7 @@ function createPrismaMock() {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
},
smsChannelGroupItem: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
},
channelRouteRule: {
@@ -113,28 +114,43 @@ describe('ChannelsService', () => {
passwordCipher: 'secret',
srcId: '10690000',
});
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', channelId: 'channel-1' });
await service.createGroup({ code: 'G-MOBILE', name: '移动组', retryEnabled: true, retryTimeLimitHours: 24 });
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
data: expect.objectContaining({
protocol: 'CMPP',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
sendRegion: '全国',
status: 'active',
}),
});
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
data: expect.objectContaining({ retryEnabled: true, retryTimeLimitHours: 24 }),
});
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
channelId: 'channel-1',
channelId: undefined,
carrier: 'mobile',
priority: 100,
status: 'active',
}),
});
});
it('rejects direct single-channel route rules', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
expect(() => service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' }))
.toThrow('Route rules can only bind channel groups');
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
});
it('upserts signature report material per channel field', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
+32 -3
View File
@@ -6,6 +6,7 @@ export interface CreateChannelDto {
code: string;
name: string;
carrier?: string;
sendRegion?: string;
protocol?: string;
gatewayHost: string;
gatewayPort: number;
@@ -25,6 +26,8 @@ export interface CreateChannelGroupDto {
name: string;
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
}
export interface CreateChannelGroupItemDto {
@@ -143,6 +146,7 @@ export class ChannelsService {
code: data.code,
name: data.name,
carrier: data.carrier,
sendRegion: data.sendRegion ?? '全国',
protocol: data.protocol ?? 'CMPP',
gatewayHost: data.gatewayHost,
gatewayPort,
@@ -207,6 +211,7 @@ export class ChannelsService {
account: source.account,
passwordCipher: source.passwordCipher,
srcId: source.srcId,
sendRegion: source.sendRegion,
cmppVersion: source.cmppVersion,
rateLimitPerSecond: source.rateLimitPerSecond,
unitPrice: source.unitPrice,
@@ -382,17 +387,29 @@ export class ChannelsService {
}
createGroup(data: CreateChannelGroupDto) {
const retryTimeLimitHours = data.retryTimeLimitHours ?? 72;
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
}
return this.prisma.smsChannelGroup.create({
data: {
code: data.code,
name: data.name,
description: data.description,
status: data.status ?? 'active',
retryEnabled: data.retryEnabled ?? true,
retryTimeLimitHours,
},
});
}
addGroupItem(data: CreateChannelGroupItemDto) {
async addGroupItem(data: CreateChannelGroupItemDto) {
const existing = await this.prisma.smsChannelGroupItem.findFirst({
where: { groupId: data.groupId, channelId: data.channelId },
});
if (existing) {
throw new BadRequestException('通道组内不能重复配置同一通道');
}
return this.prisma.smsChannelGroupItem.create({
data: {
groupId: data.groupId,
@@ -416,14 +433,26 @@ export class ChannelsService {
}
createRouteRule(data: CreateRouteRuleDto) {
if (!data.applicationId) {
throw new BadRequestException('applicationId is required for channel group routing');
}
if (!data.carrier) {
throw new BadRequestException('carrier is required for application channel group routing');
}
if (data.channelId) {
throw new BadRequestException('Route rules can only bind channel groups, not single channels');
}
if (data.province) {
throw new BadRequestException('Province routing must be configured inside the channel group');
}
return this.prisma.channelRouteRule.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
groupId: data.groupId,
channelId: data.channelId,
channelId: undefined,
carrier: data.carrier,
province: data.province,
province: undefined,
priority: data.priority ?? 100,
status: data.status ?? 'active',
},
@@ -4,6 +4,7 @@ import { TenantId } from '../common/tenant-id.decorator';
import {
CreateBlacklistDto,
CreateDrainageFieldDto,
CreatePhoneCarrierRuleDto,
CreatePhoneSegmentDto,
CreateSensitiveWordDto,
DictionariesService,
@@ -25,6 +26,16 @@ export class DictionariesController {
return this.dictionaries.createPhoneSegment(body);
}
@Get('phone-carrier-rules')
listPhoneCarrierRules() {
return this.dictionaries.listPhoneCarrierRules();
}
@Post('phone-carrier-rules')
createPhoneCarrierRule(@Body() body: CreatePhoneCarrierRuleDto) {
return this.dictionaries.createPhoneCarrierRule(body);
}
@Get('sensitive-words')
listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listSensitiveWords({ keyword, status });
@@ -9,6 +9,14 @@ export interface CreatePhoneSegmentDto {
city?: string;
}
export interface CreatePhoneCarrierRuleDto {
carrier: string;
pattern: string;
priority?: number;
status?: string;
remark?: string;
}
export interface CreateSensitiveWordDto {
word: string;
level?: string;
@@ -56,6 +64,30 @@ export class DictionariesService {
return this.prisma.phoneSegment.create({ data });
}
listPhoneCarrierRules() {
return this.prisma.phoneCarrierRule.findMany({ orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], take: 200 });
}
createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
if (!data.carrier || !data.pattern) {
throw new BadRequestException('carrier and pattern are required');
}
try {
new RegExp(data.pattern);
} catch {
throw new BadRequestException('pattern must be a valid regular expression');
}
return this.prisma.phoneCarrierRule.create({
data: {
carrier: data.carrier,
pattern: data.pattern,
priority: data.priority ?? 100,
status: data.status ?? 'active',
remark: data.remark,
},
});
}
listSensitiveWords(query: DictionaryListQuery = {}) {
return this.prisma.sensitiveWord.findMany({
where: {
+36 -3
View File
@@ -27,7 +27,26 @@ function createPrismaMock() {
rateLimitPerSecond: 100,
unitPrice: 3,
status: 'active',
carrier: 'mobile',
sendRegion: '全国',
config: { serviceId: 'SMS' },
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
};
const route = {
id: 'route-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
carrier: 'mobile',
priority: 100,
status: 'active',
group: {
id: 'group-1',
status: 'active',
retryEnabled: true,
retryTimeLimitHours: 72,
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', priority: 1, province: null, channel }],
},
};
return {
tenant: {
@@ -65,7 +84,13 @@ function createPrismaMock() {
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]),
},
channelRouteRule: {
findFirst: jest.fn().mockResolvedValue(null),
findFirst: jest.fn().mockResolvedValue(route),
},
phoneCarrierRule: {
findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]),
},
phoneSegment: {
findUnique: jest.fn().mockResolvedValue({ prefix: '1380000', province: '山东', city: '济南' }),
},
smsChannel: {
findFirst: jest.fn().mockResolvedValue(channel),
@@ -77,7 +102,7 @@ function createPrismaMock() {
smsSubmitRecord: {
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findMany: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
smsReceiptRecord: {
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
@@ -316,7 +341,15 @@ describe('SendChainService', () => {
});
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
const { service, billing } = createService();
const { service, prisma, billing } = createService();
prisma.channelRouteRule.findFirst.mockResolvedValue({
id: 'route-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
carrier: 'mobile',
group: { id: 'group-1', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] },
});
await service.handleSubmitResult({
messageId: 'MSG-1',
+301 -73
View File
@@ -80,6 +80,25 @@ interface SendJob {
messageRecordId: string;
}
type RoutedChannel = {
channel: {
id: string;
code: string;
account: string;
srcId: string;
rateLimitPerSecond: number;
unitPrice: number;
status: string;
carrier?: string | null;
sendRegion: string;
config?: unknown;
};
carrier: string;
province?: string | null;
groupId: string;
routeScope: 'province' | 'national';
};
const SEND_QUEUE = 'sms.send.queue';
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
@@ -443,63 +462,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!message || message.status !== 'queued') {
return { skipped: true };
}
const channel = await this.selectChannel(message.tenantId, message.applicationId ?? undefined);
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
const submitId = `SUB-${randomUUID()}`;
const session = await this.prisma.cmppSubmitSession.upsert({
where: { sessionNo: `OPEN-${channel.id}` },
update: { submitTotal: { increment: 1 } },
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
});
await this.prisma.smsSubmitRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
channelId: channel.id,
sessionId: session.id,
submitId,
submitStatus: 'queued',
},
});
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelId: channel.id, submitId, status: 'submit_queued', submitStatus: 'queued' },
});
await this.getGatewayQueue().add('submit-command', {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: randomUUID(),
messageId: message.messageId,
channelId: channel.id,
createdAt: new Date().toISOString(),
tenantId: message.tenantId,
applicationId: message.applicationId ?? 'unknown',
taskId: message.batchTaskId,
submitId,
phoneNumber: message.phoneNumber,
content: message.content,
signature: message.template?.signature?.name ?? 'SMS',
templateId: message.templateId ?? 'unknown',
billingUnits: message.billingUnits,
route: {
channelCode: channel.code,
cmppAccountCode: channel.account,
priority: 0,
rateLimitPerSecond: channel.rateLimitPerSecond,
},
cmpp: {
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
? String(channel.config.serviceId)
: 'SMS',
srcId: channel.srcId,
registeredDelivery: 1,
msgFmt: 8,
},
retry: { attempt: 0, maxAttempts: 3 },
});
await this.refreshTaskProgress(message.batchTaskId);
return { submitted: true, messageRecordId: message.id, channelId: channel.id };
try {
const routed = await this.selectChannelForMessage(message);
return this.submitMessageToGateway(message, routed, 0);
} catch (error) {
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', errorMessage: reason },
});
await this.releaseMessageReservation(message, reason);
await this.refreshTaskProgress(message.batchTaskId);
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
}
}
async handleSubmitResult(data: GatewaySubmitResultDto) {
@@ -520,6 +495,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (data.submitStatus === 'accepted') {
await this.chargeAcceptedMessage(message);
} else {
const retried = await this.retryMessageIfAllowed(message, data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发');
if (retried) {
await this.refreshTaskProgress(message.batchTaskId);
return retried;
}
await this.releaseMessageReservation(message, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
}
await this.prisma.smsMessageRecord.update({
@@ -543,9 +523,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const status =
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
if (status === 'failed') {
await this.refundMessage(message, '最终失败退款');
}
await this.prisma.smsReceiptRecord.create({
data: {
tenantId: message.tenantId,
@@ -561,6 +538,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
deliveredAt,
},
});
if (status === 'failed') {
const retried = await this.retryMessageIfAllowed(message, '回执失败补发');
if (retried) {
await this.refreshTaskProgress(message.batchTaskId);
return retried;
}
await this.refundMessage(message, '最终失败退款');
}
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
@@ -623,30 +608,232 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { timeout: candidates.length };
}
private async selectChannel(tenantId: string, applicationId?: string) {
private async submitMessageToGateway(
message: {
id: string;
tenantId: string;
batchTaskId: string;
applicationId?: string | null;
templateId?: string | null;
messageId: string;
phoneNumber: string;
content: string;
billingUnits: number;
template?: { signature?: { name?: string | null } | null } | null;
},
routed: RoutedChannel,
attempt: number,
) {
const channel = routed.channel;
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
const submitId = `SUB-${randomUUID()}`;
const session = await this.prisma.cmppSubmitSession.upsert({
where: { sessionNo: `OPEN-${channel.id}` },
update: { submitTotal: { increment: 1 } },
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
});
await this.prisma.smsSubmitRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
channelId: channel.id,
sessionId: session.id,
submitId,
submitStatus: 'queued',
},
});
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
channelId: channel.id,
submitId,
status: 'submit_queued',
submitStatus: 'queued',
receiptStatus: null,
errorCode: null,
errorMessage: attempt > 0 ? `${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
},
});
await this.getGatewayQueue().add('submit-command', {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: randomUUID(),
messageId: message.messageId,
channelId: channel.id,
createdAt: new Date().toISOString(),
tenantId: message.tenantId,
applicationId: message.applicationId ?? 'unknown',
taskId: message.batchTaskId,
submitId,
phoneNumber: message.phoneNumber,
content: message.content,
signature: message.template?.signature?.name ?? 'SMS',
templateId: message.templateId ?? 'unknown',
billingUnits: message.billingUnits,
route: {
channelCode: channel.code,
cmppAccountCode: channel.account,
priority: attempt,
rateLimitPerSecond: channel.rateLimitPerSecond,
carrier: routed.carrier,
province: routed.province ?? undefined,
scope: routed.routeScope,
groupId: routed.groupId,
},
cmpp: {
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
? String(channel.config.serviceId)
: 'SMS',
srcId: channel.srcId,
registeredDelivery: 1,
msgFmt: 8,
},
retry: { attempt, maxAttempts: 0 },
});
await this.refreshTaskProgress(message.batchTaskId);
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
}
private async retryMessageIfAllowed(
message: {
id: string;
tenantId: string;
batchTaskId: string;
applicationId?: string | null;
templateId?: string | null;
messageId: string;
phoneNumber: string;
content: string;
billingUnits: number;
queuedAt?: Date;
},
reason: string,
) {
const attempts = await this.prisma.smsSubmitRecord.findMany({
where: { messageRecordId: message.id },
orderBy: { createdAt: 'asc' },
take: 200,
});
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
const ageHours = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 3_600_000;
if (ageHours >= 72) {
return null;
}
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
if (!route.group.retryEnabled || ageHours >= Math.min(route.group.retryTimeLimitHours, 72)) {
return null;
}
try {
const routed = await this.selectChannelForMessage(message, {
forceNational: true,
excludeChannelIds: attemptedChannelIds,
});
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { errorMessage: reason },
});
return this.submitMessageToGateway(message, routed, attempts.length);
} catch {
return null;
}
}
private async selectChannelForMessage(
message: { tenantId: string; applicationId?: string | null; phoneNumber: string },
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> {
if (!message.applicationId) {
throw new BadRequestException('短信应用未配置,无法选择通道组');
}
const carrier = await this.identifyCarrier(message.phoneNumber);
const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier);
const province = await this.identifyProvince(message.phoneNumber);
const excluded = new Set(options.excludeChannelIds ?? []);
const items = route.group.items.filter((item) => !excluded.has(item.channelId) && isCarrierCompatible(item.channel.carrier, carrier));
const provinceCandidates = options.forceNational ? [] : items.filter((item) => isProvinceChannel(item, province));
const nationalCandidates = items.filter((item) => isNationalChannel(item));
const selected = [...provinceCandidates, ...nationalCandidates].find((item) => this.isChannelSendAvailable(item.channel));
if (!selected) {
throw new NotFoundException('无可用在线通道');
}
return {
channel: selected.channel,
carrier,
province,
groupId: route.groupId,
routeScope: isNationalChannel(selected) ? 'national' : 'province',
};
}
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
const route = await this.prisma.channelRouteRule.findFirst({
where: {
status: 'active',
OR: [{ tenantId, applicationId }, { tenantId, applicationId: null }, { tenantId: null, applicationId: null }],
tenantId,
applicationId,
carrier,
channelId: null,
province: null,
},
include: { channel: true, group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
});
const routedChannel = route?.channel ?? route?.group.items.find((item) => item.channel.status === 'active')?.channel;
if (routedChannel) {
return routedChannel;
if (!route) {
throw new NotFoundException('企业应用未配置对应运营商通道组');
}
const channel = await this.prisma.smsChannel.findFirst({ where: { status: 'active' }, orderBy: { createdAt: 'asc' } });
if (!channel) {
throw new NotFoundException('No active SMS channel available');
if (route.group.status !== 'active') {
throw new BadRequestException('企业应用绑定的通道组已停用');
}
return channel;
return route;
}
private async identifyCarrier(phoneNumber: string) {
const rules = await this.prisma.phoneCarrierRule.findMany({
where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
take: 100,
});
for (const rule of rules) {
try {
if (new RegExp(rule.pattern).test(phoneNumber)) {
return normalizeCarrier(rule.carrier);
}
} catch {
continue;
}
}
return 'mobile';
}
private async identifyProvince(phoneNumber: string) {
for (let length = Math.min(7, phoneNumber.length); length >= 3; length -= 1) {
const segment = await this.prisma.phoneSegment.findUnique({ where: { prefix: phoneNumber.slice(0, length) } });
if (segment?.province) {
return segment.province;
}
}
return null;
}
private isChannelSendAvailable(channel: { status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }> }) {
if (channel.status !== 'active') {
return false;
}
return (channel.connectionStates ?? []).some((connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && ['online', 'connected'].includes(connection.status),
);
}
private async resolveUnitPrice(tenantId: string, applicationId?: string) {
try {
const channel = await this.selectChannel(tenantId, applicationId);
return channel.unitPrice ?? 0;
const route = await this.prisma.channelRouteRule.findFirst({
where: { tenantId, applicationId, status: 'active', channelId: null },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
});
const channel = route?.group.items.find((item) => item.channel.status === 'active')?.channel;
return channel?.unitPrice ?? 0;
} catch {
return 0;
}
@@ -695,6 +882,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}) {
const amountCents = message.amountCents ?? 0;
const smsUnits = message.billingUnits ?? 0;
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') {
return;
}
if (amountCents + smsUnits > 0) {
await this.billing.release({
tenantId: message.tenantId,
@@ -713,7 +904,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
relatedId: message.messageId,
remark: '提交成功扣费',
});
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
const data = {
tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined,
@@ -741,6 +931,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
return;
}
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
if (charged) {
return;
}
await this.billing.release({
tenantId: message.tenantId,
amountCents: message.amountCents,
@@ -914,6 +1108,40 @@ function cellByHeader(headers: string[], cells: string[], candidates: string[])
return index >= 0 ? cells[index] : undefined;
}
function normalizeCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
return value || 'mobile';
}
function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier;
}
function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
const itemProvince = normalizeRegion(item.province);
const sendRegion = normalizeRegion(item.channel.sendRegion);
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
}
function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) {
if (!province) {
return false;
}
const target = normalizeRegion(province);
const itemProvince = normalizeRegion(item.province);
const sendRegion = normalizeRegion(item.channel.sendRegion);
return itemProvince === target || sendRegion === target;
}
function bullmqConnection() {
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return {