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;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export class AdminOperationsController {
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('contentKeyword') contentKeyword?: string,
|
||||
@Query('channelKeyword') channelKeyword?: string,
|
||||
@Query('carrier') carrier?: string,
|
||||
@Query('queuedAtFrom') queuedAtFrom?: string,
|
||||
@Query('queuedAtTo') queuedAtTo?: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -54,6 +55,7 @@ export class AdminOperationsController {
|
||||
phoneNumber,
|
||||
contentKeyword,
|
||||
channelKeyword,
|
||||
carrier,
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
status,
|
||||
@@ -70,6 +72,7 @@ export class AdminOperationsController {
|
||||
@Query('phoneNumber') phoneNumber: string | undefined,
|
||||
@Query('contentKeyword') contentKeyword: string | undefined,
|
||||
@Query('channelKeyword') channelKeyword: string | undefined,
|
||||
@Query('carrier') carrier: string | undefined,
|
||||
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
|
||||
@Query('queuedAtTo') queuedAtTo: string | undefined,
|
||||
@Query('status') status: string | undefined,
|
||||
@@ -82,6 +85,7 @@ export class AdminOperationsController {
|
||||
phoneNumber,
|
||||
contentKeyword,
|
||||
channelKeyword,
|
||||
carrier,
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
status,
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('OperationsService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
|
||||
it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -202,6 +202,7 @@ describe('OperationsService', () => {
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
contentKeyword: '验证码',
|
||||
carrier: 'mobile',
|
||||
queuedAtFrom: '2026-07-01',
|
||||
queuedAtTo: '2026-07-02',
|
||||
status: 'delivered',
|
||||
@@ -215,6 +216,7 @@ describe('OperationsService', () => {
|
||||
batchTaskId: 'task-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
|
||||
status: 'delivered',
|
||||
content: { contains: '验证码', mode: 'insensitive' },
|
||||
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
|
||||
@@ -238,6 +240,34 @@ describe('OperationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats null and nonstandard carrier values as unrecognized', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await service.listMessages({ carrier: 'unknown' });
|
||||
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ carrier: null },
|
||||
{
|
||||
carrier: {
|
||||
notIn: [
|
||||
'mobile', 'cmcc', '移动', '中国移动',
|
||||
'unicom', 'cucc', '联通', '中国联通',
|
||||
'telecom', 'ctcc', '电信', '中国电信',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
@@ -369,7 +399,13 @@ describe('OperationsService', () => {
|
||||
todaySpendCents: 24000n,
|
||||
balanceCents: 1000000n,
|
||||
creditCents: 50000n,
|
||||
}]);
|
||||
}]).mockResolvedValueOnce([
|
||||
{ hour: 9, submittedCount: 12n, successCount: 10n },
|
||||
{ hour: 10, submittedCount: 5n, successCount: 4n },
|
||||
]).mockResolvedValueOnce([
|
||||
{ category: 'templates', count: 3n, averageProcessingMs: 90_000n },
|
||||
{ category: 'signatures', count: 2n, averageProcessingMs: 120_000n },
|
||||
]);
|
||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(2)
|
||||
@@ -394,6 +430,17 @@ describe('OperationsService', () => {
|
||||
total: 5,
|
||||
},
|
||||
today: expect.objectContaining({ returnedCents: 10 }),
|
||||
hourlySendTrend: expect.arrayContaining([
|
||||
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
|
||||
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
|
||||
]),
|
||||
auditProcessingSpeed: [
|
||||
{ category: 'enterpriseCertifications', label: '企业认证', count: 0, averageProcessingMs: null },
|
||||
{ category: 'smsAudits', label: '短信审核', count: 0, averageProcessingMs: null },
|
||||
{ category: 'templates', label: '模板', count: 3, averageProcessingMs: 90000 },
|
||||
{ category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 },
|
||||
{ category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null },
|
||||
],
|
||||
enterpriseSpendRanks: [{
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '租户A',
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MessageQuery {
|
||||
messageId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
carrier?: string;
|
||||
status?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
@@ -321,11 +322,14 @@ export class OperationsService {
|
||||
}
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const sinceToday = startOfToday();
|
||||
const businessDay = qualityBusinessDay();
|
||||
const sinceToday = businessDay.startAt;
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
||||
const todayMessageWhereClause = {
|
||||
...messageWhereClause,
|
||||
queuedAt: { gte: sinceToday, lt: businessDay.endAt },
|
||||
};
|
||||
const [
|
||||
taskCount,
|
||||
messageGroups,
|
||||
@@ -345,6 +349,8 @@ export class OperationsService {
|
||||
downstreamStalledPendingCount,
|
||||
downstreamStalledAckCount,
|
||||
downstreamRecentFailedCount,
|
||||
hourlySendRows,
|
||||
auditSpeedRows,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
@@ -451,8 +457,124 @@ export class OperationsService {
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
hour: number;
|
||||
submittedCount: bigint;
|
||||
successCount: bigint;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM message."queuedAt" AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
|
||||
COUNT(*)::bigint AS "submittedCount",
|
||||
COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`),
|
||||
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
|
||||
this.prisma.$queryRaw<Array<{
|
||||
category: string;
|
||||
count: bigint;
|
||||
averageProcessingMs: bigint | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH review_samples AS (
|
||||
SELECT
|
||||
'enterpriseCertifications'::text AS category,
|
||||
certification."submittedAt" AS "submittedAt",
|
||||
certification."reviewedAt" AS "reviewedAt"
|
||||
FROM "EnterpriseCertification" certification
|
||||
WHERE certification."reviewedAt" >= ${businessDay.startAt}
|
||||
AND certification."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'smsAudits'::text,
|
||||
task."createdAt",
|
||||
task."reviewedAt"
|
||||
FROM "SmsSendTask" task
|
||||
WHERE task."reviewedAt" >= ${businessDay.startAt}
|
||||
AND task."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'drainageInfos'::text,
|
||||
drainage."submittedAt",
|
||||
drainage."reviewedAt"
|
||||
FROM "SmsDrainageInfo" drainage
|
||||
WHERE drainage."reviewedAt" >= ${businessDay.startAt}
|
||||
AND drainage."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CASE review."targetType"
|
||||
WHEN 'sms_signature' THEN 'signatures'
|
||||
WHEN 'sms_template' THEN 'templates'
|
||||
END,
|
||||
submission."createdAt",
|
||||
review."createdAt"
|
||||
FROM "AuditRecord" review
|
||||
JOIN LATERAL (
|
||||
SELECT pending."createdAt"
|
||||
FROM "AuditRecord" pending
|
||||
WHERE pending."targetType" = review."targetType"
|
||||
AND pending."targetId" = review."targetId"
|
||||
AND pending."statusAfter" = 'pending'
|
||||
AND pending."createdAt" <= review."createdAt"
|
||||
ORDER BY pending."createdAt" DESC
|
||||
LIMIT 1
|
||||
) submission ON true
|
||||
WHERE review."targetType" IN ('sms_signature', 'sms_template')
|
||||
AND review."statusBefore" = 'pending'
|
||||
AND review."statusAfter" IN ('approved', 'rejected')
|
||||
AND review."createdAt" >= ${businessDay.startAt}
|
||||
AND review."createdAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null})
|
||||
)
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*)::bigint AS count,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs"
|
||||
FROM review_samples
|
||||
WHERE "reviewedAt" >= "submittedAt"
|
||||
GROUP BY category
|
||||
`),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||
const row = hourlyRowsByHour.get(hour);
|
||||
return {
|
||||
hour,
|
||||
label: `${String(hour).padStart(2, '0')}:00`,
|
||||
submittedCount: Number(row?.submittedCount ?? 0),
|
||||
successCount: Number(row?.successCount ?? 0),
|
||||
};
|
||||
});
|
||||
const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row]));
|
||||
const auditProcessingSpeed = [
|
||||
['enterpriseCertifications', '企业认证'],
|
||||
['smsAudits', '短信审核'],
|
||||
['templates', '模板'],
|
||||
['signatures', '签名'],
|
||||
['drainageInfos', '引流信息'],
|
||||
].map(([category, label]) => {
|
||||
const row = auditSpeedByCategory.get(category);
|
||||
return {
|
||||
category,
|
||||
label,
|
||||
count: Number(row?.count ?? 0),
|
||||
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
|
||||
};
|
||||
});
|
||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||
return {
|
||||
taskCount,
|
||||
@@ -473,6 +595,8 @@ export class OperationsService {
|
||||
gatewayConnections: connectionGroups,
|
||||
pendingAuditCount: pendingAudits.total,
|
||||
pendingAudits,
|
||||
hourlySendTrend,
|
||||
auditProcessingSpeed,
|
||||
downstreamDeliverySummary: {
|
||||
pending: downstreamPendingCount,
|
||||
failed: downstreamFailedCount,
|
||||
@@ -507,6 +631,8 @@ export class OperationsService {
|
||||
gatewayConnections: [],
|
||||
pendingAuditCount: dashboard.pendingAuditCount,
|
||||
pendingAudits: dashboard.pendingAudits,
|
||||
hourlySendTrend: dashboard.hourlySendTrend,
|
||||
auditProcessingSpeed: dashboard.auditProcessingSpeed,
|
||||
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
|
||||
accounts: dashboard.accounts.map(clientAccountView),
|
||||
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
|
||||
@@ -1678,6 +1804,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
batchTaskId: query.taskId,
|
||||
messageId: query.messageId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
...carrierWhere(query.carrier),
|
||||
...statusWhere,
|
||||
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||
@@ -1690,6 +1817,35 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
};
|
||||
}
|
||||
|
||||
const recognizedCarrierValues = [
|
||||
'mobile', 'cmcc', '移动', '中国移动',
|
||||
'unicom', 'cucc', '联通', '中国联通',
|
||||
'telecom', 'ctcc', '电信', '中国电信',
|
||||
];
|
||||
|
||||
function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
|
||||
if (!carrier) return {};
|
||||
// Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized.
|
||||
if (carrier === 'unknown') {
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ carrier: null },
|
||||
{ carrier: { notIn: recognizedCarrierValues } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const valuesByCarrier: Record<string, string[]> = {
|
||||
mobile: ['mobile', 'cmcc', '移动', '中国移动'],
|
||||
unicom: ['unicom', 'cucc', '联通', '中国联通'],
|
||||
telecom: ['telecom', 'ctcc', '电信', '中国电信'],
|
||||
};
|
||||
return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {};
|
||||
}
|
||||
|
||||
function startOfShanghaiDay(value: string) {
|
||||
return new Date(`${value}T00:00:00+08:00`);
|
||||
}
|
||||
@@ -1735,12 +1891,6 @@ function normalizeGroupBy(groupBy?: string) {
|
||||
return 'channelId';
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
|
||||
return {
|
||||
tenantId,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
Reference in New Issue
Block a user