|
|
|
@@ -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;
|
|
|
|
|
}
|