Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import { PrismaClient } from '@prisma/client';
import { createLogger } from '@lisglosips/observability';
import { createRedisClient } from '@lisglosips/redis';
import { publishPendingConfig } from './publisher.js';
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-config-publisher';
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
const prisma = new PrismaClient();
const redis = createRedisClient(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
let stopping = false;
async function tick(): Promise<void> {
const result = await publishPendingConfig(prisma, redis);
if (result.published) {
logger.info(
{
version: result.version,
previousVersion: result.previousVersion,
outboxCount: result.outboxIds.length,
manifest: result.manifest
},
'Config snapshot published'
);
}
}
async function loop(): Promise<void> {
await redis.connect();
logger.info('Config publisher started');
while (!stopping) {
try {
await tick();
} catch (error) {
logger.error({ error }, 'Config publisher tick failed');
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
async function shutdown(): Promise<void> {
stopping = true;
logger.info('Config publisher stopping');
await redis.quit();
await prisma.$disconnect();
}
process.on('SIGTERM', () => {
void shutdown().finally(() => process.exit(0));
});
process.on('SIGINT', () => {
void shutdown().finally(() => process.exit(0));
});
void loop();
@@ -0,0 +1,357 @@
import crypto from 'node:crypto';
import type { PrismaClient } from '@prisma/client';
import {
CONFIG_ACTIVE_VERSION_KEY,
CONFIG_PREVIOUS_VERSION_KEY,
configVersionManifestKey,
configVersionPrefix,
type RedisClient
} from '@lisglosips/redis';
export interface PublishConfigResult {
published: boolean;
version?: string;
previousVersion?: string | null;
outboxIds: string[];
manifest?: ConfigManifest;
}
export interface ConfigManifest {
version: string;
generatedAt: string;
customerCount: number;
gatewayCount: number;
policyCount: number;
vendorGatewayCount: number;
lineGroupCount: number;
checksum: string;
}
type ConfigSnapshot = {
customers: Array<{
id: string;
status: string;
balance: string;
creditLimit: string;
minBalance: string;
}>;
gateways: Array<{
id: string;
customerId: string;
authMode: string;
sourceIp: string | null;
sipUsername: string | null;
sipDomain: string | null;
sipHa1: string | null;
status: string;
}>;
policies: Array<{
id: string;
customerId: string;
gatewayId: string;
lineGroupId: string;
priority: number;
callerMode: string;
callerValue: string | null;
calleeMode: string;
calleeValue: string | null;
status: string;
}>;
vendorGateways: Array<{
id: string;
vendorId: string;
authMode: string;
host: string;
port: number;
transport: string;
sipUsername: string | null;
sipHa1: string | null;
cpsLimit: number;
concurrencyLimit: number;
billingCycleSec: number;
cycleRate: string;
status: string;
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
codecs: Array<{ codec: string; priority: number }>;
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: string; priority: number }>;
}>;
lineGroups: Array<{
id: string;
name: string;
status: string;
items: Array<{
id: string;
vendorGatewayId: string;
priority: number;
weight: number;
concurrencyCap: number;
status: string;
}>;
}>;
};
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'] },
status: 'PENDING',
availableAt: { lte: now }
},
orderBy: [{ createdAt: 'asc' }],
take: 100
});
if (pending.length === 0) {
return { published: false, outboxIds: [] };
}
const lockedAt = now;
const locked = await prisma.outboxEvent.updateMany({
where: {
id: { in: pending.map((event) => event.id) },
status: 'PENDING'
},
data: {
status: 'PROCESSING',
lockedAt,
attempts: { increment: 1 }
}
});
if (locked.count === 0) {
return { published: false, outboxIds: [] };
}
const processingIds = pending.map((event) => event.id);
try {
const snapshot = await loadSnapshot(prisma);
const version = `${now.getTime()}`;
const previousVersion = await redis.get(CONFIG_ACTIVE_VERSION_KEY);
const manifest = await writeSnapshot(redis, version, snapshot, previousVersion, now);
await prisma.outboxEvent.updateMany({
where: { id: { in: processingIds }, status: 'PROCESSING' },
data: {
status: 'PUBLISHED',
processedAt: new Date(),
lastError: null
}
});
return {
published: true,
version,
previousVersion,
outboxIds: processingIds,
manifest
};
} catch (error) {
await prisma.outboxEvent.updateMany({
where: { id: { in: processingIds }, status: 'PROCESSING' },
data: {
status: 'FAILED',
lastError: error instanceof Error ? error.message.slice(0, 1000) : 'Unknown publish error'
}
});
throw error;
}
}
export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolledBack: boolean; activeVersion: string | null; previousVersion: string | null }> {
const activeVersion = await redis.get(CONFIG_ACTIVE_VERSION_KEY);
const previousVersion = await redis.get(CONFIG_PREVIOUS_VERSION_KEY);
if (!previousVersion) {
return { rolledBack: false, activeVersion, previousVersion };
}
await redis
.multi()
.set(CONFIG_ACTIVE_VERSION_KEY, previousVersion)
.set(CONFIG_PREVIOUS_VERSION_KEY, activeVersion ?? '')
.exec();
return { rolledBack: true, activeVersion: previousVersion, previousVersion: activeVersion };
}
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
const [customers, gateways, policies, vendorGateways, lineGroups] = await prisma.$transaction([
prisma.customer.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }]
}),
prisma.customerGateway.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }]
}),
prisma.customerGatewayPolicy.findMany({
where: { deletedAt: null },
orderBy: [{ gatewayId: 'asc' }, { priority: 'asc' }]
}),
prisma.vendorGateway.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }],
include: {
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
codecs: { orderBy: [{ priority: 'asc' }] },
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
}
}),
prisma.landingLineGroup.findMany({
where: { deletedAt: null },
orderBy: [{ id: 'asc' }],
include: {
items: { orderBy: [{ priority: 'asc' }] }
}
})
]);
return {
customers: customers.map((customer) => ({
id: customer.id,
status: customer.status,
balance: customer.balance.toFixed(6),
creditLimit: customer.creditLimit.toFixed(6),
minBalance: customer.minBalance.toFixed(6)
})),
gateways: gateways.map((gateway) => ({
id: gateway.id,
customerId: gateway.customerId,
authMode: gateway.authMode,
sourceIp: gateway.sourceIp,
sipUsername: gateway.sipUsername,
sipDomain: gateway.sipDomain,
sipHa1: gateway.sipHa1,
status: gateway.status
})),
policies: policies.map((policy) => ({
id: policy.id,
customerId: policy.customerId,
gatewayId: policy.gatewayId,
lineGroupId: policy.lineGroupId,
priority: policy.priority,
callerMode: policy.callerMode,
callerValue: policy.callerValue,
calleeMode: policy.calleeMode,
calleeValue: policy.calleeValue,
status: policy.status
})),
vendorGateways: vendorGateways.map((gateway) => ({
id: gateway.id,
vendorId: gateway.vendorId,
authMode: gateway.authMode,
host: gateway.host,
port: gateway.port,
transport: gateway.transport,
sipUsername: gateway.sipUsername,
sipHa1: gateway.sipHa1,
cpsLimit: gateway.cpsLimit,
concurrencyLimit: gateway.concurrencyLimit,
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
status: gateway.status,
forbiddenPeriods: gateway.forbiddenPeriods.map((period) => ({
weekdayMask: period.weekdayMask,
startTime: period.startTime,
endTime: period.endTime
})),
codecs: gateway.codecs.map((codec) => ({
codec: codec.codec,
priority: codec.priority
})),
prefixRules: gateway.prefixRules.map((rule) => ({
direction: rule.direction,
matchPrefix: rule.matchPrefix,
replacePrefix: rule.replacePrefix,
priority: rule.priority
}))
})),
lineGroups: lineGroups.map((group) => ({
id: group.id,
name: group.name,
status: group.status,
items: group.items.map((item) => ({
id: item.id,
vendorGatewayId: item.vendorGatewayId,
priority: item.priority,
weight: item.weight,
concurrencyCap: item.concurrencyCap,
status: item.status
}))
}))
};
}
async function writeSnapshot(
redis: RedisClient,
version: string,
snapshot: ConfigSnapshot,
previousVersion: string | null,
now: Date
): Promise<ConfigManifest> {
const prefix = configVersionPrefix(version);
const checksum = crypto.createHash('sha256').update(stableJson(snapshot)).digest('hex');
const manifest: ConfigManifest = {
version,
generatedAt: now.toISOString(),
customerCount: snapshot.customers.length,
gatewayCount: snapshot.gateways.length,
policyCount: snapshot.policies.length,
vendorGatewayCount: snapshot.vendorGateways.length,
lineGroupCount: snapshot.lineGroups.length,
checksum
};
const multi = redis.multi();
multi.set(configVersionManifestKey(version), JSON.stringify(manifest));
for (const customer of snapshot.customers) {
multi.set(`${prefix}:customer:${customer.id}`, JSON.stringify(customer));
}
for (const gateway of snapshot.gateways) {
multi.set(`${prefix}:customer_gateway:${gateway.id}`, JSON.stringify(gateway));
if ((gateway.authMode === 'IP' || gateway.authMode === 'MIXED') && gateway.sourceIp) {
multi.set(`${prefix}:auth:ip:${gateway.sourceIp}`, gateway.id);
}
if ((gateway.authMode === 'SIP_DIGEST' || gateway.authMode === 'MIXED') && gateway.sipUsername && gateway.sipDomain) {
multi.set(`${prefix}:auth:sip:${gateway.sipUsername}@${gateway.sipDomain}`, gateway.id);
}
}
for (const policy of snapshot.policies) {
multi.rpush(`${prefix}:customer_gateway:${policy.gatewayId}:policies`, JSON.stringify(policy));
}
for (const gateway of snapshot.vendorGateways) {
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
}
for (const lineGroup of snapshot.lineGroups) {
multi.set(`${prefix}:line_group:${lineGroup.id}`, JSON.stringify(lineGroup));
for (const item of lineGroup.items) {
multi.rpush(`${prefix}:line_group:${lineGroup.id}:items`, JSON.stringify(item));
}
}
if (previousVersion) {
multi.set(CONFIG_PREVIOUS_VERSION_KEY, previousVersion);
}
multi.set(CONFIG_ACTIVE_VERSION_KEY, version);
await multi.exec();
return manifest;
}
function stableJson(value: unknown): string {
return JSON.stringify(value, Object.keys(flattenKeys(value)).sort());
}
function flattenKeys(value: unknown, keys: Record<string, true> = {}): Record<string, true> {
if (Array.isArray(value)) {
for (const item of value) {
flattenKeys(item, keys);
}
} else if (value && typeof value === 'object') {
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
keys[key] = true;
flattenKeys(nested, keys);
}
}
return keys;
}