feat: add number library routing and cdr location support

This commit is contained in:
hectorzhao
2026-06-24 11:39:32 +08:00
parent 5fa1bd35e9
commit 7057fd3c42
63 changed files with 6361 additions and 566 deletions
@@ -0,0 +1,63 @@
import { Body, Controller, Get, Inject, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuditAction } from '../audit/audit.metadata.js';
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
import { NumberLibraryService } from './number-library.service.js';
@ApiTags('number-library')
@Controller('number-library')
export class NumberLibraryController {
constructor(@Inject(NumberLibraryService) private readonly numberLibraryService: NumberLibraryService) {}
@Get('cities')
@RequirePermissions('number_library.view')
listCities(@Query() query: unknown) {
return this.numberLibraryService.listCities(query as never);
}
@Post('cities/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_cities', objectType: 'geo_city' })
importCities(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importCities(body as never, currentUser?.id);
}
@Get('phone-segments')
@RequirePermissions('number_library.view')
listPhoneSegments(@Query() query: unknown) {
return this.numberLibraryService.listPhoneSegments(query as never);
}
@Post('phone-segments/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_phone_segments', objectType: 'phone_number_segment' })
importPhoneSegments(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importPhoneSegments(body as never, currentUser?.id);
}
@Get('area-codes')
@RequirePermissions('number_library.view')
listAreaCodes(@Query() query: unknown) {
return this.numberLibraryService.listAreaCodes(query as never);
}
@Post('area-codes/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_area_codes', objectType: 'phone_area_code' })
importAreaCodes(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importAreaCodes(body as never, currentUser?.id);
}
@Get('carrier-prefix-rules')
@RequirePermissions('number_library.view')
listCarrierPrefixRules(@Query() query: unknown) {
return this.numberLibraryService.listCarrierPrefixRules(query as never);
}
@Post('carrier-prefix-rules/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_carrier_prefix_rules', objectType: 'carrier_prefix_rule' })
importCarrierPrefixRules(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importCarrierPrefixRules(body as never, currentUser?.id);
}
}
@@ -0,0 +1,240 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
import type { CurrentUser } from '../security/security.metadata.js';
import {
NUMBER_LIBRARY_REPOSITORY,
type AreaCodeInput,
type CarrierPrefixInput,
type CityInput,
type ImportResult,
type NumberCarrier,
type NumberLibraryRepository,
type PageQuery,
type PhoneSegmentInput
} from './number-library.repository.js';
class MemoryIdentityRepository implements IdentityRepository {
users = new Map<string, CurrentUser>();
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
return this.users.get(userId) ?? null;
}
}
class MemoryAuditRepository implements AuditRepository {
entries: AuditEntryInput[] = [];
async write(input: AuditEntryInput): Promise<void> {
this.entries.push(input);
}
}
class MemoryNumberLibraryRepository implements NumberLibraryRepository {
cities: CityInput[] = [];
segments: PhoneSegmentInput[] = [];
areaCodes: AreaCodeInput[] = [];
carrierRules: CarrierPrefixInput[] = [];
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
return this.page(this.cities.filter((city) => !query.keyword || city.cityName.includes(query.keyword) || city.provinceName.includes(query.keyword)), query);
}
async importCities(items: CityInput[]): Promise<ImportResult> {
this.upsert(this.cities, items, (item) => item.code);
return { imported: items.length };
}
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
return this.page(
this.segments.filter(
(item) =>
(!query.segment7 || item.segment7.startsWith(query.segment7)) &&
(!query.cityCode || item.cityCode === query.cityCode) &&
(!query.carrier || item.carrier === query.carrier)
),
query
);
}
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
this.upsert(this.segments, items, (item) => item.segment7);
return { imported: items.length };
}
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
return this.page(
this.areaCodes.filter((item) => (!query.areaCode || item.areaCode.startsWith(query.areaCode)) && (!query.cityCode || item.cityCode === query.cityCode)),
query
);
}
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
this.upsert(this.areaCodes, items, (item) => item.areaCode);
return { imported: items.length };
}
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
return this.page(
this.carrierRules.filter((item) => (!query.prefix || item.prefix.startsWith(query.prefix)) && (!query.carrier || item.carrier === query.carrier)),
query
);
}
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
this.upsert(this.carrierRules, items, (item) => item.prefix);
return { imported: items.length };
}
private upsert<T>(target: T[], items: T[], key: (item: T) => string): void {
for (const item of items) {
const index = target.findIndex((existing) => key(existing) === key(item));
if (index >= 0) {
target[index] = item;
} else {
target.push(item);
}
}
}
private page(items: unknown[], query: PageQuery): { items: unknown[]; total: number } {
return { items: items.slice(query.skip, query.skip + query.take), total: items.length };
}
}
describe('number library API', () => {
let app: NestFastifyApplication;
let audit: MemoryAuditRepository;
const tokenFor = (userId: string) =>
signAccessToken(
{
sub: userId,
username: userId,
roles: ['test'],
typ: 'access'
},
{
secret: 'test-only-access-token-secret-min-32-bytes',
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
ttlSeconds: 900
}
);
beforeAll(async () => {
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
const identities = new MemoryIdentityRepository();
audit = new MemoryAuditRepository();
identities.users.set('usr_ops', {
id: 'usr_ops',
username: 'ops',
roles: ['运营管理员'],
permissions: ['number_library.view', 'number_library.manage'] as PermissionKey[]
});
identities.users.set('usr_viewer', {
id: 'usr_viewer',
username: 'viewer',
roles: ['只读'],
permissions: ['number_library.view'] as PermissionKey[]
});
const { AppModule } = await import('../app.module.js');
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(IDENTITY_REPOSITORY)
.useValue(identities)
.overrideProvider(AUDIT_REPOSITORY)
.useValue(audit)
.overrideProvider(NUMBER_LIBRARY_REPOSITORY)
.useValue(new MemoryNumberLibraryRepository())
.compile();
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
app.setGlobalPrefix('api/v2');
await app.init();
await app.getHttpAdapter().getInstance().ready();
}, 30000);
afterAll(async () => {
await app?.close();
});
it('rejects imports without number_library.manage', async () => {
await request(app.getHttpServer())
.post('/api/v2/number-library/cities/import')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.send({ items: [] })
.expect(403);
});
it('imports and lists cities, phone segments, area codes, and carrier rules', async () => {
await request(app.getHttpServer())
.post('/api/v2/number-library/cities/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ code: '340100', provinceCode: '340000', provinceName: '安徽', cityCode: '340100', cityName: '合肥', cityLevel: '地级市' }] })
.expect(201)
.expect((response) => expect(response.body).toEqual({ imported: 1 }));
await request(app.getHttpServer())
.post('/api/v2/number-library/phone-segments/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ segment7: '1385510', cityCode: '340100', provinceName: '安徽', cityName: '合肥', carrier: 'MOBILE', batchId: 'batch_test' }] })
.expect(201);
await request(app.getHttpServer())
.post('/api/v2/number-library/area-codes/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ areaCode: '0551', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
.expect(201);
await request(app.getHttpServer())
.post('/api/v2/number-library/carrier-prefix-rules/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ prefix: '138', carrier: 'MOBILE', priority: 10 }] })
.expect(201);
await request(app.getHttpServer())
.get('/api/v2/number-library/phone-segments?segment7=138&carrier=MOBILE')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.expect(200)
.expect((response) => {
expect(response.body.total).toBe(1);
expect(response.body.items[0]).toMatchObject({ segment7: '1385510', cityName: '合肥', carrier: 'MOBILE' });
});
await request(app.getHttpServer())
.get('/api/v2/number-library/area-codes?areaCode=0551')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.expect(200)
.expect((response) => expect(response.body.items[0]).toMatchObject({ areaCode: '0551', cityName: '合肥' }));
await request(app.getHttpServer())
.get('/api/v2/number-library/carrier-prefix-rules?prefix=138')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.expect(200)
.expect((response) => expect(response.body.items[0]).toMatchObject({ prefix: '138', carrier: 'MOBILE' }));
expect(audit.entries.some((entry) => entry.module === 'number_library' && entry.action === 'import_cities')).toBe(true);
});
it('validates import batch shape and segment digits', async () => {
await request(app.getHttpServer())
.post('/api/v2/number-library/phone-segments/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ segment7: '13855', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
.expect(400)
.expect((response) => expect(response.body).toMatchObject({ code: 'DIGITS_INVALID' }));
});
});
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { NumberLibraryController } from './number-library.controller.js';
import { NUMBER_LIBRARY_REPOSITORY, PrismaNumberLibraryRepository } from './number-library.repository.js';
import { NumberLibraryService } from './number-library.service.js';
@Module({
controllers: [NumberLibraryController],
providers: [
NumberLibraryService,
{
provide: NUMBER_LIBRARY_REPOSITORY,
useClass: PrismaNumberLibraryRepository
}
],
exports: [NumberLibraryService]
})
export class NumberLibraryModule {}
@@ -0,0 +1,310 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export type NumberCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
export interface PageQuery {
keyword?: string;
take: number;
skip: number;
}
export interface CityInput {
code: string;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
cityLevel: string;
status?: 'ENABLED' | 'DISABLED';
effectiveFrom?: Date | null;
effectiveTo?: Date | null;
actorId?: string;
}
export interface PhoneSegmentInput {
segment7: string;
cityCode: string;
provinceName: string;
cityName: string;
carrier?: NumberCarrier;
source?: string | null;
batchId?: string | null;
effectiveFrom?: Date | null;
effectiveTo?: Date | null;
actorId?: string;
}
export interface AreaCodeInput {
areaCode: string;
cityCode: string;
provinceName: string;
cityName: string;
source?: string | null;
batchId?: string | null;
actorId?: string;
}
export interface CarrierPrefixInput {
prefix: string;
carrier: NumberCarrier;
priority: number;
source?: string | null;
batchId?: string | null;
effectiveFrom?: Date | null;
effectiveTo?: Date | null;
actorId?: string;
}
export interface ImportResult {
imported: number;
}
export const NUMBER_LIBRARY_REPOSITORY = Symbol('NUMBER_LIBRARY_REPOSITORY');
function outboxId(): string {
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
}
export interface NumberLibraryRepository {
listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }>;
importCities(items: CityInput[]): Promise<ImportResult>;
listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult>;
listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }>;
importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult>;
listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult>;
}
@Injectable()
export class PrismaNumberLibraryRepository implements NumberLibraryRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.GeoCityWhereInput = {
deletedAt: null,
OR: query.keyword
? [
{ provinceName: { contains: query.keyword } },
{ cityName: { contains: query.keyword } },
{ cityCode: { contains: query.keyword } },
{ provinceCode: { contains: query.keyword } }
]
: undefined
};
const [items, total] = await this.prisma.$transaction([
this.prisma.geoCity.findMany({ where, orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.geoCity.count({ where })
]);
return { items, total };
}
async importCities(items: CityInput[]): Promise<ImportResult> {
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.geoCity.upsert({
where: { code: item.code },
update: {
provinceCode: item.provinceCode,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
cityLevel: item.cityLevel,
status: item.status ?? 'ENABLED',
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
code: item.code,
provinceCode: item.provinceCode,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
cityLevel: item.cityLevel,
status: item.status ?? 'ENABLED',
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'geo_city.changed', items.length);
});
return { imported: items.length };
}
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.PhoneNumberSegmentWhereInput = {
deletedAt: null,
segment7: query.segment7 ? { startsWith: query.segment7 } : undefined,
cityCode: query.cityCode,
carrier: query.carrier,
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
};
const [items, total] = await this.prisma.$transaction([
this.prisma.phoneNumberSegment.findMany({ where, orderBy: [{ segment7: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.phoneNumberSegment.count({ where })
]);
return { items, total };
}
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
await this.ensureCities(items.map((item) => item.cityCode));
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.phoneNumberSegment.upsert({
where: { segment7: item.segment7 },
update: {
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
carrier: item.carrier ?? 'UNKNOWN',
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
segment7: item.segment7,
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
carrier: item.carrier ?? 'UNKNOWN',
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'phone_number_segment.changed', items.length);
});
return { imported: items.length };
}
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.PhoneAreaCodeWhereInput = {
deletedAt: null,
areaCode: query.areaCode ? { startsWith: query.areaCode } : undefined,
cityCode: query.cityCode,
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
};
const [items, total] = await this.prisma.$transaction([
this.prisma.phoneAreaCode.findMany({ where, orderBy: [{ areaCode: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.phoneAreaCode.count({ where })
]);
return { items, total };
}
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
await this.ensureCities(items.map((item) => item.cityCode));
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.phoneAreaCode.upsert({
where: { areaCode: item.areaCode },
update: {
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
source: item.source,
batchId: item.batchId,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
areaCode: item.areaCode,
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
source: item.source,
batchId: item.batchId,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'phone_area_code.changed', items.length);
});
return { imported: items.length };
}
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.CarrierPrefixRuleWhereInput = {
deletedAt: null,
prefix: query.prefix ? { startsWith: query.prefix } : undefined,
carrier: query.carrier
};
const [items, total] = await this.prisma.$transaction([
this.prisma.carrierPrefixRule.findMany({ where, orderBy: [{ prefix: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.carrierPrefixRule.count({ where })
]);
return { items, total };
}
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.carrierPrefixRule.upsert({
where: { prefix: item.prefix },
update: {
carrier: item.carrier,
priority: item.priority,
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
prefix: item.prefix,
carrier: item.carrier,
priority: item.priority,
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'carrier_prefix_rule.changed', items.length);
});
return { imported: items.length };
}
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, eventType: string, itemCount: number): Promise<void> {
await tx.outboxEvent.create({
data: {
id: outboxId(),
aggregateType: 'number_library_config',
aggregateId: 'number-library',
eventType,
payload: { eventType, itemCount }
}
});
}
private async ensureCities(cityCodes: string[]): Promise<void> {
const uniqueCodes = [...new Set(cityCodes)];
const count = await this.prisma.geoCity.count({ where: { cityCode: { in: uniqueCodes }, deletedAt: null } });
if (count !== uniqueCodes.length) {
throw new BadRequestException({ code: 'CITY_NOT_FOUND', message: 'One or more city codes are invalid.' });
}
}
}
@@ -0,0 +1,186 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
NUMBER_LIBRARY_REPOSITORY,
type AreaCodeInput,
type CarrierPrefixInput,
type CityInput,
type NumberCarrier,
type NumberLibraryRepository,
type PageQuery,
type PhoneSegmentInput
} from './number-library.repository.js';
const MAX_IMPORT_ITEMS = 1000;
type QueryRecord = Record<string, unknown>;
@Injectable()
export class NumberLibraryService {
constructor(@Inject(NUMBER_LIBRARY_REPOSITORY) private readonly numberLibrary: NumberLibraryRepository) {}
listCities(query: QueryRecord) {
return this.numberLibrary.listCities(this.page(query));
}
importCities(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
code: this.limitedString(item.code, 'code', 12),
provinceCode: this.limitedString(item.provinceCode, 'provinceCode', 12),
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
cityName: this.limitedString(item.cityName, 'cityName', 80),
cityLevel: this.limitedString(item.cityLevel, 'cityLevel', 32),
status: item.status === undefined ? 'ENABLED' : this.status(item.status),
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
actorId
})) satisfies CityInput[];
return this.numberLibrary.importCities(items);
}
listPhoneSegments(query: QueryRecord) {
return this.numberLibrary.listPhoneSegments({
...this.page(query),
segment7: query.segment7 === undefined ? undefined : this.digits(query.segment7, 'segment7', 1, 7),
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12),
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
});
}
importPhoneSegments(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
segment7: this.digits(item.segment7, 'segment7', 7, 7),
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
cityName: this.limitedString(item.cityName, 'cityName', 80),
carrier: item.carrier === undefined ? 'UNKNOWN' : this.carrier(item.carrier),
source: this.optionalLimitedString(item.source, 'source', 120),
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
actorId
})) satisfies PhoneSegmentInput[];
return this.numberLibrary.importPhoneSegments(items);
}
listAreaCodes(query: QueryRecord) {
return this.numberLibrary.listAreaCodes({
...this.page(query),
areaCode: query.areaCode === undefined ? undefined : this.digits(query.areaCode, 'areaCode', 2, 8),
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12)
});
}
importAreaCodes(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
areaCode: this.digits(item.areaCode, 'areaCode', 2, 8),
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
cityName: this.limitedString(item.cityName, 'cityName', 80),
source: this.optionalLimitedString(item.source, 'source', 120),
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
actorId
})) satisfies AreaCodeInput[];
return this.numberLibrary.importAreaCodes(items);
}
listCarrierPrefixRules(query: QueryRecord) {
return this.numberLibrary.listCarrierPrefixRules({
...this.page(query),
prefix: query.prefix === undefined ? undefined : this.digits(query.prefix, 'prefix', 1, 4),
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
});
}
importCarrierPrefixRules(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
prefix: this.digits(item.prefix, 'prefix', 3, 4),
carrier: this.carrier(item.carrier),
priority: this.integer(item.priority ?? 100, 'priority', 1, 10_000),
source: this.optionalLimitedString(item.source, 'source', 120),
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
actorId
})) satisfies CarrierPrefixInput[];
return this.numberLibrary.importCarrierPrefixRules(items);
}
private page(query: QueryRecord): PageQuery {
return {
keyword: query.keyword === undefined ? undefined : this.limitedString(query.keyword, 'keyword', 80),
take: this.integer(query.take ?? 50, 'take', 1, 500),
skip: this.integer(query.skip ?? 0, 'skip', 0, 1_000_000)
};
}
private items(body: QueryRecord): QueryRecord[] {
if (!Array.isArray(body.items) || body.items.length === 0 || body.items.length > MAX_IMPORT_ITEMS) {
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: `items must contain 1-${MAX_IMPORT_ITEMS} records.` });
}
if (!body.items.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: 'items must be objects.' });
}
return body.items as QueryRecord[];
}
private limitedString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
}
const trimmed = value.trim();
if (trimmed.length > maxLength) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
}
return trimmed;
}
private optionalLimitedString(value: unknown, field: string, maxLength: number): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
return this.limitedString(value, field, maxLength);
}
private digits(value: unknown, field: string, minLength: number, maxLength: number): string {
const text = this.limitedString(value, field, maxLength);
if (!new RegExp(`^\\d{${minLength},${maxLength}}$`).test(text)) {
throw new BadRequestException({ code: 'DIGITS_INVALID', message: `${field} must contain ${minLength}-${maxLength} digits.` });
}
return text;
}
private integer(value: unknown, field: string, min: number, max: number): number {
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
if (!Number.isInteger(number) || number < min || number > max) {
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} is invalid.` });
}
return number;
}
private carrier(value: unknown): NumberCarrier {
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
return value;
}
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
}
private status(value: unknown): 'ENABLED' | 'DISABLED' {
if (value === 'ENABLED' || value === 'DISABLED') {
return value;
}
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
}
private optionalDate(value: unknown, field: string): Date | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
return date;
}
}