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
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@lisglosips/worker-cdr",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "cross-env LISGLOSIPS_SERVICE_NAME=worker-cdr tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js"
},
"dependencies": {
"@lisglosips/database": "workspace:*",
"@lisglosips/observability": "workspace:*",
"@lisglosips/redis": "workspace:*"
},
"devDependencies": {
"cross-env": "10.1.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Prisma } from '@lisglosips/database';
export interface CycleRateInput {
durationSec: number;
billingCycleSec: number;
cycleRate: Prisma.Decimal | string | number;
}
export interface CycleCharge {
billSec: number;
cycles: number;
amount: Prisma.Decimal;
}
export function calculateCycleCharge(input: CycleRateInput): CycleCharge {
if (!Number.isInteger(input.durationSec) || input.durationSec < 0) {
throw new Error(`Invalid durationSec: ${input.durationSec}`);
}
if (!Number.isInteger(input.billingCycleSec) || input.billingCycleSec <= 0 || input.billingCycleSec > 60) {
throw new Error(`Invalid billingCycleSec: ${input.billingCycleSec}`);
}
const cycles = input.durationSec === 0 ? 0 : Math.ceil(input.durationSec / input.billingCycleSec);
const billSec = cycles * input.billingCycleSec;
const amount = new Prisma.Decimal(input.cycleRate).mul(cycles).toDecimalPlaces(6);
return {
billSec,
cycles,
amount
};
}
export function isBillableSipCode(sipCode: number): boolean {
return sipCode >= 200 && sipCode < 300;
}
+113
View File
@@ -0,0 +1,113 @@
import { createLogger } from '@lisglosips/observability';
import { PrismaClient } from '@lisglosips/database';
import {
CDR_CONSUMER_GROUP,
CDR_STREAM,
CdrRetryableError,
createRedisClient,
ensureCdrConsumerGroup,
processCdrBatch,
processPendingCdrBatch,
type CdrRedisCommands,
type CdrStreamHandler
} from '@lisglosips/redis';
import { CdrRatingService, PrismaCdrRatingStore } from './rating.js';
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-cdr';
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
const redisUrl = process.env.REDIS_URL;
const consumer = process.env.CDR_CONSUMER_NAME ?? `${serviceName}-${process.pid}`;
const blockMs = Number.parseInt(process.env.CDR_BLOCK_MS ?? '5000', 10);
const batchSize = Number.parseInt(process.env.CDR_BATCH_SIZE ?? '50', 10);
const pendingIdleMs = Number.parseInt(process.env.CDR_PENDING_IDLE_MS ?? '60000', 10);
let shuttingDown = false;
async function main(): Promise<void> {
if (!redisUrl) {
throw new Error('REDIS_URL is required for worker-cdr');
}
const redis = createRedisClient(redisUrl);
const prisma = new PrismaClient();
const ratingService = new CdrRatingService(new PrismaCdrRatingStore(prisma));
const handler: CdrStreamHandler = async (event, redisId) => {
let result;
try {
result = await ratingService.rate(event);
} catch (error) {
if (isTransientDatabaseError(error)) {
throw new CdrRetryableError(error instanceof Error ? error.message : 'Transient database error');
}
throw error;
}
logger.info(
{
redisId,
eventId: event.event_id,
callId: event.call_id,
nodeId: event.node_id,
sipCode: event.sipCode,
configVersion: event.config_version,
rating: result
},
'CDR stream event rated'
);
return result.outcome === 'duplicate' ? 'duplicate' : 'processed';
};
await redis.connect();
await prisma.$connect();
const cdrRedis = redis as unknown as CdrRedisCommands;
await ensureCdrConsumerGroup(cdrRedis);
logger.info({ stream: CDR_STREAM, group: CDR_CONSUMER_GROUP, consumer }, 'CDR worker started');
while (!shuttingDown) {
const pending = await processPendingCdrBatch(cdrRedis, handler, {
consumer,
count: batchSize,
minIdleMs: pendingIdleMs
});
if (pending.processed || pending.duplicates || pending.deadlettered) {
logger.info(pending, 'CDR pending batch processed');
}
const fresh = await processCdrBatch(cdrRedis, handler, {
consumer,
count: batchSize,
blockMs
});
if (fresh.processed || fresh.duplicates || fresh.deadlettered) {
logger.info(fresh, 'CDR batch processed');
}
}
redis.disconnect();
await prisma.$disconnect();
}
process.on('SIGTERM', () => {
logger.info('CDR worker stopping');
shuttingDown = true;
});
process.on('SIGINT', () => {
logger.info('CDR worker stopping');
shuttingDown = true;
});
main().catch((error: unknown) => {
logger.error({ error }, 'CDR worker failed');
process.exit(1);
});
function isTransientDatabaseError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Can't reach database server") ||
message.includes('Timed out fetching a new connection') ||
message.includes('Connection terminated') ||
message.includes('ECONNREFUSED')
);
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import { Prisma } from '@lisglosips/database';
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
import { calculateCycleCharge } from './billing.js';
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, type CreateCustomerChargeInput, type CreateRatedInput } from './rating.js';
class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
rawByEventId = new Map<string, { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED' }>();
ratedByRawId = new Map<string, { id: string; rawCdrId: string; customerFee: Prisma.Decimal }>();
vendorGateway = { id: 'vgw_1', vendorId: 'ven_1', billingCycleSec: 6, cycleRate: new Prisma.Decimal('0.012000') };
customer = { id: 'cus_1', balance: new Prisma.Decimal('10.000000') };
charges: CreateCustomerChargeInput[] = [];
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
return operation(this);
}
async findRawByEventId(eventId: string) {
return this.rawByEventId.get(eventId) ?? null;
}
async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string) {
const raw = { id: rawCdrId, eventId: event.event_id, ratingStatus: 'UNRATED' as const };
this.rawByEventId.set(event.event_id, raw);
return raw;
}
async findRatedByRawCdrId(rawCdrId: string) {
return this.ratedByRawId.get(rawCdrId) ?? null;
}
async findVendorGateway() {
return this.vendorGateway;
}
async lockCustomer() {
return this.customer;
}
async createRated(input: CreateRatedInput) {
const rated = { id: input.id, rawCdrId: input.rawCdrId, customerFee: input.customerFee };
this.ratedByRawId.set(input.rawCdrId, rated);
return rated;
}
async markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void> {
for (const [eventId, raw] of this.rawByEventId) {
if (raw.id === rawCdrId) {
this.rawByEventId.set(eventId, { ...raw, ratingStatus: status });
}
}
}
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
this.charges.push(input);
}
async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise<void> {
this.customer.balance = balance;
}
}
function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEvent {
return {
schema_version: '1',
event_id: 'evt_1',
idempotency_key: 'call_1:2026-06-21T00:00:30.000Z',
call_id: 'call_1',
node_id: 'a1',
opensips_instance: 'opensips-a1',
ingress_a_ip: '100.90.90.90',
rtpengine_node: 'a1',
customer_id: 'cus_1',
customer_gateway_id: 'cgw_1',
customer_gateway_policy_id: 'cgp_1',
source_ip: '100.93.185.30',
caller: '1001',
callee: '13800138000',
vendor_id: 'ven_1',
vendor_gateway_id: 'vgw_1',
line_group_id: 'lg_1',
started_at: '2026-06-21T00:00:00.000Z',
answered_at: '2026-06-21T00:00:02.000Z',
ended_at: '2026-06-21T00:00:30.000Z',
duration_sec: '28',
sip_code: '200',
hangup_reason: 'NORMAL_CLEARING',
recording_key: '',
config_version: '1',
created_at: '2026-06-21T00:00:31.000Z',
durationSeconds: 28,
sipCode: 200,
...overrides
};
}
describe('S23 CDR rating', () => {
it('calculates Decimal cycle charges with ceiling billing seconds', () => {
const charge = calculateCycleCharge({ durationSec: 28, billingCycleSec: 6, cycleRate: '0.012000' });
expect(charge.cycles).toBe(5);
expect(charge.billSec).toBe(30);
expect(charge.amount.toFixed(6)).toBe('0.060000');
});
it('rates a successful CDR and deducts customer balance once', async () => {
const store = new MemoryStore();
const service = new CdrRatingService(store);
const result = await service.rate(event());
const duplicate = await service.rate(event());
expect(result).toMatchObject({
outcome: 'rated',
customerFee: '0.060000',
vendorCost: '0.060000',
grossProfit: '0.000000',
billSec: 30
});
expect(duplicate.outcome).toBe('duplicate');
expect(store.customer.balance.toFixed(6)).toBe('9.940000');
expect(store.charges).toHaveLength(1);
expect(store.charges[0]?.amount.toFixed(6)).toBe('-0.060000');
});
it('skips failed or zero-duration CDRs without balance changes', async () => {
const store = new MemoryStore();
const service = new CdrRatingService(store);
const result = await service.rate(event({ event_id: 'evt_failed', sip_code: '503', sipCode: 503, duration_sec: '0', durationSeconds: 0 }));
expect(result.outcome).toBe('skipped');
expect(result.customerFee).toBe('0.000000');
expect(store.customer.balance.toFixed(6)).toBe('10.000000');
expect(store.charges).toHaveLength(0);
});
});
+380
View File
@@ -0,0 +1,380 @@
import crypto from 'node:crypto';
import { Prisma, PrismaClient } from '@lisglosips/database';
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
import { calculateCycleCharge, isBillableSipCode } from './billing.js';
export type RatingOutcome = 'rated' | 'skipped' | 'duplicate' | 'failed';
export interface RatingResult {
outcome: RatingOutcome;
rawCdrId: string;
ratedCdrId?: string;
customerFee: string;
vendorCost: string;
grossProfit: string;
billSec: number;
}
interface StoredRawCdr {
id: string;
eventId: string;
ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED';
}
interface StoredRatedCdr {
id: string;
rawCdrId: string;
}
interface StoredVendorGateway {
id: string;
vendorId: string;
billingCycleSec: number;
cycleRate: Prisma.Decimal;
}
interface LockedCustomer {
id: string;
balance: Prisma.Decimal;
}
export interface CdrRatingTransaction {
findRawByEventId(eventId: string): Promise<StoredRawCdr | null>;
createRaw(event: ParsedCdrStreamEvent, rawCdrId: string): Promise<StoredRawCdr>;
findRatedByRawCdrId(rawCdrId: string): Promise<StoredRatedCdr | null>;
findVendorGateway(vendorGatewayId: string): Promise<StoredVendorGateway | null>;
lockCustomer(customerId: string): Promise<LockedCustomer | null>;
createRated(input: CreateRatedInput): Promise<StoredRatedCdr>;
markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void>;
createCustomerCharge(input: CreateCustomerChargeInput): Promise<void>;
updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void>;
}
export interface CdrRatingStore {
transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T>;
}
export interface CreateRatedInput {
id: string;
rawCdrId: string;
billSec: number;
customerFee: Prisma.Decimal;
vendorCost: Prisma.Decimal;
grossProfit: Prisma.Decimal;
customerRate: Prisma.InputJsonValue;
vendorRate: Prisma.InputJsonValue;
}
export interface CreateCustomerChargeInput {
id: string;
customerId: string;
rawCdrId: string;
eventId: string;
amount: Prisma.Decimal;
beforeBalance: Prisma.Decimal;
afterBalance: Prisma.Decimal;
}
export class CdrRatingService {
constructor(private readonly store: CdrRatingStore) {}
async rate(event: ParsedCdrStreamEvent): Promise<RatingResult> {
return this.store.transaction(async (tx) => {
const existing = await tx.findRawByEventId(event.event_id);
if (existing) {
const rated = await tx.findRatedByRawCdrId(existing.id);
return {
outcome: 'duplicate',
rawCdrId: existing.id,
ratedCdrId: rated?.id,
customerFee: '0.000000',
vendorCost: '0.000000',
grossProfit: '0.000000',
billSec: 0
};
}
const raw = await tx.createRaw(event, prefixedId('raw'));
if (!isBillableSipCode(event.sipCode) || event.durationSeconds <= 0) {
await tx.markRawStatus(raw.id, 'SKIPPED');
return zeroResult('skipped', raw.id);
}
if (!billableId(event.customer_id) || !billableId(event.vendor_gateway_id)) {
await tx.markRawStatus(raw.id, 'SKIPPED');
return zeroResult('skipped', raw.id);
}
const vendorGateway = await tx.findVendorGateway(event.vendor_gateway_id);
const customer = await tx.lockCustomer(event.customer_id);
if (!vendorGateway || !customer) {
await tx.markRawStatus(raw.id, 'FAILED');
return zeroResult('failed', raw.id);
}
const vendorCharge = calculateCycleCharge({
durationSec: event.durationSeconds,
billingCycleSec: vendorGateway.billingCycleSec,
cycleRate: vendorGateway.cycleRate
});
const customerFee = vendorCharge.amount;
const vendorCost = vendorCharge.amount;
const grossProfit = customerFee.minus(vendorCost).toDecimalPlaces(6);
const afterBalance = customer.balance.minus(customerFee).toDecimalPlaces(6);
const ratedId = prefixedId('rated');
await tx.createRated({
id: ratedId,
rawCdrId: raw.id,
billSec: vendorCharge.billSec,
customerFee,
vendorCost,
grossProfit,
customerRate: {
source: 'S23_MINIMAL_MIRROR_VENDOR_RATE',
billingCycleSec: vendorGateway.billingCycleSec,
cycleRate: vendorGateway.cycleRate.toFixed(6)
},
vendorRate: {
source: 'vendor_gateway',
vendorGatewayId: vendorGateway.id,
billingCycleSec: vendorGateway.billingCycleSec,
cycleRate: vendorGateway.cycleRate.toFixed(6)
}
});
await tx.createCustomerCharge({
id: prefixedId('cdrchg'),
customerId: customer.id,
rawCdrId: raw.id,
eventId: event.event_id,
amount: customerFee.negated().toDecimalPlaces(6),
beforeBalance: customer.balance,
afterBalance
});
await tx.updateCustomerBalance(customer.id, afterBalance);
await tx.markRawStatus(raw.id, 'RATED');
return {
outcome: 'rated',
rawCdrId: raw.id,
ratedCdrId: ratedId,
customerFee: customerFee.toFixed(6),
vendorCost: vendorCost.toFixed(6),
grossProfit: grossProfit.toFixed(6),
billSec: vendorCharge.billSec
};
});
}
}
export class PrismaCdrRatingStore implements CdrRatingStore {
constructor(private readonly prisma: PrismaClient) {}
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
return this.prisma.$transaction(async (tx) => operation(new PrismaCdrRatingTransaction(tx)), {
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
timeout: 30000
});
}
}
class PrismaCdrRatingTransaction implements CdrRatingTransaction {
constructor(private readonly tx: Prisma.TransactionClient) {}
async findRawByEventId(eventId: string): Promise<StoredRawCdr | null> {
return this.tx.rawCdr.findUnique({
where: { eventId: storageEventId(eventId) },
select: { id: true, eventId: true, ratingStatus: true }
});
}
async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string): Promise<StoredRawCdr> {
return this.tx.rawCdr.create({
data: {
id: rawCdrId,
eventId: storageEventId(event.event_id),
callId: event.call_id,
customerId: nullableId(event.customer_id),
customerGatewayId: nullableId(event.customer_gateway_id),
customerGatewayPolicyId: nullableId(event.customer_gateway_policy_id),
sourceIp: emptyToNull(event.source_ip),
caller: event.caller || 'unknown',
callee: event.callee || 'unknown',
vendorId: nullableId(event.vendor_id),
vendorGatewayId: nullableId(event.vendor_gateway_id),
lineGroupId: nullableId(event.line_group_id),
startedAt: parseCdrDate(event.started_at, event.created_at),
answeredAt: parseOptionalCdrDate(event.answered_at),
endedAt: parseCdrDate(event.ended_at, event.created_at),
durationSec: event.durationSeconds,
sipCode: event.sipCode,
hangupReason: emptyToNull(event.hangup_reason),
recordingKey: emptyToNull(event.recording_key),
configVersion: parseOptionalInt(event.config_version),
ratingStatus: 'UNRATED',
payload: eventToJson(event)
},
select: { id: true, eventId: true, ratingStatus: true }
});
}
async findRatedByRawCdrId(rawCdrId: string): Promise<StoredRatedCdr | null> {
return this.tx.ratedCdr.findUnique({
where: { rawCdrId },
select: { id: true, rawCdrId: true }
});
}
async findVendorGateway(vendorGatewayId: string): Promise<StoredVendorGateway | null> {
return this.tx.vendorGateway.findUnique({
where: { id: vendorGatewayId },
select: { id: true, vendorId: true, billingCycleSec: true, cycleRate: true }
});
}
async lockCustomer(customerId: string): Promise<LockedCustomer | null> {
const [customer] = await this.tx.$queryRaw<LockedCustomer[]>`SELECT id, balance FROM customers WHERE id = ${customerId} AND deleted_at IS NULL FOR UPDATE`;
return customer ?? null;
}
async createRated(input: CreateRatedInput): Promise<StoredRatedCdr> {
return this.tx.ratedCdr.create({
data: input,
select: { id: true, rawCdrId: true }
});
}
async markRawStatus(rawCdrId: string, status: 'RATED' | 'SKIPPED' | 'FAILED'): Promise<void> {
await this.tx.rawCdr.update({
where: { id: rawCdrId },
data: { ratingStatus: status }
});
}
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
await this.tx.customerRecharge.create({
data: {
id: input.id,
customerId: input.customerId,
amount: input.amount,
beforeBalance: input.beforeBalance,
afterBalance: input.afterBalance,
idempotencyKey: `cdr:${input.eventId}`,
remark: `CDR_CHARGE:${input.rawCdrId}`,
status: 'SUCCEEDED',
createdBy: 'worker-cdr'
}
});
}
async updateCustomerBalance(customerId: string, balance: Prisma.Decimal): Promise<void> {
await this.tx.customer.update({
where: { id: customerId },
data: {
balance,
updatedBy: 'worker-cdr',
version: { increment: 1 }
}
});
}
}
function storageEventId(eventId: string): string {
if (eventId.length <= 64) {
return eventId;
}
const hash = crypto.createHash('sha256').update(eventId).digest('hex').slice(0, 16);
return `${eventId.slice(0, 47)}-${hash}`;
}
function zeroResult(outcome: RatingOutcome, rawCdrId: string): RatingResult {
return {
outcome,
rawCdrId,
customerFee: '0.000000',
vendorCost: '0.000000',
grossProfit: '0.000000',
billSec: 0
};
}
function prefixedId(prefix: string): string {
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32)}`;
}
function billableId(value: string): boolean {
return Boolean(nullableId(value));
}
function nullableId(value: string): string | null {
const normalized = value.trim();
if (!normalized || normalized === 'none' || normalized === 'unknown' || normalized === 'no_active_version') {
return null;
}
return normalized;
}
function emptyToNull(value: string): string | null {
const normalized = value.trim();
return !normalized || normalized === 'none' ? null : normalized;
}
function parseOptionalInt(value: string): number | null {
if (!value || value === 'none') {
return null;
}
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : null;
}
function parseOptionalCdrDate(value: string): Date | null {
if (!value || value === 'none') {
return null;
}
return parseCdrDate(value, value);
}
function parseCdrDate(value: string, fallback: string): Date {
const candidate = value && value !== 'none' ? value : fallback;
if (/^\d+$/.test(candidate)) {
return new Date(Number.parseInt(candidate, 10) * 1000);
}
const parsed = new Date(candidate);
if (Number.isNaN(parsed.getTime())) {
return new Date();
}
return parsed;
}
function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
return {
schema_version: event.schema_version,
event_id: event.event_id,
idempotency_key: event.idempotency_key,
call_id: event.call_id,
node_id: event.node_id,
opensips_instance: event.opensips_instance,
ingress_a_ip: event.ingress_a_ip,
rtpengine_node: event.rtpengine_node,
source_ip: event.source_ip,
caller: event.caller,
callee: event.callee,
customer_id: event.customer_id,
customer_gateway_id: event.customer_gateway_id,
customer_gateway_policy_id: event.customer_gateway_policy_id,
vendor_id: event.vendor_id,
vendor_gateway_id: event.vendor_gateway_id,
line_group_id: event.line_group_id,
started_at: event.started_at,
answered_at: event.answered_at,
ended_at: event.ended_at,
duration_sec: event.duration_sec,
sip_code: event.sip_code,
hangup_reason: event.hangup_reason,
recording_key: event.recording_key,
config_version: event.config_version,
created_at: event.created_at
};
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"references": [
{ "path": "../../packages/database" },
{ "path": "../../packages/observability" },
{ "path": "../../packages/redis" }
],
"include": ["src/**/*.ts"]
}