feat: optimize routing and operations views

This commit is contained in:
hectorzhao
2026-07-29 22:32:28 +08:00
parent 500f43f673
commit c0a4317a7e
21 changed files with 718 additions and 86 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import { forwardRef, Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module';
import { DictionariesModule } from '../dictionaries/dictionaries.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RiskReviewModule } from '../risk-review/risk-review.module';
import { SmsConfigModule } from '../sms-config/sms-config.module';
@@ -10,7 +11,7 @@ import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service';
@Module({
imports: [PrismaModule, BillingModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule)],
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService],
exports: [SendChainService],
+21 -1
View File
@@ -143,7 +143,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', pattern: '^13[4-9]', priority: 1, status: 'active' }]),
},
phoneSegment: {
findUnique: jest.fn().mockResolvedValue({ prefix: '1380000', province: '山东', city: '济南' }),
findMany: jest.fn().mockResolvedValue([{ prefix: '1380000', province: '山东', city: '济南' }]),
},
smsChannel: {
findFirst: jest.fn().mockResolvedValue(channel),
@@ -1873,6 +1873,26 @@ describe('SendChainService', () => {
});
});
it('reuses persisted carrier and province without querying routing dictionaries again', async () => {
const { service, prisma } = createService();
service['identifyCarrier'] = jest.fn();
service['identifyProvince'] = jest.fn();
await expect(service['selectChannelForMessage']({
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
phoneNumber: '13800000001',
carrier: 'mobile',
province: '山东',
})).resolves.toEqual(expect.objectContaining({ carrier: 'mobile', province: '山东' }));
expect(service['identifyCarrier']).not.toHaveBeenCalled();
expect(service['identifyProvince']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled();
});
it('updates submit result status, charges billing, and task progress', async () => {
const { service, prisma, billing } = createService();
+29 -31
View File
@@ -8,6 +8,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { OpenApiService } from '../open-api/open-api.service';
@@ -315,13 +316,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
private upstreamReceiptInboxScanRunning = false;
private readonly phoneRouting: PhoneRoutingLookupService;
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
private readonly riskReview: RiskReviewService,
@Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService,
) {}
@Optional() phoneRouting?: PhoneRoutingLookupService,
) {
this.phoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma);
}
onModuleInit() {
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
@@ -3600,6 +3605,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
queuedAt?: Date;
clientSrcId?: string | null;
applicationExtension?: string | null;
carrier?: string | null;
province?: string | null;
},
reason: string,
sourceSubmitRecordId?: string,
@@ -3659,7 +3666,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
})}`);
return null;
}
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
const retryCarrier = message.carrier
? normalizeCarrier(message.carrier)
: await this.identifyCarrier(message.phoneNumber);
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier);
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
@@ -3673,7 +3683,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return null;
}
try {
const routed = await this.selectChannelForMessage(message, {
const routed = await this.selectChannelForMessage({ ...message, carrier: retryCarrier }, {
forceNational: true,
excludeChannelIds: attemptedChannelIds,
});
@@ -3708,18 +3718,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async selectChannelForMessage(
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> {
if (!message.applicationId) {
throw new BadRequestException('短信应用未配置,无法选择通道组');
}
const carrier = await this.identifyCarrier(message.phoneNumber);
const province = await this.identifyProvince(message.phoneNumber);
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { carrier, province },
});
const hasPersistedRouting = Boolean(message.carrier);
const [carrier, province] = hasPersistedRouting
? [normalizeCarrier(message.carrier), message.province ?? null]
: await Promise.all([
this.identifyCarrier(message.phoneNumber),
this.identifyProvince(message.phoneNumber),
]);
if (!hasPersistedRouting) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { carrier, province },
});
}
const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier);
const excluded = new Set(options.excludeChannelIds ?? []);
const signatureId = await this.resolveMessageSignatureId(message);
@@ -3777,30 +3794,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async identifyCarrier(phoneNumber: string) {
const rules = await this.prisma.phoneCarrierRule.findMany({
where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
for (const rule of rules) {
try {
if (new RegExp(rule.pattern).test(phoneNumber)) {
return normalizeCarrier(rule.carrier);
}
} catch {
continue;
}
}
return 'mobile';
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
}
private async identifyProvince(phoneNumber: string) {
for (let length = Math.min(7, phoneNumber.length); length >= 3; length -= 1) {
const segment = await this.prisma.phoneSegment.findUnique({ where: { prefix: phoneNumber.slice(0, length) } });
if (segment?.province) {
return segment.province;
}
}
return null;
return this.phoneRouting.identifyProvince(phoneNumber);
}
private isChannelSendAvailable(channel: { status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }> }) {