fix: complete caller analytics reliability remediation

This commit is contained in:
hectorzhao
2026-09-01 10:03:29 +08:00
parent a116926867
commit 7f8825cf3a
25 changed files with 850 additions and 202 deletions
@@ -1,5 +1,5 @@
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ANALYTICS_VERSION, COUNT_KEYS, Prisma, analyticsRates, emptyCounts, type AnalyticsCounts } from '@lisglosips/database';
import { ANALYTICS_REVISION, ANALYTICS_VERSION, COUNT_KEYS, Prisma, analyticsHash, analyticsRates, emptyCounts, type AnalyticsCounts } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
import type { CurrentUser } from '../security/security.metadata.js';
@@ -68,46 +68,56 @@ export class CallerAnalyticsService {
}
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([
const [numbers, trends, health, alerts, gaps] = 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'`)
,this.db.$queryRaw<Array<{ id:string; kind:string; started_at:Date; ended_at:Date|null }>>`SELECT id,kind,started_at,ended_at FROM caller_analysis_gaps WHERE status='OPEN' AND started_at<${q.to} AND (ended_at IS NULL OR ended_at>=${q.from}) ORDER BY started_at LIMIT 100`
]);
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 h = health[0]; const captureSince=h?.payload.captureSince?new Date(String(h.payload.captureSince)):null;
const coverageStatus=!h||Date.now()-+h.updated_at>10000||h.payload.degraded||h.payload.coverageStatus!=='CONTINUOUS'||gaps.length?'DEGRADED':captureSince&&q.from<captureSince?'PARTIAL':'CONTINUOUS';
const withCoverage=(r:Row)=>{const value=normalize(r);if(coverageStatus!=='CONTINUOUS'){value.connectionRate=null;value.overallAnswerRate=null;value.connectedAnswerRate=null;}return value;};
const alertScopeMatches = coverageStatus==='CONTINUOUS' && !q.customerGatewayId && !q.vendorId && !q.vendorGatewayId && !q.city && !q.carrier && +q.to-Date.now()>-90000 && +q.to-Date.now()<5000 && +q.to-+q.from===900000;
const annotated = numbers.map(r => { const matching=alerts.filter(a=>a.customer_id===r.customerId&&a.caller===r.caller); return { ...withCoverage(r), alert: alertScopeMatches&&matching.length ? { reasons:matching.map(a=>(a.payload as {reason?:string}).reason).filter(Boolean), scope:'最近15分钟号码全量范围' } : 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));
return (q.order === 'asc' ? cmp : -cmp) || String(a.caller).localeCompare(String(b.caller)) || String(a.customerId).localeCompare(String(b.customerId));
});
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,
const quality=coverageStatus==='CONTINUOUS'?'LIVE':'DEGRADED'; const asOf=new Date(); const snapshotId=analyticsHash([ANALYTICS_REVISION,q.view,q.from.toISOString(),q.to.toISOString(),asOf.toISOString()]);
const summaryRates=analyticsRates(sum); if(coverageStatus!=='CONTINUOUS'){summaryRates.connectionRate=null;summaryRates.overallAnswerRate=null;summaryRates.connectedAnswerRate=null;}
return { metricVersion: ANALYTICS_VERSION, analysisRevision:ANALYTICS_REVISION, snapshotId, view: q.view, from: q.from, to: q.to, asOf, qualityStatus: quality, coverageStatus,
dataThrough: h?.payload.dataThrough ?? null, lagMs: h?.payload.lagMs ?? null, durationPrecision: 'milliseconds',
health: h?.payload ?? null, summary: analyticsRates(sum), summaryScope: '时间及业务筛选;样本/比例/异常筛选仅影响号码列表',
health: h?.payload ?? null, gaps, summary: summaryRates, summaryScope: '时间及业务筛选;样本/比例/异常筛选仅影响号码列表',
numbers: filtered.slice(q.skip, q.skip + q.take), total: filtered.length, skip: q.skip, take: q.take,
trends: trends.map(r => ({ ...normalize(r), time: Number(r.time) })), historyNotice: '仅统计新采集链路的数据;旧固定6秒话单不参与应答统计或校准。' };
trends: trends.map(r => ({ ...withCoverage(r), time: Number(r.time) })), baseFilters:{view:q.view,from:q.from,to:q.to,customerId:q.customerId,customerGatewayId:q.customerGatewayId,vendorId:q.vendorId,vendorGatewayId:q.vendorGatewayId,caller:q.caller,city:q.city,carrier:q.carrier}, numberFilters:{minSamples:q.minSamples,onlyAlerts:q.onlyAlerts,connectionMin:q.connectionMin,connectionMax:q.connectionMax,overallMin:q.overallMin,overallMax:q.overallMax,connectedMin:q.connectedMin,connectedMax:q.connectedMax},
metricAvailability:{connectionRate:coverageStatus==='CONTINUOUS'&&!sum.unknownCalls,answerRates:coverageStatus==='CONTINUOUS'&&!sum.durationUnknownCalls}, historyNotice: coverageStatus==='CONTINUOUS'?'当前窗口在连续采集覆盖内;旧固定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 };
const [rows,breakdownRows]=await this.db.$transaction([
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}`),
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 ORDER BY vendor_gateway_id,city,carrier LIMIT 501`)
]);
return { rows, breakdown:breakdownRows.slice(0,500).map(normalize), breakdownTruncated:breakdownRows.length>500, skip:q.skip, take:q.take };
}
async options(user: CurrentUser) {
const scope = await this.scope(user, '');
const [customers, customerGateways, vendorGateways] = await Promise.all([
const [customers, customerGateways, vendors, 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 }),
scope ? this.db.$queryRaw<Array<{id:string;name:string}>>(Prisma.sql`SELECT DISTINCT vendor_id AS id,vendor_id AS name FROM caller_analysis_states WHERE customer_id IN (${Prisma.join(scope)}) AND vendor_id<>'' LIMIT 2000`) : this.db.vendor.findMany({where:{deletedAt:null},select:{id:true,name: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 };
return { customers, customerGateways, vendors, vendorGateways };
}
}