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
+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 {