feat: optimize routing and operations views
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DictionariesController } from './dictionaries.controller';
|
||||
import { DictionariesService } from './dictionaries.service';
|
||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DictionariesController],
|
||||
providers: [DictionariesService],
|
||||
exports: [DictionariesService],
|
||||
providers: [DictionariesService, PhoneRoutingLookupService],
|
||||
exports: [DictionariesService, PhoneRoutingLookupService],
|
||||
})
|
||||
export class DictionariesModule {}
|
||||
|
||||
@@ -11,6 +11,8 @@ function createPrismaMock() {
|
||||
phoneCarrierRule: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'rule-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'rule-1' }),
|
||||
},
|
||||
sensitiveWord: {
|
||||
findMany: jest.fn(),
|
||||
@@ -210,6 +212,17 @@ describe('DictionariesService', () => {
|
||||
expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
|
||||
});
|
||||
|
||||
it('invalidates the sending cache after carrier rule changes', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const phoneRoutingLookup = { invalidateCarrierRules: jest.fn() };
|
||||
const service = new DictionariesService(prisma as never, phoneRoutingLookup as never);
|
||||
|
||||
await service.createPhoneCarrierRule({ carrier: 'mobile', pattern: '^138' });
|
||||
await service.deletePhoneCarrierRule('rule-1');
|
||||
|
||||
expect(phoneRoutingLookup.invalidateCarrierRules).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||
|
||||
export interface CreatePhoneSegmentDto {
|
||||
prefix: string;
|
||||
@@ -83,7 +84,10 @@ export interface DictionaryListQuery {
|
||||
|
||||
@Injectable()
|
||||
export class DictionariesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService,
|
||||
) {}
|
||||
|
||||
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
@@ -129,7 +133,7 @@ export class DictionariesService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
|
||||
async createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
|
||||
if (!data.carrier || !data.pattern) {
|
||||
throw new BadRequestException('carrier and pattern are required');
|
||||
}
|
||||
@@ -138,7 +142,7 @@ export class DictionariesService {
|
||||
} catch {
|
||||
throw new BadRequestException('pattern must be a valid regular expression');
|
||||
}
|
||||
return this.prisma.phoneCarrierRule.create({
|
||||
const created = await this.prisma.phoneCarrierRule.create({
|
||||
data: {
|
||||
carrier: data.carrier,
|
||||
pattern: data.pattern,
|
||||
@@ -147,10 +151,14 @@ export class DictionariesService {
|
||||
remark: data.remark,
|
||||
},
|
||||
});
|
||||
this.phoneRoutingLookup?.invalidateCarrierRules();
|
||||
return created;
|
||||
}
|
||||
|
||||
deletePhoneCarrierRule(id: string) {
|
||||
return this.prisma.phoneCarrierRule.delete({ where: { id } });
|
||||
async deletePhoneCarrierRule(id: string) {
|
||||
const deleted = await this.prisma.phoneCarrierRule.delete({ where: { id } });
|
||||
this.phoneRoutingLookup?.invalidateCarrierRules();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
listSensitiveWords(query: DictionaryListQuery = {}) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
phoneCarrierRule: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ carrier: 'telecom', pattern: '^133' },
|
||||
{ carrier: 'mobile', pattern: '^13[5-9]' },
|
||||
]),
|
||||
},
|
||||
phoneSegment: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ prefix: '1380000', province: '山东' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('PhoneRoutingLookupService', () => {
|
||||
it('compiles and caches active carrier rules across concurrent lookups', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new PhoneRoutingLookupService(prisma as never);
|
||||
|
||||
await expect(Promise.all([
|
||||
service.identifyCarrier('13800000001'),
|
||||
service.identifyCarrier('13300000001'),
|
||||
service.identifyCarrier('13800000002'),
|
||||
])).resolves.toEqual(['mobile', 'telecom', 'mobile']);
|
||||
|
||||
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledWith({
|
||||
where: { status: 'active' },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { carrier: true, pattern: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads carrier rules immediately after explicit invalidation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new PhoneRoutingLookupService(prisma as never);
|
||||
|
||||
await service.identifyCarrier('13800000001');
|
||||
service.invalidateCarrierRules();
|
||||
await service.identifyCarrier('13800000001');
|
||||
|
||||
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not let an in-flight stale load overwrite an invalidated cache', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
let resolveFirstLoad: ((rows: Array<{ carrier: string; pattern: string }>) => void) | undefined;
|
||||
prisma.phoneCarrierRule.findMany
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstLoad = resolve; }))
|
||||
.mockResolvedValueOnce([{ carrier: 'telecom', pattern: '^138' }]);
|
||||
const service = new PhoneRoutingLookupService(prisma as never);
|
||||
|
||||
const staleLookup = service.identifyCarrier('13800000001');
|
||||
service.invalidateCarrierRules();
|
||||
await expect(service.identifyCarrier('13800000001')).resolves.toBe('telecom');
|
||||
resolveFirstLoad?.([{ carrier: 'mobile', pattern: '^138' }]);
|
||||
await expect(staleLookup).resolves.toBe('mobile');
|
||||
await expect(service.identifyCarrier('13800000001')).resolves.toBe('telecom');
|
||||
|
||||
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('finds the longest phone prefix with one database query', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||
{ prefix: '138', province: '全国' },
|
||||
{ prefix: '1380000', province: '山东' },
|
||||
{ prefix: '13800', province: '华东' },
|
||||
]);
|
||||
const service = new PhoneRoutingLookupService(prisma as never);
|
||||
|
||||
await expect(service.identifyProvince('13800000001')).resolves.toBe('山东');
|
||||
|
||||
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
||||
where: { prefix: { in: ['1380000', '138000', '13800', '1380', '138'] } },
|
||||
select: { prefix: true, province: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null after one query when no phone prefix exists', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneSegment.findMany.mockResolvedValue([]);
|
||||
const service = new PhoneRoutingLookupService(prisma as never);
|
||||
|
||||
await expect(service.identifyProvince('00000000000')).resolves.toBeNull();
|
||||
expect(prisma.phoneSegment.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_CARRIER_RULE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface CompiledCarrierRule {
|
||||
carrier: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhoneRoutingLookupService {
|
||||
private carrierRuleCache?: { expiresAt: number; rules: CompiledCarrierRule[] };
|
||||
private carrierRuleLoad?: { generation: number; promise: Promise<CompiledCarrierRule[]> };
|
||||
private carrierRuleGeneration = 0;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
const rules = await this.getCarrierRules();
|
||||
return rules.find((rule) => rule.pattern.test(phoneNumber))?.carrier;
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
const prefixes = phonePrefixes(phoneNumber);
|
||||
if (prefixes.length === 0) return null;
|
||||
const segments = await this.prisma.phoneSegment.findMany({
|
||||
where: { prefix: { in: prefixes } },
|
||||
select: { prefix: true, province: true },
|
||||
});
|
||||
const provinceByPrefix = new Map(segments.map((segment) => [segment.prefix, segment.province]));
|
||||
for (const prefix of prefixes) {
|
||||
const province = provinceByPrefix.get(prefix);
|
||||
if (province) return province;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
invalidateCarrierRules() {
|
||||
this.carrierRuleGeneration += 1;
|
||||
this.carrierRuleCache = undefined;
|
||||
}
|
||||
|
||||
private async getCarrierRules() {
|
||||
const now = Date.now();
|
||||
if (this.carrierRuleCache && this.carrierRuleCache.expiresAt > now) {
|
||||
return this.carrierRuleCache.rules;
|
||||
}
|
||||
const generation = this.carrierRuleGeneration;
|
||||
if (this.carrierRuleLoad?.generation === generation) return this.carrierRuleLoad.promise;
|
||||
const promise = this.loadCarrierRules(generation);
|
||||
this.carrierRuleLoad = { generation, promise };
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (this.carrierRuleLoad?.promise === promise) {
|
||||
this.carrierRuleLoad = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCarrierRules(generation: number) {
|
||||
const rows = await this.prisma.phoneCarrierRule.findMany({
|
||||
where: { status: 'active' },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { carrier: true, pattern: true },
|
||||
});
|
||||
const rules = rows.flatMap((row) => {
|
||||
try {
|
||||
return [{ carrier: row.carrier, pattern: new RegExp(row.pattern) }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
if (generation === this.carrierRuleGeneration) {
|
||||
this.carrierRuleCache = {
|
||||
expiresAt: Date.now() + carrierRuleCacheTtlMs(),
|
||||
rules,
|
||||
};
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
|
||||
function phonePrefixes(phoneNumber: string) {
|
||||
const maxLength = Math.min(7, phoneNumber.length);
|
||||
return Array.from({ length: Math.max(0, maxLength - 2) }, (_, index) =>
|
||||
phoneNumber.slice(0, maxLength - index));
|
||||
}
|
||||
|
||||
function carrierRuleCacheTtlMs() {
|
||||
const configured = Number(process.env.PHONE_CARRIER_RULE_CACHE_TTL_MS);
|
||||
return Number.isFinite(configured) && configured > 0
|
||||
? Math.floor(configured)
|
||||
: DEFAULT_CARRIER_RULE_CACHE_TTL_MS;
|
||||
}
|
||||
Reference in New Issue
Block a user