feat: add number library routing and cdr location support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user