feat: add caller connection and answer rate analytics
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
normalizeVendorGateway,
|
||||
} from './utils/formatters.js';
|
||||
import { DashboardPage } from './pages/DashboardPage.jsx';
|
||||
import { CallerAnalyticsPage } from './pages/CallerAnalyticsPage.jsx';
|
||||
import { ActiveCallsPage } from './pages/ActiveCallsPage.jsx';
|
||||
import { CustomersPage } from './pages/CustomersPage.jsx';
|
||||
import { RechargeRecordsPage } from './pages/RechargeRecordsPage.jsx';
|
||||
@@ -58,6 +59,7 @@ const navGroups = [
|
||||
items: [
|
||||
{ key: 'activeCalls', label: '当前通话', permissions: PAGE_PERMISSIONS.activeCalls },
|
||||
{ key: 'cdr', label: '话单中心', permissions: PAGE_PERMISSIONS.cdr },
|
||||
{ key: 'callerAnalytics', label: '主叫号码分析', permissions: PAGE_PERMISSIONS.callerAnalytics },
|
||||
{ key: 'quality', label: '质检中心', permissions: PAGE_PERMISSIONS.quality },
|
||||
{ key: 'sipops', label: 'SIP 运维', pending: true },
|
||||
{ key: 'monitoring', label: '监控告警', pending: true },
|
||||
@@ -76,6 +78,7 @@ const navGroups = [
|
||||
|
||||
|
||||
const pages = {
|
||||
callerAnalytics: CallerAnalyticsPage,
|
||||
dashboard: DashboardPage,
|
||||
activeCalls: ActiveCallsPage,
|
||||
customers: CustomersPage,
|
||||
|
||||
@@ -128,6 +128,9 @@ function queryString(params = {}) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
callerAnalytics: (params, options) => request(`/caller-analytics/overview${queryString(params)}`, options),
|
||||
callerAnalyticsCalls: (params, options) => request(`/caller-analytics/calls${queryString(params)}`, options),
|
||||
callerAnalyticsOptions: options => request('/caller-analytics/options', options),
|
||||
captcha: () => request('/auth/captcha'),
|
||||
login: async (body) => {
|
||||
const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) });
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { PageTitle, Panel, Toolbar, SimpleTable, Drawer, Pagination } from '../components/layout.jsx';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
import { formatDateTime } from '../utils/formatters.js';
|
||||
|
||||
const percent = v => v === null || v === undefined ? '—' : `${Number(v).toFixed(2)}%`;
|
||||
const rateColumns = [
|
||||
{ key: 'connectionRate', label: '实时接通率', render: r => percent(r.connectionRate) },
|
||||
{ key: 'overallAnswerRate', label: '实时总体应答率', render: r => percent(r.overallAnswerRate) },
|
||||
{ key: 'connectedAnswerRate', label: '实时已接通应答率', render: r => percent(r.connectedAnswerRate) }
|
||||
];
|
||||
const countColumns = [{ key: 'totalCalls', label: '总呼叫数' }, { key: 'connectedCalls', label: '接通数' }, { key: 'notConnectedCalls', label: '未接通数' }, { key: 'answeredCalls', label: '应答数' }];
|
||||
const initial = { view: 'landing', minutes: '15', caller: '', customerId: '', customerGatewayId: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
|
||||
export function CallerAnalyticsPage() {
|
||||
const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial);
|
||||
const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendorGateways: [] });
|
||||
const [auto, setAuto] = useState(true); const [tick, setTick] = useState(0);
|
||||
const [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
|
||||
const [detailSkip, setDetailSkip] = useState(0); const sequence = useRef(0);
|
||||
useEffect(() => { const c = new AbortController(); api.callerAnalyticsOptions({ signal: c.signal }).then(setOptions).catch(e => { if (!c.signal.aborted) setError(explainApiError(e)); }); return () => c.abort(); }, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); const id = ++sequence.current;
|
||||
let timer;
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const q = { ...filters, onlyAlerts: String(filters.onlyAlerts) };
|
||||
if (q.minutes === 'today') { const now = new Date(); q.from = new Date(now.toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' }) + 'T00:00:00+08:00').toISOString(); delete q.minutes; }
|
||||
if (q.minutes === 'custom') { q.from = new Date(q.from).toISOString(); q.to = new Date(q.to).toISOString(); delete q.minutes; }
|
||||
const result = await api.callerAnalytics(q, { signal: controller.signal });
|
||||
if (id === sequence.current) { setData(result); setError(''); }
|
||||
} catch (e) { if (!controller.signal.aborted && id === sequence.current) setError(explainApiError(e)); }
|
||||
finally { if (!controller.signal.aborted && id === sequence.current) { setLoading(false); if (auto) timer = setTimeout(refresh, 4000); } }
|
||||
};
|
||||
void refresh(); return () => { controller.abort(); clearTimeout(timer); };
|
||||
}, [filters, auto, tick]);
|
||||
useEffect(() => {
|
||||
if (!target || !data) return undefined;
|
||||
const c = new AbortController();
|
||||
api.callerAnalyticsCalls({ ...filters, from: data.from, to: data.to, caller: target.caller, customerId: target.customerId, skip: detailSkip, take: 25 }, { signal: c.signal })
|
||||
.then(r => { setDetail(r); setDetailError(''); }).catch(e => { if (!c.signal.aborted) setDetailError(explainApiError(e)); });
|
||||
return () => c.abort();
|
||||
}, [target, data, filters, detailSkip]);
|
||||
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorGatewayId: '' } : {}) }));
|
||||
const names = Object.fromEntries(options.customers.map(c => [c.id, c.name]));
|
||||
const gatewayNames = Object.fromEntries(options.vendorGateways.map(c => [c.id, c.name]));
|
||||
const select = (label, key, choices) => <Field label={label}><Select value={draft[key]} onChange={e => change(key, e.target.value)}><option value="">全部</option>{choices.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></Field>;
|
||||
const openDetail = row => { setTarget(row); setDetail(null); setDetailSkip(0); setDetailError(''); };
|
||||
const metrics = data?.summary;
|
||||
const timeline = data?.trends ?? [];
|
||||
const max = Math.max(1, ...timeline.map(r => r.totalCalls));
|
||||
return <>
|
||||
<PageTitle title="主叫号码分析" desc="接通:180 / 183 或后续阶段;应答:实际通话时长大于 0。" actions={<><Button variant="outline" onClick={() => setAuto(v => !v)}>{auto ? '自动刷新中' : '已暂停刷新'}</Button><Button disabled={loading} onClick={() => setTick(v => v + 1)}>{loading ? '更新中…' : '刷新'}</Button></>} />
|
||||
{error ? <Alert tone="danger" title="更新失败,保留上次数据">{error}</Alert> : null}
|
||||
<Panel title="筛选条件"><form onSubmit={e => { e.preventDefault(); setFilters({ ...draft, skip: 0 }); setTarget(null); }}>
|
||||
<Toolbar>
|
||||
<Field label="号码视角"><Select value={draft.view} onChange={e => change('view', e.target.value)}><option value="landing">落地主叫</option><option value="original">原始主叫</option></Select></Field>
|
||||
<Field label="时间窗口"><Select value={draft.minutes} onChange={e => change('minutes', e.target.value)}>{[5,15,30,60].map(n => <option key={n} value={n}>最近 {n} 分钟</option>)}<option value="today">今日</option><option value="custom">自定义</option></Select></Field>
|
||||
{draft.minutes === 'custom' ? <><Field label="开始时间"><Input type="datetime-local" required value={draft.from || ''} onChange={e => change('from', e.target.value)} /></Field><Field label="结束时间"><Input type="datetime-local" required value={draft.to || ''} onChange={e => change('to', e.target.value)} /></Field></> : null}
|
||||
<Field label="主叫号码"><Input value={draft.caller} onChange={e => change('caller', e.target.value)} placeholder="精确匹配号码" /></Field>
|
||||
{select('客户', 'customerId', options.customers)}
|
||||
{select('客户网关', 'customerGatewayId', options.customerGateways.filter(g => !draft.customerId || g.customerId === draft.customerId))}
|
||||
{draft.view === 'landing' ? select('落地网关', 'vendorGatewayId', options.vendorGateways) : null}
|
||||
{select('被叫运营商', 'carrier', ['MOBILE','UNICOM','TELECOM','BROADCAST','MVNO','UNKNOWN'].map((id,i) => ({id,name:['移动','联通','电信','广电','虚拟运营商','未知'][i]})))}
|
||||
<Field label="被叫城市代码"><Input value={draft.city} onChange={e => change('city', e.target.value)} /></Field>
|
||||
<Field label="最小呼叫数"><Input type="number" min="0" value={draft.minSamples} onChange={e => change('minSamples', e.target.value)} /></Field>
|
||||
<Field label="排序"><Select value={draft.sort} onChange={e => change('sort', e.target.value)}>{[...countColumns, ...rateColumns].map(c => <option key={c.key} value={c.key}>{c.label}</option>)}</Select></Field>
|
||||
<Field label="顺序"><Select value={draft.order} onChange={e => change('order', e.target.value)}><option value="desc">从高到低</option><option value="asc">从低到高</option></Select></Field>
|
||||
<Field label="异常筛选"><Select value={String(draft.onlyAlerts)} onChange={e => change('onlyAlerts', e.target.value === 'true')}><option value="false">全部号码</option><option value="true">仅看告警</option></Select></Field>
|
||||
</Toolbar>
|
||||
<details className="cra-rate-filters"><summary>接通率 / 应答率区间</summary><Toolbar>{[['connection','接通率'],['overall','总体应答率'],['connected','已接通应答率']].map(([key,label]) => <Field key={key} label={`${label}(%)`}><span className="table-actions"><Input aria-label={`${label}下限`} type="number" min="0" max="100" value={draft[`${key}Min`] ?? ''} onChange={e => change(`${key}Min`, e.target.value)} placeholder="0" /><span>至</span><Input aria-label={`${label}上限`} type="number" min="0" max="100" value={draft[`${key}Max`] ?? ''} onChange={e => change(`${key}Max`, e.target.value)} placeholder="100" /></span></Field>)}</Toolbar></details>
|
||||
<Button type="submit">查询</Button>
|
||||
</form></Panel>
|
||||
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
|
||||
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 未知 ${metrics.unknownCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。</Alert> : null}
|
||||
<Panel title="分钟趋势" aside={<span className="muted-text">灰:呼叫 / 蓝:接通 / 绿:应答</span>}>
|
||||
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
|
||||
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
|
||||
</Panel>
|
||||
<Panel title="主叫号码列表" aside={<Badge>{data?.total ?? 0} 个号码</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={(data?.numbers ?? []).map(r => ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[
|
||||
{key:'caller',label:'主叫号码',render:r => <Button size="sm" variant="ghost" onClick={() => openDetail(r)}>{r.caller}</Button>}, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns,
|
||||
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls ? '证据不足' : '观察'}</Badge>}
|
||||
]} />
|
||||
<Pagination total={data?.total ?? 0} take={filters.take} skip={filters.skip} count={data?.numbers.length ?? 0} loading={loading} onPageChange={skip => setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} />
|
||||
</Panel>
|
||||
{target ? <Drawer title={`号码详情 · ${target.caller}`} onClose={() => setTarget(null)}>
|
||||
{detailError ? <Alert tone="danger" title="详情更新失败">{detailError}</Alert> : null}
|
||||
<p>{names[target.customerId] || target.customerId} · {filters.view === 'landing' ? '落地主叫' : '原始主叫'}</p>
|
||||
<Panel title="线路 / 地区 / 运营商分布"><SimpleTable rows={(detail?.breakdown ?? []).map((r,i) => ({...r,id:i,gateway:gatewayNames[r.vendorGatewayId] || r.vendorGatewayId || '业务呼叫汇总'}))} columns={[{key:'gateway',label:'落地网关'},{key:'city',label:'城市'},{key:'carrier',label:'运营商'},...countColumns,...rateColumns]} /></Panel>
|
||||
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
|
||||
const p=r.payload; const legs=p.legs ? Object.values(p.legs) : [p];
|
||||
return {...r, callId:p.start?.callId || p.event?.callId, original:p.start?.caller || p.event?.caller,
|
||||
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : '未达接通').join(' / '),
|
||||
final:legs.map(l => l.finalCode || '进行中').join(' / '), duration:`${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.activeCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
|
||||
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'最终结果'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
|
||||
<div className="table-actions"><Button variant="outline" disabled={!detailSkip} onClick={() => setDetailSkip(v => Math.max(0,v-25))}>上一页</Button><Button variant="outline" disabled={(detail?.rows.length ?? 0)<25} onClick={() => setDetailSkip(v => v+25)}>下一页</Button></div>
|
||||
<p className="muted-text">按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。</p>
|
||||
</Drawer> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const PAGE_PERMISSIONS = {
|
||||
callerAnalytics: ['caller_analytics.view'],
|
||||
dashboard: ['dashboard.view'],
|
||||
activeCalls: ['active_calls.view'],
|
||||
customers: ['customers.view'],
|
||||
|
||||
@@ -2893,3 +2893,12 @@ a {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.cra-metrics { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin: 20px 0; }
|
||||
.cra-rate-filters { margin: 12px 0; }
|
||||
.cra-chart { display: flex; gap: 8px; overflow-x: auto; min-height: 160px; padding: 12px 0; }
|
||||
.cra-chart-column { flex: 1 0 38px; max-width: 70px; text-align: center; }
|
||||
.cra-bars { display: flex; align-items: flex-end; justify-content: center; height: 120px; gap: 2px; }
|
||||
.cra-bars span { width: 9px; min-height: 1px; background: #94a3b8; border-radius: 2px 2px 0 0; }
|
||||
.cra-bars span:nth-child(2) { background: #2563eb; }
|
||||
.cra-bars span:nth-child(3) { background: #16a34a; }
|
||||
.cra-chart small { color: #64748b; font-size: 10px; }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PrismaClient, Prisma, analyticsHash, analyticsRates, COUNT_KEYS, type AnalyticsCounts } from '@lisglosips/database';
|
||||
|
||||
export async function evaluateCallerAlerts(db: PrismaClient) {
|
||||
const end = new Date(Math.floor(Date.now() / 60000) * 60000); const start = new Date(+end - 15 * 60000);
|
||||
const aggregates = Prisma.join(COUNT_KEYS.map(k => Prisma.sql`SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,${`$.${k}`})) AS SIGNED)) AS ${Prisma.raw(k)}`));
|
||||
const rows = await db.$queryRaw<Array<AnalyticsCounts & { customer_id: string; caller: string; view: string }>>(Prisma.sql`SELECT customer_id, caller, view, ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${start} AND started_at<${end} GROUP BY customer_id, caller, view HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>=50`);
|
||||
for (const row of rows) {
|
||||
const counts = Object.fromEntries(COUNT_KEYS.map(k => [k, Number(row[k])])) as unknown as AnalyticsCounts;
|
||||
const rates = analyticsRates(counts);
|
||||
if (rates.unknownCalls || rates.activeCalls / rates.totalCalls > 0.2) continue;
|
||||
const reasons = [
|
||||
rates.connectionRate !== null && rates.connectionRate < Number(process.env.ANALYTICS_LOW_CONNECTION_RATE ?? 20) ? '低接通率' : '',
|
||||
rates.overallAnswerRate !== null && rates.overallAnswerRate < Number(process.env.ANALYTICS_LOW_OVERALL_RATE ?? 10) ? '低总体应答率' : '',
|
||||
rates.connectedCalls >= 30 && rates.connectedAnswerRate !== null && rates.connectedAnswerRate < Number(process.env.ANALYTICS_LOW_CONNECTED_RATE ?? 20) ? '低已接通应答率' : ''
|
||||
].filter(Boolean);
|
||||
const previous = await db.$queryRaw<Array<AnalyticsCounts>>(Prisma.sql`SELECT ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${new Date(+start - 15 * 60000)} AND started_at<${start} AND customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view}`);
|
||||
const p = analyticsRates(Object.fromEntries(COUNT_KEYS.map(k => [k, Number(previous[0]?.[k] ?? 0)])) as unknown as AnalyticsCounts);
|
||||
if (p.totalCalls >= 50 && !p.unknownCalls && p.activeCalls / p.totalCalls <= 0.2) {
|
||||
for (const [key, label] of [['connectionRate','接通率骤降'],['overallAnswerRate','总体应答率骤降'],['connectedAnswerRate','已接通应答率骤降']] as const) {
|
||||
if (key === 'connectedAnswerRate' && (p.connectedCalls < 30 || rates.connectedCalls < 30)) continue;
|
||||
if (p[key] !== null && rates[key] !== null && p[key]! - rates[key]! > Number(process.env.ANALYTICS_RATE_DROP_POINTS ?? 15)) reasons.push(label);
|
||||
}
|
||||
}
|
||||
const tail = await db.$queryRaw<Array<{ counts: AnalyticsCounts }>>`SELECT counts FROM caller_analysis_states WHERE customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view} AND started_at>=${start} AND started_at<${end} ORDER BY started_at DESC, id DESC LIMIT 20`;
|
||||
if (tail.length === 20 && tail.every(t => t.counts.failedCalls === 1)) reasons.push('连续20次已结束未接通');
|
||||
const id = analyticsHash([row.customer_id, row.caller, row.view]);
|
||||
const existing = await db.$queryRaw<Array<{ status: string; payload: { streak?: number; recovered?: number; since?: string } }>>`SELECT status,payload FROM caller_analysis_alerts WHERE id=${id}`;
|
||||
const streak = reasons.length ? (existing[0]?.payload.streak ?? 0) + 1 : 0;
|
||||
const recovered = reasons.length ? 0 : (existing[0]?.payload.recovered ?? 0) + 1;
|
||||
const status = streak >= 3 ? 'ACTIVE' : existing[0]?.status === 'ACTIVE' && recovered < 3 ? 'ACTIVE' : 'OBSERVING';
|
||||
const payload = JSON.stringify({ reasons, rates, streak, recovered, windowStart: start, windowEnd: end, since: existing[0]?.payload.since ?? new Date().toISOString() });
|
||||
await db.$executeRaw`INSERT INTO caller_analysis_alerts(id,customer_id,view,caller,status,payload,updated_at) VALUES (${id},${row.customer_id},${row.view},${row.caller},${status},${payload},NOW(3)) ON DUPLICATE KEY UPDATE status=VALUES(status),payload=VALUES(payload),updated_at=VALUES(updated_at)`;
|
||||
}
|
||||
// Old low-sample windows are no longer active evidence, not permanent alarms.
|
||||
await db.$executeRaw`UPDATE caller_analysis_alerts SET status='EXPIRED' WHERE updated_at<${new Date(Date.now() - 120000)} AND status='ACTIVE'`;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { open, readFile, writeFile, rename, stat, appendFile } from 'node:fs/promises';
|
||||
import { hostname } from 'node:os';
|
||||
import { PrismaClient, Prisma, CallerAnalyticsStore, ANALYTICS_GROUP, ANALYTICS_STREAM, analyticsHash, parseAnalyticsEvent, type AnalyticsCall, type AnalyticsEvent } from '@lisglosips/database';
|
||||
import { createRedisClient } from '@lisglosips/redis';
|
||||
import { createLogger } from '@lisglosips/observability';
|
||||
import { evaluateCallerAlerts } from './analytics-alerts.js';
|
||||
|
||||
const logger = createLogger('caller-analytics');
|
||||
const db = new PrismaClient(); const redis = createRedisClient(process.env.REDIS_URL ?? '');
|
||||
const store = new CallerAnalyticsStore(db);
|
||||
const spool = process.env.ANALYTICS_SPOOL ?? '/var/log/lisglosips/caller-analytics.log';
|
||||
const checkpoint = process.env.ANALYTICS_CHECKPOINT ?? '/var/lib/lisglosips/caller-analytics-offset.json';
|
||||
let cursor = { offset: 0, ino: 0 }; let stopping = false; let lastAlerts = 0;
|
||||
let dataThrough: string | null = null; let gap = false;
|
||||
const consumer = `${hostname()}-${process.pid}`;
|
||||
process.on('SIGTERM', () => { stopping = true; }); process.on('SIGINT', () => { stopping = true; });
|
||||
|
||||
function decodeLine(line: string): AnalyticsEvent | null {
|
||||
const marker = line.indexOf('CRA1|'); if (marker < 0) return null;
|
||||
const parts = line.slice(marker).trim().split('|');
|
||||
if (parts.length !== 20) throw new Error('Malformed producer record');
|
||||
const decoded = parts.slice(8).map(s => Buffer.from(s, 'base64').toString('utf8'));
|
||||
const [uid, callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId] = decoded;
|
||||
const at = Number(parts[2]) * 1000 + Math.floor(Number(parts[3]) / 1000);
|
||||
const startedAt = Number(parts[4]) * 1000 + Math.floor(Number(parts[5]) / 1000);
|
||||
const fields = { version: '1', eventId: analyticsHash(parts), callUid: analyticsHash(uid), callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId,
|
||||
kind: parts[1], at: String(at), startedAt: String(startedAt), code: parts[6], source: parts[7], talkMs: '-1', sequence: '0' };
|
||||
return parseAnalyticsEvent(Object.entries(fields).flat());
|
||||
}
|
||||
async function collect() {
|
||||
const info = await stat(spool);
|
||||
if (cursor.ino && (cursor.ino !== info.ino || info.size < cursor.offset)) { gap = true; cursor.offset = 0; }
|
||||
cursor.ino = info.ino;
|
||||
const file = await open(spool, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(1024 * 1024, Math.max(0, info.size - cursor.offset)));
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, cursor.offset);
|
||||
const last = buffer.subarray(0, bytesRead).lastIndexOf(10); if (last < 0) return;
|
||||
for (const line of buffer.subarray(0, last + 1).toString('utf8').split('\n').filter(Boolean)) {
|
||||
let event: AnalyticsEvent | null;
|
||||
try { event = decodeLine(line); } catch {
|
||||
await appendFile(`${checkpoint}.deadletter`, `${JSON.stringify({ at:new Date(), line })}\n`, { mode:0o600 });
|
||||
gap = true; continue;
|
||||
}
|
||||
if (event) await redis.xadd(ANALYTICS_STREAM, '*', 'event', JSON.stringify(event));
|
||||
}
|
||||
// Persist only after durable queue writes. Crash before checkpoint replays harmlessly.
|
||||
cursor.offset += last + 1;
|
||||
await writeFile(`${checkpoint}.tmp`, JSON.stringify(cursor), { mode: 0o600 }); await rename(`${checkpoint}.tmp`, checkpoint);
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
async function entries(entries: Array<[string, string[]]>) {
|
||||
for (const [id, fields] of entries) {
|
||||
const event = JSON.parse(fields[1]) as AnalyticsEvent;
|
||||
await store.consume(event);
|
||||
await redis.xack(ANALYTICS_STREAM, ANALYTICS_GROUP, id);
|
||||
dataThrough = new Date(event.at).toISOString();
|
||||
}
|
||||
}
|
||||
async function observeActive() {
|
||||
const response = await fetch(process.env.ANALYTICS_MI_URL ?? 'http://127.0.0.1:8888/mi', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] }), signal: AbortSignal.timeout(2000) });
|
||||
const body = await response.json() as { result?: { Dialogs?: Array<{ callid: string; state: number }> } };
|
||||
if (!response.ok || !body.result?.Dialogs) throw new Error('Dialog observation unavailable');
|
||||
const calls = await db.$queryRaw<Array<{ payload: AnalyticsCall }>>`SELECT c.payload FROM caller_analysis_calls c WHERE EXISTS (SELECT 1 FROM caller_analysis_states s WHERE s.call_key=c.id AND s.view='original' AND CAST(JSON_UNQUOTE(JSON_EXTRACT(s.counts,'$.activeCalls')) AS SIGNED)>0) LIMIT 2000`;
|
||||
const live = new Map(body.result.Dialogs.map(d => [d.callid, Number(d.state)]));
|
||||
const now = Date.now();
|
||||
for (const { payload } of calls) for (const leg of Object.values(payload.legs)) {
|
||||
if (leg.endedAt !== null || leg.acceptedAt === null || leg.talkMs > 0) continue;
|
||||
// Only a confirmed live dialog supplies provisional positive duration. Never a browser timer.
|
||||
if ((live.get(leg.event.callId) ?? 0) >= 3 && (live.get(leg.event.callId) ?? 0) <= 4 && now > leg.acceptedAt) {
|
||||
await store.consume({ ...leg.event, kind: 'DURATION', at: now, talkMs: now - leg.acceptedAt, source: 'dialog-observation', eventId: analyticsHash([leg.event.callUid, leg.event.attemptId, 'positive-duration']) });
|
||||
}
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
try { cursor = JSON.parse(await readFile(checkpoint, 'utf8')); } catch { /* First start reads the retained spool. */ }
|
||||
await redis.connect(); await db.$connect();
|
||||
try { await redis.xgroup('CREATE', ANALYTICS_STREAM, ANALYTICS_GROUP, '0', 'MKSTREAM'); } catch (e) { if (!(e instanceof Error) || !e.message.includes('BUSYGROUP')) throw e; }
|
||||
let pendingCursor = '0-0';
|
||||
while (!stopping) {
|
||||
let degraded = gap; let reason = '';
|
||||
try {
|
||||
await collect();
|
||||
const claimed = await redis.xautoclaim(ANALYTICS_STREAM, ANALYTICS_GROUP, consumer, 5000, pendingCursor, 'COUNT', 100) as [string, Array<[string, string[]]>];
|
||||
pendingCursor = claimed[0]; await entries(claimed[1]);
|
||||
const fresh = await redis.xreadgroup('GROUP', ANALYTICS_GROUP, consumer, 'COUNT', 200, 'BLOCK', 100, 'STREAMS', ANALYTICS_STREAM, '>') as Array<[string, Array<[string, string[]]>]> | null;
|
||||
if (fresh) for (const [, batch] of fresh) await entries(batch);
|
||||
await observeActive();
|
||||
const groups = await redis.xinfo('GROUPS', ANALYTICS_STREAM) as Array<Array<string | number>>;
|
||||
const group = groups.map(g => Object.fromEntries(Array.from({ length: g.length / 2 }, (_, i) => [g[i * 2], g[i * 2 + 1]]))).find(g => g.name === ANALYTICS_GROUP);
|
||||
if (Number(group?.pending ?? 0) || Number(group?.lag ?? 0)) { degraded = true; reason = 'QUEUE_BACKLOG'; }
|
||||
if (Date.now() - lastAlerts > 15000 && !degraded) { await evaluateCallerAlerts(db); lastAlerts = Date.now(); }
|
||||
} catch (e) { degraded = true; reason = e instanceof Error ? e.message.slice(0, 200) : 'Worker error'; logger.error({ reason }, 'Analytics cycle failed'); }
|
||||
try {
|
||||
const payload = JSON.stringify({ degraded, reason, dataThrough, lagMs: degraded ? null : 0, captureSince: process.env.ANALYTICS_CAPTURE_SINCE ?? null, spoolOffset: cursor.offset, gap, updatedAt: new Date().toISOString() });
|
||||
await db.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_health(id,payload,updated_at) VALUES ('worker',${payload},NOW(3)) ON DUPLICATE KEY UPDATE payload=VALUES(payload),updated_at=VALUES(updated_at)`);
|
||||
} catch { logger.error('Analytics heartbeat write failed'); }
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
}
|
||||
redis.disconnect(); await db.$disconnect();
|
||||
}
|
||||
void main().catch(e => { logger.error({ message: e instanceof Error ? e.message : 'Startup failed' }, 'Analytics failed'); process.exitCode = 1; redis.disconnect(); void db.$disconnect(); });
|
||||
Reference in New Issue
Block a user