feat: add number library routing and cdr location support
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Inject, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { RequirePermissions } from '../security/security.metadata.js';
|
||||
import { ActiveCallsService } from './active-calls.service.js';
|
||||
|
||||
@ApiTags('active-calls')
|
||||
@Controller('active-calls')
|
||||
export class ActiveCallsController {
|
||||
constructor(@Inject(ActiveCallsService) private readonly activeCallsService: ActiveCallsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('active_calls.view')
|
||||
list() {
|
||||
return this.activeCallsService.list();
|
||||
}
|
||||
|
||||
@Post(':id/hangup')
|
||||
@RequirePermissions('active_calls.manage')
|
||||
@AuditAction({ module: 'active_calls', action: 'hangup', objectType: 'dialog', objectIdParam: 'id' })
|
||||
hangup(@Param('id') id: string) {
|
||||
return this.activeCallsService.hangup(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActiveCallsController } from './active-calls.controller.js';
|
||||
import { ActiveCallsService } from './active-calls.service.js';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ActiveCallsController],
|
||||
providers: [ActiveCallsService, OpenSipsMiClient]
|
||||
})
|
||||
export class ActiveCallsModule {}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BadGatewayException, BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ActiveCallsService, normalizeDialogs } from './active-calls.service.js';
|
||||
import type { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
describe('active calls service', () => {
|
||||
it('normalizes OpenSIPS dialog list payloads', () => {
|
||||
const calls = normalizeDialogs(
|
||||
{
|
||||
Dialogs: [
|
||||
{
|
||||
ID: '6ae.4b38d013',
|
||||
callid: 'call-1@lisglosips-t',
|
||||
from_tag: 'from-a',
|
||||
to_tag: 'to-b',
|
||||
from_uri: 'sip:1001@s21.lisglosips.test',
|
||||
to_uri: 'sip:2001@s21.lisglosips.test',
|
||||
state: 'confirmed',
|
||||
start_time: '1782100000'
|
||||
}
|
||||
]
|
||||
},
|
||||
new Date('2026-06-22T08:00:10.000Z')
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({
|
||||
id: '6ae.4b38d013',
|
||||
callId: 'call-1@lisglosips-t',
|
||||
fromTag: 'from-a',
|
||||
toTag: 'to-b',
|
||||
caller: 'sip:1001@s21.lisglosips.test',
|
||||
callee: 'sip:2001@s21.lisglosips.test',
|
||||
state: 'confirmed'
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts caller and landing IPs from dialog contacts and SDP', () => {
|
||||
const calls = normalizeDialogs({
|
||||
Dialogs: [
|
||||
{
|
||||
ID: 'fd4.a9a0c987',
|
||||
callid: 'call-2@lisglosips-t',
|
||||
from_uri: 'sip:s28-ip-1001@s28.customer.local',
|
||||
to_uri: 'sip:13800138000@100.90.90.90:15060',
|
||||
caller_contact: 'sip:s28-ip-1001@100.93.185.30:47200',
|
||||
caller_sdp: 'v=0\r\nc=IN IP4 100.93.185.30\r\n',
|
||||
CALLEES: [
|
||||
{
|
||||
callee_sdp: 'v=0\r\nc=IN IP4 192.0.2.80\r\n'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
callerIp: '100.93.185.30',
|
||||
landingIp: '192.0.2.80'
|
||||
});
|
||||
});
|
||||
|
||||
it('requests hangup for safe dialog identifiers', async () => {
|
||||
const miClient = {
|
||||
endDialog: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OpenSipsMiClient;
|
||||
const service = new ActiveCallsService(miClient);
|
||||
|
||||
await expect(service.hangup('call-1%40lisglosips-t')).resolves.toMatchObject({
|
||||
dialogId: 'call-1@lisglosips-t',
|
||||
status: 'HANGUP_REQUESTED'
|
||||
});
|
||||
expect(miClient.endDialog).toHaveBeenCalledWith('call-1@lisglosips-t');
|
||||
});
|
||||
|
||||
it('accepts OpenSIPS operation-failed hangup when the dialog is terminating', async () => {
|
||||
const miClient = {
|
||||
endDialog: vi.fn().mockRejectedValue(
|
||||
new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
message: 'Operation failed',
|
||||
detail: { code: 500, message: 'Operation failed' }
|
||||
})
|
||||
),
|
||||
listDialogs: vi.fn().mockResolvedValue({
|
||||
Dialogs: [{ ID: '6ae.4b38d013', callid: 'call-1@lisglosips-t', state: 5 }]
|
||||
})
|
||||
} as unknown as OpenSipsMiClient;
|
||||
const service = new ActiveCallsService(miClient);
|
||||
|
||||
await expect(service.hangup('6ae.4b38d013')).resolves.toMatchObject({
|
||||
dialogId: '6ae.4b38d013',
|
||||
status: 'HANGUP_IN_PROGRESS',
|
||||
state: '5'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe dialog identifiers', async () => {
|
||||
const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient);
|
||||
|
||||
await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
export interface ActiveCallSummary {
|
||||
id: string;
|
||||
callId: string;
|
||||
fromTag: string | null;
|
||||
toTag: string | null;
|
||||
caller: string | null;
|
||||
callee: string | null;
|
||||
state: string | null;
|
||||
startedAt: string | null;
|
||||
durationSec: number | null;
|
||||
lifetimeSec: number | null;
|
||||
callerIp: string | null;
|
||||
landingIp: string | null;
|
||||
callerContact: string | null;
|
||||
calleeContact: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/;
|
||||
|
||||
@Injectable()
|
||||
export class ActiveCallsService {
|
||||
constructor(@Inject(OpenSipsMiClient) private readonly miClient: OpenSipsMiClient) {}
|
||||
|
||||
async list(now = new Date()) {
|
||||
const result = await this.miClient.listDialogs();
|
||||
const calls = normalizeDialogs(result, now);
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
source: 'opensips-mi',
|
||||
total: calls.length,
|
||||
items: calls
|
||||
};
|
||||
}
|
||||
|
||||
async hangup(dialogId: string) {
|
||||
const normalized = this.dialogId(dialogId);
|
||||
try {
|
||||
const result = await this.miClient.endDialog(normalized);
|
||||
return {
|
||||
dialogId: normalized,
|
||||
status: 'HANGUP_REQUESTED',
|
||||
source: 'opensips-mi',
|
||||
result
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isOpenSipsOperationFailed(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sleep(800);
|
||||
const calls = normalizeDialogs(await this.miClient.listDialogs());
|
||||
const current = calls.find((call) => call.id === normalized || call.callId === normalized);
|
||||
if (!current) {
|
||||
return {
|
||||
dialogId: normalized,
|
||||
status: 'HANGUP_CONFIRMED',
|
||||
source: 'opensips-mi'
|
||||
};
|
||||
}
|
||||
if (current.state === '5') {
|
||||
return {
|
||||
dialogId: normalized,
|
||||
status: 'HANGUP_IN_PROGRESS',
|
||||
source: 'opensips-mi',
|
||||
state: current.state
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private dialogId(value: string): string {
|
||||
const decoded = decodeURIComponent(value).trim();
|
||||
if (!SAFE_DIALOG_ID.test(decoded)) {
|
||||
throw new BadRequestException({
|
||||
code: 'ACTIVE_CALL_ID_INVALID',
|
||||
message: 'Active call id is invalid.'
|
||||
});
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenSipsOperationFailed(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object' || !('getResponse' in error) || typeof error.getResponse !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const response = error.getResponse() as unknown;
|
||||
if (!response || typeof response !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = response as { code?: unknown; detail?: { code?: unknown; message?: unknown } };
|
||||
return record.code === 'ACTIVE_CALLS_MI_ERROR' && record.detail?.code === 500 && record.detail.message === 'Operation failed';
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function normalizeDialogs(result: unknown, now = new Date()): ActiveCallSummary[] {
|
||||
const records = collectDialogRecords(result);
|
||||
return records
|
||||
.map((record) => toSummary(record, now))
|
||||
.filter((call): call is ActiveCallSummary => call !== null)
|
||||
.sort((left, right) => (right.durationSec ?? 0) - (left.durationSec ?? 0));
|
||||
}
|
||||
|
||||
function collectDialogRecords(value: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => collectDialogRecords(item));
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (looksLikeDialog(value)) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
return Object.values(value).flatMap((item) => collectDialogRecords(item));
|
||||
}
|
||||
|
||||
function looksLikeDialog(record: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(record).map((key) => key.toLowerCase().replaceAll(/[^a-z0-9]/g, ''));
|
||||
return keys.some((key) => key === 'id' || key === 'dialogid' || key === 'dlgdid' || key === 'did') && keys.some((key) => key === 'callid');
|
||||
}
|
||||
|
||||
function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummary | null {
|
||||
const callId = stringField(record, ['callid', 'call_id', 'Call-ID', 'call-id']);
|
||||
const id = stringField(record, ['ID', 'id', 'dialog_id', 'dialogId', 'dlg_id', 'dlgId', 'DID', 'did']) ?? callId;
|
||||
if (!id || !callId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdRaw = stringField(record, ['start_time', 'startTime', 'created', 'creation_time', 'init_ts', 'timestamp']);
|
||||
const startedAt = normalizeTime(createdRaw);
|
||||
const lifetimeSec = numberField(record, ['lifetime', 'duration', 'timeout']);
|
||||
const durationSec = startedAt ? Math.max(0, Math.floor((now.getTime() - new Date(startedAt).getTime()) / 1000)) : lifetimeSec;
|
||||
const calleeRecord = firstRecordField(record, ['CALLEES', 'callees', 'callee']);
|
||||
const callerContact = stringField(record, ['caller_contact', 'from_contact', 'fromContact']);
|
||||
const calleeContact =
|
||||
stringField(record, ['callee_contact', 'to_contact', 'toContact']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_contact', 'to_contact', 'toContact']) : null);
|
||||
const callerSdp = stringField(record, ['caller_sdp', 'callerSdp', 'from_sdp', 'sdp']);
|
||||
const calleeSdp =
|
||||
stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null);
|
||||
|
||||
return {
|
||||
id,
|
||||
callId,
|
||||
fromTag: stringField(record, ['from_tag', 'fromtag', 'fromTag']),
|
||||
toTag: stringField(record, ['to_tag', 'totag', 'toTag']),
|
||||
caller: stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri']),
|
||||
callee: stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri']),
|
||||
state: stringField(record, ['state', 'status']),
|
||||
startedAt,
|
||||
durationSec,
|
||||
lifetimeSec,
|
||||
callerIp: firstNonNull([
|
||||
hostFromSipAddress(callerContact),
|
||||
ipFromSdp(callerSdp),
|
||||
ipFromSipAddress(stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri'])),
|
||||
ipFromSipAddress(stringField(record, ['caller_bind_addr', 'from_bind_addr']))
|
||||
]),
|
||||
landingIp: firstNonNull([
|
||||
hostFromSipAddress(calleeContact),
|
||||
ipFromSdp(calleeSdp),
|
||||
ipFromSipAddress(stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri'])),
|
||||
ipFromSipAddress(stringField(record, ['callee_bind_addr', 'to_bind_addr']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_bind_addr', 'to_bind_addr']) : null))
|
||||
]),
|
||||
callerContact,
|
||||
calleeContact,
|
||||
raw: record
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function numberField(record: Record<string, unknown>, names: string[]): number | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN;
|
||||
if (Number.isSafeInteger(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCaseInsensitive(record: Record<string, unknown>, name: string): unknown {
|
||||
const wanted = normalizeKey(name);
|
||||
const found = Object.entries(record).find(([key]) => normalizeKey(key) === wanted);
|
||||
return found?.[1];
|
||||
}
|
||||
|
||||
function firstRecordField(record: Record<string, unknown>, names: string[]): Record<string, unknown> | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
if (isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const found = value.find(isRecord);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonNull(values: Array<string | null>): string | null {
|
||||
return values.find((value): value is string => !!value) ?? null;
|
||||
}
|
||||
|
||||
function hostFromSipAddress(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.match(/^(?:sips?:)?(?:[^@;\s]+@)?\[?([A-Fa-f0-9:.]+)\]?/);
|
||||
return match ? normalizeIp(match[1]) : null;
|
||||
}
|
||||
|
||||
function ipFromSipAddress(value: string | null): string | null {
|
||||
return normalizeIp(hostFromSipAddress(value));
|
||||
}
|
||||
|
||||
function ipFromSdp(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.match(/^c=IN IP[46] ([^\r\n\s]+)/m);
|
||||
return match ? normalizeIp(match[1]) : null;
|
||||
}
|
||||
|
||||
function normalizeIp(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim().replace(/^\[/, '').replace(/\]$/, '');
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed) || /^[A-Fa-f0-9:]+$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function normalizeTime(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
const seconds = Number.parseInt(value, 10);
|
||||
if (Number.isSafeInteger(seconds) && seconds > 0) {
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BadGatewayException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
|
||||
export interface MiResponse<T = unknown> {
|
||||
jsonrpc?: string;
|
||||
result?: T;
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
id?: number | string | null;
|
||||
}
|
||||
|
||||
export interface CommandExecutor {
|
||||
run(args: string[], timeoutMs: number): Promise<string>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SshCommandExecutor implements CommandExecutor {
|
||||
run(args: string[], timeoutMs: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`ssh command timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(stdout).toString('utf8'));
|
||||
return;
|
||||
}
|
||||
reject(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpenSipsMiClient {
|
||||
private readonly executor: CommandExecutor = new SshCommandExecutor();
|
||||
|
||||
constructor(private readonly configService: ConfigService<RuntimeConfig, true>) {}
|
||||
|
||||
async listDialogs(): Promise<unknown> {
|
||||
return this.call('dlg_list');
|
||||
}
|
||||
|
||||
async endDialog(dialogId: string): Promise<unknown> {
|
||||
return this.call('dlg_end_dlg', [dialogId]);
|
||||
}
|
||||
|
||||
private async call(method: string, params: string[] = []): Promise<unknown> {
|
||||
const config = this.configService.get('activeCalls', { infer: true });
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args = [];
|
||||
if (config.sshConfig) {
|
||||
args.push('-F', config.sshConfig);
|
||||
}
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
|
||||
let output: string;
|
||||
try {
|
||||
output = await this.executor.run(args, config.timeoutMs);
|
||||
} catch (error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
|
||||
message: 'OpenSIPS control plane is unavailable.',
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
|
||||
let response: MiResponse;
|
||||
try {
|
||||
response = JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
message: response.error.message ?? 'OpenSIPS MI command failed.',
|
||||
detail: response.error
|
||||
});
|
||||
}
|
||||
|
||||
return response.result;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { LOG_REDACT_PATHS } from '@lisglosips/observability';
|
||||
import { appConfig, validationSchema } from '../shared/config.js';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
||||
import { AuditModule } from './audit/audit.module.js';
|
||||
import { ActiveCallsModule } from './active-calls/active-calls.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { CdrsModule } from './cdrs/cdrs.module.js';
|
||||
import { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js';
|
||||
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
|
||||
import { CustomersModule } from './customers/customers.module.js';
|
||||
@@ -15,6 +17,7 @@ import { DashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { HealthModule } from './health/health.module.js';
|
||||
import { LandingLineGroupsModule } from './landing-line-groups/landing-line-groups.module.js';
|
||||
import { NumberLibraryModule } from './number-library/number-library.module.js';
|
||||
import { RechargesModule } from './recharges/recharges.module.js';
|
||||
import { RecordingsModule } from './recordings/recordings.module.js';
|
||||
import { QualityModule } from './quality/quality.module.js';
|
||||
@@ -55,6 +58,8 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
||||
SecurityModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
ActiveCallsModule,
|
||||
CdrsModule,
|
||||
DashboardModule,
|
||||
CustomersModule,
|
||||
CustomerGatewaysModule,
|
||||
@@ -63,6 +68,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
||||
VendorsModule,
|
||||
VendorGatewaysModule,
|
||||
LandingLineGroupsModule,
|
||||
NumberLibraryModule,
|
||||
QualityModule,
|
||||
RecordingsModule,
|
||||
UsersModule,
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { Body, Controller, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Public } from '../security/security.metadata.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { CaptchaService } from './captcha.service.js';
|
||||
import { parseCookie, serializeCookie } from './cookie.js';
|
||||
|
||||
interface LoginBody {
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
captchaId?: unknown;
|
||||
captchaCode?: unknown;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
@Inject(AuthService) private readonly authService: AuthService,
|
||||
@Inject(CaptchaService) private readonly captchaService: CaptchaService
|
||||
) {}
|
||||
|
||||
@Get('captcha')
|
||||
@ApiOperation({ summary: 'Create a one-time image captcha for login' })
|
||||
captcha() {
|
||||
const challenge = this.captchaService.createChallenge();
|
||||
|
||||
return {
|
||||
captchaId: challenge.id,
|
||||
imageDataUrl: challenge.imageDataUrl,
|
||||
expiresAt: challenge.expiresAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with username and password' })
|
||||
async login(@Body() body: LoginBody, @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
const { username, password } = this.readCredentials(body);
|
||||
const { username, password, captchaId, captchaCode } = this.readCredentials(body);
|
||||
if (!this.captchaService.verify(captchaId, captchaCode)) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'AUTH_CAPTCHA_INVALID',
|
||||
message: 'Captcha verification failed.'
|
||||
});
|
||||
}
|
||||
const result = await this.authService.login(username, password, this.requestContext(request));
|
||||
|
||||
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
|
||||
@@ -56,8 +80,17 @@ export class AuthController {
|
||||
);
|
||||
}
|
||||
|
||||
private readCredentials(body: LoginBody): { username: string; password: string } {
|
||||
if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) {
|
||||
private readCredentials(body: LoginBody): { username: string; password: string; captchaId: string; captchaCode: string } {
|
||||
if (
|
||||
typeof body.username !== 'string' ||
|
||||
typeof body.password !== 'string' ||
|
||||
typeof body.captchaId !== 'string' ||
|
||||
typeof body.captchaCode !== 'string' ||
|
||||
!body.username.trim() ||
|
||||
!body.password ||
|
||||
!body.captchaId ||
|
||||
!body.captchaCode.trim()
|
||||
) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'AUTH_INVALID_CREDENTIALS',
|
||||
message: 'Invalid username or password.'
|
||||
@@ -66,7 +99,9 @@ export class AuthController {
|
||||
|
||||
return {
|
||||
username: body.username,
|
||||
password: body.password
|
||||
password: body.password,
|
||||
captchaId: body.captchaId,
|
||||
captchaCode: body.captchaCode
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth';
|
||||
import { CaptchaService } from './captcha.service.js';
|
||||
import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js';
|
||||
|
||||
class E2eAuthRepository implements AuthRepository {
|
||||
@@ -77,6 +78,7 @@ class E2eAuthRepository implements AuthRepository {
|
||||
describe('LisgloSIPS Auth API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let repo: E2eAuthRepository;
|
||||
let captchaService: CaptchaService;
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -101,6 +103,7 @@ describe('LisgloSIPS Auth API', () => {
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
captchaService = app.get(CaptchaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -108,9 +111,20 @@ describe('LisgloSIPS Auth API', () => {
|
||||
});
|
||||
|
||||
it('logs in, refreshes with cookie rotation, and logs out', async () => {
|
||||
const login = await request(app.getHttpServer())
|
||||
const publicCaptcha = await request(app.getHttpServer()).get('/api/v2/auth/captcha').expect(200);
|
||||
expect(publicCaptcha.body.captchaId).toBeTypeOf('string');
|
||||
expect(publicCaptcha.body.imageDataUrl).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
expect(publicCaptcha.body.answer).toBeUndefined();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/auth/login')
|
||||
.send({ username: 'operator', password: secret })
|
||||
.expect(401);
|
||||
|
||||
const captcha = captchaService.createChallenge();
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/v2/auth/login')
|
||||
.send({ username: 'operator', password: secret, captchaId: captcha.id, captchaCode: captcha.answer })
|
||||
.expect(200);
|
||||
const loginCookie = login.headers['set-cookie'][0];
|
||||
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { PrismaAuthRepository } from './auth.repository.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { CaptchaService } from './captcha.service.js';
|
||||
import { AUTH_REPOSITORY } from './auth.types.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
CaptchaService,
|
||||
PrismaAuthRepository,
|
||||
{
|
||||
provide: AUTH_REPOSITORY,
|
||||
useExisting: PrismaAuthRepository
|
||||
}
|
||||
],
|
||||
exports: [AuthService]
|
||||
exports: [AuthService, CaptchaService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -77,16 +77,19 @@ class MemoryAuthRepository implements AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function config() {
|
||||
function config(overrides: Record<string, unknown> = {}) {
|
||||
const values = new Map<string, unknown>([
|
||||
['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'],
|
||||
['auth.accessTokenTtlSeconds', 900],
|
||||
['auth.refreshTokenTtlDays', 7],
|
||||
['auth.lockMaxFailures', 3],
|
||||
['auth.lockWindowSeconds', 60],
|
||||
['auth.loginThrottleMaxFailures', 10],
|
||||
['auth.loginThrottleWindowSeconds', 300],
|
||||
['auth.tokenIssuer', 'lisglosips-api'],
|
||||
['auth.tokenAudience', 'lisglosips-web']
|
||||
]);
|
||||
Object.entries(overrides).forEach(([key, value]) => values.set(key, value));
|
||||
|
||||
return {
|
||||
get: (key: string) => values.get(key)
|
||||
@@ -124,6 +127,15 @@ describe('AuthService', () => {
|
||||
await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('throttles repeated login attempts even when the username is unknown', async () => {
|
||||
service = new AuthService(repo, config({ 'auth.loginThrottleMaxFailures': 3 }) as never);
|
||||
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it('rotates refresh sessions and rejects reuse', async () => {
|
||||
const login = await service.login('operator', secret);
|
||||
const refresh = await service.refresh(login.tokens.refreshToken);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ACCESS_TOKEN_TYPE,
|
||||
@@ -36,8 +36,15 @@ export interface AuthResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginThrottleRecord {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly loginThrottle = new Map<string, LoginThrottleRecord>();
|
||||
|
||||
constructor(
|
||||
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
|
||||
@Inject(ConfigService) private readonly config: ConfigService<RuntimeConfig, true>
|
||||
@@ -45,23 +52,30 @@ export class AuthService {
|
||||
|
||||
async login(username: string, password: string, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> {
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
const user = await this.repository.findUserByUsername(normalizedUsername);
|
||||
const now = new Date();
|
||||
|
||||
this.assertLoginAllowed(normalizedUsername, context.ip, now);
|
||||
|
||||
const user = await this.repository.findUserByUsername(normalizedUsername);
|
||||
|
||||
if (!user) {
|
||||
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) {
|
||||
await this.registerFailure(user, now);
|
||||
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
if (!(await verifyPasswordArgon2id(password, user.passwordHash))) {
|
||||
await this.registerFailure(user, now);
|
||||
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
this.clearLoginThrottleForUser(normalizedUsername);
|
||||
await this.repository.markLoginSuccess(user.id, context.ip);
|
||||
const tokens = await this.issueTokens(user, context);
|
||||
|
||||
@@ -175,6 +189,53 @@ export class AuthService {
|
||||
return Boolean(user.lockedUntil && user.lockedUntil > now);
|
||||
}
|
||||
|
||||
private assertLoginAllowed(username: string, ip: string | undefined, now: Date): void {
|
||||
const maxFailures = this.config.get('auth.loginThrottleMaxFailures', { infer: true });
|
||||
const nowMs = now.getTime();
|
||||
|
||||
for (const key of this.throttleKeys(username, ip)) {
|
||||
const record = this.loginThrottle.get(key);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (record.resetAt <= nowMs) {
|
||||
this.loginThrottle.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (record.count >= maxFailures) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: 'AUTH_LOGIN_THROTTLED',
|
||||
message: 'Too many login attempts. Please try again later.'
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerLoginThrottleFailure(username: string, ip: string | undefined, now: Date): void {
|
||||
const windowSeconds = this.config.get('auth.loginThrottleWindowSeconds', { infer: true });
|
||||
const resetAt = now.getTime() + windowSeconds * 1000;
|
||||
|
||||
for (const key of this.throttleKeys(username, ip)) {
|
||||
const record = this.loginThrottle.get(key);
|
||||
if (!record || record.resetAt <= now.getTime()) {
|
||||
this.loginThrottle.set(key, { count: 1, resetAt });
|
||||
} else {
|
||||
this.loginThrottle.set(key, { count: record.count + 1, resetAt: record.resetAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearLoginThrottleForUser(username: string): void {
|
||||
this.loginThrottle.delete(`user:${username}`);
|
||||
}
|
||||
|
||||
private throttleKeys(username: string, ip: string | undefined): string[] {
|
||||
return [`user:${username}`, `ip:${ip || 'unknown'}`];
|
||||
}
|
||||
|
||||
private invalidCredentials(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'AUTH_INVALID_CREDENTIALS',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export interface CaptchaChallenge {
|
||||
id: string;
|
||||
answer: string;
|
||||
imageDataUrl: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
interface StoredCaptcha {
|
||||
answer: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const CAPTCHA_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const CAPTCHA_LENGTH = 5;
|
||||
const CAPTCHA_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
private readonly challenges = new Map<string, StoredCaptcha>();
|
||||
|
||||
createChallenge(now = new Date()): CaptchaChallenge {
|
||||
this.cleanup(now.getTime());
|
||||
const id = crypto.randomUUID();
|
||||
const answer = this.randomAnswer();
|
||||
const expiresAt = new Date(now.getTime() + CAPTCHA_TTL_MS);
|
||||
|
||||
this.challenges.set(id, {
|
||||
answer,
|
||||
expiresAt: expiresAt.getTime()
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
answer,
|
||||
expiresAt,
|
||||
imageDataUrl: this.renderSvgDataUrl(answer, id)
|
||||
};
|
||||
}
|
||||
|
||||
verify(id: string, answer: string, now = new Date()): boolean {
|
||||
this.cleanup(now.getTime());
|
||||
const challenge = this.challenges.get(id);
|
||||
this.challenges.delete(id);
|
||||
|
||||
if (!challenge || challenge.expiresAt <= now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = answer.trim().toUpperCase();
|
||||
return normalized.length === challenge.answer.length && crypto.timingSafeEqual(Buffer.from(normalized), Buffer.from(challenge.answer));
|
||||
}
|
||||
|
||||
private randomAnswer(): string {
|
||||
let answer = '';
|
||||
for (let index = 0; index < CAPTCHA_LENGTH; index += 1) {
|
||||
answer += CAPTCHA_ALPHABET[crypto.randomInt(0, CAPTCHA_ALPHABET.length)];
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
private renderSvgDataUrl(answer: string, id: string): string {
|
||||
const shortId = id.replaceAll('-', '').slice(0, 8);
|
||||
const noise = Array.from({ length: 8 }, (_, index) => {
|
||||
const x1 = crypto.randomInt(0, 132);
|
||||
const y1 = crypto.randomInt(8, 42);
|
||||
const x2 = crypto.randomInt(0, 132);
|
||||
const y2 = crypto.randomInt(8, 42);
|
||||
const opacity = index % 2 === 0 ? '0.24' : '0.16';
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#2563eb" stroke-width="1.3" opacity="${opacity}" />`;
|
||||
}).join('');
|
||||
const chars = answer.split('').map((char, index) => {
|
||||
const x = 18 + index * 21;
|
||||
const y = 32 + crypto.randomInt(-3, 4);
|
||||
const rotate = crypto.randomInt(-12, 13);
|
||||
return `<text x="${x}" y="${y}" transform="rotate(${rotate} ${x} ${y})">${char}</text>`;
|
||||
}).join('');
|
||||
const dots = Array.from({ length: 20 }, () => {
|
||||
const cx = crypto.randomInt(4, 128);
|
||||
const cy = crypto.randomInt(5, 43);
|
||||
return `<circle cx="${cx}" cy="${cy}" r="1" fill="#94a3b8" opacity="0.55" />`;
|
||||
}).join('');
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="132" height="48" viewBox="0 0 132 48" role="img" aria-label="captcha ${shortId}">
|
||||
<rect width="132" height="48" rx="7" fill="#f8fafc"/>
|
||||
${noise}
|
||||
${dots}
|
||||
<g font-family="Inter, Arial, sans-serif" font-size="24" font-weight="800" letter-spacing="2" fill="#111827">${chars}</g>
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
}
|
||||
|
||||
private cleanup(nowMs: number): void {
|
||||
for (const [id, challenge] of this.challenges.entries()) {
|
||||
if (challenge.expiresAt <= nowMs) {
|
||||
this.challenges.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Inject, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermissions } from '../security/security.metadata.js';
|
||||
import { CdrsService } from './cdrs.service.js';
|
||||
|
||||
@ApiTags('cdrs')
|
||||
@Controller('cdrs')
|
||||
export class CdrsController {
|
||||
constructor(@Inject(CdrsService) private readonly cdrsService: CdrsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('cdr.view')
|
||||
list(@Query() query: Record<string, unknown>) {
|
||||
return this.cdrsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('cdr.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.cdrsService.get(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CdrsController } from './cdrs.controller.js';
|
||||
import { CdrsService } from './cdrs.service.js';
|
||||
import { CDRS_REPOSITORY, PrismaCdrsRepository } from './cdrs.repository.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CdrsController],
|
||||
providers: [
|
||||
CdrsService,
|
||||
PrismaCdrsRepository,
|
||||
{
|
||||
provide: CDRS_REPOSITORY,
|
||||
useExisting: PrismaCdrsRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class CdrsModule {}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type CdrCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
|
||||
|
||||
export interface CdrQuery {
|
||||
caller?: string;
|
||||
callee?: string;
|
||||
customerGatewayId?: string;
|
||||
vendorGatewayId?: string;
|
||||
cityCode?: string;
|
||||
carrier?: CdrCarrier;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface CdrListItem {
|
||||
id: string;
|
||||
eventId: string;
|
||||
callId: string;
|
||||
sourceIp: string | null;
|
||||
caller: string;
|
||||
callee: string;
|
||||
calleeCityCode: string | null;
|
||||
calleeCityName: string | null;
|
||||
calleeProvinceName: string | null;
|
||||
calleeOperator: CdrCarrier;
|
||||
calleeNumberType: string;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
customerGatewayId: string | null;
|
||||
customerGatewayName: string | null;
|
||||
vendorId: string | null;
|
||||
vendorName: string | null;
|
||||
vendorGatewayId: string | null;
|
||||
vendorGatewayName: string | null;
|
||||
vendorGatewayHost: string | null;
|
||||
vendorGatewayPort: number | null;
|
||||
lineGroupId: string | null;
|
||||
lineGroupName: string | null;
|
||||
startedAt: Date;
|
||||
answeredAt: Date | null;
|
||||
endedAt: Date;
|
||||
durationSec: number;
|
||||
sipCode: number;
|
||||
hangupReason: string | null;
|
||||
recordingKey: string | null;
|
||||
configVersion: number | null;
|
||||
ratingStatus: string;
|
||||
customerFee: string | null;
|
||||
vendorCost: string | null;
|
||||
grossProfit: string | null;
|
||||
billSec: number | null;
|
||||
}
|
||||
|
||||
export interface CdrsRepository {
|
||||
list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>;
|
||||
get(id: string): Promise<CdrListItem>;
|
||||
}
|
||||
|
||||
export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY');
|
||||
|
||||
type RawCdrRecord = Prisma.RawCdrGetPayload<{
|
||||
include: {
|
||||
customer: { select: { name: true } };
|
||||
customerGateway: { select: { name: true } };
|
||||
vendor: { select: { name: true } };
|
||||
vendorGateway: { select: { name: true; host: true; port: true } };
|
||||
lineGroup: { select: { name: true } };
|
||||
ratedCdr: { select: { customerFee: true; vendorCost: true; grossProfit: true; billSec: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCdrsRepository implements CdrsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> {
|
||||
const where = this.where(query);
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.rawCdr.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: 'desc' }],
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
include: this.includeCdr()
|
||||
}),
|
||||
this.prisma.rawCdr.count({ where })
|
||||
]);
|
||||
return { items: items.map((item) => this.toItem(item)), total };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CdrListItem> {
|
||||
const item = await this.prisma.rawCdr.findUnique({
|
||||
where: { id },
|
||||
include: this.includeCdr()
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' });
|
||||
}
|
||||
return this.toItem(item);
|
||||
}
|
||||
|
||||
private where(query: CdrQuery): Prisma.RawCdrWhereInput {
|
||||
return {
|
||||
caller: query.caller ? { contains: query.caller } : undefined,
|
||||
callee: query.callee ? { contains: query.callee } : undefined,
|
||||
customerGatewayId: query.customerGatewayId,
|
||||
vendorGatewayId: query.vendorGatewayId,
|
||||
calleeCityCode: query.cityCode,
|
||||
calleeOperator: query.carrier
|
||||
};
|
||||
}
|
||||
|
||||
private includeCdr() {
|
||||
return {
|
||||
customer: { select: { name: true } },
|
||||
customerGateway: { select: { name: true } },
|
||||
vendor: { select: { name: true } },
|
||||
vendorGateway: { select: { name: true, host: true, port: true } },
|
||||
lineGroup: { select: { name: true } },
|
||||
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } }
|
||||
} satisfies Prisma.RawCdrInclude;
|
||||
}
|
||||
|
||||
private toItem(item: RawCdrRecord): CdrListItem {
|
||||
return {
|
||||
id: item.id,
|
||||
eventId: item.eventId,
|
||||
callId: item.callId,
|
||||
sourceIp: item.sourceIp,
|
||||
caller: item.caller,
|
||||
callee: item.callee,
|
||||
calleeCityCode: item.calleeCityCode,
|
||||
calleeCityName: item.calleeCityName,
|
||||
calleeProvinceName: item.calleeProvinceName,
|
||||
calleeOperator: item.calleeOperator,
|
||||
calleeNumberType: item.calleeNumberType,
|
||||
customerId: item.customerId,
|
||||
customerName: item.customer?.name ?? null,
|
||||
customerGatewayId: item.customerGatewayId,
|
||||
customerGatewayName: item.customerGateway?.name ?? null,
|
||||
vendorId: item.vendorId,
|
||||
vendorName: item.vendor?.name ?? null,
|
||||
vendorGatewayId: item.vendorGatewayId,
|
||||
vendorGatewayName: item.vendorGateway?.name ?? null,
|
||||
vendorGatewayHost: item.vendorGateway?.host ?? null,
|
||||
vendorGatewayPort: item.vendorGateway?.port ?? null,
|
||||
lineGroupId: item.lineGroupId,
|
||||
lineGroupName: item.lineGroup?.name ?? null,
|
||||
startedAt: item.startedAt,
|
||||
answeredAt: item.answeredAt,
|
||||
endedAt: item.endedAt,
|
||||
durationSec: item.durationSec,
|
||||
sipCode: item.sipCode,
|
||||
hangupReason: item.hangupReason,
|
||||
recordingKey: item.recordingKey,
|
||||
configVersion: item.configVersion,
|
||||
ratingStatus: item.ratingStatus,
|
||||
customerFee: item.ratedCdr?.customerFee.toFixed(6) ?? null,
|
||||
vendorCost: item.ratedCdr?.vendorCost.toFixed(6) ?? null,
|
||||
grossProfit: item.ratedCdr?.grossProfit.toFixed(6) ?? null,
|
||||
billSec: item.ratedCdr?.billSec ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CdrsService } from './cdrs.service.js';
|
||||
import type { CdrListItem, CdrQuery, CdrsRepository } from './cdrs.repository.js';
|
||||
|
||||
class MemoryCdrsRepository implements CdrsRepository {
|
||||
lastQuery: CdrQuery | null = null;
|
||||
|
||||
async list(query: CdrQuery) {
|
||||
this.lastQuery = query;
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CdrListItem> {
|
||||
return {
|
||||
id,
|
||||
eventId: 'evt_1',
|
||||
callId: 'call_1',
|
||||
sourceIp: null,
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
calleeCityCode: '340100',
|
||||
calleeCityName: '合肥市',
|
||||
calleeProvinceName: '安徽省',
|
||||
calleeOperator: 'MOBILE',
|
||||
calleeNumberType: 'MOBILE',
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
customerGatewayId: null,
|
||||
customerGatewayName: null,
|
||||
vendorId: null,
|
||||
vendorName: null,
|
||||
vendorGatewayId: null,
|
||||
vendorGatewayName: null,
|
||||
vendorGatewayHost: null,
|
||||
vendorGatewayPort: null,
|
||||
lineGroupId: null,
|
||||
lineGroupName: null,
|
||||
startedAt: new Date(),
|
||||
answeredAt: null,
|
||||
endedAt: new Date(),
|
||||
durationSec: 0,
|
||||
sipCode: 503,
|
||||
hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED',
|
||||
recordingKey: null,
|
||||
configVersion: null,
|
||||
ratingStatus: 'SKIPPED',
|
||||
customerFee: null,
|
||||
vendorCost: null,
|
||||
grossProfit: null,
|
||||
billSec: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('CDR service', () => {
|
||||
it('normalizes filters for city and carrier queries', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' });
|
||||
|
||||
expect(repository.lastQuery).toMatchObject({
|
||||
caller: '1001',
|
||||
cityCode: '340100',
|
||||
carrier: 'MOBILE',
|
||||
take: 20,
|
||||
skip: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid carrier filters', async () => {
|
||||
const service = new CdrsService(new MemoryCdrsRepository());
|
||||
|
||||
expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { CDRS_REPOSITORY, type CdrCarrier, type CdrQuery, type CdrsRepository } from './cdrs.repository.js';
|
||||
|
||||
@Injectable()
|
||||
export class CdrsService {
|
||||
constructor(@Inject(CDRS_REPOSITORY) private readonly cdrs: CdrsRepository) {}
|
||||
|
||||
list(rawQuery: Record<string, unknown>) {
|
||||
const query: CdrQuery = {
|
||||
caller: this.optionalString(rawQuery.caller, 64),
|
||||
callee: this.optionalString(rawQuery.callee, 64),
|
||||
customerGatewayId: this.optionalString(rawQuery.customerGatewayId, 32),
|
||||
vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32),
|
||||
cityCode: this.optionalString(rawQuery.cityCode, 12),
|
||||
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
||||
take: this.int(rawQuery.take, 100, 1, 500),
|
||||
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
|
||||
};
|
||||
return this.cdrs.list(query);
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.cdrs.get(id);
|
||||
}
|
||||
|
||||
private optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private carrier(value: unknown): CdrCarrier {
|
||||
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
|
||||
return value;
|
||||
}
|
||||
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
|
||||
}
|
||||
|
||||
private int(value: unknown, defaultValue: number, min: number, max: number): number {
|
||||
if (value === undefined) return defaultValue;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
@@ -48,4 +48,11 @@ export class CustomerGatewaysController {
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'delete', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,13 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async softDelete(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const deleted = { ...current, status: 'DISABLED' as CustomerGatewayStatus, policyCount: 0 };
|
||||
this.gateways.delete(gatewayId);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
customerId?: string;
|
||||
@@ -269,5 +276,7 @@ describe('S13 customer gateways API', () => {
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/customer-gateways/cgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface CustomerGatewaysRepository {
|
||||
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||
softDelete(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||
}
|
||||
|
||||
export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY');
|
||||
@@ -168,6 +169,35 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
async softDelete(gatewayIdValue: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.customerGatewayPolicy.updateMany({
|
||||
where: { gatewayId: gatewayIdValue, deletedAt: null },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId
|
||||
}
|
||||
});
|
||||
const deleted = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'customer_gateway.deleted');
|
||||
return deleted;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
|
||||
@@ -103,6 +103,10 @@ export class CustomerGatewaysService {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.softDelete(gatewayId, actorId);
|
||||
}
|
||||
|
||||
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
|
||||
@@ -176,6 +176,7 @@ class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository {
|
||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||
policyCount: input.policyCount ?? 0,
|
||||
customerGatewayCount: input.customerGatewayCount ?? 0,
|
||||
items,
|
||||
createdAt: new Date('2026-06-21T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T07:00:00.000Z')
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface LineGroupSummary {
|
||||
concurrencyCapSum: number;
|
||||
gatewayConcurrencyLimitSum: number;
|
||||
policyCount: number;
|
||||
customerGatewayCount: number;
|
||||
items: LineGroupItemSummary[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -139,11 +140,14 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
return groups.map((group) => this.toSummary(group));
|
||||
const usageCounts = await this.customerGatewayUsageCounts(groups.map((group) => group.id));
|
||||
return groups.map((group) => this.toSummary(group, usageCounts.get(group.id) ?? 0));
|
||||
}
|
||||
|
||||
async get(lineGroupIdValue: string): Promise<LineGroupSummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue));
|
||||
const group = await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
|
||||
return this.toSummary(group, usageCounts.get(lineGroupIdValue) ?? 0);
|
||||
}
|
||||
|
||||
async create(input: CreateLineGroupInput): Promise<LineGroupSummary> {
|
||||
@@ -201,11 +205,10 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
|
||||
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const linkedPolicies = await this.prisma.customerGatewayPolicy.count({
|
||||
where: { lineGroupId: lineGroupIdValue, deletedAt: null }
|
||||
});
|
||||
if (linkedPolicies > 0) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' });
|
||||
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
|
||||
const linkedCustomerGateways = usageCounts.get(lineGroupIdValue) ?? 0;
|
||||
if (linkedCustomerGateways > 0) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active customer gateways cannot be deleted.' });
|
||||
}
|
||||
|
||||
const group = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -222,7 +225,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted');
|
||||
return deleted;
|
||||
});
|
||||
return this.toSummary(group);
|
||||
return this.toSummary(group, 0);
|
||||
}
|
||||
|
||||
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
||||
@@ -342,6 +345,27 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
return this.get(lineGroupIdValue);
|
||||
}
|
||||
|
||||
private async customerGatewayUsageCounts(lineGroupIds: string[]): Promise<Map<string, number>> {
|
||||
if (lineGroupIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await this.prisma.customerGatewayPolicy.groupBy({
|
||||
by: ['lineGroupId', 'gatewayId'],
|
||||
where: {
|
||||
lineGroupId: { in: lineGroupIds },
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (!row.lineGroupId) {
|
||||
continue;
|
||||
}
|
||||
counts.set(row.lineGroupId, (counts.get(row.lineGroupId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private includeItem() {
|
||||
return {
|
||||
vendorGateway: {
|
||||
@@ -427,7 +451,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(group: LineGroupRecord): LineGroupSummary {
|
||||
private toSummary(group: LineGroupRecord, customerGatewayCount = 0): LineGroupSummary {
|
||||
const items = group.items.map((item) => this.toItemSummary(item));
|
||||
const enabledItems = items.filter((item) => item.status === 'ENABLED');
|
||||
return {
|
||||
@@ -440,6 +464,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
|
||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||
policyCount: group._count.policies,
|
||||
customerGatewayCount,
|
||||
items,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: group.updatedAt
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Body, Controller, Get, Inject, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { NumberLibraryService } from './number-library.service.js';
|
||||
|
||||
@ApiTags('number-library')
|
||||
@Controller('number-library')
|
||||
export class NumberLibraryController {
|
||||
constructor(@Inject(NumberLibraryService) private readonly numberLibraryService: NumberLibraryService) {}
|
||||
|
||||
@Get('cities')
|
||||
@RequirePermissions('number_library.view')
|
||||
listCities(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listCities(query as never);
|
||||
}
|
||||
|
||||
@Post('cities/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_cities', objectType: 'geo_city' })
|
||||
importCities(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importCities(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('phone-segments')
|
||||
@RequirePermissions('number_library.view')
|
||||
listPhoneSegments(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listPhoneSegments(query as never);
|
||||
}
|
||||
|
||||
@Post('phone-segments/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_phone_segments', objectType: 'phone_number_segment' })
|
||||
importPhoneSegments(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importPhoneSegments(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('area-codes')
|
||||
@RequirePermissions('number_library.view')
|
||||
listAreaCodes(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listAreaCodes(query as never);
|
||||
}
|
||||
|
||||
@Post('area-codes/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_area_codes', objectType: 'phone_area_code' })
|
||||
importAreaCodes(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importAreaCodes(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('carrier-prefix-rules')
|
||||
@RequirePermissions('number_library.view')
|
||||
listCarrierPrefixRules(@Query() query: unknown) {
|
||||
return this.numberLibraryService.listCarrierPrefixRules(query as never);
|
||||
}
|
||||
|
||||
@Post('carrier-prefix-rules/import')
|
||||
@RequirePermissions('number_library.manage')
|
||||
@AuditAction({ module: 'number_library', action: 'import_carrier_prefix_rules', objectType: 'carrier_prefix_rule' })
|
||||
importCarrierPrefixRules(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.numberLibraryService.importCarrierPrefixRules(body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
NUMBER_LIBRARY_REPOSITORY,
|
||||
type AreaCodeInput,
|
||||
type CarrierPrefixInput,
|
||||
type CityInput,
|
||||
type ImportResult,
|
||||
type NumberCarrier,
|
||||
type NumberLibraryRepository,
|
||||
type PageQuery,
|
||||
type PhoneSegmentInput
|
||||
} from './number-library.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryNumberLibraryRepository implements NumberLibraryRepository {
|
||||
cities: CityInput[] = [];
|
||||
segments: PhoneSegmentInput[] = [];
|
||||
areaCodes: AreaCodeInput[] = [];
|
||||
carrierRules: CarrierPrefixInput[] = [];
|
||||
|
||||
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(this.cities.filter((city) => !query.keyword || city.cityName.includes(query.keyword) || city.provinceName.includes(query.keyword)), query);
|
||||
}
|
||||
|
||||
async importCities(items: CityInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.cities, items, (item) => item.code);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(
|
||||
this.segments.filter(
|
||||
(item) =>
|
||||
(!query.segment7 || item.segment7.startsWith(query.segment7)) &&
|
||||
(!query.cityCode || item.cityCode === query.cityCode) &&
|
||||
(!query.carrier || item.carrier === query.carrier)
|
||||
),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.segments, items, (item) => item.segment7);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(
|
||||
this.areaCodes.filter((item) => (!query.areaCode || item.areaCode.startsWith(query.areaCode)) && (!query.cityCode || item.cityCode === query.cityCode)),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.areaCodes, items, (item) => item.areaCode);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
return this.page(
|
||||
this.carrierRules.filter((item) => (!query.prefix || item.prefix.startsWith(query.prefix)) && (!query.carrier || item.carrier === query.carrier)),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
|
||||
this.upsert(this.carrierRules, items, (item) => item.prefix);
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
private upsert<T>(target: T[], items: T[], key: (item: T) => string): void {
|
||||
for (const item of items) {
|
||||
const index = target.findIndex((existing) => key(existing) === key(item));
|
||||
if (index >= 0) {
|
||||
target[index] = item;
|
||||
} else {
|
||||
target.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private page(items: unknown[], query: PageQuery): { items: unknown[]; total: number } {
|
||||
return { items: items.slice(query.skip, query.skip + query.take), total: items.length };
|
||||
}
|
||||
}
|
||||
|
||||
describe('number library API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['number_library.view', 'number_library.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['number_library.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(NUMBER_LIBRARY_REPOSITORY)
|
||||
.useValue(new MemoryNumberLibraryRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('rejects imports without number_library.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/cities/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ items: [] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('imports and lists cities, phone segments, area codes, and carrier rules', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/cities/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ code: '340100', provinceCode: '340000', provinceName: '安徽', cityCode: '340100', cityName: '合肥', cityLevel: '地级市' }] })
|
||||
.expect(201)
|
||||
.expect((response) => expect(response.body).toEqual({ imported: 1 }));
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/phone-segments/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ segment7: '1385510', cityCode: '340100', provinceName: '安徽', cityName: '合肥', carrier: 'MOBILE', batchId: 'batch_test' }] })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/area-codes/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ areaCode: '0551', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/carrier-prefix-rules/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ prefix: '138', carrier: 'MOBILE', priority: 10 }] })
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v2/number-library/phone-segments?segment7=138&carrier=MOBILE')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body.total).toBe(1);
|
||||
expect(response.body.items[0]).toMatchObject({ segment7: '1385510', cityName: '合肥', carrier: 'MOBILE' });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v2/number-library/area-codes?areaCode=0551')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200)
|
||||
.expect((response) => expect(response.body.items[0]).toMatchObject({ areaCode: '0551', cityName: '合肥' }));
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v2/number-library/carrier-prefix-rules?prefix=138')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200)
|
||||
.expect((response) => expect(response.body.items[0]).toMatchObject({ prefix: '138', carrier: 'MOBILE' }));
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'number_library' && entry.action === 'import_cities')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates import batch shape and segment digits', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/number-library/phone-segments/import')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ items: [{ segment7: '13855', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
|
||||
.expect(400)
|
||||
.expect((response) => expect(response.body).toMatchObject({ code: 'DIGITS_INVALID' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NumberLibraryController } from './number-library.controller.js';
|
||||
import { NUMBER_LIBRARY_REPOSITORY, PrismaNumberLibraryRepository } from './number-library.repository.js';
|
||||
import { NumberLibraryService } from './number-library.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [NumberLibraryController],
|
||||
providers: [
|
||||
NumberLibraryService,
|
||||
{
|
||||
provide: NUMBER_LIBRARY_REPOSITORY,
|
||||
useClass: PrismaNumberLibraryRepository
|
||||
}
|
||||
],
|
||||
exports: [NumberLibraryService]
|
||||
})
|
||||
export class NumberLibraryModule {}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type NumberCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
|
||||
|
||||
export interface PageQuery {
|
||||
keyword?: string;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface CityInput {
|
||||
code: string;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
cityLevel: string;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface PhoneSegmentInput {
|
||||
segment7: string;
|
||||
cityCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
carrier?: NumberCarrier;
|
||||
source?: string | null;
|
||||
batchId?: string | null;
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface AreaCodeInput {
|
||||
areaCode: string;
|
||||
cityCode: string;
|
||||
provinceName: string;
|
||||
cityName: string;
|
||||
source?: string | null;
|
||||
batchId?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CarrierPrefixInput {
|
||||
prefix: string;
|
||||
carrier: NumberCarrier;
|
||||
priority: number;
|
||||
source?: string | null;
|
||||
batchId?: string | null;
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
}
|
||||
|
||||
export const NUMBER_LIBRARY_REPOSITORY = Symbol('NUMBER_LIBRARY_REPOSITORY');
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
export interface NumberLibraryRepository {
|
||||
listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }>;
|
||||
importCities(items: CityInput[]): Promise<ImportResult>;
|
||||
listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
|
||||
importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult>;
|
||||
listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }>;
|
||||
importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult>;
|
||||
listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
|
||||
importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaNumberLibraryRepository implements NumberLibraryRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.GeoCityWhereInput = {
|
||||
deletedAt: null,
|
||||
OR: query.keyword
|
||||
? [
|
||||
{ provinceName: { contains: query.keyword } },
|
||||
{ cityName: { contains: query.keyword } },
|
||||
{ cityCode: { contains: query.keyword } },
|
||||
{ provinceCode: { contains: query.keyword } }
|
||||
]
|
||||
: undefined
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.geoCity.findMany({ where, orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.geoCity.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importCities(items: CityInput[]): Promise<ImportResult> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.geoCity.upsert({
|
||||
where: { code: item.code },
|
||||
update: {
|
||||
provinceCode: item.provinceCode,
|
||||
provinceName: item.provinceName,
|
||||
cityCode: item.cityCode,
|
||||
cityName: item.cityName,
|
||||
cityLevel: item.cityLevel,
|
||||
status: item.status ?? 'ENABLED',
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
code: item.code,
|
||||
provinceCode: item.provinceCode,
|
||||
provinceName: item.provinceName,
|
||||
cityCode: item.cityCode,
|
||||
cityName: item.cityName,
|
||||
cityLevel: item.cityLevel,
|
||||
status: item.status ?? 'ENABLED',
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'geo_city.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.PhoneNumberSegmentWhereInput = {
|
||||
deletedAt: null,
|
||||
segment7: query.segment7 ? { startsWith: query.segment7 } : undefined,
|
||||
cityCode: query.cityCode,
|
||||
carrier: query.carrier,
|
||||
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.phoneNumberSegment.findMany({ where, orderBy: [{ segment7: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.phoneNumberSegment.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
|
||||
await this.ensureCities(items.map((item) => item.cityCode));
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.phoneNumberSegment.upsert({
|
||||
where: { segment7: item.segment7 },
|
||||
update: {
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
carrier: item.carrier ?? 'UNKNOWN',
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
segment7: item.segment7,
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
carrier: item.carrier ?? 'UNKNOWN',
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'phone_number_segment.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.PhoneAreaCodeWhereInput = {
|
||||
deletedAt: null,
|
||||
areaCode: query.areaCode ? { startsWith: query.areaCode } : undefined,
|
||||
cityCode: query.cityCode,
|
||||
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.phoneAreaCode.findMany({ where, orderBy: [{ areaCode: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.phoneAreaCode.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
|
||||
await this.ensureCities(items.map((item) => item.cityCode));
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.phoneAreaCode.upsert({
|
||||
where: { areaCode: item.areaCode },
|
||||
update: {
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
areaCode: item.areaCode,
|
||||
cityCode: item.cityCode,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'phone_area_code.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
|
||||
const where: Prisma.CarrierPrefixRuleWhereInput = {
|
||||
deletedAt: null,
|
||||
prefix: query.prefix ? { startsWith: query.prefix } : undefined,
|
||||
carrier: query.carrier
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.carrierPrefixRule.findMany({ where, orderBy: [{ prefix: 'asc' }], take: query.take, skip: query.skip }),
|
||||
this.prisma.carrierPrefixRule.count({ where })
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
await tx.carrierPrefixRule.upsert({
|
||||
where: { prefix: item.prefix },
|
||||
update: {
|
||||
carrier: item.carrier,
|
||||
priority: item.priority,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
updatedBy: item.actorId,
|
||||
version: { increment: 1 },
|
||||
deletedAt: null
|
||||
},
|
||||
create: {
|
||||
prefix: item.prefix,
|
||||
carrier: item.carrier,
|
||||
priority: item.priority,
|
||||
source: item.source,
|
||||
batchId: item.batchId,
|
||||
effectiveFrom: item.effectiveFrom,
|
||||
effectiveTo: item.effectiveTo,
|
||||
createdBy: item.actorId,
|
||||
updatedBy: item.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, 'carrier_prefix_rule.changed', items.length);
|
||||
});
|
||||
return { imported: items.length };
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, eventType: string, itemCount: number): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'number_library_config',
|
||||
aggregateId: 'number-library',
|
||||
eventType,
|
||||
payload: { eventType, itemCount }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCities(cityCodes: string[]): Promise<void> {
|
||||
const uniqueCodes = [...new Set(cityCodes)];
|
||||
const count = await this.prisma.geoCity.count({ where: { cityCode: { in: uniqueCodes }, deletedAt: null } });
|
||||
if (count !== uniqueCodes.length) {
|
||||
throw new BadRequestException({ code: 'CITY_NOT_FOUND', message: 'One or more city codes are invalid.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
NUMBER_LIBRARY_REPOSITORY,
|
||||
type AreaCodeInput,
|
||||
type CarrierPrefixInput,
|
||||
type CityInput,
|
||||
type NumberCarrier,
|
||||
type NumberLibraryRepository,
|
||||
type PageQuery,
|
||||
type PhoneSegmentInput
|
||||
} from './number-library.repository.js';
|
||||
|
||||
const MAX_IMPORT_ITEMS = 1000;
|
||||
|
||||
type QueryRecord = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class NumberLibraryService {
|
||||
constructor(@Inject(NUMBER_LIBRARY_REPOSITORY) private readonly numberLibrary: NumberLibraryRepository) {}
|
||||
|
||||
listCities(query: QueryRecord) {
|
||||
return this.numberLibrary.listCities(this.page(query));
|
||||
}
|
||||
|
||||
importCities(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
code: this.limitedString(item.code, 'code', 12),
|
||||
provinceCode: this.limitedString(item.provinceCode, 'provinceCode', 12),
|
||||
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
|
||||
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
|
||||
cityName: this.limitedString(item.cityName, 'cityName', 80),
|
||||
cityLevel: this.limitedString(item.cityLevel, 'cityLevel', 32),
|
||||
status: item.status === undefined ? 'ENABLED' : this.status(item.status),
|
||||
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
|
||||
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
|
||||
actorId
|
||||
})) satisfies CityInput[];
|
||||
return this.numberLibrary.importCities(items);
|
||||
}
|
||||
|
||||
listPhoneSegments(query: QueryRecord) {
|
||||
return this.numberLibrary.listPhoneSegments({
|
||||
...this.page(query),
|
||||
segment7: query.segment7 === undefined ? undefined : this.digits(query.segment7, 'segment7', 1, 7),
|
||||
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12),
|
||||
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
|
||||
});
|
||||
}
|
||||
|
||||
importPhoneSegments(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
segment7: this.digits(item.segment7, 'segment7', 7, 7),
|
||||
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
|
||||
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
|
||||
cityName: this.limitedString(item.cityName, 'cityName', 80),
|
||||
carrier: item.carrier === undefined ? 'UNKNOWN' : this.carrier(item.carrier),
|
||||
source: this.optionalLimitedString(item.source, 'source', 120),
|
||||
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
|
||||
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
|
||||
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
|
||||
actorId
|
||||
})) satisfies PhoneSegmentInput[];
|
||||
return this.numberLibrary.importPhoneSegments(items);
|
||||
}
|
||||
|
||||
listAreaCodes(query: QueryRecord) {
|
||||
return this.numberLibrary.listAreaCodes({
|
||||
...this.page(query),
|
||||
areaCode: query.areaCode === undefined ? undefined : this.digits(query.areaCode, 'areaCode', 2, 8),
|
||||
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12)
|
||||
});
|
||||
}
|
||||
|
||||
importAreaCodes(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
areaCode: this.digits(item.areaCode, 'areaCode', 2, 8),
|
||||
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
|
||||
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
|
||||
cityName: this.limitedString(item.cityName, 'cityName', 80),
|
||||
source: this.optionalLimitedString(item.source, 'source', 120),
|
||||
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
|
||||
actorId
|
||||
})) satisfies AreaCodeInput[];
|
||||
return this.numberLibrary.importAreaCodes(items);
|
||||
}
|
||||
|
||||
listCarrierPrefixRules(query: QueryRecord) {
|
||||
return this.numberLibrary.listCarrierPrefixRules({
|
||||
...this.page(query),
|
||||
prefix: query.prefix === undefined ? undefined : this.digits(query.prefix, 'prefix', 1, 4),
|
||||
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
|
||||
});
|
||||
}
|
||||
|
||||
importCarrierPrefixRules(body: QueryRecord, actorId?: string) {
|
||||
const items = this.items(body).map((item) => ({
|
||||
prefix: this.digits(item.prefix, 'prefix', 3, 4),
|
||||
carrier: this.carrier(item.carrier),
|
||||
priority: this.integer(item.priority ?? 100, 'priority', 1, 10_000),
|
||||
source: this.optionalLimitedString(item.source, 'source', 120),
|
||||
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
|
||||
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
|
||||
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
|
||||
actorId
|
||||
})) satisfies CarrierPrefixInput[];
|
||||
return this.numberLibrary.importCarrierPrefixRules(items);
|
||||
}
|
||||
|
||||
private page(query: QueryRecord): PageQuery {
|
||||
return {
|
||||
keyword: query.keyword === undefined ? undefined : this.limitedString(query.keyword, 'keyword', 80),
|
||||
take: this.integer(query.take ?? 50, 'take', 1, 500),
|
||||
skip: this.integer(query.skip ?? 0, 'skip', 0, 1_000_000)
|
||||
};
|
||||
}
|
||||
|
||||
private items(body: QueryRecord): QueryRecord[] {
|
||||
if (!Array.isArray(body.items) || body.items.length === 0 || body.items.length > MAX_IMPORT_ITEMS) {
|
||||
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: `items must contain 1-${MAX_IMPORT_ITEMS} records.` });
|
||||
}
|
||||
if (!body.items.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
|
||||
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: 'items must be objects.' });
|
||||
}
|
||||
return body.items as QueryRecord[];
|
||||
}
|
||||
|
||||
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private optionalLimitedString(value: unknown, field: string, maxLength: number): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private digits(value: unknown, field: string, minLength: number, maxLength: number): string {
|
||||
const text = this.limitedString(value, field, maxLength);
|
||||
if (!new RegExp(`^\\d{${minLength},${maxLength}}$`).test(text)) {
|
||||
throw new BadRequestException({ code: 'DIGITS_INVALID', message: `${field} must contain ${minLength}-${maxLength} digits.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private integer(value: unknown, field: string, min: number, max: number): number {
|
||||
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
|
||||
if (!Number.isInteger(number) || number < min || number > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
private carrier(value: unknown): NumberCarrier {
|
||||
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
|
||||
return value;
|
||||
}
|
||||
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
|
||||
}
|
||||
|
||||
private status(value: unknown): 'ENABLED' | 'DISABLED' {
|
||||
if (value === 'ENABLED' || value === 'DISABLED') {
|
||||
return value;
|
||||
}
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
private optionalDate(value: unknown, field: string): Date | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,10 @@ export class PrismaRechargesRepository implements RechargesRepository {
|
||||
}
|
||||
|
||||
private async listCustomerRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> {
|
||||
const where = accountId ? { customerId: accountId } : {};
|
||||
const where: Prisma.CustomerRechargeWhereInput = {
|
||||
...(accountId ? { customerId: accountId } : {}),
|
||||
NOT: [{ createdBy: 'worker-cdr' }, { idempotencyKey: { startsWith: 'cdr:' } }, { remark: { startsWith: 'CDR_CHARGE:' } }]
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.customerRecharge.findMany({
|
||||
where,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
@@ -29,6 +29,13 @@ export class RolesController {
|
||||
return this.rolesService.updateRole(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@RequirePermissions('roles.manage')
|
||||
@AuditAction({ module: 'roles', action: 'delete', objectType: 'role', objectIdParam: 'id' })
|
||||
removeRole(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.rolesService.removeRole(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@RequirePermissions('roles.view')
|
||||
listPermissions() {
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface RolesRepository {
|
||||
listPermissions(): Promise<PermissionSummary[]>;
|
||||
createRole(input: CreateRoleInput): Promise<RoleSummary>;
|
||||
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
|
||||
softDeleteRole(roleId: string, actorId?: string): Promise<RoleSummary>;
|
||||
}
|
||||
|
||||
export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY');
|
||||
@@ -169,6 +170,54 @@ export class PrismaRolesRepository implements RolesRepository {
|
||||
return this.toSummary(role);
|
||||
}
|
||||
|
||||
async softDeleteRole(roleIdValue: string, actorId?: string): Promise<RoleSummary> {
|
||||
const role = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.role.findUnique({ where: { id: roleIdValue } });
|
||||
if (!existing || existing.deletedAt) {
|
||||
throw new NotFoundException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
|
||||
}
|
||||
|
||||
if (existing.builtIn) {
|
||||
throw new ForbiddenException({
|
||||
code: 'BUILT_IN_ROLE_PROTECTED',
|
||||
message: 'Built-in roles are protected.'
|
||||
});
|
||||
}
|
||||
|
||||
const linkedUsers = await tx.userRole.count({
|
||||
where: {
|
||||
roleId: roleIdValue,
|
||||
user: { deletedAt: null }
|
||||
}
|
||||
});
|
||||
|
||||
if (linkedUsers > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'ROLE_HAS_USERS',
|
||||
message: 'Role with active users cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
return tx.role.update({
|
||||
where: { id: roleIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { userRoles: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(role);
|
||||
}
|
||||
|
||||
private async ensurePermissions(permissionIds: string[]): Promise<void> {
|
||||
const uniquePermissionIds = [...new Set(permissionIds)];
|
||||
const count = await this.prisma.permission.count({
|
||||
|
||||
@@ -49,6 +49,10 @@ export class RolesService {
|
||||
return this.roles.updateRole(roleId, input);
|
||||
}
|
||||
|
||||
removeRole(roleId: string, actorId?: string) {
|
||||
return this.roles.softDeleteRole(roleId, actorId);
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
|
||||
@@ -83,6 +83,10 @@ class MemoryUsersRepository implements UsersRepository {
|
||||
return this.summary(userId, 'operator', ['ROLE_SUPER_ADMIN'], true);
|
||||
}
|
||||
|
||||
async softDelete(userId: string): Promise<UserSummary> {
|
||||
return this.summary(userId, 'operator', []);
|
||||
}
|
||||
|
||||
private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary {
|
||||
return {
|
||||
id,
|
||||
@@ -125,6 +129,10 @@ class MemoryRolesRepository implements RolesRepository {
|
||||
return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']);
|
||||
}
|
||||
|
||||
async softDeleteRole(roleId: string): Promise<RoleSummary> {
|
||||
return this.role(roleId, false, []);
|
||||
}
|
||||
|
||||
private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from './audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from './security/identity.repository.js';
|
||||
import type { CurrentUser } from './security/security.metadata.js';
|
||||
import {
|
||||
ROLES_REPOSITORY,
|
||||
type CreateRoleInput,
|
||||
type PermissionSummary,
|
||||
type RoleSummary,
|
||||
type RolesRepository,
|
||||
type UpdateRoleInput
|
||||
} from './roles/roles.repository.js';
|
||||
import {
|
||||
USERS_REPOSITORY,
|
||||
type CreateUserInput,
|
||||
type UpdateUserInput,
|
||||
type UserSummary,
|
||||
type UsersRepository
|
||||
} from './users/users.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRolesRepository implements RolesRepository {
|
||||
private readonly roles = new Map<string, RoleSummary>();
|
||||
private readonly permissions: PermissionSummary[] = [{ id: 'users.manage', module: 'users', action: 'manage', description: null }];
|
||||
|
||||
constructor() {
|
||||
this.roles.set('rol_builtin', this.summary({ id: 'rol_builtin', name: '系统管理员', builtIn: true, userCount: 1 }));
|
||||
this.roles.set('rol_used', this.summary({ id: 'rol_used', name: '运营', userCount: 1 }));
|
||||
this.roles.set('rol_blocked', this.summary({ id: 'rol_blocked', name: '仍在使用', userCount: 1 }));
|
||||
this.roles.set('rol_empty', this.summary({ id: 'rol_empty', name: '空角色', userCount: 0 }));
|
||||
}
|
||||
|
||||
async listRoles(): Promise<RoleSummary[]> {
|
||||
return [...this.roles.values()];
|
||||
}
|
||||
|
||||
async listPermissions(): Promise<PermissionSummary[]> {
|
||||
return this.permissions;
|
||||
}
|
||||
|
||||
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
|
||||
const role = this.summary({ id: 'rol_created', name: input.name, description: input.description ?? null });
|
||||
this.roles.set(role.id, role);
|
||||
return role;
|
||||
}
|
||||
|
||||
async updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary> {
|
||||
const current = this.roles.get(roleId) ?? this.summary({ id: roleId, name: 'Missing Role' });
|
||||
const role: RoleSummary = {
|
||||
...current,
|
||||
name: input.name ?? current.name,
|
||||
description: input.description === undefined ? current.description : input.description,
|
||||
status: input.status ?? current.status,
|
||||
permissionIds: input.permissionIds ?? current.permissionIds,
|
||||
updatedAt: new Date('2026-06-24T08:00:00.000Z')
|
||||
};
|
||||
this.roles.set(role.id, role);
|
||||
return role;
|
||||
}
|
||||
|
||||
async softDeleteRole(roleId: string): Promise<RoleSummary> {
|
||||
const role = this.roles.get(roleId);
|
||||
if (!role) {
|
||||
throw new BadRequestException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
|
||||
}
|
||||
if (role.builtIn) {
|
||||
throw new ForbiddenException({ code: 'BUILT_IN_ROLE_PROTECTED', message: 'Built-in roles are protected.' });
|
||||
}
|
||||
if (role.userCount > 0) {
|
||||
throw new BadRequestException({ code: 'ROLE_HAS_USERS', message: 'Role with active users cannot be deleted.' });
|
||||
}
|
||||
const deleted: RoleSummary = { ...role, status: 'DISABLED', updatedAt: new Date('2026-06-24T08:00:00.000Z') };
|
||||
this.roles.delete(roleId);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
decrementUserCount(roleIds: string[]) {
|
||||
for (const roleId of roleIds) {
|
||||
const role = this.roles.get(roleId);
|
||||
if (role) {
|
||||
this.roles.set(roleId, { ...role, userCount: Math.max(0, role.userCount - 1) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private summary(input: { id: string; name: string; description?: string | null; builtIn?: boolean; userCount?: number }): RoleSummary {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
builtIn: input.builtIn ?? false,
|
||||
status: 'ENABLED',
|
||||
permissionIds: ['users.manage'],
|
||||
userCount: input.userCount ?? 0,
|
||||
createdAt: new Date('2026-06-24T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T07:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryUsersRepository implements UsersRepository {
|
||||
private readonly users = new Map<string, UserSummary>();
|
||||
|
||||
constructor(private readonly roles: MemoryRolesRepository) {
|
||||
this.users.set('usr_seed', this.summary({ id: 'usr_seed', username: 'seed', displayName: 'Seed User', roleIds: ['rol_used'], roles: ['运营'] }));
|
||||
}
|
||||
|
||||
async list(): Promise<UserSummary[]> {
|
||||
return [...this.users.values()];
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput): Promise<UserSummary> {
|
||||
const user = this.summary({ id: 'usr_created', username: input.username, displayName: input.displayName, roleIds: input.roleIds });
|
||||
this.users.set(user.id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(userId: string, input: UpdateUserInput): Promise<UserSummary> {
|
||||
const current = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
|
||||
const updated: UserSummary = {
|
||||
...current,
|
||||
displayName: input.displayName ?? current.displayName,
|
||||
phone: input.phone === undefined ? current.phone : input.phone,
|
||||
email: input.email === undefined ? current.email : input.email,
|
||||
status: input.status ?? current.status,
|
||||
roleIds: input.roleIds ?? current.roleIds,
|
||||
updatedAt: new Date('2026-06-24T08:00:00.000Z')
|
||||
};
|
||||
this.users.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resetPassword(userId: string): Promise<UserSummary> {
|
||||
return this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
|
||||
}
|
||||
|
||||
async softDelete(userId: string): Promise<UserSummary> {
|
||||
const user = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
|
||||
const deleted: UserSummary = { ...user, status: 'DISABLED', roleIds: [], roles: [], updatedAt: new Date('2026-06-24T08:00:00.000Z') };
|
||||
this.users.delete(userId);
|
||||
this.roles.decrementUserCount(user.roleIds);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private summary(input: { id: string; username: string; displayName: string; roleIds?: string[]; roles?: string[] }): UserSummary {
|
||||
return {
|
||||
id: input.id,
|
||||
username: input.username,
|
||||
displayName: input.displayName,
|
||||
phone: null,
|
||||
email: null,
|
||||
status: 'ENABLED',
|
||||
requirePasswordChange: false,
|
||||
lastLoginAt: null,
|
||||
roles: input.roles ?? [],
|
||||
roleIds: input.roleIds ?? [],
|
||||
createdAt: new Date('2026-06-24T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T07:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S40 users and roles delete API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
const roles = new MemoryRolesRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['users.view', 'roles.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(ROLES_REPOSITORY)
|
||||
.useValue(roles)
|
||||
.overrideProvider(USERS_REPOSITORY)
|
||||
.useValue(new MemoryUsersRepository(roles))
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('rejects delete operations without manage permissions', async () => {
|
||||
await request(app.getHttpServer()).delete('/api/v2/users/usr_seed').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403);
|
||||
await request(app.getHttpServer()).delete('/api/v2/roles/rol_empty').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403);
|
||||
});
|
||||
|
||||
it('soft deletes users with an audit entry', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/users/usr_seed')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ id: 'usr_seed', status: 'DISABLED', roleIds: [] });
|
||||
});
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'users' && entry.action === 'delete' && entry.objectId === 'usr_seed')).toBe(true);
|
||||
});
|
||||
|
||||
it('protects built-in and still-used roles, then deletes an empty custom role with audit', async () => {
|
||||
await request(app.getHttpServer()).delete('/api/v2/roles/rol_builtin').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(403);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/roles/rol_blocked')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(400)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ code: 'ROLE_HAS_USERS' });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/roles/rol_empty')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ id: 'rol_empty', status: 'DISABLED' });
|
||||
});
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'roles' && entry.action === 'delete' && entry.objectId === 'rol_empty')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
@@ -35,4 +35,11 @@ export class UsersController {
|
||||
resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.resetPassword(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('users.manage')
|
||||
@AuditAction({ module: 'users', action: 'delete', objectType: 'user', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface UsersRepository {
|
||||
create(input: CreateUserInput): Promise<UserSummary>;
|
||||
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
|
||||
resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary>;
|
||||
softDelete(userId: string, actorId?: string): Promise<UserSummary>;
|
||||
}
|
||||
|
||||
export const USERS_REPOSITORY = Symbol('USERS_REPOSITORY');
|
||||
@@ -167,24 +168,61 @@ export class PrismaUsersRepository implements UsersRepository {
|
||||
}
|
||||
|
||||
async resetPassword(userIdValue: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary> {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordAlgo,
|
||||
requirePasswordChange: true,
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
|
||||
if (!exists || exists.deletedAt) {
|
||||
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
|
||||
}
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordAlgo,
|
||||
requirePasswordChange: true,
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
async softDelete(userIdValue: string, actorId?: string): Promise<UserSummary> {
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
|
||||
if (!exists || exists.deletedAt) {
|
||||
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
|
||||
}
|
||||
|
||||
await tx.userRole.deleteMany({ where: { userId: userIdValue } });
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
|
||||
@@ -68,6 +68,10 @@ export class UsersService {
|
||||
return this.users.resetPassword(userId, await hashPasswordArgon2id(this.password(body.password)), PASSWORD_ALGO_ARGON2ID, actorId);
|
||||
}
|
||||
|
||||
remove(userId: string, actorId?: string): Promise<UserSummary> {
|
||||
return this.users.softDelete(userId, actorId);
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
@@ -48,4 +48,11 @@ export class VendorGatewaysController {
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('vendor_gateways.manage')
|
||||
@AuditAction({ module: 'vendor_gateways', action: 'delete', objectType: 'vendor_gateway', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
return this.update(gatewayId, { status });
|
||||
}
|
||||
|
||||
async softDelete(gatewayId: string): Promise<VendorGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const deleted = this.summary({ ...current, status: 'DISABLED' });
|
||||
this.gateways.delete(gatewayId);
|
||||
this.outboxEvents += 1;
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private summary(input: Partial<VendorGatewaySummary> & { id: string; name: string }): VendorGatewaySummary {
|
||||
const cycleRate = input.cycleRate ?? '0.000000';
|
||||
const billingCycleSec = input.billingCycleSec ?? 60;
|
||||
@@ -313,5 +321,7 @@ describe('S16 vendor gateways API', () => {
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/vendor-gateways/vgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
@@ -100,6 +100,7 @@ export interface VendorGatewaysRepository {
|
||||
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise<VendorGatewaySummary>;
|
||||
softDelete(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary>;
|
||||
}
|
||||
|
||||
export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY');
|
||||
@@ -244,6 +245,38 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
async softDelete(gatewayIdValue: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
const linkedLineGroups = await this.prisma.landingLineGroupItem.count({
|
||||
where: {
|
||||
vendorGatewayId: gatewayIdValue,
|
||||
lineGroup: { deletedAt: null }
|
||||
}
|
||||
});
|
||||
if (linkedLineGroups > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'VENDOR_GATEWAY_IN_LINE_GROUP',
|
||||
message: 'Vendor gateway referenced by active line groups cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.vendorGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'vendor_gateway.deleted');
|
||||
return deleted;
|
||||
});
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async ensureVendor(vendorId: string): Promise<void> {
|
||||
const vendor = await this.prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
|
||||
@@ -121,6 +121,10 @@ export class VendorGatewaysService {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
return this.gateways.softDelete(gatewayId, actorId);
|
||||
}
|
||||
|
||||
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
|
||||
@@ -18,12 +18,20 @@ export interface RuntimeConfig {
|
||||
redis: {
|
||||
url: string;
|
||||
};
|
||||
activeCalls: {
|
||||
sshHost: string;
|
||||
sshConfig?: string;
|
||||
remoteCommand: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
auth: {
|
||||
accessTokenSecret: string;
|
||||
accessTokenTtlSeconds: number;
|
||||
refreshTokenTtlDays: number;
|
||||
lockMaxFailures: number;
|
||||
lockWindowSeconds: number;
|
||||
loginThrottleMaxFailures: number;
|
||||
loginThrottleWindowSeconds: number;
|
||||
cookieSecure: boolean;
|
||||
tokenIssuer: string;
|
||||
tokenAudience: string;
|
||||
@@ -39,6 +47,10 @@ export const validationSchema = Joi.object({
|
||||
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
||||
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
||||
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
||||
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
|
||||
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
|
||||
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
|
||||
ACTIVE_CALLS_TIMEOUT_MS: Joi.number().integer().min(1000).max(30000).default(5000),
|
||||
AUTH_ACCESS_TOKEN_SECRET: Joi.string().min(32).when('NODE_ENV', {
|
||||
is: 'production',
|
||||
then: Joi.required(),
|
||||
@@ -48,12 +60,16 @@ export const validationSchema = Joi.object({
|
||||
AUTH_REFRESH_TOKEN_TTL_DAYS: Joi.number().integer().min(1).max(30).default(7),
|
||||
AUTH_LOCK_MAX_FAILURES: Joi.number().integer().min(3).max(20).default(5),
|
||||
AUTH_LOCK_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(900),
|
||||
AUTH_COOKIE_SECURE: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
AUTH_LOGIN_THROTTLE_MAX_FAILURES: Joi.number().integer().min(3).max(100).default(10),
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(300),
|
||||
AUTH_COOKIE_SECURE: Joi.any().default(true),
|
||||
AUTH_TOKEN_ISSUER: Joi.string().default('lisglosips-api'),
|
||||
AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web')
|
||||
});
|
||||
|
||||
export function appConfig(): RuntimeConfig {
|
||||
const cookieSecureValue = (process.env.AUTH_COOKIE_SECURE ?? 'true').trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
||||
|
||||
return {
|
||||
service: {
|
||||
name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api'
|
||||
@@ -72,13 +88,21 @@ export function appConfig(): RuntimeConfig {
|
||||
redis: {
|
||||
url: process.env.REDIS_URL ?? ''
|
||||
},
|
||||
activeCalls: {
|
||||
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
|
||||
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
|
||||
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
|
||||
timeoutMs: Number(process.env.ACTIVE_CALLS_TIMEOUT_MS ?? 5000)
|
||||
},
|
||||
auth: {
|
||||
accessTokenSecret: process.env.AUTH_ACCESS_TOKEN_SECRET ?? 'dev-only-change-this-auth-secret-32-bytes-min',
|
||||
accessTokenTtlSeconds: Number(process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS ?? 900),
|
||||
refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7),
|
||||
lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5),
|
||||
lockWindowSeconds: Number(process.env.AUTH_LOCK_WINDOW_SECONDS ?? 900),
|
||||
cookieSecure: (process.env.AUTH_COOKIE_SECURE ?? 'true') !== 'false',
|
||||
loginThrottleMaxFailures: Number(process.env.AUTH_LOGIN_THROTTLE_MAX_FAILURES ?? 10),
|
||||
loginThrottleWindowSeconds: Number(process.env.AUTH_LOGIN_THROTTLE_WINDOW_SECONDS ?? 300),
|
||||
cookieSecure: cookieSecureValue !== 'false' && cookieSecureValue !== '0',
|
||||
tokenIssuer: process.env.AUTH_TOKEN_ISSUER ?? 'lisglosips-api',
|
||||
tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web'
|
||||
}
|
||||
|
||||
+1257
-115
File diff suppressed because it is too large
Load Diff
+88
-1
@@ -1,6 +1,18 @@
|
||||
const API_BASE = (import.meta.env.VITE_LISGLOSIPS_API_BASE || '/api/v2').replace(/\/$/, '');
|
||||
const ACCESS_TOKEN_KEY = 'lisglosips.accessToken';
|
||||
|
||||
export function getAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setAccessToken(token) {
|
||||
if (token) {
|
||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status, code } = {}) {
|
||||
super(message);
|
||||
@@ -55,12 +67,39 @@ function idempotencyKey(scope) {
|
||||
return `${scope}:${random}`;
|
||||
}
|
||||
|
||||
function queryString(params = {}) {
|
||||
const entries = Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '' && value !== 'all');
|
||||
return entries.length ? `?${new URLSearchParams(Object.fromEntries(entries))}` : '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
captcha: () => request('/auth/captcha'),
|
||||
login: async (body) => {
|
||||
const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) });
|
||||
setAccessToken(response.accessToken);
|
||||
return response;
|
||||
},
|
||||
refresh: async () => {
|
||||
const response = await request('/auth/refresh', { method: 'POST' });
|
||||
setAccessToken(response.accessToken);
|
||||
return response;
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await request('/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
setAccessToken('');
|
||||
}
|
||||
},
|
||||
dashboardSummary: () => request('/dashboard/summary'),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
||||
activeCalls: () => request('/active-calls'),
|
||||
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`),
|
||||
customers: () => request('/customers'),
|
||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
deleteCustomer: (id) => request(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
rechargeCustomer: (id, body) =>
|
||||
request(`/customers/${encodeURIComponent(id)}/recharges`, {
|
||||
method: 'POST',
|
||||
@@ -69,20 +108,68 @@ export const api = {
|
||||
vendors: () => request('/vendors'),
|
||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
rechargeVendor: (id, body) =>
|
||||
request(`/vendors/${encodeURIComponent(id)}/recharges`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
||||
}),
|
||||
customerGateways: () => request('/customer-gateways'),
|
||||
enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
vendorGateways: () => request('/vendor-gateways'),
|
||||
enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
landingLineGroups: () => request('/landing-line-groups'),
|
||||
deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
recharges: () => request('/recharges?take=100'),
|
||||
users: () => request('/users'),
|
||||
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
roles: () => request('/roles'),
|
||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
auditLogs: () => request('/audit-logs?take=100'),
|
||||
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
};
|
||||
|
||||
export function explainApiError(error) {
|
||||
if (error instanceof ApiError && error.code === 'AUTH_CAPTCHA_INVALID') {
|
||||
return '验证码错误或已过期,请重新输入。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'AUTH_INVALID_CREDENTIALS') {
|
||||
return '用户名、密码或验证码不正确。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'CUSTOMER_HAS_GATEWAYS') {
|
||||
return '该客户仍有关联客户网关,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'VENDOR_HAS_GATEWAYS') {
|
||||
return '该供应商仍有关联落地网关,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'LINE_GROUP_IN_USE') {
|
||||
return '该落地线路组仍被客户网关使用,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'VENDOR_GATEWAY_IN_LINE_GROUP') {
|
||||
return '该落地网关仍被落地线路组引用,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'ROLE_HAS_USERS') {
|
||||
return '该角色仍有关联用户,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'BUILT_IN_ROLE_PROTECTED') {
|
||||
return '系统内置角色受保护,不能删除或修改关键权限。';
|
||||
}
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return 'API 已启用鉴权,请先通过登录接口获取会话或在同源环境使用有效 Cookie。';
|
||||
return '登录状态已失效,请重新登录。';
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 429) {
|
||||
return '登录尝试过于频繁,请稍后再试。';
|
||||
}
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
@@ -58,6 +58,80 @@ button:disabled {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(241, 242, 245, 0.96)),
|
||||
var(--page);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
width: min(100%, 420px);
|
||||
padding: 28px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-brand span {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 132px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
border-color: var(--selected);
|
||||
}
|
||||
|
||||
.captcha-image img {
|
||||
display: block;
|
||||
width: 132px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.prototype-app {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
@@ -668,6 +742,11 @@ button:disabled {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -759,6 +838,12 @@ button:disabled {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.proto-table .table-cell-compact {
|
||||
max-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.proto-table tbody tr {
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
@@ -850,6 +935,80 @@ button:disabled {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cdr-detail {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cdr-detail-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 42px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cdr-detail-hero div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cdr-detail-hero span,
|
||||
.cdr-detail-section h3 {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.cdr-detail-hero strong {
|
||||
overflow: hidden;
|
||||
color: var(--brand);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cdr-detail-hero svg {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 9px;
|
||||
color: var(--selected);
|
||||
background: var(--selected-soft);
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.cdr-detail-strip,
|
||||
.cdr-detail-grid,
|
||||
.cdr-timeline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cdr-detail-strip {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cdr-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cdr-timeline {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cdr-detail-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-list div,
|
||||
.ops-row {
|
||||
display: flex;
|
||||
@@ -1444,6 +1603,12 @@ button:disabled {
|
||||
border-color: #b9e8c7;
|
||||
}
|
||||
|
||||
.ui-alert-danger {
|
||||
color: #991b1b;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1594,10 +1759,19 @@ button:disabled {
|
||||
.match-grid,
|
||||
.option-grid,
|
||||
.rate-config-fields,
|
||||
.cdr-detail-hero,
|
||||
.cdr-detail-strip,
|
||||
.cdr-detail-grid,
|
||||
.cdr-timeline,
|
||||
.code-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cdr-detail-hero svg {
|
||||
justify-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.inline-control,
|
||||
.time-range,
|
||||
.prefix-rule {
|
||||
@@ -1615,6 +1789,15 @@ button:disabled {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.captcha-image,
|
||||
.captcha-image img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ 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';
|
||||
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, 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);
|
||||
@@ -53,10 +52,6 @@ class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||
}
|
||||
}
|
||||
|
||||
async createCustomerCharge(input: CreateCustomerChargeInput): Promise<void> {
|
||||
this.charges.push(input);
|
||||
}
|
||||
|
||||
async updateCustomerBalance(_customerId: string, balance: Prisma.Decimal): Promise<void> {
|
||||
this.customer.balance = balance;
|
||||
}
|
||||
@@ -78,6 +73,11 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
|
||||
source_ip: '100.93.185.30',
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
callee_city_code: '340100',
|
||||
callee_city_name: '合肥市',
|
||||
callee_province_name: '安徽省',
|
||||
callee_operator: 'MOBILE',
|
||||
callee_number_type: 'MOBILE',
|
||||
vendor_id: 'ven_1',
|
||||
vendor_gateway_id: 'vgw_1',
|
||||
line_group_id: 'lg_1',
|
||||
@@ -105,7 +105,7 @@ describe('S23 CDR rating', () => {
|
||||
expect(charge.amount.toFixed(6)).toBe('0.060000');
|
||||
});
|
||||
|
||||
it('rates a successful CDR and deducts customer balance once', async () => {
|
||||
it('rates a successful CDR and deducts customer balance once without recharge ledger records', async () => {
|
||||
const store = new MemoryStore();
|
||||
const service = new CdrRatingService(store);
|
||||
|
||||
@@ -121,8 +121,6 @@ describe('S23 CDR rating', () => {
|
||||
});
|
||||
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 () => {
|
||||
@@ -134,6 +132,5 @@ describe('S23 CDR rating', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface CdrRatingTransaction {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -67,16 +66,6 @@ export interface CreateRatedInput {
|
||||
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) {}
|
||||
|
||||
@@ -144,15 +133,6 @@ export class CdrRatingService {
|
||||
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');
|
||||
|
||||
@@ -202,6 +182,11 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
sourceIp: emptyToNull(event.source_ip),
|
||||
caller: event.caller || 'unknown',
|
||||
callee: event.callee || 'unknown',
|
||||
calleeCityCode: emptyToNull(event.callee_city_code),
|
||||
calleeCityName: emptyToNull(event.callee_city_name),
|
||||
calleeProvinceName: emptyToNull(event.callee_province_name),
|
||||
calleeOperator: numberCarrier(event.callee_operator),
|
||||
calleeNumberType: phoneNumberType(event.callee_number_type),
|
||||
vendorId: nullableId(event.vendor_id),
|
||||
vendorGatewayId: nullableId(event.vendor_gateway_id),
|
||||
lineGroupId: nullableId(event.line_group_id),
|
||||
@@ -253,22 +238,6 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -326,7 +295,7 @@ function parseOptionalInt(value: string): number | null {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
return Number.isInteger(parsed) && parsed >= -2147483648 && parsed <= 2147483647 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalCdrDate(value: string): Date | null {
|
||||
@@ -361,6 +330,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
source_ip: event.source_ip,
|
||||
caller: event.caller,
|
||||
callee: event.callee,
|
||||
callee_city_code: event.callee_city_code,
|
||||
callee_city_name: event.callee_city_name,
|
||||
callee_province_name: event.callee_province_name,
|
||||
callee_operator: event.callee_operator,
|
||||
callee_number_type: event.callee_number_type,
|
||||
customer_id: event.customer_id,
|
||||
customer_gateway_id: event.customer_gateway_id,
|
||||
customer_gateway_policy_id: event.customer_gateway_policy_id,
|
||||
@@ -378,3 +352,11 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
created_at: event.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function numberCarrier(value: string): 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN' {
|
||||
return value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' ? value : 'UNKNOWN';
|
||||
}
|
||||
|
||||
function phoneNumberType(value: string): 'MOBILE' | 'LANDLINE' | 'INTERNATIONAL' | 'UNKNOWN' {
|
||||
return value === 'MOBILE' || value === 'LANDLINE' || value === 'INTERNATIONAL' ? value : 'UNKNOWN';
|
||||
}
|
||||
|
||||
@@ -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