feat: add caller connection and answer rate analytics

This commit is contained in:
hectorzhao
2026-08-31 17:21:20 +08:00
parent 8627228bbc
commit 5284d41f9a
29 changed files with 1284 additions and 0 deletions
+2
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { CallerAnalyticsModule } from './caller-analytics/caller-analytics.module.js';
import { ConfigModule } from '@nestjs/config';
import crypto from 'node:crypto';
import { LoggerModule } from 'nestjs-pino';
@@ -60,6 +61,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
AuditModule,
AuthModule,
ActiveCallsModule,
CallerAnalyticsModule,
BusinessPrefixesModule,
CdrsModule,
DashboardModule,
@@ -0,0 +1,18 @@
import { Controller, Get, Inject, Module, Query } from '@nestjs/common';
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
import { CallerAnalyticsService } from './caller-analytics.service.js';
@Controller('caller-analytics')
@RequirePermissions('caller_analytics.view')
class CallerAnalyticsController {
constructor(@Inject(CallerAnalyticsService) private readonly service: CallerAnalyticsService) {}
@Get('overview') overview(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('summary') summary(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('numbers') numbers(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('trends') trends(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('calls') calls(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.detail(q, user); }
@Get('breakdown') breakdown(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.detail(q, user); }
@Get('options') options(@CurrentUserParam() user: CurrentUser) { return this.service.options(user); }
}
@Module({ controllers: [CallerAnalyticsController], providers: [CallerAnalyticsService] })
export class CallerAnalyticsModule {}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { parseAnalyticsQuery } from './caller-analytics.service.js';
describe('analytics query boundary', () => {
it('rejects inverted, excessive and invalid date windows', () => {
for (const q of [{from:'bad'}, {from:'2026-01-01',to:'2026-02-01'}, {from:'2026-08-02',to:'2026-08-01'}]) expect(() => parseAnalyticsQuery(q)).toThrow();
});
it('rejects sort injection and mixed-grain vendor filters', () => {
expect(() => parseAnalyticsQuery({sort:'caller; DROP TABLE x'})).toThrow();
expect(() => parseAnalyticsQuery({view:'original',vendorGatewayId:'g'})).toThrow();
});
it('rejects invalid percentage and pagination values', () => {
expect(() => parseAnalyticsQuery({connectionMin:'101'})).toThrow();
expect(() => parseAnalyticsQuery({take:'1.5'})).toThrow();
});
});
@@ -0,0 +1,113 @@
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ANALYTICS_VERSION, COUNT_KEYS, Prisma, analyticsRates, emptyCounts, type AnalyticsCounts } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
import type { CurrentUser } from '../security/security.metadata.js';
type Query = Record<string, unknown>;
export interface AnalyticsQuery {
view: 'original' | 'landing'; from: Date; to: Date; customerId: string; caller: string;
customerGatewayId: string; vendorId: string; vendorGatewayId: string; city: string; carrier: string;
minSamples: number; skip: number; take: number; sort: string; order: string; onlyAlerts: boolean;
connectionMin: number; connectionMax: number; overallMin: number; overallMax: number; connectedMin: number; connectedMax: number;
}
export function parseAnalyticsQuery(raw: Query, now = new Date()): AnalyticsQuery {
const str = (key: string, max: number) => {
const v = raw[key] ?? ''; if (typeof v !== 'string' || v.length > max) throw new BadRequestException(`Invalid ${key}`); return v.trim();
};
const number = (key: string, fallback: number, min: number, max: number) => {
const v = raw[key] === undefined || raw[key] === '' ? fallback : Number(raw[key]);
if (!Number.isFinite(v) || v < min || v > max) throw new BadRequestException(`Invalid ${key}`); return v;
};
const view = str('view', 16) || 'landing'; if (!['original', 'landing'].includes(view)) throw new BadRequestException('Invalid view');
const to = raw.to ? new Date(str('to', 40)) : now;
const from = raw.from ? new Date(str('from', 40)) : new Date(to.getTime() - number('minutes', 15, 1, 10080) * 60000);
if (!Number.isFinite(+from) || !Number.isFinite(+to) || from >= to || +to - +from > 7 * 86400000 || +to > +now + 5000) throw new BadRequestException('Time range must be within 7 days and not in future');
const sort = str('sort', 32) || 'totalCalls';
if (!['caller', ...COUNT_KEYS, 'connectionRate', 'overallAnswerRate', 'connectedAnswerRate'].includes(sort)) throw new BadRequestException('Invalid sort');
const order = str('order', 4) || 'desc'; if (!['asc', 'desc'].includes(order)) throw new BadRequestException('Invalid order');
const result: AnalyticsQuery = {
view: view as AnalyticsQuery['view'], from, to, customerId: str('customerId', 32), caller: str('caller', 64),
customerGatewayId: str('customerGatewayId', 32), vendorId: str('vendorId', 32), vendorGatewayId: str('vendorGatewayId', 32), city: str('city', 12), carrier: str('carrier', 20),
minSamples: number('minSamples', 0, 0, 1e9), skip: number('skip', 0, 0, 1e6), take: number('take', 25, 1, 100), sort, order,
onlyAlerts: raw.onlyAlerts === 'true', connectionMin: number('connectionMin', 0, 0, 100), connectionMax: number('connectionMax', 100, 0, 100),
overallMin: number('overallMin', 0, 0, 100), overallMax: number('overallMax', 100, 0, 100), connectedMin: number('connectedMin', 0, 0, 100), connectedMax: number('connectedMax', 100, 0, 100)
};
if (!Number.isInteger(result.skip) || !Number.isInteger(result.take) || result.connectionMin > result.connectionMax || result.overallMin > result.overallMax || result.connectedMin > result.connectedMax) throw new BadRequestException('Invalid range');
if (view === 'original' && (result.vendorId || result.vendorGatewayId)) throw new BadRequestException('线路筛选请切换落地主叫,避免混用业务呼叫与尝试分母');
return result;
}
type Row = Record<string, string | number | null>;
const countSql = Prisma.join(COUNT_KEYS.map(k => Prisma.sql`COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED)),0) AS ${Prisma.raw(k)}`));
const normalize = (row: Row) => ({ ...row, caller: String(row.caller ?? ''), customerId: String(row.customerId ?? ''), ...analyticsRates(Object.fromEntries(COUNT_KEYS.map(k => [k, Number(row[k] ?? 0)])) as unknown as AnalyticsCounts) });
@Injectable()
export class CallerAnalyticsService {
constructor(@Inject(PrismaService) private readonly db: PrismaService) {}
private async scope(user: CurrentUser, customerId: string): Promise<string[] | null> {
if (user.permissions.includes('caller_analytics.view_all')) return null;
const access = await this.db.$queryRaw<Array<{ customer_id: string }>>`SELECT customer_id FROM caller_analysis_access WHERE user_id=${user.id}`;
const ids = access.map(a => a.customer_id);
if (!ids.length || (customerId && !ids.includes(customerId))) throw new ForbiddenException('没有该客户的主叫分析权限');
return ids;
}
private where(q: AnalyticsQuery, scope: string[] | null): Prisma.Sql {
const parts = [Prisma.sql`view=${q.view}`];
const fields = { customerId: 'customer_id', customerGatewayId: 'customer_gateway_id', vendorId: 'vendor_id', vendorGatewayId: 'vendor_gateway_id', caller: 'caller', city: 'city', carrier: 'carrier' } as const;
for (const [key, column] of Object.entries(fields)) { const v = q[key as keyof typeof fields]; if (v) parts.push(Prisma.sql`${Prisma.raw(column)}=${v}`); }
if (scope) parts.push(Prisma.sql`customer_id IN (${Prisma.join(scope)})`);
return Prisma.join(parts, ' AND ');
}
private source(q: AnalyticsQuery, scope: string[] | null) {
const start = new Date(Math.ceil(+q.from / 60000) * 60000); const end = new Date(Math.floor(+q.to / 60000) * 60000);
const where = this.where(q, scope);
if (start >= end) return Prisma.sql`SELECT * FROM caller_analysis_states WHERE ${where} AND started_at>=${q.from} AND started_at<${q.to}`;
const columns = Prisma.raw('started_at, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, city, carrier, counts');
return Prisma.sql`SELECT ${columns} FROM caller_analysis_minute_buckets WHERE ${where} AND started_at>=${start} AND started_at<${end}
UNION ALL SELECT ${columns} FROM caller_analysis_states WHERE ${where} AND started_at>=${q.from} AND started_at<${q.to} AND (started_at<${start} OR started_at>=${end})`;
}
async overview(raw: Query, user: CurrentUser) {
const q = parseAnalyticsQuery(raw); const scope = await this.scope(user, q.customerId); const source = this.source(q, scope);
const [numbers, trends, health, alerts] = await this.db.$transaction([
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT customer_id AS customerId, caller, ${countSql} FROM (${source}) a GROUP BY customer_id, caller HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 10001`),
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT FLOOR(UNIX_TIMESTAMP(started_at)/60)*60000 AS time, ${countSql} FROM (${source}) a GROUP BY time ORDER BY time`),
this.db.$queryRaw<Array<{ payload: Record<string, unknown>; updated_at: Date }>>`SELECT payload, updated_at FROM caller_analysis_health WHERE id='worker'`,
this.db.$queryRaw<Array<{ customer_id: string; caller: string; payload: unknown; status: string }>>(Prisma.sql`SELECT customer_id, caller, payload, status FROM caller_analysis_alerts WHERE view=${q.view} ${scope ? Prisma.sql`AND customer_id IN (${Prisma.join(scope)})` : Prisma.empty} AND status='ACTIVE'`)
]);
if (numbers.length > 10000) throw new BadRequestException('号码超过10000个,请缩短时间或筛选客户');
const annotated = numbers.map(r => ({ ...normalize(r), alert: alerts.find(a => a.customer_id === r.customerId && a.caller === r.caller)?.payload ?? null }));
const fits = (v: number | null, min: number, max: number) => v === null ? min === 0 && max === 100 : v >= min && v <= max;
const filtered = annotated.filter(r => r.totalCalls >= q.minSamples && (!q.onlyAlerts || r.alert) && fits(r.connectionRate, q.connectionMin, q.connectionMax) && fits(r.overallAnswerRate, q.overallMin, q.overallMax) && fits(r.connectedAnswerRate, q.connectedMin, q.connectedMax));
filtered.sort((a, b) => {
const av = a[q.sort as keyof typeof a]; const bv = b[q.sort as keyof typeof b];
if (av === null) return bv === null ? 0 : 1; if (bv === null) return -1;
const cmp = typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : Number(av) - Number(bv);
return (q.order === 'asc' ? cmp : -cmp) || String(a.caller).localeCompare(String(b.caller));
});
const sum = annotated.reduce((a, r) => { for (const k of COUNT_KEYS) a[k] += r[k]; return a; }, emptyCounts());
const h = health[0]; const quality = !h || Date.now() - +h.updated_at > 10000 || h.payload.degraded ? 'DEGRADED' : 'LIVE';
return { metricVersion: ANALYTICS_VERSION, view: q.view, from: q.from, to: q.to, asOf: new Date(), qualityStatus: quality,
dataThrough: h?.payload.dataThrough ?? null, lagMs: h?.payload.lagMs ?? null, durationPrecision: 'milliseconds',
health: h?.payload ?? null, summary: analyticsRates(sum), summaryScope: '时间及业务筛选;样本/比例/异常筛选仅影响号码列表',
numbers: filtered.slice(q.skip, q.skip + q.take), total: filtered.length, skip: q.skip, take: q.take,
trends: trends.map(normalize), historyNotice: '仅统计新采集链路的数据;旧固定6秒话单不参与应答统计或校准。' };
}
async detail(raw: Query, user: CurrentUser) {
const q = parseAnalyticsQuery(raw); if (!q.caller || !q.customerId) throw new BadRequestException('请选择客户和主叫号码');
const scope = await this.scope(user, q.customerId); const where = this.where(q, scope);
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>(Prisma.sql`SELECT id, call_key AS callKey, started_at AS startedAt, caller, callee, vendor_gateway_id AS vendorGatewayId, counts, payload FROM caller_analysis_states WHERE ${where} AND started_at>=${q.from} AND started_at<${q.to} ORDER BY started_at DESC, id DESC LIMIT ${q.take} OFFSET ${q.skip}`);
const source = this.source(q, scope);
const breakdown = await this.db.$queryRaw<Row[]>(Prisma.sql`SELECT vendor_gateway_id AS vendorGatewayId, city, carrier, ${countSql} FROM (${source}) a GROUP BY vendor_gateway_id, city, carrier HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 500`);
return { rows, breakdown: breakdown.map(normalize), skip: q.skip, take: q.take };
}
async options(user: CurrentUser) {
const scope = await this.scope(user, '');
const [customers, customerGateways, vendorGateways] = await Promise.all([
this.db.customer.findMany({ where: { ...(scope ? { id: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true }, take: 2000 }),
this.db.customerGateway.findMany({ where: { ...(scope ? { customerId: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true, customerId: true }, take: 2000 }),
// Limited users only receive gateway IDs they actually have traffic on, not the global vendor configuration.
scope ? this.db.$queryRaw<Array<{ id: string; name: string }>>(Prisma.sql`SELECT DISTINCT vendor_gateway_id AS id, vendor_gateway_id AS name FROM caller_analysis_states WHERE customer_id IN (${Prisma.join(scope)}) AND vendor_gateway_id<>'' LIMIT 2000`) : this.db.vendorGateway.findMany({ where: { deletedAt: null }, select: { id: true, name: true }, take: 2000 })
]);
return { customers, customerGateways, vendorGateways };
}
}