diff --git a/apps/api/src/modules/active-calls/opensips-mi.client.spec.ts b/apps/api/src/modules/active-calls/opensips-mi.client.spec.ts new file mode 100644 index 0000000..633de72 --- /dev/null +++ b/apps/api/src/modules/active-calls/opensips-mi.client.spec.ts @@ -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; + 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' }) }); + }); +}); diff --git a/apps/api/src/modules/active-calls/opensips-mi.client.ts b/apps/api/src/modules/active-calls/opensips-mi.client.ts index 752fb39..dad4975 100644 --- a/apps/api/src/modules/active-calls/opensips-mi.client.ts +++ b/apps/api/src/modules/active-calls/opensips-mi.client.ts @@ -63,17 +63,13 @@ export class OpenSipsMiClient { private async call(method: string, params: string[] = []): Promise { 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; + let response: MiResponse; 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) { + if (error instanceof BadGatewayException) throw error; throw new BadGatewayException({ code: 'ACTIVE_CALLS_MI_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) { throw new BadGatewayException({ code: 'ACTIVE_CALLS_MI_ERROR', @@ -101,6 +87,42 @@ export class OpenSipsMiClient { return response.result; } + + private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise { + 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 { + 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 { diff --git a/apps/api/src/modules/audit-logs/audit-logs.repository.ts b/apps/api/src/modules/audit-logs/audit-logs.repository.ts index e3d615c..b06390a 100644 --- a/apps/api/src/modules/audit-logs/audit-logs.repository.ts +++ b/apps/api/src/modules/audit-logs/audit-logs.repository.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@lisglosips/database'; import { PrismaService } from '../database/prisma.service.js'; export interface AuditLogQuery { @@ -8,6 +9,8 @@ export interface AuditLogQuery { objectType?: string; objectId?: string; result?: 'SUCCESS' | 'FAILURE'; + createdFrom?: Date; + createdTo?: Date; take: number; skip: number; } @@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> { - const where = { + const where: Prisma.AuditLogWhereInput = { module: query.module, action: query.action, userId: query.userId, objectType: query.objectType, 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([ diff --git a/apps/api/src/modules/audit-logs/audit-logs.service.ts b/apps/api/src/modules/audit-logs/audit-logs.service.ts index cf78662..3dde935 100644 --- a/apps/api/src/modules/audit-logs/audit-logs.service.ts +++ b/apps/api/src/modules/audit-logs/audit-logs.service.ts @@ -13,6 +13,8 @@ export class AuditLogsService { objectType: this.optionalString(rawQuery.objectType), objectId: this.optionalString(rawQuery.objectId), 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), skip: this.positiveInt(rawQuery.skip, 0, 10_000) }; @@ -56,4 +58,16 @@ export class AuditLogsService { 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; + } } diff --git a/apps/api/src/modules/recordings/recordings.repository.ts b/apps/api/src/modules/recordings/recordings.repository.ts index c896c5a..d181ccf 100644 --- a/apps/api/src/modules/recordings/recordings.repository.ts +++ b/apps/api/src/modules/recordings/recordings.repository.ts @@ -65,8 +65,16 @@ export interface SaveReviewInput { export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY'); +export interface RecordingListQuery { + status?: string; + reviewStatus?: 'PENDING' | 'REVIEWED'; + limit?: number; + startedFrom?: Date; + startedTo?: Date; +} + export interface RecordingsRepository { - list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise; + list(query?: RecordingListQuery): Promise; getDetail(id: string): Promise; getReadyForPlayback(id: string): Promise; saveReview(input: SaveReviewInput): Promise; @@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{ export class PrismaRecordingsRepository implements RecordingsRepository { constructor(private readonly prisma: PrismaService) {} - async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise { + async list(query: RecordingListQuery = {}): Promise { 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({ where: { 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' }], take, diff --git a/apps/api/src/modules/recordings/recordings.service.ts b/apps/api/src/modules/recordings/recordings.service.ts index fa646b8..5348457 100644 --- a/apps/api/src/modules/recordings/recordings.service.ts +++ b/apps/api/src/modules/recordings/recordings.service.ts @@ -26,11 +26,13 @@ export class RecordingsService { @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({ status: query.status === undefined ? undefined : this.recordingStatus(query.status), 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))); } @@ -123,6 +125,17 @@ export class RecordingsService { } 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 { diff --git a/apps/api/src/shared/config.ts b/apps/api/src/shared/config.ts index f1b7ff3..2b4feaf 100644 --- a/apps/api/src/shared/config.ts +++ b/apps/api/src/shared/config.ts @@ -19,6 +19,8 @@ export interface RuntimeConfig { url: string; }; activeCalls: { + mode: 'http' | 'ssh'; + httpUrl: string; sshHost: string; sshConfig?: string; remoteCommand: string; @@ -47,6 +49,8 @@ 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_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_CONFIG: Joi.string().allow('').optional(), 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 ?? '' }, 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', sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined, remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control', diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index 47d6ddb..495898b 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Alert, Badge, Button, Field, Input } from './components/ui.jsx'; import { Icon, PageLoadingDialog } from './components/layout.jsx'; import { api, explainApiError } from './api.js'; +import { dateRangeParams, lastSevenDays } from './utils/dateRange.js'; import { formatCurrency, normalizeAuditLog, @@ -298,7 +299,8 @@ export default function App() { add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser))); add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole))); } 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') { add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway))); add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway))); @@ -318,6 +320,18 @@ export default function App() { setApiLoading(false); }; 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 } = {}) => { setActiveCallsLoading(true); if (!silent) setActiveCallsBlockingLoading(true); @@ -528,7 +542,7 @@ export default function App() { : active === 'roles' ? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) } : active === 'operationLogs' - ? { logRows, apiLoading, apiError, refreshApi } + ? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs } : {}; if (!authUser) { diff --git a/apps/web/src/api.js b/apps/web/src/api.js index bc0c6f1..9254bcf 100644 --- a/apps/web/src/api.js +++ b/apps/web/src/api.js @@ -153,7 +153,7 @@ export const api = { hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }), cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`), 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)}`), recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`), 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' }), roles: (options) => request('/roles', options), 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)}`), createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', 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' }), disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }), 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 }) }), - 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 }) }), - 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 }) }), - 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 }) }), }; diff --git a/apps/web/src/components/layout.jsx b/apps/web/src/components/layout.jsx index 27c9887..3962ce1 100644 --- a/apps/web/src/components/layout.jsx +++ b/apps/web/src/components/layout.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Badge, Button } from './ui.jsx'; +import { Alert, Badge, Button, Field, Input } from './ui.jsx'; const selectedBlue = '#2563EB'; @@ -42,6 +42,36 @@ export function Toolbar({ children }) { return
{children}
; } +export function DateRangeFields({ startDate, endDate, onStartChange, onEndChange }) { + return ( + <> + onStartChange(event.target.value)} /> + onEndChange(event.target.value)} /> + + ); +} + +export function Pagination({ total, take, skip, count, loading, onPageChange, onPageSizeChange }) { + const start = total ? skip + 1 : 0; + const end = Math.min(total, skip + count); + return ( +
+ 显示 {start}-{end} 条,共 {total} 条 +
+ + + +
+
+ ); +} + export function Panel({ title, aside, children, className = '' }) { return (
diff --git a/apps/web/src/pages/CdrPage.jsx b/apps/web/src/pages/CdrPage.jsx index f81d7b0..a04f7a0 100644 --- a/apps/web/src/pages/CdrPage.jsx +++ b/apps/web/src/pages/CdrPage.jsx @@ -1,8 +1,11 @@ import { useEffect, useState } from 'react'; 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 { api, explainApiError } from '../api.js'; +import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js'; + +const defaultRange = lastSevenDays(); export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) { const [detailCdr, setDetailCdr] = useState(null); @@ -24,8 +27,8 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can vendorGatewayId: 'all', cityCode: '', carrier: 'all', - startedFrom: '', - startedTo: '', + startedFrom: defaultRange.startDate, + startedTo: defaultRange.endDate, take: '25', skip: 0, }); @@ -100,8 +103,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can vendorGatewayId: nextFilters.vendorGatewayId, cityCode: nextFilters.cityCode.trim(), carrier: nextFilters.carrier, - startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '', - startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '', + ...dateRangeParams(nextFilters.startedFrom, nextFilters.startedTo), take: nextFilters.take, skip: String(nextFilters.skip), }); @@ -133,7 +135,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can void loadCdrs(nextFilters); }; 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); void loadCdrs(nextFilters); }; @@ -230,8 +232,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can - updateFilter('startedFrom', event.target.value)} /> - updateFilter('startedTo', event.target.value)} /> + updateFilter('startedFrom', value)} onEndChange={(value) => updateFilter('startedTo', value)} /> updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /> - + ); } @@ -207,7 +218,7 @@ export function NumberLibraryPage({ can = () => true }) { - + ); } @@ -216,7 +227,7 @@ export function NumberLibraryPage({ can = () => true }) { <> updateFilter('areaCode', event.target.value)} placeholder="如 0551" /> updateFilter('cityCode', event.target.value)} placeholder="如 340100" /> - + ); } @@ -234,7 +245,7 @@ export function NumberLibraryPage({ can = () => true }) { - + ); }; @@ -327,6 +338,17 @@ export function NumberLibraryPage({ can = () => true }) { ) : null} {renderTable(tab.value)} + {tab.value === activeTab ? ( + changePage(skip)} + onPageSizeChange={(take) => changePage(0, take)} + /> + ) : null} ), }))} diff --git a/apps/web/src/pages/OperationLogsPage.jsx b/apps/web/src/pages/OperationLogsPage.jsx index a31e6a0..029faf2 100644 --- a/apps/web/src/pages/OperationLogsPage.jsx +++ b/apps/web/src/pages/OperationLogsPage.jsx @@ -1,7 +1,10 @@ import { useMemo, useState } from 'react'; 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 { dateRangeParams, lastSevenDays } from '../utils/dateRange.js'; + +const defaultRange = lastSevenDays(); function toneForStatus(status) { 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 [moduleFilter, setModuleFilter] = useState('all'); const [resultFilter, setResultFilter] = useState('all'); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); + const [startDate, setStartDate] = useState(defaultRange.startDate); + const [endDate, setEndDate] = useState(defaultRange.endDate); const [detailLog, setDetailLog] = useState(null); const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]); 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); }); }, [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 ( <> }>导出日志} /> - + void queryLogs()} /> + setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /> - setStartDate(event.target.value)} /> - setEndDate(event.target.value)} /> + + 共 {visibleLogs.length} 条} className="wide-panel"> diff --git a/apps/web/src/pages/QualityPage.jsx b/apps/web/src/pages/QualityPage.jsx index 3708448..728c3a0 100644 --- a/apps/web/src/pages/QualityPage.jsx +++ b/apps/web/src/pages/QualityPage.jsx @@ -1,17 +1,19 @@ import { useEffect, useRef, useState } from 'react'; 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 { api, explainApiError } from '../api.js'; +import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js'; const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' }; +const defaultRange = lastSevenDays(); export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) { const [ruleRows, setRuleRows] = useState([]); const [recordingRows, setRecordingRows] = useState([]); const [qualityLoading, setQualityLoading] = useState(false); 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 [editingRule, setEditingRule] = useState(undefined); const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm); @@ -51,6 +53,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => const params = { limit: nextFilter.limit, reviewStatus: nextFilter.reviewStatus, + ...dateRangeParams(nextFilter.startDate, nextFilter.endDate), }; const recordingList = await api.recordings(params); setRecordingRows((recordingList || []).map(normalizeRecording)); @@ -293,6 +296,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => const changeRecordingFilter = (key, value) => { const nextFilter = { ...recordingFilter, [key]: value }; setRecordingFilter(nextFilter); + }; + const resetRecordingFilter = () => { + const nextFilter = { reviewStatus: 'all', limit: recordingFilter.limit, ...defaultRange }; + setRecordingFilter(nextFilter); void refreshQuality(nextFilter); }; @@ -313,6 +320,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => + changeRecordingFilter('startDate', value)} onEndChange={(value) => changeRecordingFilter('endDate', value)} /> + + 共 {recordingRows.length} 条录音} className="wide-panel"> 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; +} diff --git a/docs/TEST_PLAN_AND_CASES.md b/docs/TEST_PLAN_AND_CASES.md index 59e615a..232e9e3 100644 --- a/docs/TEST_PLAN_AND_CASES.md +++ b/docs/TEST_PLAN_AND_CASES.md @@ -1941,6 +1941,40 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 | | 安全检查 | 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. 缺陷分级 | 级别 | 定义 | 示例 | @@ -1965,7 +1999,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts ## 11. 当前已知风险与补充建议 - Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。 -- 当前真实 80 万手机号段尚未导入。号码库性能、导入耗时、批量校验和 CDR 归属地准确性需单独做数据量级测试。 +- S57 号码库采用 517,258 条有效七位号段;归属和运营商字段是公开号段库口径,携号转网后的当前运营商不能仅凭该字段断言,仍需运营商实时数据源校验。 - 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。 - 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。 - 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。 diff --git a/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md b/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md index 2cc0267..d12a4b3 100644 --- a/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md +++ b/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md @@ -16,11 +16,28 @@ - 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。 - 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` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。 - 未修改 TLS 证书、Nginx 或生产部署配置。 -- 未发布生产。当前改动仅在本地源码、测试和构建层验证。 +- S57 发布结果、生产数量和浏览器证据记录在对应发布报告中。 ## 本地验证 diff --git a/infra/server-b/s57/deploy-ui-number-library.sh b/infra/server-b/s57/deploy-ui-number-library.sh new file mode 100644 index 0000000..22a016f --- /dev/null +++ b/infra/server-b/s57/deploy-ui-number-library.sh @@ -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 diff --git a/scripts/build-release-artifact.mjs b/scripts/build-release-artifact.mjs index bf42022..2b07ba8 100644 --- a/scripts/build-release-artifact.mjs +++ b/scripts/build-release-artifact.mjs @@ -72,8 +72,10 @@ const copyEntries = [ ['packages/redis/node_modules', 'packages/redis/node_modules', true], ['infra/server-b/s30', 'infra/server-b/s30', 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], - ['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 = {}) { diff --git a/scripts/import-number-library.mjs b/scripts/import-number-library.mjs new file mode 100644 index 0000000..6961945 --- /dev/null +++ b/scripts/import-number-library.mjs @@ -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(); +}