feat: add number library routing and cdr location support
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||
import { CONFIG_ACTIVE_VERSION_KEY } from '@lisglosips/redis';
|
||||
import { publishPendingConfig } from './publisher.js';
|
||||
|
||||
class MemoryRedisMulti {
|
||||
constructor(private readonly writes: Array<{ command: string; key: string; value?: string }>) {}
|
||||
|
||||
set(key: string, value: string) {
|
||||
this.writes.push({ command: 'set', key, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
rpush(key: string, value: string) {
|
||||
this.writes.push({ command: 'rpush', key, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
sadd(key: string, value: string) {
|
||||
this.writes.push({ command: 'sadd', key, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
async exec() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRedis {
|
||||
writes: Array<{ command: string; key: string; value?: string }> = [];
|
||||
|
||||
async get(key: string) {
|
||||
return key === CONFIG_ACTIVE_VERSION_KEY ? null : null;
|
||||
}
|
||||
|
||||
multi() {
|
||||
return new MemoryRedisMulti(this.writes);
|
||||
}
|
||||
}
|
||||
|
||||
function prismaFixture(): PrismaClient {
|
||||
const now = new Date('2026-06-24T03:20:00.000Z');
|
||||
return {
|
||||
outboxEvent: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'out_number_library',
|
||||
aggregateType: 'number_library_config',
|
||||
status: 'PENDING',
|
||||
availableAt: now,
|
||||
createdAt: now
|
||||
}
|
||||
],
|
||||
updateMany: async () => ({ count: 1 })
|
||||
},
|
||||
$transaction: async (operations: Array<Promise<unknown>>) => Promise.all(operations),
|
||||
customer: {
|
||||
findMany: async () => [
|
||||
{ id: 'cus_1', status: 'ENABLED', balance: new Prisma.Decimal('10'), creditLimit: new Prisma.Decimal('0'), minBalance: new Prisma.Decimal('0') }
|
||||
]
|
||||
},
|
||||
customerGateway: {
|
||||
findMany: async () => [
|
||||
{ id: 'cgw_1', customerId: 'cus_1', authMode: 'IP', sourceIp: '100.93.185.30', sipUsername: null, sipDomain: null, sipHa1: null, status: 'ENABLED' }
|
||||
]
|
||||
},
|
||||
customerGatewayPolicy: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'cgp_1',
|
||||
customerId: 'cus_1',
|
||||
gatewayId: 'cgw_1',
|
||||
lineGroupId: 'lg_1',
|
||||
priority: 1,
|
||||
callerMode: 'ANY',
|
||||
callerValue: null,
|
||||
calleeMode: 'ANY',
|
||||
calleeValue: null,
|
||||
status: 'ENABLED'
|
||||
}
|
||||
]
|
||||
},
|
||||
vendorGateway: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'vgw_1',
|
||||
vendorId: 'ven_1',
|
||||
authMode: 'IP',
|
||||
host: '100.93.185.30',
|
||||
port: 50620,
|
||||
transport: 'udp',
|
||||
sipUsername: null,
|
||||
sipHa1: null,
|
||||
cpsLimit: 10,
|
||||
concurrencyLimit: 30,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: new Prisma.Decimal('0.012'),
|
||||
status: 'ENABLED',
|
||||
forbiddenPeriods: [],
|
||||
codecs: [],
|
||||
prefixRules: [],
|
||||
blockedRegions: [{ regionScope: 'CITY', provinceCode: '340000', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
landingLineGroup: {
|
||||
findMany: async () => [
|
||||
{ id: 'lg_1', name: '默认线路组', status: 'ENABLED', items: [{ id: 'lgi_1', vendorGatewayId: 'vgw_1', priority: 1, weight: 1, concurrencyCap: 30, status: 'ENABLED' }] }
|
||||
]
|
||||
},
|
||||
geoCity: {
|
||||
findMany: async () => [
|
||||
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市', cityLevel: 'PREFECTURE', status: 'ENABLED' }
|
||||
]
|
||||
},
|
||||
phoneNumberSegment: {
|
||||
findMany: async () => [
|
||||
{ segment7: '1380013', cityCode: '340100', provinceName: '安徽省', cityName: '合肥市', carrier: 'MOBILE', city: { provinceCode: '340000' } }
|
||||
]
|
||||
},
|
||||
phoneAreaCode: {
|
||||
findMany: async () => [
|
||||
{ areaCode: '0551', cityCode: '340100', provinceName: '安徽省', cityName: '合肥市', city: { provinceCode: '340000' } }
|
||||
]
|
||||
},
|
||||
carrierPrefixRule: {
|
||||
findMany: async () => [
|
||||
{ prefix: '138', carrier: 'MOBILE', priority: 100 }
|
||||
]
|
||||
}
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe('S42 config publisher number library snapshot', () => {
|
||||
it('publishes number library lookup keys and gateway blocked-region sets', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const now = new Date('2026-06-24T03:20:00.000Z');
|
||||
|
||||
const result = await publishPendingConfig(prismaFixture(), redis as never, now);
|
||||
const version = `${now.getTime()}`;
|
||||
|
||||
expect(result.published).toBe(true);
|
||||
expect(result.manifest).toMatchObject({
|
||||
cityCount: 1,
|
||||
phoneSegmentCount: 1,
|
||||
areaCodeCount: 1,
|
||||
carrierPrefixRuleCount: 1,
|
||||
blockedRegionCount: 1
|
||||
});
|
||||
expect(redis.writes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:phone_segment:1380013` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:area_code:0551` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:carrier_prefix:138` }),
|
||||
expect.objectContaining({ command: 'sadd', key: `cfg:v:${version}:vendor_gateway:vgw_1:blocked_city_codes`, value: '340100' })
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@ export interface ConfigManifest {
|
||||
policyCount: number;
|
||||
vendorGatewayCount: number;
|
||||
lineGroupCount: number;
|
||||
cityCount: number;
|
||||
phoneSegmentCount: number;
|
||||
areaCodeCount: number;
|
||||
carrierPrefixRuleCount: number;
|
||||
blockedRegionCount: number;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
@@ -74,6 +79,13 @@ type ConfigSnapshot = {
|
||||
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
|
||||
codecs: Array<{ codec: string; priority: number }>;
|
||||
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: string; priority: number }>;
|
||||
blockedRegions: Array<{
|
||||
regionScope: string;
|
||||
provinceCode: string | null;
|
||||
provinceName: string | null;
|
||||
cityCode: string | null;
|
||||
cityName: string | null;
|
||||
}>;
|
||||
}>;
|
||||
lineGroups: Array<{
|
||||
id: string;
|
||||
@@ -88,12 +100,43 @@ type ConfigSnapshot = {
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
cities: Array<{
|
||||
code: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
cityLevel: string;
|
||||
status: string;
|
||||
}>;
|
||||
phoneSegments: Array<{
|
||||
segment7: string;
|
||||
cityCode: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
carrier: string;
|
||||
numberType: 'MOBILE';
|
||||
}>;
|
||||
areaCodes: Array<{
|
||||
areaCode: string;
|
||||
cityCode: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
numberType: 'LANDLINE';
|
||||
}>;
|
||||
carrierPrefixRules: Array<{
|
||||
prefix: string;
|
||||
carrier: string;
|
||||
priority: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function publishPendingConfig(prisma: PrismaClient, redis: RedisClient, now = new Date()): Promise<PublishConfigResult> {
|
||||
const pending = await prisma.outboxEvent.findMany({
|
||||
where: {
|
||||
aggregateType: { in: ['customer_gateway_config', 'vendor_gateway_config', 'line_group_config'] },
|
||||
aggregateType: { in: ['customer_gateway_config', 'vendor_gateway_config', 'line_group_config', 'number_library_config'] },
|
||||
status: 'PENDING',
|
||||
availableAt: { lte: now }
|
||||
},
|
||||
@@ -174,7 +217,7 @@ export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolled
|
||||
}
|
||||
|
||||
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
const [customers, gateways, policies, vendorGateways, lineGroups] = await prisma.$transaction([
|
||||
const [customers, gateways, policies, vendorGateways, lineGroups, cities, phoneSegments, areaCodes, carrierPrefixRules] = await prisma.$transaction([
|
||||
prisma.customer.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }]
|
||||
@@ -193,7 +236,8 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
include: {
|
||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||
codecs: { orderBy: [{ priority: 'asc' }] },
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
|
||||
blockedRegions: { orderBy: [{ regionScope: 'asc' }, { provinceCode: 'asc' }, { cityCode: 'asc' }] }
|
||||
}
|
||||
}),
|
||||
prisma.landingLineGroup.findMany({
|
||||
@@ -202,6 +246,24 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
include: {
|
||||
items: { orderBy: [{ priority: 'asc' }] }
|
||||
}
|
||||
}),
|
||||
prisma.geoCity.findMany({
|
||||
where: { deletedAt: null, status: 'ENABLED' },
|
||||
orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }]
|
||||
}),
|
||||
prisma.phoneNumberSegment.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ segment7: 'asc' }],
|
||||
include: { city: { select: { provinceCode: true } } }
|
||||
}),
|
||||
prisma.phoneAreaCode.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ areaCode: 'asc' }],
|
||||
include: { city: { select: { provinceCode: true } } }
|
||||
}),
|
||||
prisma.carrierPrefixRule.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ prefix: 'desc' }, { priority: 'asc' }]
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -263,6 +325,13 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
matchPrefix: rule.matchPrefix,
|
||||
replacePrefix: rule.replacePrefix,
|
||||
priority: rule.priority
|
||||
})),
|
||||
blockedRegions: gateway.blockedRegions.map((region) => ({
|
||||
regionScope: region.regionScope,
|
||||
provinceCode: region.provinceCode,
|
||||
provinceName: region.provinceName,
|
||||
cityCode: region.cityCode,
|
||||
cityName: region.cityName
|
||||
}))
|
||||
})),
|
||||
lineGroups: lineGroups.map((group) => ({
|
||||
@@ -277,6 +346,37 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
concurrencyCap: item.concurrencyCap,
|
||||
status: item.status
|
||||
}))
|
||||
})),
|
||||
cities: cities.map((city) => ({
|
||||
code: city.code,
|
||||
provinceCode: city.provinceCode,
|
||||
provinceName: city.provinceName,
|
||||
cityCode: city.cityCode,
|
||||
cityName: city.cityName,
|
||||
cityLevel: city.cityLevel,
|
||||
status: city.status
|
||||
})),
|
||||
phoneSegments: phoneSegments.map((segment) => ({
|
||||
segment7: segment.segment7,
|
||||
cityCode: segment.cityCode,
|
||||
provinceCode: segment.city.provinceCode,
|
||||
provinceName: segment.provinceName,
|
||||
cityName: segment.cityName,
|
||||
carrier: segment.carrier,
|
||||
numberType: 'MOBILE'
|
||||
})),
|
||||
areaCodes: areaCodes.map((areaCode) => ({
|
||||
areaCode: areaCode.areaCode,
|
||||
cityCode: areaCode.cityCode,
|
||||
provinceCode: areaCode.city.provinceCode,
|
||||
provinceName: areaCode.provinceName,
|
||||
cityName: areaCode.cityName,
|
||||
numberType: 'LANDLINE'
|
||||
})),
|
||||
carrierPrefixRules: carrierPrefixRules.map((rule) => ({
|
||||
prefix: rule.prefix,
|
||||
carrier: rule.carrier,
|
||||
priority: rule.priority
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -298,6 +398,11 @@ async function writeSnapshot(
|
||||
policyCount: snapshot.policies.length,
|
||||
vendorGatewayCount: snapshot.vendorGateways.length,
|
||||
lineGroupCount: snapshot.lineGroups.length,
|
||||
cityCount: snapshot.cities.length,
|
||||
phoneSegmentCount: snapshot.phoneSegments.length,
|
||||
areaCodeCount: snapshot.areaCodes.length,
|
||||
carrierPrefixRuleCount: snapshot.carrierPrefixRules.length,
|
||||
blockedRegionCount: snapshot.vendorGateways.reduce((sum, gateway) => sum + gateway.blockedRegions.length, 0),
|
||||
checksum
|
||||
};
|
||||
|
||||
@@ -321,6 +426,14 @@ async function writeSnapshot(
|
||||
}
|
||||
for (const gateway of snapshot.vendorGateways) {
|
||||
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
|
||||
for (const region of gateway.blockedRegions) {
|
||||
if (region.regionScope === 'CITY' && region.cityCode) {
|
||||
multi.sadd(`${prefix}:vendor_gateway:${gateway.id}:blocked_city_codes`, region.cityCode);
|
||||
}
|
||||
if (region.regionScope === 'PROVINCE' && region.provinceCode) {
|
||||
multi.sadd(`${prefix}:vendor_gateway:${gateway.id}:blocked_province_codes`, region.provinceCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const lineGroup of snapshot.lineGroups) {
|
||||
multi.set(`${prefix}:line_group:${lineGroup.id}`, JSON.stringify(lineGroup));
|
||||
@@ -328,6 +441,18 @@ async function writeSnapshot(
|
||||
multi.rpush(`${prefix}:line_group:${lineGroup.id}:items`, JSON.stringify(item));
|
||||
}
|
||||
}
|
||||
for (const city of snapshot.cities) {
|
||||
multi.set(`${prefix}:geo_city:${city.cityCode}`, JSON.stringify(city));
|
||||
}
|
||||
for (const segment of snapshot.phoneSegments) {
|
||||
multi.set(`${prefix}:phone_segment:${segment.segment7}`, JSON.stringify(segment));
|
||||
}
|
||||
for (const areaCode of snapshot.areaCodes) {
|
||||
multi.set(`${prefix}:area_code:${areaCode.areaCode}`, JSON.stringify(areaCode));
|
||||
}
|
||||
for (const rule of snapshot.carrierPrefixRules) {
|
||||
multi.set(`${prefix}:carrier_prefix:${rule.prefix}`, JSON.stringify(rule));
|
||||
}
|
||||
|
||||
if (previousVersion) {
|
||||
multi.set(CONFIG_PREVIOUS_VERSION_KEY, previousVersion);
|
||||
|
||||
Reference in New Issue
Block a user