feat: optimize UI queries and seed number library

This commit is contained in:
hectorzhao
2026-08-27 20:24:18 +08:00
parent 138297d191
commit 564234b769
22 changed files with 631 additions and 84 deletions
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConfigService } from '@nestjs/config';
import type { RuntimeConfig } from '../../shared/config.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
function client() {
const config = {
get: () => ({
mode: 'http',
httpUrl: 'http://127.0.0.1:8888/mi',
sshHost: 'unused',
remoteCommand: 'unused',
timeoutMs: 3000
})
} as unknown as ConfigService<RuntimeConfig, true>;
return new OpenSipsMiClient(config);
}
describe('OpenSipsMiClient HTTP MI', () => {
afterEach(() => vi.unstubAllGlobals());
it('lists dialogs through the local JSON-RPC endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
});
it('preserves OpenSIPS MI errors for the API layer', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
});
});
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
private async call(method: string, params: string[] = []): Promise<unknown> { private async call(method: string, params: string[] = []): Promise<unknown> {
const config = this.configService.get('activeCalls', { infer: true }); const config = this.configService.get('activeCalls', { infer: true });
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' '); let response: MiResponse;
const args = [];
if (config.sshConfig) {
args.push('-F', config.sshConfig);
}
args.push(config.sshHost, remoteCommand);
let output: string;
try { try {
output = await this.executor.run(args, config.timeoutMs); response = config.mode === 'http'
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
: await this.callSsh(config, method, params);
} catch (error) { } catch (error) {
if (error instanceof BadGatewayException) throw error;
throw new BadGatewayException({ throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_UNAVAILABLE', code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
message: 'OpenSIPS control plane is unavailable.', message: 'OpenSIPS control plane is unavailable.',
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
}); });
} }
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) { if (response.error) {
throw new BadGatewayException({ throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_ERROR', code: 'ACTIVE_CALLS_MI_ERROR',
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
return response.result; return response.result;
} }
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(timeoutMs)
});
if (!response.ok) {
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
}
try {
return await response.json() as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
const args: string[] = [];
if (config.sshConfig) args.push('-F', config.sshConfig);
args.push(config.sshHost, remoteCommand);
const output = await this.executor.run(args, config.timeoutMs);
try {
return JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
} }
function shellQuote(value: string): string { function shellQuote(value: string): string {
@@ -1,4 +1,5 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js'; import { PrismaService } from '../database/prisma.service.js';
export interface AuditLogQuery { export interface AuditLogQuery {
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
objectType?: string; objectType?: string;
objectId?: string; objectId?: string;
result?: 'SUCCESS' | 'FAILURE'; result?: 'SUCCESS' | 'FAILURE';
createdFrom?: Date;
createdTo?: Date;
take: number; take: number;
skip: number; skip: number;
} }
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> { async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
const where = { const where: Prisma.AuditLogWhereInput = {
module: query.module, module: query.module,
action: query.action, action: query.action,
userId: query.userId, userId: query.userId,
objectType: query.objectType, objectType: query.objectType,
objectId: query.objectId, objectId: query.objectId,
result: query.result result: query.result,
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
}; };
const [items, total] = await this.prisma.$transaction([ const [items, total] = await this.prisma.$transaction([
@@ -13,6 +13,8 @@ export class AuditLogsService {
objectType: this.optionalString(rawQuery.objectType), objectType: this.optionalString(rawQuery.objectType),
objectId: this.optionalString(rawQuery.objectId), objectId: this.optionalString(rawQuery.objectId),
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result), result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
take: this.positiveInt(rawQuery.take, 50, 100), take: this.positiveInt(rawQuery.take, 50, 100),
skip: this.positiveInt(rawQuery.skip, 0, 10_000) skip: this.positiveInt(rawQuery.skip, 0, 10_000)
}; };
@@ -56,4 +58,16 @@ export class AuditLogsService {
return parsed; return parsed;
} }
private optionalDate(value: unknown, field: string): Date | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
} }
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY'); export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
export interface RecordingListQuery {
status?: string;
reviewStatus?: 'PENDING' | 'REVIEWED';
limit?: number;
startedFrom?: Date;
startedTo?: Date;
}
export interface RecordingsRepository { export interface RecordingsRepository {
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>; list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
getDetail(id: string): Promise<RecordingDetail>; getDetail(id: string): Promise<RecordingDetail>;
getReadyForPlayback(id: string): Promise<RecordingPlayback>; getReadyForPlayback(id: string): Promise<RecordingPlayback>;
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>; saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
export class PrismaRecordingsRepository implements RecordingsRepository { export class PrismaRecordingsRepository implements RecordingsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> { async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
const take = Math.min(query.limit ?? 100, 500); const take = Math.min(query.limit ?? 100, 500);
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
const recordings = await this.prisma.recording.findMany({ const recordings = await this.prisma.recording.findMany({
where: { where: {
status: query.status ? (query.status as never) : 'READY', status: query.status ? (query.status as never) : 'READY',
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
OR: startedAt ? [
{ rawCdr: { is: { startedAt } } },
{ rawCdrId: null, createdAt: startedAt }
] : undefined
}, },
orderBy: [{ createdAt: 'desc' }], orderBy: [{ createdAt: 'desc' }],
take, take,
@@ -26,11 +26,13 @@ export class RecordingsService {
@Inject(QualityService) private readonly qualityService: QualityService @Inject(QualityService) private readonly qualityService: QualityService
) {} ) {}
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) { async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
const recordings = await this.recordings.list({ const recordings = await this.recordings.list({
status: query.status === undefined ? undefined : this.recordingStatus(query.status), status: query.status === undefined ? undefined : this.recordingStatus(query.status),
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus), reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500) limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
}); });
return Promise.all(recordings.map((recording) => this.withSampling(recording))); return Promise.all(recordings.map((recording) => this.withSampling(recording)));
} }
@@ -123,6 +125,17 @@ export class RecordingsService {
} }
return parsed; return parsed;
} }
private date(value: unknown, field: string): Date {
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
} }
export function internalRecordingPath(storageKey: string): string { export function internalRecordingPath(storageKey: string): string {
+6
View File
@@ -19,6 +19,8 @@ export interface RuntimeConfig {
url: string; url: string;
}; };
activeCalls: { activeCalls: {
mode: 'http' | 'ssh';
httpUrl: string;
sshHost: string; sshHost: string;
sshConfig?: string; sshConfig?: string;
remoteCommand: string; remoteCommand: string;
@@ -47,6 +49,8 @@ export const validationSchema = Joi.object({
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'), LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(), DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(), REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
ACTIVE_CALLS_MODE: Joi.string().valid('http', 'ssh').default('http'),
ACTIVE_CALLS_HTTP_URL: Joi.string().uri({ scheme: ['http', 'https'] }).default('http://127.0.0.1:8888/mi'),
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'), ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(), ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'), ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
@@ -89,6 +93,8 @@ export function appConfig(): RuntimeConfig {
url: process.env.REDIS_URL ?? '' url: process.env.REDIS_URL ?? ''
}, },
activeCalls: { activeCalls: {
mode: process.env.ACTIVE_CALLS_MODE === 'ssh' ? 'ssh' : 'http',
httpUrl: process.env.ACTIVE_CALLS_HTTP_URL ?? 'http://127.0.0.1:8888/mi',
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a', sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined, sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control', remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
+16 -2
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx'; import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
import { Icon, PageLoadingDialog } from './components/layout.jsx'; import { Icon, PageLoadingDialog } from './components/layout.jsx';
import { api, explainApiError } from './api.js'; import { api, explainApiError } from './api.js';
import { dateRangeParams, lastSevenDays } from './utils/dateRange.js';
import { import {
formatCurrency, formatCurrency,
normalizeAuditLog, normalizeAuditLog,
@@ -298,7 +299,8 @@ export default function App() {
add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser))); add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser)));
add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole))); add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole)));
} else if (page === 'operationLogs') { } else if (page === 'operationLogs') {
add('audit.view', () => api.auditLogs(signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog))); const range = lastSevenDays();
add('audit.view', () => api.auditLogs(dateRangeParams(range.startDate, range.endDate, 'createdFrom', 'createdTo'), signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
} else if (page === 'cdr') { } else if (page === 'cdr') {
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway))); add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway))); add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
@@ -318,6 +320,18 @@ export default function App() {
setApiLoading(false); setApiLoading(false);
}; };
const refreshApi = () => loadPageData(active, authUser, { force: true }); const refreshApi = () => loadPageData(active, authUser, { force: true });
const refreshAuditLogs = async (params = {}) => {
setApiLoading(true);
setApiError('');
try {
const value = await api.auditLogs(params);
setLogRows((value.items || []).map(normalizeAuditLog));
} catch (error) {
setApiError(explainApiError(error));
} finally {
setApiLoading(false);
}
};
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => { const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
setActiveCallsLoading(true); setActiveCallsLoading(true);
if (!silent) setActiveCallsBlockingLoading(true); if (!silent) setActiveCallsBlockingLoading(true);
@@ -528,7 +542,7 @@ export default function App() {
: active === 'roles' : active === 'roles'
? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) } ? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) }
: active === 'operationLogs' : active === 'operationLogs'
? { logRows, apiLoading, apiError, refreshApi } ? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs }
: {}; : {};
if (!authUser) { if (!authUser) {
+6 -6
View File
@@ -153,7 +153,7 @@ export const api = {
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }), hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`), cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`),
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`), cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`), recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 50, ...params })}`),
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`), recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`), recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`),
saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }), saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }),
@@ -202,20 +202,20 @@ export const api = {
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
roles: (options) => request('/roles', options), roles: (options) => request('/roles', options),
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
auditLogs: (options) => request('/audit-logs?take=100', options), auditLogs: (params = {}, options) => request(`/audit-logs${queryString({ take: 100, ...params })}`, options),
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`), businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }), createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }), enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }), disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`), numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 25, ...params })}`),
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`), numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 25, ...params })}`),
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`), numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 25, ...params })}`),
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }), 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 })}`), numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 25, ...params })}`),
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
}; };
+31 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Badge, Button } from './ui.jsx'; import { Alert, Badge, Button, Field, Input } from './ui.jsx';
const selectedBlue = '#2563EB'; const selectedBlue = '#2563EB';
@@ -42,6 +42,36 @@ export function Toolbar({ children }) {
return <div className="toolbar">{children}</div>; return <div className="toolbar">{children}</div>;
} }
export function DateRangeFields({ startDate, endDate, onStartChange, onEndChange }) {
return (
<>
<Field label="开始日期"><Input type="date" value={startDate} max={endDate || undefined} onChange={(event) => onStartChange(event.target.value)} /></Field>
<Field label="结束日期"><Input type="date" value={endDate} min={startDate || undefined} onChange={(event) => onEndChange(event.target.value)} /></Field>
</>
);
}
export function Pagination({ total, take, skip, count, loading, onPageChange, onPageSizeChange }) {
const start = total ? skip + 1 : 0;
const end = Math.min(total, skip + count);
return (
<div className="pagination-bar" aria-label="分页">
<span>显示 {start}-{end} {total} </span>
<div className="pagination-actions">
<label>每页
<select value={take} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<Button size="sm" variant="outline" disabled={loading || skip === 0} onClick={() => onPageChange(Math.max(0, skip - take))}>上一页</Button>
<Button size="sm" variant="outline" disabled={loading || skip + count >= total} onClick={() => onPageChange(skip + take)}>下一页</Button>
</div>
</div>
);
}
export function Panel({ title, aside, children, className = '' }) { export function Panel({ title, aside, children, className = '' }) {
return ( return (
<section className={`prototype-panel ${className}`}> <section className={`prototype-panel ${className}`}>
+9 -8
View File
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx'; import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js'; import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const defaultRange = lastSevenDays();
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) { export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
const [detailCdr, setDetailCdr] = useState(null); const [detailCdr, setDetailCdr] = useState(null);
@@ -24,8 +27,8 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
vendorGatewayId: 'all', vendorGatewayId: 'all',
cityCode: '', cityCode: '',
carrier: 'all', carrier: 'all',
startedFrom: '', startedFrom: defaultRange.startDate,
startedTo: '', startedTo: defaultRange.endDate,
take: '25', take: '25',
skip: 0, skip: 0,
}); });
@@ -100,8 +103,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
vendorGatewayId: nextFilters.vendorGatewayId, vendorGatewayId: nextFilters.vendorGatewayId,
cityCode: nextFilters.cityCode.trim(), cityCode: nextFilters.cityCode.trim(),
carrier: nextFilters.carrier, carrier: nextFilters.carrier,
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '', ...dateRangeParams(nextFilters.startedFrom, nextFilters.startedTo),
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
take: nextFilters.take, take: nextFilters.take,
skip: String(nextFilters.skip), skip: String(nextFilters.skip),
}); });
@@ -133,7 +135,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
void loadCdrs(nextFilters); void loadCdrs(nextFilters);
}; };
const resetFilters = () => { const resetFilters = () => {
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 }; const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: defaultRange.startDate, startedTo: defaultRange.endDate, take: filters.take, skip: 0 };
setFilters(nextFilters); setFilters(nextFilters);
void loadCdrs(nextFilters); void loadCdrs(nextFilters);
}; };
@@ -230,8 +232,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
<option value="UNKNOWN">未知</option> <option value="UNKNOWN">未知</option>
</Select> </Select>
</Field> </Field>
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field> <DateRangeFields startDate={filters.startedFrom} endDate={filters.startedTo} onStartChange={(value) => updateFilter('startedFrom', value)} onEndChange={(value) => updateFilter('startedTo', value)} />
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
<Field label="每页"> <Field label="每页">
<Select value={filters.take} onChange={(event) => { <Select value={filters.take} onChange={(event) => {
const nextFilters = { ...filters, take: event.target.value, skip: 0 }; const nextFilters = { ...filters, take: event.target.value, skip: 0 };
+1 -14
View File
@@ -1,5 +1,5 @@
import { Badge, Button } from '../components/ui.jsx'; import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx'; import { Icon, PageTitle, Panel, ApiNotice, MiniBarChart, LineChart } from '../components/layout.jsx';
import { formatCurrency } from '../utils/formatters.js'; import { formatCurrency } from '../utils/formatters.js';
const pendingMetrics = [ const pendingMetrics = [
@@ -32,7 +32,6 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
const trendBuckets = dashboardTrends?.buckets || []; const trendBuckets = dashboardTrends?.buckets || [];
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0]; const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0];
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0]; const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
return ( return (
<> <>
<PageTitle <PageTitle
@@ -57,18 +56,6 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}> <Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} /> <LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
</Panel> </Panel>
<Panel title="客户消费 TOP 10">
<div className="rank-list">
<EmptyState title="消费排名待接入">当前 Dashboard 接口暂未提供客户消费排名避免额外预加载客户列表</EmptyState>
</div>
</Panel>
<Panel title="失败响应码分布">
<div className="code-grid">
{failureCodes.length ? failureCodes.map((code) => (
<div key={code.sipCode}><strong>{code.count}</strong><span>{code.sipCode}</span></div>
)) : <EmptyState title="暂无失败码">今日没有失败 CDR API 尚未返回数据</EmptyState>}
</div>
</Panel>
</section> </section>
</> </>
); );
+36 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx'; import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable, Pagination } from '../components/layout.jsx';
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js'; import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
@@ -53,10 +53,10 @@ export function NumberLibraryPage({ can = () => true }) {
const [rows, setRows] = useState(emptyNumberLibraryRows); const [rows, setRows] = useState(emptyNumberLibraryRows);
const [totals, setTotals] = useState(emptyNumberLibraryTotals); const [totals, setTotals] = useState(emptyNumberLibraryTotals);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
cities: { keyword: '' }, cities: { keyword: '', take: 25, skip: 0 },
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' }, phoneSegments: { segment7: '', cityCode: '', carrier: 'all', take: 25, skip: 0 },
areaCodes: { areaCode: '', cityCode: '' }, areaCodes: { areaCode: '', cityCode: '', take: 25, skip: 0 },
carrierPrefixRules: { prefix: '', carrier: 'all' }, carrierPrefixRules: { prefix: '', carrier: 'all', take: 25, skip: 0 },
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -66,8 +66,7 @@ export function NumberLibraryPage({ can = () => true }) {
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const canManage = can('number_library.manage'); const canManage = can('number_library.manage');
const readTab = async (tab) => { const readTab = async (tab, params = filters[tab] || {}) => {
const params = filters[tab] || {};
if (tab === 'cities') { if (tab === 'cities') {
const payload = await api.numberLibraryCities(params); const payload = await api.numberLibraryCities(params);
return normalizeNumberLibraryList(payload, (item) => ({ return normalizeNumberLibraryList(payload, (item) => ({
@@ -120,11 +119,11 @@ export function NumberLibraryPage({ can = () => true }) {
})); }));
}; };
const loadTab = async (tab = activeTab) => { const loadTab = async (tab = activeTab, params = filters[tab]) => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const result = await readTab(tab); const result = await readTab(tab, params);
setRows((current) => ({ ...current, [tab]: result.rows })); setRows((current) => ({ ...current, [tab]: result.rows }));
setTotals((current) => ({ ...current, [tab]: result.total })); setTotals((current) => ({ ...current, [tab]: result.total }));
} catch (loadError) { } catch (loadError) {
@@ -141,10 +140,22 @@ export function NumberLibraryPage({ can = () => true }) {
const updateFilter = (key, value) => { const updateFilter = (key, value) => {
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
[activeTab]: { ...current[activeTab], [key]: value }, [activeTab]: { ...current[activeTab], [key]: value, skip: 0 },
})); }));
}; };
const searchActiveTab = () => {
const next = { ...filters[activeTab], skip: 0 };
setFilters((current) => ({ ...current, [activeTab]: next }));
void loadTab(activeTab, next);
};
const changePage = (skip, take = filters[activeTab].take) => {
const next = { ...filters[activeTab], skip, take };
setFilters((current) => ({ ...current, [activeTab]: next }));
void loadTab(activeTab, next);
};
const openImport = (tab) => { const openImport = (tab) => {
setImportTarget(tab); setImportTarget(tab);
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2)); setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
@@ -187,7 +198,7 @@ export function NumberLibraryPage({ can = () => true }) {
return ( return (
<> <>
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field> <Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
} }
@@ -207,7 +218,7 @@ export function NumberLibraryPage({ can = () => true }) {
<option value="UNKNOWN">未知</option> <option value="UNKNOWN">未知</option>
</Select> </Select>
</Field> </Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
} }
@@ -216,7 +227,7 @@ export function NumberLibraryPage({ can = () => true }) {
<> <>
<Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field> <Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field>
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field> <Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
} }
@@ -234,7 +245,7 @@ export function NumberLibraryPage({ can = () => true }) {
<option value="UNKNOWN">未知</option> <option value="UNKNOWN">未知</option>
</Select> </Select>
</Field> </Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
}; };
@@ -327,6 +338,17 @@ export function NumberLibraryPage({ can = () => true }) {
</Toolbar> </Toolbar>
) : null} ) : null}
{renderTable(tab.value)} {renderTable(tab.value)}
{tab.value === activeTab ? (
<Pagination
total={totals[tab.value]}
take={filters[tab.value].take}
skip={filters[tab.value].skip}
count={rows[tab.value].length}
loading={loading}
onPageChange={(skip) => changePage(skip)}
onPageSizeChange={(take) => changePage(0, take)}
/>
) : null}
</Panel> </Panel>
), ),
}))} }))}
+26 -7
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx'; import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js'; import { operationLogRows } from '../fixtures/devFixtures.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const defaultRange = lastSevenDays();
function toneForStatus(status) { function toneForStatus(status) {
return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral'; return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
@@ -11,8 +14,8 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all'); const [moduleFilter, setModuleFilter] = useState('all');
const [resultFilter, setResultFilter] = useState('all'); const [resultFilter, setResultFilter] = useState('all');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState(defaultRange.endDate);
const [detailLog, setDetailLog] = useState(null); const [detailLog, setDetailLog] = useState(null);
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]); const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
const visibleLogs = useMemo(() => { const visibleLogs = useMemo(() => {
@@ -23,18 +26,34 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate); return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
}); });
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]); }, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); }; const queryLogs = (overrides = {}) => {
const next = { moduleFilter, resultFilter, startDate, endDate, ...overrides };
return refreshApi({
module: next.moduleFilter,
result: next.resultFilter === '成功' ? 'SUCCESS' : next.resultFilter === '失败' ? 'FAILURE' : 'all',
...dateRangeParams(next.startDate, next.endDate, 'createdFrom', 'createdTo'),
});
};
const resetFilters = () => {
setKeyword('');
setModuleFilter('all');
setResultFilter('all');
setStartDate(defaultRange.startDate);
setEndDate(defaultRange.endDate);
void queryLogs({ moduleFilter: 'all', resultFilter: 'all', ...defaultRange });
};
return ( return (
<> <>
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} /> <PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} /> <ApiNotice loading={apiLoading} error={apiError} onRetry={() => void queryLogs()} />
<PageLoadingDialog loading={apiLoading} />
<Toolbar> <Toolbar>
<Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field> <Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field>
<Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field> <Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field>
<Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field> <Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field>
<Field label="开始日期"><Input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></Field> <DateRangeFields startDate={startDate} endDate={endDate} onStartChange={setStartDate} onEndChange={setEndDate} />
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field> <Button icon={<Icon type="search" />} disabled={apiLoading} onClick={() => void queryLogs()}>查询</Button>
<Button variant="outline" onClick={resetFilters}>重置</Button> <Button variant="outline" onClick={resetFilters}>重置</Button>
</Toolbar> </Toolbar>
<Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel"> <Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel">
+12 -2
View File
@@ -1,17 +1,19 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx'; import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js'; import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' }; const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
const defaultRange = lastSevenDays();
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) { export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) {
const [ruleRows, setRuleRows] = useState([]); const [ruleRows, setRuleRows] = useState([]);
const [recordingRows, setRecordingRows] = useState([]); const [recordingRows, setRecordingRows] = useState([]);
const [qualityLoading, setQualityLoading] = useState(false); const [qualityLoading, setQualityLoading] = useState(false);
const [qualityError, setQualityError] = useState(''); const [qualityError, setQualityError] = useState('');
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50' }); const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50', ...defaultRange });
const [showRules, setShowRules] = useState(false); const [showRules, setShowRules] = useState(false);
const [editingRule, setEditingRule] = useState(undefined); const [editingRule, setEditingRule] = useState(undefined);
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm); const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
@@ -51,6 +53,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
const params = { const params = {
limit: nextFilter.limit, limit: nextFilter.limit,
reviewStatus: nextFilter.reviewStatus, reviewStatus: nextFilter.reviewStatus,
...dateRangeParams(nextFilter.startDate, nextFilter.endDate),
}; };
const recordingList = await api.recordings(params); const recordingList = await api.recordings(params);
setRecordingRows((recordingList || []).map(normalizeRecording)); setRecordingRows((recordingList || []).map(normalizeRecording));
@@ -293,6 +296,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
const changeRecordingFilter = (key, value) => { const changeRecordingFilter = (key, value) => {
const nextFilter = { ...recordingFilter, [key]: value }; const nextFilter = { ...recordingFilter, [key]: value };
setRecordingFilter(nextFilter); setRecordingFilter(nextFilter);
};
const resetRecordingFilter = () => {
const nextFilter = { reviewStatus: 'all', limit: recordingFilter.limit, ...defaultRange };
setRecordingFilter(nextFilter);
void refreshQuality(nextFilter); void refreshQuality(nextFilter);
}; };
@@ -313,6 +320,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<option value="REVIEWED">已完成</option> <option value="REVIEWED">已完成</option>
</Select> </Select>
</Field> </Field>
<DateRangeFields startDate={recordingFilter.startDate} endDate={recordingFilter.endDate} onStartChange={(value) => changeRecordingFilter('startDate', value)} onEndChange={(value) => changeRecordingFilter('endDate', value)} />
<Field label="读取条数"> <Field label="读取条数">
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}> <Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
<option value="25">25</option> <option value="25">25</option>
@@ -320,6 +328,8 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<option value="100">100</option> <option value="100">100</option>
</Select> </Select>
</Field> </Field>
<Button icon={<Icon type="search" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>查询</Button>
<Button variant="outline" disabled={qualityLoading} onClick={resetRecordingFilter}>重置</Button>
</Toolbar> </Toolbar>
<Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel"> <Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel">
<SimpleTable loading={qualityLoading} rows={recordingRows} columns={[ <SimpleTable loading={qualityLoading} rows={recordingRows} columns={[
+28
View File
@@ -2196,11 +2196,39 @@ a {
width: 100%; width: 100%;
max-width: var(--content-max-width); max-width: var(--content-max-width);
gap: 20px; gap: 20px;
align-content: start;
grid-auto-rows: max-content;
min-height: 0; min-height: 0;
padding: 28px; padding: 28px;
overflow-y: auto; overflow-y: auto;
} }
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 16px;
color: var(--muted);
font-size: 13px;
}
.pagination-actions,
.pagination-actions label {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-actions select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
padding: 0 28px 0 10px;
}
.login-shell { .login-shell {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)), linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)),
+19
View File
@@ -0,0 +1,19 @@
const pad = (value) => String(value).padStart(2, '0');
export function formatDateInput(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function lastSevenDays(reference = new Date()) {
const end = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const start = new Date(end);
start.setDate(start.getDate() - 6);
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
}
export function dateRangeParams(startDate, endDate, fromKey = 'startedFrom', toKey = 'startedTo') {
const params = {};
if (startDate) params[fromKey] = new Date(`${startDate}T00:00:00`).toISOString();
if (endDate) params[toKey] = new Date(`${endDate}T23:59:59.999`).toISOString();
return params;
}
+35 -1
View File
@@ -1941,6 +1941,40 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 | | 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 |
| 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 | | 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 |
### 8.10 S57 页面布局、日期范围与号码库
#### WEB-010 页面内容顶部对齐
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 步骤 | 登录后依次打开客户网关、用户、号码库以及数据量较少的配置页,检查标题、筛选区和列表面板。 |
| 预期结果 | 所有区块按内容高度从顶部连续排列,不因视口剩余高度产生大面积空白或组件纵向拉伸。 |
#### ACT-004 B 本机 OpenSIPS MI
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 步骤 | 1. 在 B 调用 OpenSIPS HTTP MI `dlg_list`。2. 打开当前通话。3. 发起测试呼叫后刷新。 |
| 预期结果 | 无 A 服务器 SSH 依赖;空闲时快速返回空列表,呼叫中显示真实 dialog;MI 不可用时 3 秒内失败并显示受控提示。 |
#### DATE-001 三中心默认最近一周
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 步骤 | 依次打开话单、质检、操作日志,检查日期输入和请求参数;修改范围查询,再点击重置。 |
| 预期结果 | 首次和重置均为今天至前 6 天;开始日按 00:00:00、结束日按 23:59:59.999 查询;后端只返回范围内数据。 |
#### NUM-004 号码库全量与分页性能
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 步骤 | 1. 导入器先干跑并确认未匹配为 0。2. 备份后导入。3. 核对四表数量。4. 四个页签执行首屏、翻页、页大小和筛选。 |
| 预期结果 | 371 个行政城市、517,258 个有效七位号段、321 个区号、70 个前缀规则;首屏只读取 25 条;上一页/下一页与总数正确;页面加载不扫描或返回全表。 |
## 9. 缺陷分级 ## 9. 缺陷分级
| 级别 | 定义 | 示例 | | 级别 | 定义 | 示例 |
@@ -1965,7 +1999,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
## 11. 当前已知风险与补充建议 ## 11. 当前已知风险与补充建议
- Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。 - Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。
- 当前真实 80 万手机号段尚未导入。号码库性能、导入耗时、批量校验和 CDR 归属地准确性需单独做数据量级测试 - S57 号码库采用 517,258 条有效七位号段;归属和运营商字段是公开号段库口径,携号转网后的当前运营商不能仅凭该字段断言,仍需运营商实时数据源校验
- 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。 - 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。
- 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。 - 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。
- 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。 - 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。
@@ -16,11 +16,28 @@
- 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。 - 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。
- Dashboard 未接入指标明确显示“等待真实数据”,不再使用开发 fixture 伪装实时数据;操作向文案已做中文化。 - Dashboard 未接入指标明确显示“等待真实数据”,不再使用开发 fixture 伪装实时数据;操作向文案已做中文化。
## 暂未直接实施 ## S57 补充整改
- 全局 `.page-content` 固定为顶部对齐和内容高度网格行,消除数据较少时标题、筛选区和列表面板被拉伸形成的大面积空白;分页和日期范围控件收敛为全局组件和样式。
- 当前通话不再依赖 B 到 A 的 SSH 包装命令,API 改为请求 B 本机 `http://127.0.0.1:8888/mi` 的 OpenSIPS JSON-RPC;保留 SSH 模式作为显式回滚选项。
- 话单中心、质检中心、操作日志统一默认查询最近 7 个自然日(含今天),结束日期覆盖至当天 23:59:59.999;重置后仍恢复最近一周,不再回到无限时间范围。
- 质检录音和操作日志补齐后端日期过滤,操作日志查询由“只对首批 100 条做浏览器过滤”改为日期、模块、结果的服务端过滤。
- Dashboard 删除“客户消费 TOP 10”和“失败响应码分布”两个模块。
- 号码库默认每页 25 条,四个页签分别维护分页游标、页大小和总数,只请求当前页;增加可重复执行的数据导入脚本,导入时保存来源和批次并拒绝部分匹配。
## 号码库数据口径
- 行政区划:国家统计局 2023-06-30 区划代码的 2024 整理版本;包含地级行政区和省/自治区直辖县级市,共 371 条。
- 手机号段:`phone.dat` 2502 版本,共 517,258 条有效七位号段;运营商信息受携号转网影响,只表示号段原始分配归属,不作为当前在网运营商的强校验。
- 城市区号:由号段库归属记录按城市多数匹配生成 321 条;当前表结构一个区号只能关联一个城市,共享区号按数据中出现最多的城市保存。
- 运营商前缀:由七位号段按前三位多数归属生成 70 条,业务判定仍应优先使用七位号段。
- 导入器要求所有有效号段均能映射到行政区代码;本批次干跑结果为未匹配 0 条。生产导入前必须先运行 MySQL/Redis 备份。
## S56 时暂未直接实施(现状更新)
- 未新增数据库索引。整改方案要求先以生产库 `EXPLAIN ANALYZE` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。 - 未新增数据库索引。整改方案要求先以生产库 `EXPLAIN ANALYZE` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。
- 未修改 TLS 证书、Nginx 或生产部署配置。 - 未修改 TLS 证书、Nginx 或生产部署配置。
- 未发布生产。当前改动仅在本地源码、测试和构建层验证 - S57 发布结果、生产数量和浏览器证据记录在对应发布报告中
## 本地验证 ## 本地验证
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -Eeuo pipefail
commit=${1:?commit is required}
archive=${2:?archive path is required}
expected_sha=${3:?archive sha256 is required}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup=/var/backups/lisglosips-s57/$stamp
old=$(readlink -f /opt/lisglosips/current)
new=/opt/lisglosips/releases/s57-ui-number-library-$stamp
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_sha"
test -d "$old"
test ! -e "$new"
echo "phase=data-backup"
systemctl start lisglosips-backup.service
if systemctl --quiet is-failed lisglosips-backup.service; then
systemctl status --no-pager lisglosips-backup.service
exit 1
fi
echo "phase=config-backup"
install -d -m 0700 "$backup"
cp -a /etc/lisglosips/api.env "$backup/api.env"
printf '%s\n' "$old" > "$backup/previous-release"
changed=0
rollback() {
rc=$?
if [ "$changed" -eq 1 ]; then
echo "phase=rollback rc=$rc"
cp -a "$backup/api.env" /etc/lisglosips/api.env
ln -sfn "$old" /opt/lisglosips/current
systemctl restart lisglosips@api || true
fi
exit "$rc"
}
trap rollback ERR
echo "phase=stage-release"
cp -a --reflink=auto "$old" "$new"
tar -xzf "$archive" -C "$new"
printf '%s\n' "$commit" > "$new/.deployed-commit"
printf '%s\n' "$old" > "$new/.delta-base-release"
chown -R root:lisglosips "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
chmod 0755 "$new/infra/server-b/s57/deploy-ui-number-library.sh"
tmp_env=$(mktemp)
grep -Ev '^(ACTIVE_CALLS_MODE|ACTIVE_CALLS_HTTP_URL|ACTIVE_CALLS_TIMEOUT_MS)=' /etc/lisglosips/api.env > "$tmp_env"
printf '%s\n' 'ACTIVE_CALLS_MODE=http' 'ACTIVE_CALLS_HTTP_URL=http://127.0.0.1:8888/mi' 'ACTIVE_CALLS_TIMEOUT_MS=3000' >> "$tmp_env"
install -o root -g lisglosips -m 0640 "$tmp_env" /etc/lisglosips/api.env
rm -f "$tmp_env"
changed=1
ln -sfn "$new" /opt/lisglosips/current
systemctl restart lisglosips@api
sleep 4
echo "phase=verify"
test "$(readlink -f /opt/lisglosips/current)" = "$new"
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
test "$(systemctl is-active lisglosips@api)" = active
curl --fail --silent --show-error http://127.0.0.1:3000/api/v2/health >/dev/null
curl --fail --silent --show-error -H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' \
http://127.0.0.1:8888/mi >/dev/null
/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
printf 'BACKUP=%s\nRELEASE=%s\nCOMMIT=%s\n' "$backup" "$new" "$commit"
changed=0
trap - ERR
+3 -1
View File
@@ -72,8 +72,10 @@ const copyEntries = [
['packages/redis/node_modules', 'packages/redis/node_modules', true], ['packages/redis/node_modules', 'packages/redis/node_modules', true],
['infra/server-b/s30', 'infra/server-b/s30', true], ['infra/server-b/s30', 'infra/server-b/s30', true],
['infra/server-b/s56', 'infra/server-b/s56', true], ['infra/server-b/s56', 'infra/server-b/s56', true],
['infra/server-b/s57', 'infra/server-b/s57', true],
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true], ['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true] ['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
['scripts/import-number-library.mjs', 'scripts/import-number-library.mjs', true]
]; ];
function run(command, commandArgs, options = {}) { function run(command, commandArgs, options = {}) {
+184
View File
@@ -0,0 +1,184 @@
import fs from 'node:fs';
import zlib from 'node:zlib';
import crypto from 'node:crypto';
import { PrismaClient } from '../packages/database/dist/index.js';
const args = Object.fromEntries(process.argv.slice(2).map((entry) => {
const [key, ...value] = entry.replace(/^--/, '').split('=');
return [key, value.length ? value.join('=') : true];
}));
if (!args.admin || !args.phone) {
throw new Error('Usage: node scripts/import-number-library.mjs --admin=/path/area.json.gz --phone=/path/phone.dat [--dry-run]');
}
const batchId = String(args.batch || 'number-library-20260827');
const adminSource = '国家统计局区划代码整理库 2024 (2023-06-30)';
const phoneSource = 'pangongzi/phone phone.dat 2025-02';
const adminTree = JSON.parse(zlib.gunzipSync(fs.readFileSync(String(args.admin))).toString('utf8'));
const sixDigitCode = (code) => String(code).slice(0, 6);
const normalize = (value) => String(value || '')
.replace(/\s+/g, '')
.replace(/特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|土家族苗族自治州|苗族侗族自治州|藏族自治州|蒙古族藏族自治州|柯尔克孜自治州|哈萨克自治州|自治州|省|市|地区|盟|县$/g, '');
const cities = [];
for (const province of adminTree) {
for (const city of province.children || []) {
const base = {
code: sixDigitCode(city.code),
provinceCode: sixDigitCode(province.code),
provinceName: province.name,
cityCode: sixDigitCode(city.code),
cityName: city.name === '市辖区' ? province.name : city.name,
cityLevel: 'PREFECTURE',
status: 'ENABLED'
};
cities.push(base);
if (/直辖县级行政区划/.test(city.name)) {
cities.pop();
for (const countyCity of city.children || []) {
cities.push({ ...base, code: sixDigitCode(countyCity.code), cityCode: sixDigitCode(countyCity.code), cityName: countyCity.name, cityLevel: 'COUNTY_DIRECT' });
}
}
}
}
const phoneBuffer = fs.readFileSync(String(args.phone));
const indexOffset = phoneBuffer.readUInt32LE(4);
const phoneRecords = [];
for (let offset = indexOffset; offset + 9 <= phoneBuffer.length; offset += 9) {
const segment7 = String(phoneBuffer.readUInt32LE(offset)).padStart(7, '0');
const dataOffset = phoneBuffer.readUInt32LE(offset + 4);
const end = phoneBuffer.indexOf(0, dataOffset);
const [provinceName, cityName, zipCode, areaCode] = phoneBuffer.subarray(dataOffset, end).toString('utf8').split('|');
phoneRecords.push({ segment7, provinceName, cityName, zipCode, areaCode, type: phoneBuffer[offset + 8] });
}
const byProvince = new Map();
for (const city of cities) {
const key = normalize(city.provinceName);
const values = byProvince.get(key) || [];
values.push(city);
byProvince.set(key, values);
}
function findCity(record) {
const provinceCities = byProvince.get(normalize(record.provinceName)) || [];
const aliasKey = `${normalize(record.provinceName)}|${normalize(record.cityName)}`;
const cityAliases = {
'山东|莱芜': '济南',
'新疆|巴州': '巴音郭楞',
'新疆|博州': '博尔塔拉',
'新疆|克州': '克孜勒苏',
'新疆|奎屯': '伊犁',
'青海|格尔木': '海西'
};
const cityName = cityAliases[aliasKey] || normalize(record.cityName || record.provinceName);
const exact = provinceCities.find((city) => normalize(city.cityName) === cityName);
if (exact) return exact;
const partial = provinceCities.filter((city) => normalize(city.cityName).startsWith(cityName) || cityName.startsWith(normalize(city.cityName)));
return partial.length === 1 ? partial[0] : provinceCities.length === 1 ? provinceCities[0] : null;
}
const directMatches = phoneRecords.map((record) => ({ record, city: findCity(record) }));
const areaVotes = new Map();
for (const { record, city } of directMatches) {
if (!city || !record.areaCode) continue;
const key = `${record.areaCode}|${city.cityCode}`;
areaVotes.set(key, (areaVotes.get(key) || 0) + 1);
}
const areaCity = new Map();
for (const [key, count] of areaVotes) {
const [areaCode, cityCode] = key.split('|');
const current = areaCity.get(areaCode);
if (!current || count > current.count) areaCity.set(areaCode, { cityCode, count });
}
const cityByCode = new Map(cities.map((city) => [city.cityCode, city]));
const unmatched = [];
const carrierMap = { 1: 'MOBILE', 2: 'UNICOM', 3: 'TELECOM', 4: 'MVNO', 5: 'MVNO', 6: 'MVNO', 7: 'BROADCAST', 8: 'MVNO' };
const segments = [];
for (const match of directMatches) {
if (!/^1\d{6}$/.test(match.record.segment7)) continue;
const city = match.city || cityByCode.get(areaCity.get(match.record.areaCode)?.cityCode);
if (!city) {
unmatched.push(match.record);
continue;
}
segments.push({
segment7: match.record.segment7,
cityCode: city.cityCode,
provinceName: city.provinceName,
cityName: city.cityName,
carrier: carrierMap[match.record.type] || 'UNKNOWN',
source: phoneSource,
batchId
});
}
const areaCodes = [...areaCity.entries()].map(([areaCode, vote]) => {
const city = cityByCode.get(vote.cityCode);
return { areaCode, cityCode: city.cityCode, provinceName: city.provinceName, cityName: city.cityName, source: phoneSource, batchId };
}).filter((item) => /^0\d{2,3}$/.test(item.areaCode));
const prefixVotes = new Map();
for (const segment of segments) {
const prefix = segment.segment7.slice(0, 3);
const key = `${prefix}|${segment.carrier}`;
prefixVotes.set(key, (prefixVotes.get(key) || 0) + 1);
}
const prefixWinners = new Map();
for (const [key, count] of prefixVotes) {
const [prefix, carrier] = key.split('|');
const current = prefixWinners.get(prefix);
if (!current || count > current.count) prefixWinners.set(prefix, { carrier, count });
}
const prefixes = [...prefixWinners.entries()].map(([prefix, value]) => ({ prefix, carrier: value.carrier, priority: 100, source: phoneSource, batchId }));
const report = {
batchId,
adminSource,
phoneSource,
phoneVersion: phoneBuffer.subarray(0, 4).toString('utf8'),
cities: cities.length,
sourcePhoneRecords: phoneRecords.length,
segments: segments.length,
unmatched: unmatched.length,
areaCodes: areaCodes.length,
prefixes: prefixes.length,
unmatchedLocations: [...new Map(unmatched.map(({ provinceName, cityName, areaCode }) => [`${provinceName}|${cityName}|${areaCode}`, { provinceName, cityName, areaCode }])).values()]
};
console.log(JSON.stringify(report, null, 2));
if (args['dry-run']) process.exit(0);
if (unmatched.length) throw new Error(`Refusing partial import: ${unmatched.length} phone segments could not be mapped to an administrative city.`);
const prisma = new PrismaClient();
try {
const current = {
cities: await prisma.geoCity.count(),
segments: await prisma.phoneNumberSegment.count(),
areaCodes: await prisma.phoneAreaCode.count(),
prefixes: await prisma.carrierPrefixRule.count()
};
if (Object.values(current).some(Boolean)) throw new Error(`Number library is not empty: ${JSON.stringify(current)}`);
await prisma.geoCity.createMany({ data: cities, skipDuplicates: true });
for (let offset = 0; offset < segments.length; offset += 1000) {
await prisma.phoneNumberSegment.createMany({ data: segments.slice(offset, offset + 1000), skipDuplicates: true });
}
await prisma.phoneAreaCode.createMany({ data: areaCodes, skipDuplicates: true });
await prisma.carrierPrefixRule.createMany({ data: prefixes, skipDuplicates: true });
await prisma.outboxEvent.create({
data: {
id: `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`,
aggregateType: 'number_library_config',
aggregateId: 'number-library',
eventType: 'number_library.seeded',
payload: report
}
});
console.log(JSON.stringify({ imported: report }, null, 2));
} finally {
await prisma.$disconnect();
}