14 Commits
45 changed files with 3543 additions and 4 deletions
+34
View File
@@ -4,6 +4,40 @@
## 当前状态
### 2026-09-01 主叫实时分析专项整改(已完成)
- 用户已授权执行专项核对文档中的全部整改,包括代码、数据库迁移、B机发布和受控真实呼叫。
- 已完成:ACK起算的应答证据、显式CLOSE生命周期、生产/采集计数核对、持久缺口与死信、独立Redis、保留维护、查询快照/稳定分页、供应商筛选、告警范围和降级暂停、页面证据字段。
- 本地验证:ESLint、TypeScript构建、Prisma schema、Web生产构建通过;Vitest 29个文件/118项通过;B机OpenSIPS候选配置检查通过。
- B机已发布提交`9fa1964dec98cbe3c53c901ba764373308054d5e`,发布目录`/opt/lisglosips/releases/caller-analytics-20260901T024406Z`。OpenSIPS、API、CDR Worker、分析Worker、独立Redis及维护timer均为active/enabledAPI ready为ok。
- 真实验收:6通受控SIP得到T=6/C=4/A=2/N=2,两个成功场景完成SDP协商并经过RTPEngine300ms短通话计A;真实API、MySQL状态/分钟桶、重放幂等、RBAC与页面桌面/移动端均通过。Redis重启恢复、毒事件死信、覆盖降级/恢复、维护与轮转检查通过。证据见`docs/CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md``tests/reports/caller-analytics-real-20260901.json`
- 收口:受控测试入口/出口均已禁用,临时UI会话已清零。没有修改计费、余额扣减、路由选择或旧固定6秒CDR逻辑;工作区独立的代码质量审查文件未纳入本次提交。
- 限制:未取得约定峰值CPS、号码基数和保留期,故不宣称容量上限或统计P95。代码与交付文档已推送到`origin/main`B机生效代码为`9fa1964`,其后的`367211e`仅增加交付证据与状态文档,无需再次重启服务。
### 2026-08-31 主叫实时分析设计核对与整改方案(仅审查文档)
- 产物:[主叫实时分析设计核对与整改方案](docs/CALLER_REALTIME_ANALYTICS_REVIEW_AND_REMEDIATION_20260831.md),初始基线 `116e316eca5b564f33ec3558dbd00201da173ed8`;已补核对并行提交 `d786165` 及当时可见的存储/测试候选改动,已出现修复与仍待验收项分别标注。
- 结论:保留V1.1业务口径;列出13组整改项(10组P1、3组P2),重点为时长证据、未知状态、乱序校正、完整性/留存、告警与真实验收。整改尚未实施,不代表线上已修复。
- 本轮验证:现有专项Vitest 2文件/16项通过;纯计算复现确认缺过程END误判失败、最终零时长校正受事件到达顺序影响等边界。本审查任务没有连接线上服务、执行迁移/部署、真实呼叫或Git提交推送;没有改动原设计和其他任务文件,其他任务的并行变化均保留。
- 后续按方案W0~W4推进;下方“测试发布验证中”为既有实施记录,本轮未重新核实其现场状态。
### 2026-08-31 主叫号码实时分析实施(测试发布验证中)
- 新增独立分析进程、MySQL事件/状态/分钟汇总/权限范围/告警表、真实API和主叫分析页面,计费Worker不变。
- 新采集使用OpenSIPS进展及结束观察钩子、本机rsyslog持久队列/日志、Redis Stream,再由独立进程事务去重及聚合。
- 本地校验:TypeScript、ESLint、Web构建通过;全量Vitest 29文件/114测试通过;Prisma schema通过;B候选OpenSIPS配置校验通过。测试部署/真实呼叫结果见后续发布记录,不能据此认定已上线。
- 现场发现旧成功CDR固定duration_sec=6,不能作新应答统计权威来源。保留旧计费不变,新功能仅信任独立实际时间证据。T节点SSH暂不可达,真实SIP测试改为B本机独立零费率入口/落地,不拨外部号码。
- Git中代码审查报告属于并行任务,不纳入本次提交。
### 2026-08-31 文档增补:主叫号码接通率与应答率实时分析
- 本轮任务:方案落文档并按用户要求修订接通/应答业务口径;文档已完成,功能实现待开始。
- 产物:[专项设计V1.1](docs/CALLER_REALTIME_ANALYTICS_DESIGN.md),主设计文档索引及测试计划CRA-001至CRA-024验收索引。
- 口径:180/183或后续阶段计接通;正实际通话时长计应答;总体应答率=A/T,已接通应答率=A/C。待接通与未知单列说明,不等同最终失败。
- 验证:本地源码及RFC 3261只读核对;文档公式、场景、链接和Git差异检查。24项功能用例仅为验收设计,均未执行。
- 变更边界:仅Markdown文档;无代码、迁移、生产变更、真实外呼或Git提交/推送。下方原有发布状态保留为历史记录,未在本轮重新验证。
- 遗留/下一步:实施前核验生产呼叫过程、实际通话时长来源与精度、重试机制及容量,按专项方案实施并真实联调;历史缺少180/183的数据不得伪造完整接通统计。
- 当前任务:`S56 - 全部当前服务收敛到 Server B`
- 本次变更摘要:确认 B 已同时运行 OpenSIPS、RTPEngine、录音守护进程、Web/API/Worker、MySQL、Redis 与监控;将 Recording Worker 从 B 到 A 的 SSH 拉取改为 B 本机 ready 目录读取,安装本机 finalizer timer,并通过 systemd BindPaths/附加组暴露最小目录权限。当前 release 为 `s56-b-consolidation-20260827T095812Z`,提交为 `309bffc7ffa70a760bce21ae59cb6c457c9c57fe`
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s53-brand-logo-sip-text-20260630105500`;稳定版名称 `20260630release`Git tag `20260630release` 和分支 `stable/20260630release` 均指向稳定代码提交 `9920575bbae7fbef4929843632f94d687ffbe289`。2026-06-30 开机后已恢复 A/B/T 服务:A `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-redis-auth-proxy``lisglosips-node-exporter``lisglosips-recording-finalize.timer` active`lisglosips-redis-hotpath-load.service` Result=successB `mysql``redis-server``nginx``lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher``heplify-server``lisglosips-prometheus``grafana-server` activepreflight okAPI ready ok,封版 MySQL 备份 `/data/backups/mysql/20260630T061723Z`Redis 备份 `/data/backups/redis/20260630T061723Z`T `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-s28-uas``apache2``mariadb/mysql` active。B preflight、远程 smoke、8.2/8.7/8.8 定向复测均通过;业务前缀、号码库、客户网关编辑与前缀选择 Web-only 修复后,s53 当前首页引用 Web JS `index-CrjpbrU-.js`、CSS `index-CrJGI5_R.css`,并包含 `/favicon.ico``/brand/logo1.png``/brand/logo2.png`;封版远程 smoke `REMOTE_SMOKE_20260630T061636Z.md` 通过;完整按钮级远程浏览器巡检 `REMOTE_WEB_UI_SMOKE_20260629T104040Z.md` 通过,覆盖 15 个核心菜单及安全主操作,客户网关管理编辑报告包含 `business-prefix-checkbox-toggle-ok: false->true->false`;封版报告为 `docs/20260630_RELEASE_BASELINE_REPORT.md`;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
+4
View File
@@ -12,6 +12,10 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
本文作为下一阶段执行基线,后续数据库 DDL、OpenAPI、OpenSIPS 脚本和部署脚本应从本文拆分并版本化管理。
### 1.1 2026-08-31 新增设计:主叫号码实时分析
详见 [主叫号码接通率与应答率实时分析设计方案](docs/CALLER_REALTIME_ANALYTICS_DESIGN.md)。V1.2整改代码、加法迁移及B机测试发布已于2026-09-01完成,真实SIP/API/MySQL/RBAC/UI及可靠性结果见[整改交付报告](docs/CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md)。新功能“接通数”按180 Ringing/183 Session Progress或后续阶段统计;“应答数”按ACK后可信正通话时长统计;实时总体应答率=应答数/总呼叫数,实时已接通应答率=应答数/接通数。既有首页历史指标、计费规则及旧固定6秒CDR逻辑未变更。当前发布不构成峰值容量或P95承诺。
## 2. V2 范围
### 2.1 本期实施页面
+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,123 @@
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
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';
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, 'notConnectedCalls', '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, 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 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)) || 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 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, 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 => ({ ...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 source = this.source(q, scope);
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, 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, vendors, vendorGateways };
}
}
+3
View File
@@ -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,
+3
View File
@@ -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) });
+105
View File
@@ -0,0 +1,105 @@
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: '', vendorId: '', 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: [], vendors: [], 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 !== 'custom') { delete q.from; delete q.to; }
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' ? { vendorId:'', vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
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('供应商', 'vendorId', options.vendors)}{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.coverageStatus === 'CONTINUOUS' ? '连续采集覆盖' : '数据覆盖不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 接通未知 ${metrics.unknownCalls} · 时长未知 ${metrics.durationUnknownCalls} · 暂定应答 ${metrics.provisionalAnsweredCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}183 不代表实际振铃未接通数含待接通口径 {data.metricVersion}算法 {data.analysisRevision}采集截至 {data.dataThrough ? formatDateTime(data.dataThrough) : '未知'}延迟 {data.lagMs == null ? '未知' : `${data.lagMs}ms`}</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:'durationUnknownCalls',label:'时长未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'} title={r.alert?.scope}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls || r.durationUnknownCalls ? '证据不足' : '观察'}</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 ? '直接成功' : l.talkMs > 0 ? '可信正时长补计' : '未达接通').join(' / '),
final:legs.map(l => l.finalCode ? `初始 INVITE ${l.finalCode}` : '初始 INVITE 进行中').join(' / '), termination:legs.map(l => l.terminationReason || '未结束').join(' / '), duration:r.counts.durationUnknownCalls ? '证据不足' : `${(r.counts.talkMs/1000).toFixed(3)}${r.counts.provisionalAnsweredCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'初始 INVITE 结果'},{key:'termination',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
View File
@@ -1,4 +1,5 @@
export const PAGE_PERMISSIONS = {
callerAnalytics: ['caller_analytics.view'],
dashboard: ['dashboard.view'],
activeCalls: ['active_calls.view'],
customers: ['customers.view'],
+9
View File
@@ -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; }
+20
View File
@@ -0,0 +1,20 @@
import { COUNT_KEYS, Prisma, PrismaClient, analyticsHash, analyticsRates, type AnalyticsCounts } from '@lisglosips/database';
type Row={customer_id:string;caller:string;view:string}&AnalyticsCounts;
type Prior={status:string;payload:{streak?:number;recovered?:number;since?:string;evaluationKey?:string}};
const sums=()=>Prisma.join(COUNT_KEYS.map(k=>Prisma.sql`SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,${`$.${k}`})) AS SIGNED),0)) AS ${Prisma.raw(k)}`));
const numeric=(row:Partial<Row>):AnalyticsCounts=>Object.fromEntries(COUNT_KEYS.map(k=>[k,Number(row[k]??0)]))as unknown as AnalyticsCounts;
export async function suspendCallerAlerts(db:PrismaClient,reason:string){await db.$executeRaw`UPDATE caller_analysis_alerts SET status='SUSPENDED',payload=JSON_SET(payload,'$.suspendedReason',${reason},'$.suspendedAt',${new Date().toISOString()},'$.streak',0,'$.recovered',0),updated_at=NOW(3) WHERE status IN ('ACTIVE','OBSERVING')`;}
export async function evaluateCallerAlerts(db:PrismaClient){
const end=new Date(Math.floor(Date.now()/60000)*60000),start=new Date(+end-15*60000),evaluationKey=end.toISOString(),aggregates=sums();
const rows=await db.$queryRaw<Row[]>(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(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED),0))>=50`);
for(const row of rows){const counts=numeric(row),rates=analyticsRates(counts);if(rates.unknownCalls||rates.durationUnknownCalls||rates.activeCalls/rates.totalCalls>0.2)continue;const reasons=new Map<string,string>();
if(rates.connectionRate!==null&&rates.connectionRate<Number(process.env.ANALYTICS_LOW_CONNECTION_RATE??20))reasons.set('LOW_CONNECTION','低接通率');
if(rates.overallAnswerRate!==null&&rates.overallAnswerRate<Number(process.env.ANALYTICS_LOW_OVERALL_RATE??10))reasons.set('LOW_OVERALL','低总体应答率');
if(rates.connectedCalls>=30&&rates.connectedAnswerRate!==null&&rates.connectedAnswerRate<Number(process.env.ANALYTICS_LOW_CONNECTED_RATE??20))reasons.set('LOW_CONNECTED','低已接通应答率');
const previous=await db.$queryRaw<Row[]>(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}`),p=analyticsRates(numeric(previous[0]??{}));
if(p.totalCalls>=50&&!p.unknownCalls&&!p.durationUnknownCalls&&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.set(`DROP_${key}`,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.set('CONSECUTIVE_FAILURE','连续20次已结束未接通');
for(const ruleId of['LOW_CONNECTION','LOW_OVERALL','LOW_CONNECTED','DROP_connectionRate','DROP_overallAnswerRate','DROP_connectedAnswerRate','CONSECUTIVE_FAILURE']){const id=analyticsHash([row.customer_id,row.caller,row.view,ruleId,'v2']),existing=await db.$queryRaw<Prior[]>`SELECT status,payload FROM caller_analysis_alerts WHERE id=${id}`,old=existing[0];if(old?.payload.evaluationKey===evaluationKey)continue;const hit=reasons.has(ruleId),streak=hit?(old?.payload.streak??0)+1:0,recovered=hit?0:(old?.payload.recovered??0)+1,status=streak>=3?'ACTIVE':old?.status==='ACTIVE'&&recovered<3?'ACTIVE':'OBSERVING',payload=JSON.stringify({ruleId,reason:reasons.get(ruleId)??null,rates,streak,recovered,evaluationKey,windowStart:start,windowEnd:end,scope:{customerId:row.customer_id,caller:row.caller,view:row.view,minutes:15},since:old?.payload.since??new Date().toISOString(),cooldownSeconds:60});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)`;}
}
await db.$executeRaw`UPDATE caller_analysis_alerts SET status='STALE' WHERE updated_at<${new Date(Date.now()-120000)} AND status IN ('ACTIVE','OBSERVING','SUSPENDED')`;
}
+39
View File
@@ -0,0 +1,39 @@
import { appendFile, open, readFile, rename, stat, writeFile } from 'node:fs/promises';
import { hostname } from 'node:os';
import { ANALYTICS_GROUP, ANALYTICS_STREAM, CallerAnalyticsStore, Prisma, PrismaClient, analyticsHash, parseAnalyticsEvent, validateAnalyticsEvent, type AnalyticsCall, type AnalyticsEvent } from '@lisglosips/database';
import { createRedisClient } from '@lisglosips/redis';
import { createLogger } from '@lisglosips/observability';
import { evaluateCallerAlerts, suspendCallerAlerts } 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';
interface CollectorCursor { offset:number; ino:number; gap?:boolean; eventsSinceProducerStart?:number; lastProducerCount?:number }
let cursor:CollectorCursor={offset:0,ino:0,eventsSinceProducerStart:0}; let stopping=false; let lastAlerts=0; let lastTrim=0; let lastAlertSuspend=0;
let dataThrough:string|null=null; let ingestLagMs:number|null=null; let gap=false; let bootId='unknown';
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 [uid,callId,customerId,customerGatewayId,vendorId,vendorGatewayId,caller,landingCaller,callee,city,carrier,attemptId]=parts.slice(8).map(s=>Buffer.from(s,'base64').toString('utf8'));
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 receivedAt=Date.now();
const fields={version:'2',eventId:analyticsHash(parts),callUid:analyticsHash([uid,customerId,hostname()]),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:String(at*1000),nodeId:hostname(),bootId,receivedAt:String(receivedAt),method:'INVITE',evidenceVersion:'2'};
return parseAnalyticsEvent(Object.entries(fields).flat());
}
async function recordGap(kind:string,detail:Record<string,unknown>){const id=analyticsHash([kind,detail,Date.now()]);await db.$executeRaw`INSERT INTO caller_analysis_gaps(id,node_id,kind,status,started_at,payload,created_at,updated_at) VALUES (${id},${hostname()},${kind},'OPEN',NOW(3),${JSON.stringify(detail)},NOW(3),NOW(3))`;}
async function persistCursor(){await writeFile(`${checkpoint}.tmp`,JSON.stringify({...cursor,gap}),{mode:0o600});await rename(`${checkpoint}.tmp`,checkpoint);}
async function collect(){
const info=await stat(spool); if(cursor.ino&&(cursor.ino!==info.ino||info.size<cursor.offset)){gap=true;await recordGap('SPOOL_ROTATED_OR_TRUNCATED',{previousIno:cursor.ino,currentIno:info.ino,previousOffset:cursor.offset,currentSize:info.size});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)){try{const event=decodeLine(line);if(event){await redis.xadd(ANALYTICS_STREAM,'*','event',JSON.stringify(event));cursor.eventsSinceProducerStart=(cursor.eventsSinceProducerStart??0)+1;}}catch(error){const message=error instanceof Error?error.message:'decode error';await appendFile(`${checkpoint}.deadletter`,`${JSON.stringify({at:new Date(),line,error:message})}\n`,{mode:0o600});gap=true;await recordGap('MALFORMED_SPOOL_RECORD',{offset:cursor.offset,error:message});}}
cursor.offset+=last+1;await persistCursor();}finally{await file.close();}
}
async function consumeEntries(items:Array<[string,string[]]>){for(const[id,fields]of items){try{const i=fields.findIndex(v=>v==='event');if(i<0||!fields[i+1])throw new Error('Missing event field');const event=validateAnalyticsEvent(JSON.parse(fields[i+1]));await store.consume(event);ingestLagMs=Math.max(0,(event.receivedAt??Date.now())-event.at);if(!dataThrough||event.at>Date.parse(dataThrough))dataThrough=new Date(event.at).toISOString();}catch(error){const message=error instanceof Error?error.message.slice(0,500):'Invalid queued event';const deadId=analyticsHash([ANALYTICS_STREAM,id]);await db.$executeRaw`INSERT IGNORE INTO caller_analysis_dead_letters(id,stream_id,payload,error,status,created_at,updated_at) VALUES (${deadId},${id},${JSON.stringify(fields)},${message},'OPEN',NOW(3),NOW(3))`;gap=true;}await redis.xack(ANALYTICS_STREAM,ANALYTICS_GROUP,id);}}
async function producerCount():Promise<number>{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:'get_statistics',params:{statistics:['cra_event_total']}}),signal:AbortSignal.timeout(2000)});const body=await response.json()as{result?:Record<string,number>};const value=body.result?.['script:cra_event_total'];if(!response.ok||typeof value!=='number'||!Number.isSafeInteger(value))throw new Error('Producer counter unavailable');return value;}
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 live=new Map(body.result.Dialogs.map(d=>[d.callid,Number(d.state)]));let lastId='';for(;;){const calls=await db.$queryRaw<Array<{id:string;payload:AnalyticsCall}>>`SELECT c.id,c.payload FROM caller_analysis_calls c WHERE c.id>${lastId} AND 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) ORDER BY c.id LIMIT 500`;if(!calls.length)break;const now=Date.now();for(const{payload}of calls)for(const leg of Object.values(payload.legs)){if(leg.endedAt!==null||leg.talkStartedAt==null||leg.talkMs>0)continue;if((live.get(leg.event.callId)??0)===4&&now>leg.talkStartedAt)await store.consume({...leg.event,kind:'DURATION',at:now,talkMs:now-leg.talkStartedAt,source:'dialog-acked-observation',eventId:analyticsHash([leg.event.callUid,leg.event.attemptId,'positive-duration'])});}lastId=calls[calls.length-1].id;}}
async function main(){try{bootId=(await readFile('/proc/sys/kernel/random/boot_id','utf8')).trim();}catch{bootId=`${hostname()}-unknown`;}try{const saved=JSON.parse(await readFile(checkpoint,'utf8'))as CollectorCursor;cursor={...cursor,...saved,eventsSinceProducerStart:saved.eventsSinceProducerStart??0};gap=saved.gap===true;}catch{/* first start */}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 produced=await producerCount();if(cursor.lastProducerCount!==undefined&&produced<cursor.lastProducerCount){gap=degraded=true;reason='PRODUCER_RESTART';await recordGap(reason,{previous:cursor.lastProducerCount,current:produced});cursor.eventsSinceProducerStart=0;}cursor.lastProducerCount=produced;await persistCursor();const claimed=await redis.xautoclaim(ANALYTICS_STREAM,ANALYTICS_GROUP,consumer,5000,pendingCursor,'COUNT',100)as[string,Array<[string,string[]]>];pendingCursor=claimed[0];await consumeEntries(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 consumeEntries(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(produced!==(cursor.eventsSinceProducerStart??0)){degraded=true;reason='PRODUCER_COLLECTOR_COUNT_MISMATCH';}if(!Number(group?.pending??0)&&Date.now()-lastTrim>60000){await redis.xtrim(ANALYTICS_STREAM,'MAXLEN','~',Number(process.env.ANALYTICS_STREAM_MAXLEN??1000000));lastTrim=Date.now();}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');}if(degraded&&Date.now()-lastAlertSuspend>15000){try{await suspendCallerAlerts(db,reason||'ANALYTICS_DEGRADED');lastAlertSuspend=Date.now();}catch{logger.error('Analytics alert suspension failed');}}try{const spoolSize=await stat(spool).then(s=>s.size).catch(()=>null);const coverageStatus=!degraded&&!gap&&cursor.lastProducerCount===cursor.eventsSinceProducerStart?'CONTINUOUS':'DEGRADED';const payload=JSON.stringify({degraded,reason,dataThrough,lagMs:ingestLagMs,coverageStatus,captureSince:process.env.ANALYTICS_CAPTURE_SINCE??null,spoolOffset:cursor.offset,spoolUnreadBytes:spoolSize===null?null:Math.max(0,spoolSize-cursor.offset),gap,producerEventCount:cursor.lastProducerCount??null,collectedEventCount:cursor.eventsSinceProducerStart??null,bootId,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();});
@@ -0,0 +1,56 @@
# 主叫号码分析测试环境交付记录
日期:2026-08-31。状态:代码已提交推送、测试 B 已部署、基础真实信令与后端对账通过;完整功能验收尚未完成。
## 版本与环境
- 应用版本:`2a3dcb0c68c52476d4ad38b9924607ce45acdcf5`,远端 `origin/main` 已推送成功。
- 测试入口:<https://100.90.90.91/>,菜单“主叫号码分析”。
- 当前发布目录:`/opt/lisglosips/releases/caller-analytics-20260831T095225Z`
- 本次发布备份:`/var/backups/lisglosips-caller-analytics/20260831T095225Z`;最初引入功能前的发布仍在 `/opt/lisglosips/releases/s57-ui-number-library-20260827T124730Z`
- 新增7张分析表与独立分析进程;原 CDR/计费进程未替换。本次没有部署 A/T,没有呼叫公网电话。
- 部署后 OpenSIPS、API、CDR Worker、分析 Worker 均为 active`/api/v2/health/ready` 的 MySQL/Redis 检查均为 ok。
## 本次实现
OpenSIPS 观察事件经本机 rsyslog 文件、独立 Redis Stream 进入事务状态计算,提供原始主叫和落地主叫两种统计视角、分钟趋势、号码筛选、详情证据、三个业务比例、客户范围控制及站内异常提示。沿用设计 V1.1:180/183及后续成功阶段计接通,可信正时长计应答,未接通拆分待接通与结束未接通,未知单列,分母0返回null。
实现与运行限制仍以[设计补充](CALLER_REALTIME_ANALYTICS_DESIGN.md#12-实施补充2026-08-31)及下方未验收项为准,不把基础功能部署视为整个设计全部验收通过。
## 实际验证
- TypeScript构建、ESLint、Vite生产构建、Prisma schema校验通过;全量 Vitest 29文件、116项通过。
- B 本机真实 UDP SIP UAC/UAS,通过平台原有路由,不使用 mock API 或 localStorage。
- 测试客户与网关 `cra_20260831`,主叫 `99100001`,仅 `127.0.0.1:50630`,费率0。验收后入口/出口已禁用;保留分析证据。
- 真实API验证401、无客户授权403、跨客户查询403、授权客户可见、客户筛选项不越权、非法查询400。
- 全部已采集测试呼叫的分钟桶与状态表逐字段相等:T=24、C=16、A=8、F=8、P/U/active均为0。重放一通呼叫的4条持久化事件,均返回duplicate,聚合不变。
- 末轮从 `2026-08-31T09:54:12.271Z` 开始的6通测试:T=6、C=4、A=2、N=2C/T=66.6667%、A/T=33.3333%、A/C=50%。短通话观察时长301ms。
| 实际信令场景 | C | A |
| --- | --- | --- |
| 180后486 | 1 | 0 |
| 183后487 | 1 | 0 |
| 直接486 | 0 | 0 |
| 无180/183,直接200ACK后约1.5秒BYE | 1 | 1 |
| 180、200ACK后约0.3秒BYE | 1 | 1 |
| 仅100后486 | 0 | 0 |
机器证据见 [SIP/API/DB验收JSON](../tests/reports/caller-analytics-real-20260831.json)。该证据只含本次合成测试号码,无登录凭据。
## 联调修复
修复了OpenSIPS编码转换名称、rsyslog完整程序路径匹配、MySQL趋势BigInt序列化、分钟桶JSON键、验收脚本的用户角色关联,以及发布所需Prisma引擎执行权限。原生效配置引用了未加载的旧Lua SHA,现按已安装Lua内容核对SHA并加载,不改Lua路由逻辑。
对已产生的测试分析状态,在暂停独立分析进程后事务重建了派生分钟桶;未删除原始事件、状态或CDR。新增维护脚本需明确停分析Worker后才能使用。
另补充显式最终零时长优先于较晚暂定时长、END不自动清除缺过程未知、缺口标记跨进程重启保留的逻辑和回归用例。未测得端到端延迟时返回null,不再把0当实测延迟。
## 未完成验收与启用限制
1. **页面验收未完成**:Chrome能列出测试标签页,但读取DOM、页面截图多次超时;没有取得本次新页面的有效截图、控制台记录或交互结果。不能以Vite构建/API成功代替UI验收。
2. **时长证据边界未完成**:末轮SIP脚本没有SDP/RTP;它证明响应、ACK/BYE及信令计时链路,不证明媒体协商或实际音频。当前以成功响应至结束观察的毫秒差计算结束时长;无ACK、协商失败、取消竞态、对话超时等仍需完善证据适配与真实验收。不能据此宣称全部应答边界已满足。
3. **采集完整性与可靠性未完成**:运行心跳不证明整个查询窗口完整。页面明确提示此边界,health标记OBSERVATIONS_ONLY。生产端连续水位、停采识别、共享Redis资源隔离、日志/Stream/数据库留存、毒事件处理、断电恢复与满盘保护仍需专项实现/验证。
4. **生命周期与对账未完成**:当前真实路由只有单次落地;多attempt纯状态单测不是完整重试链路验收。长期未结束呼叫的权威终态对账、可信最终时长外部来源接入、告警真实触发恢复和端到端P95≤5秒未完成验收。
5. **旧CDR问题保留**:现场既有成功CDR路径使用固定6秒。本功能不采信该值,也没有修改计费或修复既有CDR;本次无SDP测试不构成完整CDR/媒体计费回归。
因此当前交付适合继续受控测试,不作为上述缺口已关闭或具备生产完整性/容量保证的声明。并行任务的设计审查文档及IMPLEMENTATION_STATUS变更未混入本次提交。
@@ -0,0 +1,64 @@
# 主叫号码实时分析专项整改交付报告
日期:2026-09-01
状态:整改实现、B机发布及受控功能/可靠性验收完成;容量与真实多线路重试保留明确限制。
## 1. 交付版本与边界
- B机生效提交:`9fa1964dec98cbe3c53c901ba764373308054d5e`
- 发布目录:`/opt/lisglosips/releases/caller-analytics-20260901T024406Z`
- 回滚备份:`/var/backups/lisglosips-caller-analytics/20260901T024406Z`
- 发布制品SHA-256`b2f22a04312b1979a11eec62ed797e65ab6c4c8d25174075a8dbc0d723ea7435`
- 新采集连续覆盖起点保留为`2026-09-01T02:23:21Z`,发布重启不伪造新的连续历史。
- 本次只改主叫分析采集、分析存储、API、页面、独立Redis和运维脚本。没有修改计费、余额、路由选择和旧固定6秒CDR逻辑,没有部署A/T,也没有呼叫公网号码。
本地标准制品脚本在Windows创建符号链接时遇到`EPERM`。本次使用等价的delta overlay制品:B机先复制上一release,再覆盖本提交受控文件;发布脚本仍执行备份、迁移、候选配置检查、服务切换和健康门槛。
## 2. 整改结果
| 整改组 | 落地结果 | 状态 |
| --- | --- | --- |
| R01R03 时长、未知与乱序 | TALK_START在对话路由匹配后产生,ACK后开始计时;时长分未知/暂定/最终;最终零时长具有权威优先级;状态归约支持重复和乱序 | 已完成 |
| R04~R05 完整性、留存与隔离 | 生产/采集计数、水位、日志未读字节、持久gap/DLQ、pending安全裁剪;独立Redis启用AOF、noeviction及256MB上限;维护和logrotate已安装 | 已完成 |
| R06~R07 生命周期与契约 | 增加显式CLOSE、生产节点/boot/sequence、事务阶段、证据来源与算法版本;单attempt保持当前真实路由语义 | 已完成;真实重试启用前补验 |
| R08R10 API、告警与页面 | 查询覆盖、snapshotId、稳定分页、供应商筛选、未知/null比率、告警规则独立持续计数与降级暂停、详情信令证据均已落地 | 已完成 |
| R11 性能与容量 | 活跃呼叫分页、查询上限、资源隔离和延迟字段已落地;本轮只验证受控功能负载 | 工程整改完成;容量/P95未承诺 |
| R12 发布回滚 | 版本化采集V3、完整备份、逐步回滚、独立Redis备份、配置校验、空闲检查、健康和提交校验已落地并用于本次发布 | 已完成;未逐点实施破坏性失败注入 |
| R13 状态与证据 | V1.2设计、状态索引、测试索引、本报告及机器证据已同步 | 已完成 |
## 3. 验证结果
本地验证全部通过:ESLint、`tsc -b tsconfig.build.json`、Vite生产构建、Prisma schema校验;Vitest共29个文件、118项。OpenSIPS从V1/V2生成V3配置后在B机执行`opensips -C`通过。
B机最终审计显示以下服务均为`active/enabled`OpenSIPS、API、CDR Worker、Caller Analytics Worker、Caller Analytics独立Redis、维护timer。`/api/v2/health/ready`返回`status=ok`,数据库和Redis检查均为ok。独立Redis实测AOF开启、持久化状态正常、`noeviction`、上限256MBStream pending为0。
末轮6通真实本机回环SIP全部通过原有平台路由,两个成功场景包含SDP offer/answer并通过RTPEngine
| 场景 | 统计结果 |
| --- | --- |
| 180后486 | C=1A=0 |
| 183后487 | C=1A=0 |
| 直接486 | C=0A=0 |
| 直接200ACK后约1.5秒BYE | C=1A=1 |
| 180→200ACK后约300ms BYE | C=1A=1 |
| 100后486 | C=0A=0 |
汇总为`T=6、C=4、A=2、N=2`,接通率66.6667%,总体应答率33.3333%,已接通应答率50%`talkMs=1801`,未知、待接通和暂定应答均为0。实测数据覆盖为`CONTINUOUS`,末次API采集延迟468ms。该单点观测不能替代统计P95。
真实API与数据库还验证了状态表/分钟桶逐字段对账、5条事件重放幂等、未认证401、非法查询400、无授权/跨客户403、客户及供应商范围选项不越权。机器证据归档在[caller-analytics-real-20260901.json](../tests/reports/caller-analytics-real-20260901.json)。
可靠性验证包括:维护任务dry-run与实际执行、logrotate规则识别、独立Redis重启后Stream长度保持、非法JSON进入持久死信并ACK、覆盖状态转为降级、处置后恢复连续。最终保留一条`RESOLVED`的计划发布重启gap及一条`RESOLVED`的毒事件deadletter,作为审计证据。
## 4. 页面验收
优先使用Codex Browser插件访问B机时,HTTP入口被客户端阻止,HTTPS尝试超时并重置浏览器内核。按浏览器测试规范改用仓库Playwright和系统Chrome完成回退验收;登录通过真实`/auth/refresh`会话,业务数据来自真实API/MySQL,没有mock、静态替代或localStorage业务数据。
已验证应用壳、主叫分析菜单、客户/供应商筛选、连续覆盖、版本/未知字段、三个比例、详情证据及300ms时长,覆盖1440×1000桌面和390×844移动视口。移动端宽表使用现有横向滚动容器,抽屉独立纵向滚动。控制台无错误。截图保存在`.codex-private/caller-analytics-desktop-20260901.png``.codex-private/caller-analytics-mobile-20260901.png`
验收后已删除临时登录会话,数据库中对应会话数为0;受控客户入口和供应商出口`cra_20260831`均已恢复为`DISABLED`,测试证据保留。
## 5. 未作出的承诺
本轮没有可确认的峰值CPS、号码/维度基数和目标保留期,因此没有执行有业务依据的容量压测,也不宣称页面端到端P95≤5秒或机器容量上限。后续在真实多线路重试启用前,还需按实际attemptId和路由策略补做两线路重试验收。上述两项不会把当前单线路统计结果解释成未完成,但限制容量与未来重试能力的对外声明。
代码、交付文档和机器证据已推送到`origin/main`。B机生效代码为`9fa1964`;其后的`367211e`只增加交付报告、证据JSON和状态索引,因此没有为了纯文档提交再次重启B机服务。
+262
View File
@@ -0,0 +1,262 @@
# 主叫号码接通率与应答率实时分析设计方案
日期:2026-08-31
版本:V1.2(整改实施版)
状态:整改代码与部署方案已完成;最终上线与真实验收结果见对应日期的交付报告。
实施更新(2026-08-31):独立分析链路、API、页面和测试脚本已编写;部署与真实验收结果以实施状态及发布报告为准,上述为方案编制时状态。
## 1. 目标与范围
新增“主叫号码分析”页面,按号码实时查看呼叫是否到达振铃/会话进展阶段,以及是否产生正通话时长。支持异常发现、线路对比、失败原因分析和话单追溯。
本方案中的“接通”是平台自定义的业务阶段指标,不等于 SIP 最终成功应答,也不等于真人接听。此前方案中将接通数定义为 INVITE 成功应答的口径由本文替代。所有新增页面、接口、告警和测试应使用同一个口径版本 `caller-analytics-v1.1`
第一期只做统计和站内异常提示,不自动停号、换号、切换线路或修改计费。外部通知渠道另行配置授权,不在本轮发送消息。
## 2. 当前源码基础与实施差距
本地源码核对依据,不代表生产状态已核实:
- `prisma/schema.prisma``RawCdr` 已有 caller、landingCaller、客户/网关/线路维度、startedAt、answeredAt、endedAt、durationSec、sipCode 等字段。
- `packages/redis/src/cdr-stream.ts` 已有 CDR 事件流契约及消费机制;`apps/worker-cdr/src/rating.ts` 负责话单落库及计费。
- `apps/api/src/modules/dashboard/` 现有指标按话单 SIP 2xx 计算,不能直接复用为本文的“接通数”或“应答数”。
- 现有 RawCdr 没有独立的 first180At、first183At 等过程字段,最终响应码不能证明之前是否发生过振铃/进展。
- 当前通话页面的 durationSec 存在从 startedAt 计算的逻辑,不能未经核验直接作为本文的通话时长。
需新增呼叫过程采集、分析状态及预聚合。既有计费规则不随本次业务术语改变;首页旧指标保留旧版本语义,不可只改标签冒充新口径。后续统一首页时,应单独替换数据来源并展示口径版本。
## 3. 业务口径
### 3.1 总呼叫数与统计视角
默认“落地主叫”视角,可切换“原始主叫”:
| 视角 | 号码与总呼叫数 T | 用途 |
| --- | --- | --- |
| 原始主叫 | 客户送入号码;每个已识别客户的独立业务呼叫计一次 | 分析客户号码整体效果,包括平台内业务拒绝 |
| 落地主叫 | 平台向线路发送的号码;每个实际发出的线路尝试计一次 | 分析具体出局号码及线路表现 |
未认证扫描、OPTIONS、REGISTER 等不计入。客户一次呼叫的鉴权续发、SIP 重传不新增业务呼叫;线路重试用 attemptId 区分。原始主叫视角任一尝试接通则该业务呼叫接通,任一有效通话产生正时长则该业务呼叫应答,均最多计一次。不同线路尝试可分别计数,但不得相加后冒充客户呼叫数。
落地主叫不等于已经验证的被叫终端显示号码。保存号码原值和标准化值,不得无规则删除前缀;同号不同客户不能混淆归属。
### 3.2 接通数 C:达到 180 / 183 或更后阶段
接通数指初始 INVITE 已进入有效呼叫处理,并首次达到以下任一条件的去重呼叫数:
1. 收到与该初始 INVITE/线路尝试匹配的 `180 Ringing`
2. 收到匹配的 `183 Session Progress`,不要求必须带 SDP
3. 没有观测到 180/183,但直接收到初始 INVITE 的有效成功 2xx(通常为 `200 OK`):作为已进入后续阶段计入接通,原因记录为 `DIRECT_FINAL_SUCCESS`
4. 最终权威话单证明该呼叫产生正通话时长,但过程事件缺失:补计接通与应答,标注 `CDR_POSITIVE_DURATION`,不能伪造 180/183 时间。
“INVITE、Trying 均成功”按业务意图理解为 INVITE 已受理并继续推进,不硬性要求抓到 100 Trying。100 是临时处理响应;180 表示尝试提醒用户;183 表示会话进展,不证明实际振铃。状态代理不会向上游转发收到的 100,因此不能以缺少 100 排除后续有效进展。上述协议含义参考 [RFC 3261 §21.1](https://www.rfc-editor.org/rfc/rfc3261.html#section-21.1)。本文选择 180/183 作为业务接通门槛,是产品口径。
仅 INVITE 发出、100、181 或 182 不构成接通。匹配必须核实请求方法、事务与呼叫标识,不能使用 BYE、CANCEL、PRACK、OPTIONS 的 200,也不能把 re-INVITE 当新呼叫。响应与方法相关的依据见 [RFC 3261 §21.2.1](https://www.rfc-editor.org/rfc/rfc3261.html#section-21.2.1)。
线路视角只采信该出局尝试的实际下游响应,不能用本机合成振铃冒充线路进展。接通一旦成立,后续 486、487、超时等不会撤销;只能在证据校正或误关联修复时重算。
### 3.3 未接通数 N:未达到接通门槛
业务定义:未达到上述振铃/会话进展或后续阶段的呼叫数。实时展示必须区分“尚未达到”和“已经结束且未达到”:
- F:已结束且从未接通的呼叫,包括明确拒绝、取消或确认的呼叫超时。
- P:仍在处理中、尚未接通的呼叫;页面显示“待接通”,不作为最终失败。
- U:证据不足而无法判定是否接通的呼叫;页面显示“状态未知”,不强行归类。
已确定的实时未接通数 `N = F + P`,并标注“含待接通 P 次”。在无未知数据时 `T = C + N`;有未知时 `T = C + N + U`,未接通率只能展示已确认范围或暂不展示完整率,不能把 U 隐式算作失败。全部呼叫结束且完成对账后 P=U=0,此时 `N=F=T-C`
接通后无人接听、接通后取消、接通后最终失败,均归入“已接通、未应答”,不归入未接通数。另设“最终结果码分布”,避免把协议最终失败数等同未接通数。
### 3.4 应答数 A:通话时长大于 0
应答数指通话时长严格大于 0 的去重呼叫数。不以单独收到 200、存在 answeredAt 或计费时长大于 0 代替。
- 通话时长采用业务通话段的实际时长,不含呼叫建立、振铃、183 早期媒体时长,不用计费向上取整时长。
- 新采集建议保留 `talkDurationMs` 毫秒值,以 `talkDurationMs > 0` 判断,再格式化显示秒数。正的亚秒通话也应计入,不能因显示向下取整为 0 秒而漏计。
- 原始时长只有整数秒的历史话单,`durationSec > 0` 可计入应答;`durationSec = 0` 不得凭空推定存在亚秒通话,展示历史精度限制。
- 进行中的呼叫由权威会话状态及通话计时起点计算已发生的正时长,实时标注暂定。不能由前端收到 200 后自行运行计时器,也不能用 INVITE 发起时间作通话起点。
- 正时长证据不要求有 RTP 音频分析或真人检测;ACK/会话状态用于核实计时来源,不额外改变“实际通话时长 > 0”的最终业务条件。
- 最终以核验过来源和计时口径的 CDR/会话时长校准,允许撤销错误的暂定应答,并在同一事务调整统计。字段矛盾、负时长或缺少可靠计时证据进入数据质量核对。
应答数是接通数的子集,满足 `0 ≤ A ≤ C ≤ T`。其中 C-A 包含仍在等待应答、已结束但没有正时长,以及应答时长待核实的呼叫,不能全部称为“应答失败”。
### 3.5 指标公式
所有分子分母必须使用相同客户权限、号码视角、线路维度、发起时间窗口和快照,不混用全量总数与筛选后数量。
| 中文指标 | 接口建议字段 | 公式 |
| --- | --- | --- |
| 总呼叫数 | totalCalls | T |
| 接通数 | connectedCalls | C |
| 未接通数(含待接通) | notConnectedCalls | N=F+PU 单列 |
| 应答数 | answeredCalls | A |
| 实时接通率 | connectionRate | C / T × 100% |
| 实时总体应答率 | overallAnswerRate | A / T × 100% |
| 实时已接通应答率 | connectedAnswerRate | A / C × 100% |
分母为 0 时接口返回 null、页面显示“—”。比率由整数计数计算,不平均各时间桶的百分比。数据不完整时,计数与比率应标注“已观测/暂定”,未知或采集缺口不可展示为完整结果。
旧方案的“已判定接通率”不作为主指标,本版以以上三个率为准;不使用未经解释的 ASR 简称混淆业务口径。
示例:T=100C=60(其中 A=30),F=25P=15U=0。未接通数 N=40,其中待接通15;实时接通率60%,实时总体应答率30%,实时已接通应答率50%。C 中其余30次可能仍在振铃,也可能已经结束但没有正时长。
## 4. 时间窗口与状态更新
- 最近5/15/30/60分钟、今日、自定义;以发起时间归桶,后续事件回写原桶。原始视角用业务呼叫发起时间,线路视角用尝试发起时间。
- 聚合粒度默认1分钟。分钟对齐窗口必须在界面展示实际起止;自定义非整分钟边界从状态/事实数据补算边缘,禁止悄悄扩大范围。
- 数据库使用UTC,今日按Asia/Shanghai自然日计算。分子、分母与对比窗口使用统一服务端asOf。
- 记录独立事实标志:是否接通、是否正时长、是否结束、是否数据完整,而非只依赖最终 SIP 码。
- 正常过程:初始计T与P;达到180/183计C并减P;正通话时长计A;结束只更新状态/时长,不重复增加C或A。
- 未接通直接结束:P减1、F加1,N不变。已接通后失败:C不变、A按时长判定,F不增加。
- 业务呼叫未完成全部重试前,不能因一个尝试失败就判定客户呼叫最终未接通。
- 乱序、重复、跨分钟、跨日和迟到事件按稳定标识重算贡献差值;接通数不因183转180重复增加。
- 采集失联不能直接判业务超时;超时失败必须有事务/会话结束依据,否则进入U和数据异常提示。
## 5. 页面与交互
新增“主叫号码分析”,默认落地主叫,支持原始主叫切换。筛选包含时间、号码精确搜索、客户、客户网关、供应商、落地网关、被叫运营商/地区、最小样本、三个率的区间和仅看异常。
列表核心列:主叫号码、客户、总呼叫数、接通数、未接通数、应答数、实时接通率、实时总体应答率、实时已接通应答率、异常状态。待接通、未知、已结束未接通及前窗百分点变化可作为补充列或展开内容;宽表沿用横向滚动与固定标识列,不把业务数字藏到提示框。
号码详情展示分钟趋势(T/C/A及三个率)、线路对比、被叫运营商/地区拆分、180/183/直接成功分布、未接通原因、接通未应答的最终结果、主叫改写映射和关联话单。页面文案解释183不保证实际振铃,接通不等于应答。
每5秒刷新,保留筛选、排序、分页和展开详情。展示数据截至时间、采集延迟、统计口径版本和完整性。刷新失败保留旧数据并提示,不能清空成零。号码访问与导出按客户范围/权限控制,查询接口自行校验权限,不能只依赖前端隐藏。
## 6. 事件链路与数据设计
采用“OpenSIPS呼叫事件 → 独立Redis Stream → 分析Worker → MySQL状态/分钟汇总 → API及缓存 → 页面”。复用现有技术栈,不改变计费Worker消费组或计费规则。
事件类型建议为 CALL_STARTED、ATTEMPT_STARTED、TRYING、RINGING、SESSION_PROGRESS、INVITE_ACCEPTED、TALK_DURATION_OBSERVED、CALL_ENDED、CDR_RECONCILED。均需schemaVersion、eventId、callUid、attemptId(适用时)、nodeId/实例启动标识、occurredAt、receivedAt、初始事务标识、客户/线路/号码快照及证据来源。保留SIP Call-ID用于追溯,但不单独用它承担业务全局唯一性。
过程字段至少包括 first180At、first183At、inviteAcceptedAt、talkStartedAt、endedAt、talkDurationMs、connectedEvidence、finalSipCode、dataQuality。直接成功和CDR补计不得伪造first180At/first183At。通话计时起点须通过当前OpenSIPS实际事件与CDR生成代码核验;字段名不作为真实性证明。
| 建议数据对象 | 关键内容 |
| --- | --- |
| caller_analysis_events | eventId唯一约束、呼叫/尝试、事件时间、类型、来源、重放与去重证据 |
| caller_analysis_states | 以视角粒度保存T/C/N/A贡献、F/P/U状态、号码/维度快照、版本和时长来源 |
| caller_analysis_minute_buckets | 分钟、客户、号码类型/标准化号码、网关/线路、被叫分类、口径版本及各项计数/时长 |
| caller_analysis_alerts | 规则、触发快照、样本量、持续与恢复状态、冷却时间 |
原始呼叫总览和线路尝试汇总分开维护,或通过显式grain字段区分,避免多线路重复累计。分钟聚合的唯一键应使用稳定非空维度键,缺失维度用受控unknown键,不能依赖MySQL可空复合唯一键实现幂等。索引与维度组合在获知号码基数、呼叫量、保留期后验证,不宣称当前机器已具备容量。
分析Worker的去重记录、状态更新和统计差值必须同一数据库事务提交;提交后ACK。Redis缓存不是唯一事实来源;数据库提交后缓存更新失败可失效重建。消费者独立,使用独立去重命名空间,不复用计费锁以免互相吞事件。消费组重领/重放场景须验证;机制参考 [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/)。
采集失败使用有界补送/持久化方案,设计重试期限、积压容量和告警;不得让统计故障长时间阻塞SIP热路径。超过留存或补送能力产生的缺口必须可见,不能承诺无条件零丢失。最终CDR通过同一callUid/attemptId校准状态,不作为新呼叫重复累计。旧CDR关联不足时先核对,不能仅凭Call-ID猜测匹配。
建议APIGET /api/v2/caller-analytics/summary、/numbers、/trends、/breakdown、/calls;共享视角、时间与维度筛选契约。响应包含counts、rates、asOf、dataThrough、lagMs、metricVersion、qualityStatus、unknownCalls及durationPrecision。完整性无法判定时lagMs可为null,不用“最后一个业务事件的年龄”冒充采集延迟。
## 7. 历史回算与兼容
1. 有可信正时长的历史CDR可回算A并证明对应C;仅有2xx而无时长需核验CDR字段语义才能证明C,不能证明A。
2. 最终486/487/超时的话单可能先有180/183,仅凭最终码无法判定未接通。
3. 若历史信令留存完整且能准确关联,可以补回过程;否则C是已知下界,N和A/C不能输出为完整历史指标。展示“历史过程数据不足”,完整接通率/已接通应答率返回null,允许另列清楚标注的已观测计数。
4. 历史时长精度与新采集不同,应带durationPrecision,不把历史整数秒与新毫秒精度差异解释成业务变化。
5. 新实时数据生效时间、口径版本、回算批次明确记录。新旧版本不能无提示混画同一趋势或用于同比告警。
## 8. 异常规则
分别支持“低接通率”“低总体应答率”“低已接通应答率”“较前窗下降”和“连续已结束未接通”。接通率低侧重排查建立链路;接通率正常但已接通应答率低,侧重排查接通后的结果。仅作排查方向,不凭比例或单个SIP码认定封号。
建议初始模板:最近15分钟、总样本≥50;按已接通应答率触发时还需C≥30;与前一等长窗口比较时两窗均满足样本门槛;下降超过15个百分点且持续多个评估周期再提示。三个率的绝对低值阈值分别配置,原方案20%的示例不能无校准套用到新接通口径。
设置未结束占比上限、数据完整性门槛、冷却、合并和恢复迟滞;仍大量振铃、持续通话缺少可信时长、采集滞后或未知数据过多时暂缓业务告警,转为数据质量提示。连续未接通按发起顺序和已确定结果评估,不能按乱序到达的事件简单累加。
## 9. 实施步骤、风险与回滚
1. 核验当前生产OpenSIPS/CDR事件、通话计时精度、线路重试、号码基数、峰值CPS、保留期与机器资源;只读核验与受控呼叫分开安排。
2. 实施话单分析基础版:视角、列表、正时长应答、历史完整性提示、话单钻取。缺少过程数据时不宣称已支持完整接通统计。
3. 实施过程事件、独立Worker、预聚合、真实后端接口、进行中正时长更新、最终对账与站内异常,完成实时版。
4. 在受控真实呼叫与故障恢复验收后灰度启用;先观察数据质量和统计/计费隔离,再扩大覆盖。
新增影响包括OpenSIPS事件采集开销、Redis队列容量、MySQL写放大、查询和缓存负载。统计新表/新服务独立部署并使用功能开关;回滚可关闭采集钩子和分析Worker/入口,保留证据及新表,不删除既有话单、不调整计费余额和路由。生产修改及受控外呼须按届时任务授权执行。
## 10. 验收用例(设计与执行状态)
以下单呼叫终态用例默认T=1;应答时长均指可信实际通话时长。未说明缺口的用例假设完整采集。
| ID | 场景 | 预期 |
| --- | --- | --- |
| CRA-001 | INVITE→100→180→200,正通话时长 | C=1N=0A=1;三个率均100% |
| CRA-002 | INVITE→100→183→487,无正时长 | C=1N=0A=0;接通率100%,两个应答率0% |
| CRA-003 | INVITE→100→486,从未180/183/2xx | C=0N=F=1A=0;总体应答率0%,已接通应答率为null |
| CRA-004 | 未见100,直接180,随后取消 | C=1A=0,不因缺100漏计 |
| CRA-005 | 直接初始INVITE 200,正时长 | C=A=1DIRECT_FINAL_SUCCESS,无伪造振铃时间 |
| CRA-006 | 180或直接200,最终实际时长严格为0 | C=1,A=0,不能仅凭200计应答 |
| CRA-007 | 183不带SDP,或带早期媒体但未建立通话 | C=1,A=0,早期媒体不计通话时长 |
| CRA-008 | 仅100且仍在处理 | C=A=0,P=N=1,显示待接通,不标最终失败 |
| CRA-009 | 仅181/182后失败 | C=A=0F=N=1 |
| CRA-010 | 重复INVITE、180、183、200、CDR及消费重放 | T/C/A不重复,N不为负,不重复扣费 |
| CRA-011 | BYE/PRACK/CANCEL的200、re-INVITE | 不新增T/C/A |
| CRA-012 | 先180后486或确认超时 | C=1,A=0;最终失败分布增加,未接通数不增加 |
| CRA-013 | 可信通话时长500ms,页面秒值取整为0 | 新精度A=1;旧整数秒0保持历史精度限制 |
| CRA-014 | 通话进行中已产生正时长,最终CDR校正 | 实时A可见且标暂定;结束不重复,错误暂定值可事务回退 |
| CRA-015 | 一次客户呼叫经两条线路尝试,第二条应答 | 原始视角T=1;线路视角共2次尝试,归属正确 |
| CRA-016 | 同号不同客户、主叫改写及客户越权查询 | 数据不串号/不越权,两个视角的分母保持一致 |
| CRA-017 | 乱序、跨窗口/跨日应答、迟到与重启重领 | 回写发起桶,重放前后计数一致,计费链路不受影响 |
| CRA-018 | 零呼叫或零接通 | 分母0返回null;不显示NaN或无依据的0% |
| CRA-019 | 历史487,缺少过程事件 | 不断言N=1;标未知,完整接通率及A/C不可用 |
| CRA-020 | Redis/MySQL/Worker故障、补送溢出 | 显示滞后/缺口,停止相关业务误告警,恢复重放可对账 |
| CRA-021 | T=100/C=60/A=30/F=25/P=15 | N=40;三个率依次60%/30%/50% |
| CRA-022 | 本机合成180,下游直接失败且无进展 | 线路视角不计C,不能用合成回复美化线路结果 |
| CRA-023 | 只有可信最终正时长CDR,过程丢失 | 补C/A并标CDR证据,不伪造180/183,不重复T |
| CRA-024 | 采集失联导致一个呼叫阶段无法判定 | U单列,T=C+N+U,不自动判失败,率标暂定/不完整 |
功能验收须形成真实SIP信令、API返回、数据库状态/聚合与最终话单的对应证据;mock、静态页面或localStorage不构成功能验收。2026-09-01已完成公式/状态自动化回归、6通受控真实SIP、SDP/RTPEngine、真实API/MySQL/RBAC、重放幂等、故障恢复及真实页面验收,详见[整改交付报告](CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md)。正常约定负载下事件或可信正时长可用至页面可见的P95目标≤5秒仍为待测目标;当前没有约定峰值负载,不作容量承诺。真实多线路重试在路由功能启用前补验。
## 11. 本次交付边界
本次仅完成方案、业务口径、文档索引和验收用例编写与一致性检查。没有修改应用代码、执行数据库迁移、提交/推送Git、连接生产实施或发起真实呼叫。后续开发应以本文V1.1为准,不沿用“2xx=接通/应答”的旧统计实现。
## 12. 实施补充(2026-08-31
- 第11节为方案编制轮次的交付边界。后续用户已授权实施、提交推送与部署测试环境。
- B现场核验发现旧CDR在收到200后直接填入固定6秒,新链路禁止用该字段校准。新链路使用同一OpenSIPS实例的成功应答和结束观察时间差,毫秒精度;进行中以MI确认会话存在后标记暂定正时长。原计费规则与旧CDR生成保持不变,既有固定时长问题需独立整改。
- 采集先由OpenSIPS写结构化本机日志,经rsyslog队列保留,再写独立Redis Stream;本机日志游标在XADD成功后落盘,重放由数据库事务去重。轮转/截断及异常记录进入缺口提示,不承诺无限留存或无条件零丢失。
- 新权限为caller_analytics.view及caller_analytics.view_all;迁移只默认授予超级管理员。其他账号需配置caller_analysis_access客户范围,否则拒绝访问;普通view不隐含全客户权限。
- 卡片和趋势按时间及业务筛选展示整体,最小样本/比例区间/仅告警只筛选号码列表,界面明确提示该范围。单次最多10000个聚合号码,超出要求缩小时间或筛选客户;详细分布最多500个组合,查询窗口最多7天。
- 实际当前OpenSIPS路由只选择一次落地,没有切换重试流程;采集标记单attempt,状态层支持多attempt且有测试,未来新增真实重试必须同步采集独立attemptId,不能直接复用单attempt钩子。
- 历史无过程证据的话单不自动导入完整统计。页面显示新采集生效说明,旧数据不可用不等于呼叫数为0;历史补录仅接受已核验来源的RECONCILE,不接受旧固定时长CDR。
- 告警通过独立进程环境变量配置三个低率阈值和前窗下降百分点;采用样本/未结束占比门槛、连续三次评估及三次恢复,站内展示,不执行停号换路。后续应按实际业务校准默认阈值。
## 13. V1.2 整改落地规范(2026-09-01
本节覆盖V1.1实施后专项核对发现的问题,并作为运行实现的约束。原有计费、余额、路由决策和旧CDR生成语义不在本次整改范围内。
### 13.1 应答证据和生命周期
- 初始INVITE的2xx只证明已接通,不直接证明应答。只有匹配已建立对话的ACK产生`TALK_START`,之后的权威结束时间或对话存活观察才能形成正通话时长。
- 180/183后以非2xx结束时,通话时长确定为0;收到2xx但缺少ACK或最终时长证据时,单列`durationUnknownCalls`,两个应答率返回null。
- 新事件带`schemaVersion=2`、nodeId、bootId、receivedAt及证据版本。业务呼叫以显式`CLOSE`结束;单个线路尝试结束不能提前关闭原始呼叫。
- 原始视角和落地视角继续分开计数。当前路由仍只有一个真实出局尝试;未来增加重试必须为每次尝试生成独立attemptId。
### 13.2 完整性、可靠性和资源边界
- OpenSIPS的两个分析事件发射器都累加`cra_event_total`。采集器将生产计数与成功写入独立Stream的计数核对;不一致、生产进程重启、日志截断/轮转、无效记录、队列积压均进入降级状态。
- 缺口写入`caller_analysis_gaps`,毒消息写入`caller_analysis_dead_letters`。毒消息持久化后ACK,避免无限阻塞消费组;缺口未由运维核实关闭前,相关窗口比例不可用。
- 分析Stream迁移到B机独立Redis实例`127.0.0.1:6381`,使用AOF everysec、RDB、noeviction和256MB上限,不与计费热路径共享内存淘汰策略。发布前备份既有独立实例,服务和维护定时器纳入开机目标。
- 原始事件默认保留30天、呼叫状态90天、分钟桶400天;已解决缺口和死信保留90天。定时维护先统计后事务删除,OPEN记录不自动删除。
- Stream仅在消费组pending为0时近似裁剪,默认上限100万条。数据库提交成功后才ACK;重领和重放由事件唯一键与事务差值更新保证幂等。
### 13.3 API、页面和告警
- API返回`analysisRevision=caller-analytics-r2`、snapshotId、baseFilters、numberFilters、coverageStatus、dataThrough、lagMs、缺口和指标可用性。窗口不是连续完整覆盖时,三个比例统一返回null。
- 号码排序使用业务排序键、caller、customerId的固定次序;详情和拆分在同一数据库事务快照读取,拆分超过500组合时明确返回截断标志。
- 落地主叫增加供应商筛选;原始主叫禁止混用供应商及落地网关筛选。页面分别显示接通未知、时长未知和暂定应答,并把初始INVITE结果与对话结束原因分开展示。
- 站内告警按规则独立保存,使用固定分钟窗键,连续三个不同评估窗触发、连续三个窗恢复。只在最近15分钟、无维度缩窄且连续覆盖的号码全量范围展示;采集降级时告警置为SUSPENDED并停止业务触发。
### 13.4 新增验收用例
| ID | 场景 | 预期 |
| --- | --- | --- |
| CRA-025 | 初始INVITE收到200但从未收到ACK,随后结束 | C=1;A不强判;时长未知=1;应答率为null |
| CRA-026 | ACK后500ms内结束 | TALK_START至结束为正毫秒时长,A=1 |
| CRA-027 | 一个原始呼叫第一尝试失败、第二尝试应答 | 原始T=1且关闭发生在尝试耗尽/业务结束后;落地T=2 |
| CRA-028 | OpenSIPS发射器计数高于采集计数 | coverageStatus=DEGRADED,比例与业务告警不可用 |
| CRA-029 | Stream出现不合法JSON或字段 | 写入持久死信并ACK;消费继续;完整性降级 |
| CRA-030 | Redis pending存在时达到裁剪周期 | 不裁剪;pending清零后才执行有界近似裁剪 |
| CRA-031 | 查询缩窄供应商/网关/地区后存在活动告警 | 不把全量告警贴到缩窄结果;告警范围说明一致 |
| CRA-032 | 同一比例并列且分页重复查询 | 排序和分页结果稳定,snapshotId及筛选回显可追溯 |
| CRA-033 | B机重启 | 独立Redis、分析Worker和维护timer自动恢复,API明确显示恢复期覆盖状态 |
| CRA-034 | 保留维护执行 | 仅删除超过期限的数据;OPEN缺口/死信保留;分钟桶和状态仍可查询 |
@@ -0,0 +1,402 @@
# 主叫号码实时分析设计核对与整改方案
日期:2026-08-31
审查版本:V1.0
被审文档:[主叫号码接通率与应答率实时分析设计方案 V1.1](CALLER_REALTIME_ANALYTICS_DESIGN.md)
初始源码基线:`116e316eca5b564f33ec3558dbd00201da173ed8`;收尾补核对提交 `d78616561ead24e54734c8dfc9b34178bfd24ab1` 及当时可见的并行未提交差异。
结论:业务指标定义基本成立,可保留;实现已有基础,但证据可信度、乱序校正、采集完整性、运维隔离和验收闭环仍需整改,暂不具备认定“完整实时分析已验收”的依据。
## 1. 核查范围与结论边界
本次核对设计内部一致性,以及设计与本地 OpenSIPS 采集脚本、分析 Worker、状态计算、MySQL 聚合、API、React 页面、迁移、发布脚本和测试脚本的对应关系。补充查阅 SIP、OpenSIPS、Redis 官方资料,并运行现有专项单元测试和不连接外部服务的内存复现。
本次没有连接 B/T 或生产服务,没有查询线上数据库,没有执行迁移、发布、服务重启、真实呼叫或计费操作;不把历史实施记录当成本轮现场验证。原文第12节记录的 B 现场事实仍属历史记录。本地 `infra/server-a/s28/opensips.cfg` 是采集脚本依赖的路由布局参照,不等于当前 B 生效配置。
核查开始时已有以下其他任务文件,本次不改动、不纳入交付:`docs/CODE_QUALITY_REVIEW_20260831.md``tests/reports/code-quality-20260831-dependency-audit.json``tests/reports/code-quality-20260831-repro.mjs`
**并行更新说明:**核查期间另一个任务提交了 `d786165`,并继续修改存储/真实测试及新增 `scripts/rebuild-caller-analytics.mjs`。本文已补核对:gap写入游标文件、部分回滚恢复、日志匹配与趋势时间序列化已有修复代码;分钟桶JSON键修复、SQL对账与重复消费测试为收尾时可见的候选变化。下文注明相应进展,不将其全部视为未开发,也不代替另一任务宣称已部署/验收。本轮不覆盖或提交这些变化。
证据分级:
| 标记 | 含义 | 本文如何使用 |
| --- | --- | --- |
| 已复现 | 本轮直接执行本地纯计算,得到输出 | 确认状态计算缺陷,不等于线上事故 |
| 源码确认 | 当前文件可直接确认行为或缺少必要路径 | 能确认实现差距,发生概率、线上影响仍需联调 |
| 待现场验证 | 涉及生效配置、SIP 状态、资源与部署结果 | 作为实施前置门槛,不写成已发生事实 |
整改分为13组,其中10组P1、3组P2。P1指完整功能验收或正式启用前应关闭的问题;P2指应补齐的契约、展示和容量工程项。未凭本地审查认定已有P0线上事故。
## 2. 应保留的设计,以及需要澄清的约束
以下内容不应在整改时改回旧口径:
1. 接通 C 以匹配的初始 INVITE 下游180/183、直接有效2xx或可信正时长证据判定;183不代表真实振铃,C不等于真人接听。
2. 应答 A 以可信业务通话时长严格大于0判定;200本身、answeredAt、计费取整秒数均不能替代。不能为整改方便把A重新定义成2xx数量,也不额外要求真人检测或RTP分析。
3. 原始主叫按业务呼叫去重,落地主叫按实际出局尝试计数;不同客户同号不能合并归属。
4. 保留 `N=F+P``T=C+F+P+U``0≤A≤C≤T`,以及三个率 `C/T``A/T``A/C`;分母0返回null。
5. 后续进展回写发起时间桶;最终失败不撤销已有接通证据;历史缺少过程时不能用最终486/487倒推F。
6. 统计不改变计费、余额、路由,不自动停号换路。保留独立消费者、数据库事务去重及提交后ACK。
原文关于100/180/183及200与请求方法相关的说明与 [RFC 3261 §21.121.2](https://www.rfc-editor.org/rfc/rfc3261.html#section-21.1) 一致;“180/183算业务接通”仍是本平台产品定义,不是协议规定的接听率。
需新增两个独立概念:
- **呼叫分类未知 U**:已知存在的呼叫中,不能确认是否达到C的数量;继续参与上述守恒式。
- **数据覆盖缺口 / 应答证据不完整**:可能整个呼叫都未采到,或C已知但A未知。不能硬塞进U,也不能因U=0就认为所有指标完整。前者连T的真实值都可能未知,必须单独标注窗口覆盖状态。
原文“对账后P=U=0”只能作为完整采集且可修复的验收目标;不可修复的历史缺口不能为了闭环强行归零。
## 3. 分级问题与整改要求
### R01|P1|“成功响应至结束的时间差”尚未充分证明实际通话时长
**对应原文:**第3.4、6、12节。**证据:源码确认,纯计算复现;协议边界待真实验证。**
- `packages/database/src/caller-analytics-model.ts:7984` 在没有显式talkMs时,用结束时间减acceptedAt生成最终时长。
- `apps/worker-cdr/src/analytics-main.ts:6071` 只按Call-ID匹配MI,状态3或4均可生成暂定正时长。
- `scripts/instrument-caller-analytics.mjs:3138` 将挂断、超时和事务失败统一标为 `verified-dialog-final`,该字符串本身并不能证明会话时长已核验。
- `tests/api/caller-analytics-sip.py` 的INVITE/响应均为 `Content-Length: 0`,未提供SDP offer/answer。这些场景能验证SIP响应和对话流程,但不足以独立证明实际业务会话的计时口径。
OpenSIPS 3.4官方文档区分状态3(已收到最终响应,尚未收到ACK)与4(已收到ACK);对话lifetime由对话确认起计算。不能只凭字段名或状态范围声明为“实际通话时长”。参见 [OpenSIPS Dialog状态与lifetime](https://docs.opensips.org/manual/3-4/modules/dialog/#exported-pseudo-variables)。实际部署版本仍需现场核对。
**整改:**建立经过验证的时长来源适配层,分别保存 `inviteAcceptedAt``talkStartedAt``talkEndedAt``talkDurationMs``durationEvidenceId``durationStatus` 和证据版本。ACK、协商及对话状态用于核验来源,不改动最终“可信正时长>0”的业务条件。信令观察差值在核验完成前只能作为观察值,不能自动升级为权威最终时长。无ACK、协商失败、取消竞态、对话超时须逐类定义时长有效性;不确定时保留C、标记A证据待核实。使用一致时钟域;秒/微秒读取应为同一次时间采样,并检测时钟跳变,而非仅用Math.max掩盖负时长。
**关闭标准:**CRA-005/006/007/013/014及新增“200无ACK”“协商失败”“早期媒体”“零时长终态”真实验证;证明正常500ms计A、可信零时长不计A。必须保留会话开始/结束证据及来源解释。
### R02|P1|结束事件错误地清除未知,采集丢失可能被统计成失败
**对应原文:**第3.3、4、7节。**证据:已复现。**
`caller-analytics-model.ts:6878` 初始可把缺过程的leg设为unknown,但遇到 `verified-dialog-final``platform-final` 的END就清零。当前END记录不携带完整的历史阶段快照,不能证明之前没有180/183。
本轮输入只有 `END(487, verified-dialog-final)`,得到 `T=1,C=0,F=1,U=0`;输入 `ATTEMPT→UNKNOWN→END` 也得到F=1。对缺少过程证据的下游呼叫,预期应保留U,除非补回过程或有可信完整终态快照。
另外,`legCounts()` 的unknown受 `!connected` 限制:一旦C成立,A时长丢失不会通过U反映;这不是修改守恒式的理由,而是需要独立的时长完整性字段。
**整改:**分离 `ended``connectionEvidenceComplete``durationEvidenceComplete``captureGapIds`。END只证明结束,不能默认证明全过程完整。平台本地明确拒绝可通过独立的“未出局终态证据”判F;下游失败只有在阶段覆盖完整时判F。终态快照应包含已观测180/183/2xx标志、证据ID及序列覆盖,不伪造时间。
**关闭标准:**CRA-019/020/023/024;有缺口的487保留U,补回180后只转C;已接通但时长证据缺失时,A相关比率按不完整口径展示且不触发业务告警。
### R03|P1|最终校正与乱序合并不满足到达顺序无关
**对应原文:**第3.4、4、6节。**证据:已复现。**
`caller-analytics-model.ts:8689` 同时以“最终/暂定”和event.at决定更新。MI先取得在线快照、结束事件晚到时,可能已产生晚于真实结束时刻的暂定观察。最终零时长事件若event.at较早,会被忽略。
同一组事实的本轮输出:
| 输入到达顺序(t为统一起点) | 当前结果 | 应整改为 |
| --- | --- | --- |
| ACCEPTED(t+1s) → DURATION(t+4s,3000ms) → RECONCILE(t+3s,0ms) | A=1talkMs=3000 | A=0talkMs=0 |
| ACCEPTED(t+1s) → RECONCILE(t+3s,0ms) → DURATION(t+4s,3000ms) | A=0talkMs=0 | 与上一行相同 |
| ATTEMPT → UNKNOWN → END(487) | F=1U=0 | 缺口尚未修复时U=1 |
| ATTEMPT → END(487) → UNKNOWN | F=0U=1 | 与上一行相同 |
**整改:**将业务事件时间、接收时间、证据权威等级、校正版本分开。已核验最终时长优先于暂定时长;最终之间按来源修订版本选择,不能用消息到达顺序决定。未知缺口只能被对应补偿证据关闭。保留纯函数状态归约,但使同一事实集合的排列、重复、重领结果一致;更高版本纠错通过现有同事务贡献差值回写。
**关闭标准:**新增排列测试、显式零时长后迟到END/ACCEPTED测试;真实MySQL重放后events/state/buckets一致,A回退不产生负数,C按其独立有效证据保留。
**聚合层补充:**初始基线 `caller-analytics-store.ts:46` 将JSON路径 `$.totalCalls` 等作为JSON_OBJECT的键传入,而读取使用的是普通键totalCalls等。并行候选改动已把写入键修正为普通键,并新增从states重建分钟桶的维护脚本。这属于“修复代码已出现、历史聚合修复与真实验证待确认”,不能只发布新写入逻辑而留下旧桶。须核对受影响桶、states与events是否可信,备份后按第5节重建/差异核对;本轮没有执行该维护脚本。
### R04|P1|运行心跳被当作数据完整性,lagMs=0缺少测量依据
**对应原文:**第4、5、6、10节。**证据:源码确认。**
- 初始基线的gap仅在内存;并行提交 `d786165` 已将gap随游标保存并在启动恢复,原“重启必丢标志”问题已有修复代码。仍只有全局布尔值,没有受影响节点/时间范围与修复状态;保存失败、无完整新日志行、旧游标缺字段等边界仍需测试。
- `analytics-main.ts:57` 将最后处理的event.at作为dataThrough,乱序可倒退,且不代表此前无缺口。
- `analytics-main.ts:8995` 只看消费组pending/lag,正常时直接返回 `lagMs: 0`;未计入未读日志、rsyslog队列或生产端停采。
- `caller-analytics.service.ts:8890` 以Worker心跳新鲜且未degraded标LIVE。即使OpenSIPS日志钩子失效,只要日志可读、MI可用且队列空,仍可能显示LIVE。
**整改:**增加生产端序列/心跳、日志未读字节、Redis最旧未处理年龄、消费速率、DB提交水位及持久化gap表。按节点维护“连续处理到哪个生产序列”,再合成查询窗口覆盖状态。没有可靠延迟证据时lagMs返回null;空闲与停采必须可区分。恢复进程只改变运行状态,不能自动修复历史缺口。
**关闭标准:**停采但不停Worker、积压日志但Stream暂空、轮转后重启、迟到旧事件四类场景均不误标完整;质量提示可按时间窗口查询,缺口修复有审计记录。
### R05|P1|留存、持久化与资源隔离没有形成闭环
**对应原文:**第6、9节。**证据:源码确认;线上参数待核验。**
- `analytics-main.ts:4549` 在XADD返回后推进本机游标,但这不等于Redis数据已经落盘。本地Redis基线 `infra/server-b/s04/redis/redis-lisglosips.conf:818` 为AOF everysec、1536MB、noeviction,实际生效值本轮未核实。everysec存在故障丢失最近约一秒写入的边界,见 [Redis持久化文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)。
- 分析代码中未见Stream裁剪、专属日志轮转方案、事件/状态/桶/死信清理任务;rsyslog的256MB队列上限不是日志文件、Stream或MySQL表的保留期。
- `infra/server-b/caller-analytics/deploy.sh:64` 从cdr-worker环境复制REDIS_URL。独立Stream/消费组不能隔离同一Redis实例内存耗尽的影响。
- `analytics-main.ts:5255,84` 消费时直接JSON.parse并断言类型;缺少消费端schema校验和坏事件重试上限,也未处理XAUTOCLAIM可能返回的已删除pending ID列表。
Redis XACK移除的是消费组待确认记录,不能代替Stream数据删除。参见 [Redis XACK](https://redis.io/docs/latest/commands/xack/) 和 [Streams恢复说明](https://redis.io/docs/latest/develop/use-cases/streaming/)。
**整改:**建立已持久化消费水位与日志可重放区间,Redis恢复后识别回退并从保留日志补送。轮转按inode读取完旧文件后切换,游标含文件身份、序列、校验与持久化状态。为日志、Stream、events、states、buckets、deadletter分别定义容量、保留期、清理安全线和溢出行为;清理不得早于允许迟到/重放/去重窗口。超界必须登记缺口,不能静默丢弃。消费端校验输入,坏事件隔离并告警,正常事件继续推进。
资源方面先测共享Redis和MySQL余量;若无法证明统计流在故障与峰值下不影响呼叫/计费,则使用独立Redis实例或等效强隔离。仅换Redis DB编号不构成内存隔离。日志、死信含号码与Call-ID,须同步限定文件权限、访问范围、导出审计和脱敏保留规则。
**关闭标准:**断电/进程崩溃、XADD后游标提交、日志轮转、毒事件、满盘/满队列、重领/清理竞态均有真实恢复记录;数据可恢复或缺口可见,原呼叫与计费服务不受统计积压拖垮。
### R06|P1|呼叫/尝试生命周期及真实出局边界不完整
**对应原文:**第3.1、4、6、12节。**证据:源码确认,重试间隙已纯计算复现;真实重试尚未实施。**
- 当前ATTEMPT在 `t_relay()` 前发出(`instrument-caller-analytics.mjs:21`、路由基线 `opensips.cfg:307315`)。若本地转发失败且未实际发包,仍可能计入“实际发出的线路尝试”。
- `projectAnalytics():109115` 把“当前所有leg结束”视为原始呼叫结束,没有业务呼叫关闭/不再重试事实。第一条失败、第二条尚未生成的间隙,原始视角提前判F。
- 路由暂为单attempt=1,这是第12节已经承认的范围限制;现有多attempt单测证明能聚合两条记录,不能证明整个重试生命周期已支持。
- `observeActive()` 对MI中消失的呼叫没有UNKNOWN或对账处理;只有START或丢失END的记录可长期停在active/P。
**整改:**补充 `CALL_CLOSED/retryExhausted``ATTEMPT_DISPATCHED`、本地发送失败及终态对账契约。线路分母用可核验的实际派发事实;准备路由和尝试派发分开。原始呼叫只有被权威关闭且全部尝试判定完成时才判F。MI消失只表示需核对,不直接判业务超时;找不到终态依据转数据未知,并保留差异清单。
**关闭标准:**CRA-015/017/020;本地relay失败原始T=1、落地T按是否真实发送判断;重试间隙不提前判F;丢失END后不永久伪装处理中。真实重试启用前必须完成对应采集验收,不扩大当前路由功能范围。
### R07|P2|身份、版本、号码标准化与对账字段缺少可执行契约
**对应原文:**第3.1、6、7节。**证据:源码确认。**
事件接口没有显式nodeId、bootId、事务方法/CSeq/branch、receivedAt、schemaVersion;日志解码时固定version=1、sequence=0。callUid由Call-ID、From-tag及OpenSIPS启动时间组合后哈希,并不是只用Call-ID,但仍缺少显式节点与客户隔离及事务重传/鉴权续发规则。不能因使用哈希就认定全球唯一。
状态和桶没有metricVersion/analysisRevision列,桶hash也不包含版本。号码直接使用caller/landingCaller,没有原值、规范值和规则版本的明确分离。`RECONCILE`虽在类型中存在,当前采集/Worker未见实际最终话单对账生产者;详情让用户按Call-ID查找,尚无稳定话单关联键。
**整改:**定义事件JSON Schema与日志映射,增加生产身份/事务/阶段/时间字段及规范化规则。保存原号码、用于分组的规范号码和规则版本;空维度采用文档化非空哨兵。增加事实修订与聚合版本,定义callUid/attemptId到原始话单的显式关联;来源无法核实时保留“未关联”,禁止Call-ID单字段猜配。旧固定6秒CDR继续禁止用于时长校准。
**关闭标准:**同号不同客户、跨节点相同Call-ID、重传、鉴权续发、主叫改写、升级前后重放均不串数;版本升级能并行核对和回退。
### R08|P1|历史无覆盖与空窗口未区分,详情也未保持同一快照
**对应原文:**第3.5、4、5、7节及第12节补充。**证据:源码确认。**
- `overview()` 仅返回通用historyNotice;不按captureSince和缺口检查查询窗口。新采集前的窗口可返回T=0和LIVE,不能据此区分“没有呼叫”和“没有采集覆盖”。
- overview使用一次数据库事务读取列表和趋势,这是已有正确基础;但asOf在查询后生成,详情另开查询,其rows与breakdown也不在同一快照。使用相同from/to只能保证发起窗口相同,不能冻结后续状态变更。
- `detail()` 的分布直接 `LIMIT 500`,无ORDER BY、total或truncated字段,超过500组合会静默截断。
- 列表后的客户、号码排序没有完整稳定的唯一兜底键,同号跨客户且指标相同时,分页稳定性需要补足。
**整改:**增加按查询窗口计算的coverage及各指标可用性;无覆盖返回null/NO_COVERAGE,不把未知历史展示成0。建议同一响应内生成总览与选中号码详情,或使用服务端短期snapshotId缓存聚合结果;明确“发起时间窗口”和“状态截至快照”的区别。详情查询也使用一致事务。分布改为稳定分页,或返回total/truncated并禁止将截断数据解释为完整总量。
第12节关于“卡片/趋势使用业务筛选,样本/比例/仅告警只筛号码列表”的调整合理,页面已有提示,可保留;但须把baseFilters与numberFilters写进正式API契约,避免与第3.5节混淆。
**关闭标准:**CRA-017/018/019/024;采集生效前、跨覆盖边界、非整分钟窗口、跨日、并发状态更新及>500组合均能正确解释并对账。
### R09|P1|告警规则混合持续计数,且告警范围与查询范围不一致
**对应原文:**第8、12节。**证据:源码确认。**
- `analytics-alerts.ts:2631` 每个“客户+号码+视角”只有一个streak;“低接通率→低总体应答率→低接通率”会累计成三次,即使没有同一规则连续满足。
- 告警仅按号码全线路、最近15分钟计算;API按客户+号码附加ACTIVE告警,没有检查所选历史窗口、落地网关、地区等范围。筛选某条正常线路仍可能显示另一条线路造成的号码级告警。
- Worker在degraded时连评估与过期处理一起跳过,旧ACTIVE可持续保留;API未按更新时间或查询窗口过滤。健康恢复前不能将旧快照继续解释为当前业务异常。
- 当前实现有连续三次触发/恢复,但没有独立规则状态、明确冷却期、恢复阈值迟滞;同一窗口可每15秒重复评估,是否算独立持续证据未定义。
**整改:**按ruleId、客户、号码、视角、规则作用域和规则版本维护状态,分别记录触发计数、恢复计数、冷却、触发/恢复阈值与评估快照。采集异常时转SUSPENDED/STALE,保留历史原因但不继续当有效业务告警。若第一期只支持全线路15分钟号码级告警,应明确标注该作用域;历史或线路筛选下不冒充当前筛选结果的异常。
**关闭标准:**交替命中规则不误累计;同规则连续达到条件才触发;健康异常时暂停业务判断;查询历史/单线路时告警作用域清楚;仍不发送外部通知或自动处置号码。
### R10|P2|页面与API有确定的契约差异,详情功能不完整
**对应原文:**第5、6节。**证据:源码确认;本轮未做浏览器渲染验收。**
- 页面用countColumns生成排序项,其中包含 `notConnectedCalls``CallerAnalyticsPage.jsx:13,65`),API白名单没有该字段(`caller-analytics.service.ts:26`),选择该排序会被400拒绝。
- 页面未展示API已有的metricVersion、lagMs、dataThrough、captureSince;仅展示通用质量状态和asOf。
- 供应商筛选在API有vendorId,页面没有;详情缺少选中号码的独立分钟趋势、180/183/直接成功统计分布、未接通原因聚合、接通未应答最终原因聚合和稳定CDR详情链接。
- 详情用单一 `finalCode` 显示“最终结果”,采集挂断钩子填200、超时填408,可能把对话结束原因与初始INVITE最终响应混为一谈。
- 页面详情遇到“仅可信正时长RECONCILE补C、无180/183/2xx时间”时仍显示“未达接通”,与C/A计数不符。
- 自动刷新为请求完成后再等4000ms,与原文“每5秒刷新”不同;失败保留旧数据、筛选变化取消旧请求的逻辑可保留。
**整改:**共享字段/排序契约,支持N排序或去掉无效选项;补齐版本、覆盖时间、延迟未知说明。详情分离 `initialInviteFinalCode``terminationReason``localFailureCode`,显示真实connectedEvidence。补齐承诺功能;若分期延后,必须列出未交付项,不能写“页面已完成”概括替代。今日边界建议由服务端计算,统一Asia/Shanghai展示;自定义输入标明时区。保留宽表、业务数字直显、筛选和详情刷新语义。
**关闭标准:**逐一操作所有排序、筛选、分页、详情与失败重试;采集缺口和CDR补计显示一致;网络面板、真实API/DB与截图留证,静态页面不算验收。
### R11|P2|实时SLO、查询成本和容量限制需要实测拆解
**对应原文:**第6、9、10、12节。**证据:源码确认,容量待实测。**
`analytics-main.ts` 将日志采集、串行消费、全量MI查询、活跃呼叫观察、告警循环放在同一循环;`observeActive()` 固定LIMIT 2000且无遍历游标,超过范围可能长期漏观察尾部呼叫。时长大于0后不再更新,进行中的talkMs只是首次正时长观察值,不是持续实时增长时长。
API overview每次读取最多10001号码后在应用内过滤排序,summary/numbers/trends均复用完整overview;10000上限限制结果数量,不等于限制数据库扫描成本。7天分钟趋势可达到10080点,需验证前端渲染成本。告警每号码还执行多次查询。状态更新会对已有多个投影反复撤销/添加桶贡献,吞吐不能仅按事件数量估计。
**整改:**先记录峰值CPS、事件/呼叫数、号码与维度基数、平均通话时长、迟到分布、保留期和资源余量,再确定索引与任务频率。将采集、消费、MI观察和告警按职责解耦或设独立预算;活跃观察游标遍历并暴露遗漏量。API用实际SQL执行计划核验,再决定数据库分页/汇总、短缓存和趋势降采样,缓存键必须含授权范围、筛选及快照/版本。
容量估算统一使用:`事件写入率≈CPS×每呼叫事件数`;留存空间使用实测平均事件字节、索引及复制/备份开销。消费恢复能力必须大于持续新增速率,不能只测空队列瞬时吞吐。
**关闭标准:**保留原目标“约定负载下证据可用至页面可见P95≤5秒”,分解采集、消费、查询、轮询等待与渲染延迟;本轮不给机器容量承诺。若轮询无法满足整体目标,调整轮询或推送,不能把SLO起点偷换为API返回时刻。记录原呼叫建立、计费延迟与资源回归。
### R12|P1|发布脚本的完整回滚与再次发布行为需要补齐
**对应原文:**第9、12节。**证据:源码确认,未执行部署。**
`infra/server-b/caller-analytics/deploy.sh` 已有备份、候选配置校验、空闲通话检查、健康检查和加法迁移保留,这些应保留。但仍存在:
1. 已含CRA1标记时直接沿用旧采集配置(41–46行附近),后续采集器修复不会自动进入生效配置;须有明确instrumentationVersion和差异审核流程。
2. 初始rollback未恢复rsyslog配置/env及旧分析进程;并行提交 `d786165` 已补恢复配置、重启rsyslog及按原active状态拉起分析服务。仍需恢复原enable/disable状态、验证每个失败点,不能仅凭代码补齐就认定已演练通过。
3. 部分配置修改发生在changed=1之前,之后验证失败可能不进入完整回滚;只能证明主指针回退,不能证明所有步骤原子恢复。
4. 开始时一次“当前无呼叫”检查与实际重启之间存在新呼叫进入窗口;这不是持续排空保证。
5. 脚本末尾只检查服务active/API ready/commit,未验证分析权限、心跳、采集覆盖、端到端数据和持久化。不能把这些检查当完整功能验收。
**整改:**使用可重复执行的版本化采集配置生成;逐步登记变更与回滚动作,包括原配置存在性、服务enable/active状态、rsyslog重载、入口开关和DB版本。发布前后分别检查,并采用维护窗口/受控排空防止中途新呼叫。回滚默认保留分析事实与新表;恢复应用/配置/消费者组合,不回滚或删除计费账务。增加分析端到端冒烟门槛并验证服务自动启动关系。
**关闭标准:**在测试环境分别注入迁移后、rsyslog校验后、OpenSIPS安装后、API切换后、分析服务启动后的失败;每个步骤均能恢复原服务组合,保留证据且不影响既有账务。
### R13|P1|“已实施”与“已验收”记录没有闭合
**对应原文:**文首、第2、10、11、12节;主设计与实施状态索引。**证据:源码/本地报告目录确认。**
原文用实施更新和第12节解释前文历史状态,但文首仍写全部待实施,第11节仍写仅文档交付;主设计V2第1.1节仍写实现待开始。`IMPLEMENTATION_STATUS.md` 顶部写测试发布验证中,测试计划仍注明CRA-001至024全部待执行。本轮在 `tests/reports` 未检索到专项真实验收报告;这只能说明本地未归档,不能断言远端从未执行。
初始 `caller-analytics-real.mjs` 主要断言六个本机SIP场景的聚合值、详情、401和非法参数400,“real DB states”主要来自API详情。收尾时并行候选代码已增加独立states SQL求和与API汇总核对、事件重复消费保持不变的断言;本轮只检查代码,未执行这些新增步骤,也未看到其真实报告。普通view客户越权、采集丢失、计费不重复、P95、真实最终CDR校正、故障恢复与发布回滚仍无完整证据;SIP脚本未带SDP。因此不可把“六场景通过”写成24项全通过或媒体/计费全链路验收。
**整改:**原文修订为设计文档V1.2,保留V1.1业务口径,增加明确“设计/本地实现/测试部署/真实验收/正式启用”状态表;原第11节移到历史记录。建立需求—实现—CRA用例—报告路径—提交/环境对应台账;每项标记通过、失败、未执行、范围外,不以测试文件存在代替执行。若真实证据在远端,核验后脱敏归档。
**关闭标准:**24项原CRA逐条有对应结果;新增边界有证据;发布报告包含commit、配置hash、迁移版本、环境、执行时间、回滚点及未通过项。没有证据的项继续标未验收。
## 4. 推荐目标契约与实施顺序
### 4.1 最小充分整改范围
保留现有“OpenSIPS观察 → 本机留存 → 独立Stream → 分析Worker → MySQL事实/状态/分钟桶 → API/页面”架构。先修事实与状态,不以重写整个平台、引入新消息中间件或修改计费作为前提。
最小需要触及:采集脚本与版本、分析Worker/模型/存储、加法数据库迁移、API/页面契约、专项测试、部署/回滚脚本与文档。是否增加独立Redis实例由R05容量及故障隔离验证决定,作为需提前报告的范围扩展。
固定6秒旧CDR属于独立计费/话单问题。本轮只规定禁止其作为统计权威时长;不自动重算历史费用、补扣费或变更账务。后续若整改该问题,须另列范围、影响评估与验收。
### 4.2 建议新增/明确的数据字段
下表为整改目标,不表示当前已存在。物理表拆分由实施时评审,优先加法兼容。
| 对象 | 最小补充内容 | 作用 |
| --- | --- | --- |
| 事件事实 | schemaVersion、nodeId、bootId、producerSequence、occurredAt、receivedAt、初始事务身份、dispatch结果、source/evidenceId | 稳定关联、去重、追踪顺序与缺口 |
| 业务呼叫 | callUid、客户快照、businessStartedAt、closedAt、retryExhausted、callCloseEvidence | 原始视角的分母与结束条件 |
| 线路尝试 | attemptId、attemptStartedAt、dispatchedAt、first180At/183At、initialInviteFinalCode、terminationReason | 区分准备、发送、进展、最终响应与对话结束 |
| 时长证据 | talkStartedAt、talkEndedAt、talkDurationMs、durationStatus、sourceRevision、correctedAt | 暂定/最终/无效/未知及乱序校正 |
| 完整性 | 节点、起止序列/时间、gapId、影响范围、恢复/未恢复状态、处理水位 | 不以Worker活跃冒充数据完整 |
| 状态/分钟桶 | metricVersion、analysisRevision、numberNormalizationVersion、coverage信息 | 版本隔离、可回算与可回退 |
| 关联/告警 | 稳定CDR关联键;ruleId、scopeHash、ruleVersion、snapshotId、冷却/恢复状态 | 可追溯且不会混用范围 |
保留 `metricVersion=caller-analytics-v1.1` 表示业务公式未改变;增加analysisRevision表示算法/证据修复批次。若未来真正改变指标定义,才升级metricVersion。文档版本与业务口径版本不能混为一谈。
API建议在现有字段之外明确:`snapshotId``coverageStatus``captureFrom``gapIntervals``metricAvailability``provisionalAnsweredCalls``durationUnknownCalls``baseFilters``numberFilters`及分页完整性。`asOf`标数据库状态快照时间,`dataThrough`标连续覆盖水位,`lagMs`为测量值或null。
### 4.3 工作包与依赖
| 工作包 | 优先级/涉及项 | 产物 | 前置依赖与完成门槛 |
| --- | --- | --- | --- |
| W0 基线冻结 | P1R13 | V1.2文档、需求/用例矩阵、实际部署只读核对表 | 确定生效配置、版本、授权测试范围,不实施业务变更 |
| W1 证据与状态 | P1R01/R02/R03/R06;带R07字段 | 采集契约、确定性归约、真实会话时长验证 | 无法核验时长来源时,A不能宣称完整;单元边界先通过 |
| W2 完整性与可靠性 | P1R04/R05 | gap、水位、补送/死信/留存、资源隔离 | 必须完成真实Redis/MySQL恢复与计费隔离验证 |
| W3 API/页面/告警 | P1/P2R08/R09/R10/R11 | 快照、覆盖、告警状态机、契约对齐、性能报告 | 依赖W1/W2可信事实;页面不能用模拟数据验收 |
| W4 迁移/发布/验收 | P1,R12/R13 | 灰度发布、故障回滚记录、CRA证据和关闭台账 | P1全部关闭,P2未完成项须明确限制,不笼统全量验收 |
W0/W1是首批最小开工范围;W2前不得扩大长期采集留存或据此承诺“计费隔离”。数据库索引、批量策略、轮询/推送选择在实测后确定。本轮未获得当前规模、可用测试时段及现场依赖,不给出缺乏依据的人日或上线日期承诺。
## 5. 数据修复、迁移与回滚方案
1. **只读盘点与备份:**核对当前release、OpenSIPS/Redis/MySQL版本及生效参数、分析表行数/体积、Stream长度/PEL、日志及游标、采集生效时间、账务基线;备份配置与分析证据。已有发布状态先记录再变更。
2. **兼容加法迁移:**增加证据/覆盖/版本列或新表,不覆盖原始events,不删除既有raw/rated CDR;保留旧读路径。迁移前核对现有数据、唯一键及索引成本。
3. **影子重放:**用新的analysisRevision写入独立状态/桶命名空间。按客户、号码、视角、发起分钟对比旧新T/C/A/F/P/U,输出每项差异对应的事件证据与修复原因。
4. **不能恢复的历史数据:**旧事件缺少事务/时长/覆盖证据时,不因新代码部署而变成可信数据;登记未知/无覆盖,明确新证据生效边界。旧固定6秒不参与补算A。
5. **灰度读取:**先内部测试客户及受控SIP入口,再扩大授权客户范围;先观察数据质量,业务告警默认抑制,满足完整性门槛后启用。测试呼叫限定已授权环境和目标,不发外部通知。
6. **验收切换:**完成原CRA及新增用例、数据重放一致性、既有呼叫/计费回归、延迟和资源验证,再切换读取版本,归档数据覆盖起点和发布记录。
7. **回滚:**关闭业务告警和新读入口,切回上一个匹配的应用/配置/数据读版本,恢复原服务组合。存在采集性能风险时再关闭采集钩子,登记停采缺口;保留事实和新表以便修复,不全库恢复覆盖期间产生的新话单与账务。
回算、清理和去重必须共用保留期契约:允许重放的时间范围不能超过去重身份的保留范围;若超出,只允许写入新的重建版本,不能重灌在线聚合造成重复累计。
## 6. 验收矩阵与证据要求
原CRA-001至CRA-024继续有效,本次不修改其业务预期。已有单测通过仅说明部分纯计算行为正确。
| 验收组 | 关联原用例 / 新增建议编号 | 必须提供的证据 |
| --- | --- | --- |
| 接通/应答基础语义 | CRA-001009、011014、018、021023 | 真实SIP报文与事务匹配、会话时长来源、API快照、状态及桶SQL |
| 去重与多视角 | CRA-010、015~017 | 同一业务呼叫多尝试、重传/鉴权续发、同号不同客户与重领;DB及账务不重复 |
| 历史/故障/未知 | CRA-019/020/024 | 缺过程终态、缺整个呼叫、失联/轮转/重启、补送溢出及覆盖提示 |
| 时长来源边界 | CRA-025(建议新增) | 200无ACK、协商失败、早期媒体与真实500ms/0ms,区分可信/暂定/未知 |
| 最终校正乱序 | CRA-026(建议新增) | 本文R03两种顺序、重复、迟到END/ACCEPTED,终态和聚合完全一致 |
| 采集完整性 | CRA-027(建议新增) | Worker存活而生产端停采、日志积压、gap跨重启、水位乱序不误报LIVE |
| 留存与毒事件 | CRA-028(建议新增) | XADD后故障、游标恢复、轮转旧文件、坏schema、PEL被裁剪;不静默丢失 |
| 原始呼叫关闭 | CRA-029(建议新增) | 未真实出局、重试间隙、丢END后MI消失,不误算落地T和原始F |
| 快照与覆盖 | CRA-030(建议新增) | 生效前窗口、跨覆盖、非整分钟、跨日、>500组合及同步详情 |
| 告警状态机 | CRA-031(建议新增) | 交替规则、冷却、迟滞、健康暂停、历史/线路筛选作用域 |
| 权限与页面 | CRA-032(建议新增) | view但无scope、单客户scope、view_all、401/403overview/detail/options均校验;全部排序及详情证据显示 |
| 容量与隔离 | CRA-033(建议新增) | 按约定CPS/基数/保留期记录端到端P95和原有呼叫/计费指标;活跃观察超2000不漏 |
| 发布与回滚 | CRA-034(建议新增) | 发布每个失败点、二次采集脚本升级、服务自动启动、配置与应用版本恢复 |
真实验收每个用例至少记录:runId、时间/环境、提交与配置hash、脱敏callUid/attemptId/Call-ID、预期计数、SIP与会话证据、API请求/返回、DB事件/状态/分钟桶查询、CDR关联或不能关联的原因、结果与报告路径。涉及计费隔离的用例,还必须独立核对原始话单、计费话单、余额变更和消费去重,而非只看分析API。
建议统一归档到 `tests/reports/caller-analytics/<runId>/`;真实数据按最小必要原则脱敏。没有覆盖的用例必须显式未执行,不用“正常”或“已支持”代替结果。
## 7. 原设计文档的逐节修订清单
此处给出待实施修订,不在本次直接重写原文,避免把建议误记为已落实。
| 原位置 | 建议修订 |
| --- | --- |
| 文首、第11/12节 | 当前状态集中成表;第11节移为历史交付记录,第12节事实附环境/时间/证据路径;设计版本V1.2、业务口径仍V1.1 |
| 第2节 | 改为“已有实现与未关闭差距”,准确列出分析表/Worker/API/页面,不再统称待新增 |
| 第3.1节 | 明确派发边界、业务呼叫关闭、单attempt现状与未来重试前置门槛 |
| 第3.3/3.5节 | 区分U、覆盖缺口、A证据不完整,明确null/暂定规则与baseFilters/numberFilters |
| 第3.4节 | 写明经过验证的时长来源、权威等级和校正版本,去掉以标签或差值自动认定“实际”的歧义 |
| 第4节 | 增加确定性归约、业务关闭、水位和时钟域;明确快照不等于窗口结束时间 |
| 第5节 | 增加页面已交付/待补列表;补供应商、详情、证据、延迟与覆盖展示 |
| 第6节 | 把建议事件名与实际START/ATTEMPT等做映射;补字段Schema、版本唯一键、补送/死信/保留期与对账生产者 |
| 第7节 | 规定新覆盖生效时间、不可修复历史规则、算法修复批次及影子回算方法 |
| 第8节 | 逐规则持续计数、范围、评估周期、冷却/迟滞、健康暂停及恢复条件 |
| 第9节 | 引用逐步发布/回滚清单和资源隔离门槛,不以服务active等同功能正常 |
| 第10节 | 保留24项原用例,增补CRA-025~034并链接真实报告;P95仍为待测目标 |
| 相关索引 | 同步主设计V2第1.1节、IMPLEMENTATION_STATUS和TEST_PLAN_AND_CASES,历史状态不覆盖当前状态 |
## 8. 本轮验证记录与复现方法
### 8.1 已执行的专项测试
执行本地现有依赖,不安装/升级依赖:
```text
node node_modules/vitest/vitest.mjs run packages/database/src/caller-analytics.spec.ts apps/api/src/modules/caller-analytics/caller-analytics.service.spec.ts
```
本轮2026-08-31 17:32Asia/Shanghai)结果:2个文件通过,16项测试通过;其中模型13项、查询参数3项。未在本轮重跑全量114项、构建或现场测试,因此不能引用旧全量结果作本轮验收。
### 8.2 纯计算复现
本轮用本地TypeScript转译源码后以内存模块加载 `foldAnalytics/projectAnalytics`,不实例化PrismaClient、不连接Redis、不读环境密钥、不调用SIP。以下等价复现可在项目支持TypeScript导入的Node/tsx环境执行;事件均为构造输入,用于证明算法边界,不能代替真实业务验收。
```javascript
import { foldAnalytics, projectAnalytics } from './packages/database/src/caller-analytics-model.ts';
const t = Date.UTC(2026, 7, 31);
const event = (kind, extra = {}) => ({
eventId: kind, callUid: 'review-u1', callId: 'review-c1', attemptId: '1',
kind, at: t + 1000, startedAt: t,
customerId: 'review-c', customerGatewayId: 'cg', vendorId: 'v', vendorGatewayId: 'vg',
caller: '123', landingCaller: '456', callee: '789', city: 'x', carrier: 'UNKNOWN',
code: 0, talkMs: null, source: 'opensips', sequence: 0, ...extra
});
const run = events => projectAnalytics(events.reduce((c, e) => foldAnalytics(c, e), null))[0];
const attempt = event('ATTEMPT');
const accepted = event('ACCEPTED', { code: 200 });
const provisional = event('DURATION', { at: t + 4000, talkMs: 3000 });
const finalZero = event('RECONCILE', { at: t + 3000, talkMs: 0, source: 'verified-dialog-final' });
const end = event('END', { code: 487, source: 'verified-dialog-final' });
console.log(run([end])); // 当前F=1,U=0
console.log(run([attempt, event('UNKNOWN'), end])); // 当前F=1,U=0
console.log(run([attempt, end, event('UNKNOWN')])); // 当前F=0,U=1
console.log(run([attempt, accepted, provisional, finalZero])); // 当前A=1,talkMs=3000
console.log(run([attempt, accepted, finalZero, provisional])); // 当前A=0,talkMs=0
```
另两组本轮纯计算观察:第一尝试失败且尚无业务关闭事实时原始F=1;只有ACCEPTED及32秒后的对话END、无独立会话确认事实时A=1、talkMs=32000。前者证明多attempt生命周期仍不足;后者证明当前模型依赖信令时间差,不能单独证明实际通话成立。
### 8.3 本轮没有完成的验证
未读取线上活动配置、未执行真实MySQL/Redis查询与事务重放、未运行SIP脚本、未验收UI渲染、未做容量/故障/回滚演练。本文件是审查与整改方案,所有整改工作包尚待实施,不应据此标记缺陷已修复或功能已上线。
## 9. 主要依据索引
行号对应本次源码基线,后续修改后以函数/路由名及提交为准。
| 本地文件 | 核查用途 |
| --- | --- |
| [原设计](CALLER_REALTIME_ANALYTICS_DESIGN.md) | 业务语义、实现补充、24项CRA与交付边界 |
| [状态模型](../packages/database/src/caller-analytics-model.ts) | 60–129行,事实合并、时长校正、投影、比率 |
| [存储事务](../packages/database/src/caller-analytics-store.ts) | consume/bucket/state,事务去重和贡献差值 |
| [分析Worker](../apps/worker-cdr/src/analytics-main.ts) | 1898行,日志/Stream/MI/健康与恢复 |
| [告警](../apps/worker-cdr/src/analytics-alerts.ts) | 3–35行,窗口、规则状态及过期 |
| [采集脚本](../scripts/instrument-caller-analytics.mjs) | 11–38行,身份、挂钩、阶段和结束事件 |
| [API](../apps/api/src/modules/caller-analytics/caller-analytics.service.ts) | parseAnalyticsQuery/overview/detail/options,筛选、权限、快照、分布及覆盖 |
| [页面](../apps/web/src/pages/CallerAnalyticsPage.jsx) | 排序、刷新、展示与详情契约 |
| [迁移](../prisma/migrations/20260831090000_caller_analytics/migration.sql) | 表、索引、版本字段与权限 |
| [发布脚本](../infra/server-b/caller-analytics/deploy.sh) | 备份、迁移、配置切换、回滚与发布门槛 |
| [Redis基线](../infra/server-b/s04/redis/redis-lisglosips.conf) | 本地持久化/内存配置参照,不代表现场 |
| [真实测试入口](../tests/api/caller-analytics-real.mjs) / [SIP脚本](../tests/api/caller-analytics-sip.py) | 六场景覆盖范围及缺失证据 |
| [实施状态](../IMPLEMENTATION_STATUS.md) / [主设计V2](../SOFTSWITCH_PLATFORM_DESIGN_V2.md) / [测试计划](TEST_PLAN_AND_CASES.md) | 状态一致性与验收索引 |
外部依据使用本轮查阅的RFC及OpenSIPS/Redis官方文档;不据此猜测现场软件版本或配置。所有方案中的生产变更、受控呼叫、数据回算和发布,都应在后续实施任务的实际授权范围内执行。
+4
View File
@@ -1975,6 +1975,10 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 步骤 | 1. 导入器先干跑并确认未匹配为 0。2. 备份后导入。3. 核对四表数量。4. 四个页签执行首屏、翻页、页大小和筛选。 |
| 预期结果 | 371 个行政城市、517,258 个有效七位号段、321 个区号、70 个前缀规则;首屏只读取 25 条;上一页/下一页与总数正确;页面加载不扫描或返回全表。 |
## 8A. 主叫号码实时分析验收设计索引(2026-08-31)
[主叫号码实时分析V1.2](CALLER_REALTIME_ANALYTICS_DESIGN.md)第10节及第13节定义CRA-001至CRA-034。2026-09-01已完成状态/公式/乱序/告警/查询契约的自动化回归(全量Vitest 29文件/118项),并以6通受控真实SIP完成180、183、直接失败、直接200、ACK后1.5秒/300ms通话、SDP/RTPEngine、真实API、MySQL状态/分钟桶、重放幂等和RBAC验收;Redis重启、毒事件死信、覆盖降级/恢复、维护任务及真实页面也已验证。结果见[整改交付报告](CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md)和[机器证据JSON](../tests/reports/caller-analytics-real-20260901.json)。未按约定峰值负载执行容量与页面端到端P95统计,因此CRA容量项保持“未执行/不作容量承诺”;真实多线路重试启用前仍须补做对应实际路由验收。
## 9. 缺陷分级
| 级别 | 定义 | 示例 |
+3 -2
View File
@@ -14,6 +14,7 @@ required_services=(
tailscaled
mysql
redis-server
lisglosips-caller-analytics-redis
postgresql
nginx
opensips
@@ -57,8 +58,8 @@ echo "== services =="
systemctl is-active "${required_services[@]}"
echo "== timers =="
systemctl is-active lisglosips-backup.timer lisglosips-recording-finalize.timer
systemctl is-enabled lisglosips.target lisglosips-backup.timer lisglosips-recording-finalize.timer \
systemctl is-active lisglosips-backup.timer lisglosips-recording-finalize.timer lisglosips-caller-analytics-maintenance.timer
systemctl is-enabled lisglosips.target lisglosips-backup.timer lisglosips-recording-finalize.timer lisglosips-caller-analytics-maintenance.timer \
lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
echo "== api ready =="
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -Eeuo pipefail
commit=${1:?commit required}
archive=${2:?archive required}
expected=${3:?sha256 required}
[[ "$commit" =~ ^[0-9a-f]{40}$ ]]
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
old=$(readlink -f /opt/lisglosips/current)
new=/opt/lisglosips/releases/caller-analytics-$stamp
backup=/var/backups/lisglosips-caller-analytics/$stamp
test -d "$old"
test ! -e "$new"
# Refuse to interrupt calls, including unrelated test calls.
curl -fsS -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' http://127.0.0.1:8888/mi | python3 -c 'import sys,json; assert json.load(sys.stdin)["result"]["Dialogs"]==[], "Active calls: retry after calls end"'
systemctl start lisglosips-backup.service
install -d -m 0700 "$backup"
if systemctl --quiet is-active lisglosips-caller-analytics-redis.service && test -f /etc/lisglosips/caller-analytics-redis.secret; then
export REDISCLI_AUTH
REDISCLI_AUTH=$(cat /etc/lisglosips/caller-analytics-redis.secret)
redis-cli -h 127.0.0.1 -p 6381 --no-auth-warning SAVE >/dev/null
unset REDISCLI_AUTH
cp -a /var/lib/redis-caller-analytics "$backup/redis-caller-analytics"
fi
cp -a /etc/opensips/opensips.cfg "$backup/opensips.cfg"
checkpoint_file=/var/lib/lisglosips/caller-analytics-offset.json
if test -f "$checkpoint_file"; then cp -a "$checkpoint_file" "$backup/caller-analytics-offset.json"; fi
printf '%s\n' "$old" > "$backup/previous-release"
managed_files=(/etc/lisglosips/caller-analytics.env /etc/rsyslog.d/35-caller-analytics.conf /etc/redis/caller-analytics.conf /etc/systemd/system/lisglosips-caller-analytics-redis.service /etc/logrotate.d/lisglosips-caller-analytics /etc/systemd/system/lisglosips-caller-analytics-maintenance.service /etc/systemd/system/lisglosips-caller-analytics-maintenance.timer /etc/systemd/system/lisglosips.target)
for file in "${managed_files[@]}"; do
if test -f "$file"; then cp -a "$file" "$backup/$(basename "$file")"; fi
done
changed=0
was_running=$(systemctl is-active lisglosips@caller-analytics || true)
was_enabled=$(systemctl is-enabled lisglosips@caller-analytics 2>/dev/null || true)
redis_was_running=$(systemctl is-active lisglosips-caller-analytics-redis 2>/dev/null || true)
redis_was_enabled=$(systemctl is-enabled lisglosips-caller-analytics-redis 2>/dev/null || true)
maintenance_was_running=$(systemctl is-active lisglosips-caller-analytics-maintenance.timer 2>/dev/null || true)
maintenance_was_enabled=$(systemctl is-enabled lisglosips-caller-analytics-maintenance.timer 2>/dev/null || true)
secret_existed=0
test -f /etc/lisglosips/caller-analytics-redis.secret && secret_existed=1
rollback() {
rc=$?
if test "$changed" = 1; then
systemctl stop lisglosips@caller-analytics || true
systemctl stop lisglosips-caller-analytics-maintenance.timer lisglosips-caller-analytics-redis || true
cp -a "$backup/opensips.cfg" /etc/opensips/opensips.cfg
if test -f "$backup/caller-analytics-offset.json"; then cp -a "$backup/caller-analytics-offset.json" "$checkpoint_file"; fi
for file in "${managed_files[@]}"; do
if test -f "$backup/$(basename "$file")"; then cp -a "$backup/$(basename "$file")" "$file"; else rm -f "$file"; fi
done
ln -sfn "$old" /opt/lisglosips/current
systemctl restart rsyslog || true
systemctl daemon-reload || true
systemctl restart opensips lisglosips@api || true
if test "$was_running" = active; then systemctl start lisglosips@caller-analytics || true; fi
if test "$was_enabled" != enabled; then systemctl disable lisglosips@caller-analytics || true; fi
if test "$redis_was_running" = active; then systemctl start lisglosips-caller-analytics-redis || true; fi
if test "$redis_was_enabled" != enabled; then systemctl disable lisglosips-caller-analytics-redis || true; fi
if test "$maintenance_was_running" = active; then systemctl start lisglosips-caller-analytics-maintenance.timer || true; fi
if test "$maintenance_was_enabled" != enabled; then systemctl disable lisglosips-caller-analytics-maintenance.timer || true; fi
if test "$secret_existed" = 0; then rm -f /etc/lisglosips/caller-analytics-redis.secret; fi
fi
echo "FAILED rc=$rc backup=$backup (additive analytics tables retained)"
exit "$rc"
}
trap rollback ERR
cp -a --reflink=auto "$old" "$new"
tar -xzf "$archive" -C "$new"
printf '%s\n' "$commit" > "$new/.deployed-commit"
printf '%s\n' "$old" > "$new/.delta-base-release"
source_cfg="$backup/opensips.cfg"
if grep -q 'CRA_INSTRUMENTATION_VERSION=3' "$source_cfg"; then
cp "$source_cfg" "$new/opensips-candidate.cfg"
else
/usr/bin/node "$new/scripts/instrument-caller-analytics.mjs" "$source_cfg" "$new/opensips-candidate.cfg"
fi
python3 "$new/infra/server-b/caller-analytics/verify-hotpath.py" "$new/opensips-candidate.cfg"
systemctl start lisglosips-redis-hotpath-load.service
opensips -C -f "$new/opensips-candidate.cfg"
set -a
source /etc/lisglosips/secrets/mysql-migrate.env
set +a
export DATABASE_URL
DATABASE_URL=$(/usr/bin/node -e 'console.log(`mysql://${encodeURIComponent(process.env.MYSQL_USER)}:${encodeURIComponent(process.env.MYSQL_PASSWORD)}@${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`)')
# Previous delta releases stripped executable bits from bundled native engines.
find "$new/node_modules/.pnpm" -path '*/@prisma/engines/schema-engine-debian-openssl-3.0.x' -type f -exec chmod 0750 {} \;
/usr/bin/node "$new/node_modules/prisma/build/index.js" migrate deploy --schema "$new/prisma/schema.prisma"
unset DATABASE_URL MYSQL_PASSWORD
install -d -o syslog -g lisglosips -m 0750 /var/log/lisglosips
touch /var/log/lisglosips/caller-analytics.log
chown syslog:lisglosips /var/log/lisglosips/caller-analytics.log
chmod 0640 /var/log/lisglosips/caller-analytics.log
install -m 0644 "$new/infra/server-b/caller-analytics/rsyslog.conf" /etc/rsyslog.d/35-caller-analytics.conf
rsyslogd -N1
changed=1
install -d -o redis -g redis -m 0750 /var/lib/redis-caller-analytics
if ! test -f /etc/lisglosips/caller-analytics-redis.secret; then openssl rand -hex 32 > /etc/lisglosips/caller-analytics-redis.secret; chown root:lisglosips /etc/lisglosips/caller-analytics-redis.secret; chmod 0640 /etc/lisglosips/caller-analytics-redis.secret; fi
analytics_redis_password=$(cat /etc/lisglosips/caller-analytics-redis.secret)
sed "s/@@ANALYTICS_REDIS_PASSWORD@@/$analytics_redis_password/" "$new/infra/server-b/caller-analytics/redis.conf" > /etc/redis/caller-analytics.conf
chown root:redis /etc/redis/caller-analytics.conf; chmod 0640 /etc/redis/caller-analytics.conf
install -m 0644 "$new/infra/server-b/caller-analytics/redis.service" /etc/systemd/system/lisglosips-caller-analytics-redis.service
install -m 0644 "$new/infra/server-b/caller-analytics/logrotate.conf" /etc/logrotate.d/lisglosips-caller-analytics
install -m 0644 "$new/infra/server-b/caller-analytics/maintenance.service" /etc/systemd/system/lisglosips-caller-analytics-maintenance.service
install -m 0644 "$new/infra/server-b/caller-analytics/maintenance.timer" /etc/systemd/system/lisglosips-caller-analytics-maintenance.timer
install -m 0644 "$new/infra/server-b/s58/systemd/lisglosips.target" /etc/systemd/system/lisglosips.target
systemctl daemon-reload
systemctl enable --now lisglosips-caller-analytics-redis.service
encoded_redis_password=$(/usr/bin/node -e 'console.log(encodeURIComponent(process.argv[1]))' "$analytics_redis_password")
if grep -q 'CRA_INSTRUMENTATION_VERSION=3' "$source_cfg" && grep -q '^ANALYTICS_CAPTURE_SINCE=' /etc/lisglosips/caller-analytics.env; then
analytics_capture_since=$(sed -n 's/^ANALYTICS_CAPTURE_SINCE=//p' /etc/lisglosips/caller-analytics.env | head -n 1)
else
analytics_capture_since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
fi
[[ "$analytics_capture_since" =~ ^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
grep '^DATABASE_URL=' /etc/lisglosips/cdr-worker.env > /etc/lisglosips/caller-analytics.env.new
printf '%s\n' "REDIS_URL=redis://:$encoded_redis_password@127.0.0.1:6381/0" 'LISGLOSIPS_ENTRYPOINT=apps/worker-cdr/dist/analytics-main.js' 'LISGLOSIPS_SERVICE_NAME=caller-analytics' 'ANALYTICS_STREAM_MAXLEN=1000000' "ANALYTICS_CAPTURE_SINCE=$analytics_capture_since" >> /etc/lisglosips/caller-analytics.env.new
install -o root -g lisglosips -m 0640 /etc/lisglosips/caller-analytics.env.new /etc/lisglosips/caller-analytics.env
rm -f /etc/lisglosips/caller-analytics.env.new
unset analytics_redis_password encoded_redis_password analytics_capture_since
chown -R root:lisglosips "$new/apps/api/dist" "$new/apps/worker-cdr/dist" "$new/packages/database/dist" "$new/packages/auth/dist" "$new/public" "$new/scripts" "$new/infra/server-b/caller-analytics"
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/apps/worker-cdr/dist" "$new/packages/database/dist" "$new/packages/auth/dist" "$new/public" "$new/scripts" "$new/infra/server-b/caller-analytics"
systemctl restart rsyslog
install -o root -g root -m 0644 "$new/opensips-candidate.cfg" /etc/opensips/opensips.cfg
ln -sfn "$new" /opt/lisglosips/current
systemctl stop lisglosips@caller-analytics
# This is a coordinated producer restart after the spool is quiescent. Reset
# only the per-process reconciliation baseline; preserve any pre-existing gap.
if test -f "$checkpoint_file"; then
python3 - "$checkpoint_file" <<'PY'
import json, os, sys
p=sys.argv[1]; st=os.stat(p)
with open(p,encoding='utf-8') as f: data=json.load(f)
spool='/var/log/lisglosips/caller-analytics.log'
assert int(data.get('offset',-1)) == os.path.getsize(spool), 'analytics spool is not quiescent'
produced=data.get('lastProducerCount'); collected=data.get('eventsSinceProducerStart')
assert produced is None or produced == collected, 'producer/collector counters differ before planned restart'
data.pop('lastProducerCount',None); data['eventsSinceProducerStart']=0
t=p+'.deploy-tmp'
with open(t,'w',encoding='utf-8') as f: json.dump(data,f,separators=(',',':'))
os.chown(t,st.st_uid,st.st_gid); os.chmod(t,st.st_mode); os.replace(t,p)
PY
fi
systemctl restart opensips lisglosips@api
systemctl enable --now lisglosips@caller-analytics
systemctl restart lisglosips@caller-analytics
systemctl enable --now lisglosips-caller-analytics-maintenance.timer
for i in $(seq 1 15); do
if curl -fsS http://127.0.0.1:3000/api/v2/health/ready >/dev/null; then break; fi
sleep 1
done
curl -fsS http://127.0.0.1:3000/api/v2/health/ready
systemctl is-active opensips lisglosips@api lisglosips@cdr-worker lisglosips@caller-analytics lisglosips-caller-analytics-redis
systemctl is-enabled lisglosips-caller-analytics-maintenance.timer
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
changed=0
trap - ERR
printf '\nRELEASE=%s\nBACKUP=%s\nCOMMIT=%s\n' "$new" "$backup" "$commit"
@@ -0,0 +1,11 @@
/var/log/lisglosips/caller-analytics.log {
daily
rotate 14
size 50M
compress
delaycompress
copytruncate
missingok
notifempty
su syslog lisglosips
}
@@ -0,0 +1,15 @@
[Unit]
Description=LisgloSIPS caller analytics retention maintenance
After=mysql.service lisglosips-caller-analytics-redis.service
[Service]
Type=oneshot
User=lisglosips
Group=lisglosips
WorkingDirectory=/opt/lisglosips/current
EnvironmentFile=/etc/lisglosips/caller-analytics.env
ExecStart=/usr/bin/node scripts/maintain-caller-analytics.mjs --apply
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
@@ -0,0 +1,10 @@
[Unit]
Description=Daily caller analytics retention maintenance
[Timer]
OnCalendar=*-*-* 03:35:00
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
@@ -0,0 +1,19 @@
bind 127.0.0.1
protected-mode yes
port 6381
dir /var/lib/redis-caller-analytics
dbfilename dump.rdb
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
appendfsync everysec
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
maxmemory 256mb
maxmemory-policy noeviction
tcp-keepalive 60
requirepass @@ANALYTICS_REDIS_PASSWORD@@
pidfile /run/redis-caller-analytics/redis.pid
logfile /var/log/redis/redis-caller-analytics.log
@@ -0,0 +1,21 @@
[Unit]
Description=LisgloSIPS isolated caller analytics Redis
After=network.target
[Service]
Type=notify
User=redis
Group=redis
RuntimeDirectory=redis-caller-analytics
RuntimeDirectoryMode=0750
ExecStart=/usr/bin/redis-server /etc/redis/caller-analytics.conf --supervised systemd
ExecStop=/bin/kill -s TERM $MAINPID
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/redis-caller-analytics /var/log/redis /run/redis-caller-analytics
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,8 @@
# OpenSIPS emits only analytics observations here. No network destination.
if (($programname == 'opensips' or $syslogtag startswith '/usr/sbin/opensips[') and $msg contains 'CRA1|') then {
action(type="omfile" file="/var/log/lisglosips/caller-analytics.log"
fileOwner="syslog" fileGroup="lisglosips" fileCreateMode="0640"
queue.type="LinkedList" queue.filename="caller-analytics"
queue.maxDiskSpace="256m" queue.saveOnShutdown="on"
action.resumeRetryCount="-1")
}
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Align candidate EVALSHA with the installed Lua bytes actually loaded by the loader."""
import hashlib
from pathlib import Path
import re
import sys
config = Path(sys.argv[1])
script = Path('/etc/opensips/lisglosips_hotpath.lua').read_text()
sha = hashlib.sha1(script.encode()).hexdigest()
text = config.read_text()
matches = re.findall(r'EVALSHA ([0-9a-f]{40}) 1 cfg:active_version', text)
assert len(matches) == 1, 'Unexpected hotpath layout'
config.write_text(text.replace('EVALSHA ' + matches[0], 'EVALSHA ' + sha))
print('Installed hotpath SHA:', sha, 'previous:', matches[0])
+2 -2
View File
@@ -1,14 +1,14 @@
[Unit]
Description=LisgloSIPS single-server stack on Server B
Wants=network-online.target tailscaled.service
Wants=mysql.service redis-server.service postgresql.service
Wants=mysql.service redis-server.service lisglosips-caller-analytics-redis.service postgresql.service
Wants=nginx.service opensips.service rtpengine-daemon.service rtpengine-recording-daemon.service
Wants=lisglosips-redis-auth-proxy.service lisglosips-redis-hotpath-load.service
Wants=lisglosips@api.service lisglosips@cdr-worker.service lisglosips@recording-worker.service lisglosips@config-publisher.service
Wants=heplify-server.service homer-app.service
Wants=lisglosips-prometheus.service grafana-server.service
Wants=lisglosips-node-exporter.service lisglosips-mysqld-exporter.service lisglosips-postgres-exporter.service lisglosips-redis-exporter.service
Wants=lisglosips-backup.timer lisglosips-recording-finalize.timer
Wants=lisglosips-backup.timer lisglosips-recording-finalize.timer lisglosips-caller-analytics-maintenance.timer
After=network-online.target tailscaled.service
After=mysql.service redis-server.service postgresql.service
+2
View File
@@ -4,6 +4,8 @@ export const ACCESS_TOKEN_TYPE = 'Bearer';
export const PASSWORD_ALGO_ARGON2ID = 'argon2id';
export type PermissionKey =
| 'caller_analytics.view'
| 'caller_analytics.view_all'
| 'dashboard.view'
| 'active_calls.view'
| 'active_calls.manage'
@@ -0,0 +1,179 @@
import { createHash } from 'node:crypto';
export const ANALYTICS_VERSION = 'caller-analytics-v1.1';
export const ANALYTICS_REVISION = 'caller-analytics-r2';
export const ANALYTICS_STREAM = 'stream:caller_analytics';
export const ANALYTICS_GROUP = 'caller-analytics-workers';
export type AnalyticsKind = 'START' | 'ATTEMPT' | 'PROGRESS' | 'ACCEPTED' | 'TALK_START' | 'DURATION' | 'END' | 'CLOSE' | 'RECONCILE' | 'UNKNOWN';
export interface AnalyticsEvent {
eventId: string; callUid: string; callId: string; attemptId: string;
kind: AnalyticsKind; at: number; startedAt: number;
customerId: string; customerGatewayId: string; vendorId: string; vendorGatewayId: string;
caller: string; landingCaller: string; callee: string; city: string; carrier: string;
code: number; talkMs: number | null; source: string; sequence: number;
schemaVersion?: number; nodeId?: string; bootId?: string; receivedAt?: number;
method?: string; cseq?: string; branch?: string; evidenceVersion?: number;
}
export interface AnalyticsLeg {
event: AnalyticsEvent; first180At: number | null; first183At: number | null;
acceptedAt: number | null; endedAt: number | null; finalCode: number;
talkMs: number; durationAt: number; durationFinal: boolean; unknown: boolean;
coverageGap?: boolean; durationAuthority?: number; talkStartedAt?: number | null;
durationComplete?: boolean; terminationReason?: string;
}
export interface AnalyticsCall {
start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>;
closedAt?: number | null; closeEvidence?: string; closeExpected?: boolean;
}
export interface AnalyticsCounts {
totalCalls: number; connectedCalls: number; answeredCalls: number;
failedCalls: number; pendingCalls: number; unknownCalls: number; activeCalls: number; talkMs: number;
durationUnknownCalls: number; provisionalAnsweredCalls: number;
}
export const COUNT_KEYS = ['totalCalls', 'connectedCalls', 'answeredCalls', 'failedCalls', 'pendingCalls', 'unknownCalls', 'activeCalls', 'talkMs', 'durationUnknownCalls', 'provisionalAnsweredCalls'] as const;
export const emptyCounts = (): AnalyticsCounts => ({ totalCalls: 0, connectedCalls: 0, answeredCalls: 0, failedCalls: 0, pendingCalls: 0, unknownCalls: 0, activeCalls: 0, talkMs: 0, durationUnknownCalls: 0, provisionalAnsweredCalls: 0 });
export function analyticsHash(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
const f: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) f[fields[i]] = fields[i + 1];
const text = (key: string, max = 255): string => {
const value = f[key] ?? '';
if (value.length > max || [...value].some(c => c.charCodeAt(0) < 32)) throw new Error(`Invalid ${key}`);
return value === 'none' ? '' : value;
};
const num = (key: string, fallback = 0): number => {
const value = f[key] === undefined ? fallback : Number(f[key]);
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid ${key}`);
return value;
};
const kind = text('kind') as AnalyticsKind;
if (!['1', '2'].includes(f.version) || !['START', 'ATTEMPT', 'PROGRESS', 'ACCEPTED', 'TALK_START', 'DURATION', 'END', 'CLOSE', 'RECONCILE', 'UNKNOWN'].includes(kind)) throw new Error('Invalid analytics schema');
const at = num('at'); const startedAt = num('startedAt');
if (at < 1_700_000_000_000 || at > Date.now() + 60_000 || startedAt > at || startedAt < 1_700_000_000_000) throw new Error('Invalid event time');
const event: AnalyticsEvent = {
eventId: text('eventId', 255), callUid: text('callUid', 255), callId: text('callId'), attemptId: text('attemptId', 80),
kind, at, startedAt, customerId: text('customerId', 32), customerGatewayId: text('customerGatewayId', 32),
vendorId: text('vendorId', 32), vendorGatewayId: text('vendorGatewayId', 32),
caller: text('caller', 64), landingCaller: text('landingCaller', 64), callee: text('callee', 64),
city: text('city', 12), carrier: text('carrier', 20), code: num('code'),
talkMs: f.talkMs === undefined || f.talkMs === '-1' ? null : num('talkMs'), source: text('source', 64), sequence: num('sequence'),
schemaVersion: Number(f.version), nodeId: text('nodeId', 64), bootId: text('bootId', 80), receivedAt: num('receivedAt', at),
method: text('method', 24), cseq: text('cseq', 64), branch: text('branch', 128), evidenceVersion: num('evidenceVersion', 1)
};
if (!event.eventId || !event.callUid || !event.callId || !event.customerId || !event.caller) throw new Error('Missing call identity');
if (event.code > 699 || (kind === 'PROGRESS' && ![100, 180, 181, 182, 183].includes(event.code))) throw new Error('Invalid response code');
if (kind === 'RECONCILE' && event.source !== 'verified-dialog-final') throw new Error('Unverified CDR cannot reconcile');
return event;
}
export function validateAnalyticsEvent(input: unknown): AnalyticsEvent {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid analytics event');
const raw = input as Record<string, unknown>;
const mapped: Record<string, string> = {};
for (const [key, value] of Object.entries(raw)) {
if (value !== null && value !== undefined) mapped[key] = String(value);
}
mapped.version = String(raw.schemaVersion ?? raw.version ?? 1);
if (raw.talkMs === null || raw.talkMs === undefined) mapped.talkMs = '-1';
return parseAnalyticsEvent(Object.entries(mapped).flat());
}
const earliest = (a: number | null, b: number): number => a === null ? b : Math.min(a, b);
export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent): AnalyticsCall {
const result: AnalyticsCall = call ? structuredClone(call) : { start: event, legs: {} };
if (result.start.customerId !== event.customerId || result.start.caller !== event.caller) throw new Error('Conflicting call identity');
if (event.startedAt < result.start.startedAt || (event.kind === 'START' && event.startedAt === result.start.startedAt)) result.start = event;
if ((event.schemaVersion ?? 1) >= 2) result.closeExpected = true;
if (event.kind === 'START') return result;
if (event.kind === 'CLOSE') {
if (result.closedAt === null || result.closedAt === undefined || event.at >= result.closedAt) {
result.closedAt = event.at; result.closeEvidence = event.source;
}
return result;
}
const key = event.attemptId || 'platform';
const leg: AnalyticsLeg = result.legs[key] ?? {
event, first180At: null, first183At: null, acceptedAt: null, endedAt: null,
finalCode: 0, talkMs: 0, durationAt: 0, durationFinal: false, unknown: event.kind !== 'ATTEMPT',
talkStartedAt: null, durationComplete: false
};
if (event.kind === 'ATTEMPT') { leg.event = event; if (!leg.coverageGap) leg.unknown = false; }
if (event.kind === 'PROGRESS' && event.code === 180) leg.first180At = earliest(leg.first180At, event.at);
if (event.kind === 'PROGRESS' && event.code === 183) leg.first183At = earliest(leg.first183At, event.at);
if (event.kind === 'ACCEPTED' && event.code >= 200 && event.code < 300) leg.acceptedAt = earliest(leg.acceptedAt, event.at);
if (event.kind === 'TALK_START') leg.talkStartedAt = earliest(leg.talkStartedAt ?? null, event.at);
if (event.kind === 'UNKNOWN') { leg.unknown = true; leg.coverageGap = true; }
if (event.kind === 'END' || event.kind === 'RECONCILE') {
if (leg.endedAt === null || event.at >= leg.endedAt) { leg.endedAt = event.at; leg.finalCode = event.code; }
// END carries the producer's accumulated phase flags through separate replayable events.
if (event.source === 'platform-final' && !event.attemptId && !leg.coverageGap) leg.unknown = false;
leg.terminationReason = event.source;
// A terminal non-2xx response proves that no answered dialog existed, so
// the final talk duration is exactly zero even if provisional 180/183 was seen.
if ((event.code < 200 || event.code >= 300) && leg.acceptedAt === null && leg.talkStartedAt === null) {
leg.talkMs = 0; leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true;
}
if (event.source === 'verified-dialog-final' && leg.talkStartedAt != null && event.talkMs === null && !leg.durationFinal) {
leg.talkMs = Math.max(0, event.at - leg.talkStartedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true;
}
}
if (event.kind === 'TALK_START' && leg.endedAt !== null && !leg.durationFinal) {
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true;
}
if (event.talkMs !== null && ['DURATION', 'END', 'RECONCILE'].includes(event.kind)) {
const final = event.kind !== 'DURATION';
const authority = event.kind === 'RECONCILE' ? 3 : final ? 2 : 0;
const priorAuthority = leg.durationAuthority ?? (leg.durationFinal ? 1 : 0);
if (authority > priorAuthority || (authority === priorAuthority && event.at >= leg.durationAt)) {
leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final; leg.durationAuthority = authority;
if (final) leg.durationComplete = true;
}
}
result.legs[key] = leg;
return result;
}
export interface AnalyticsProjection extends AnalyticsCounts {
id: string; callKey: string; view: 'original' | 'landing'; startedAt: number;
customerId: string; customerGatewayId: string; vendorId: string; vendorGatewayId: string;
caller: string; callee: string; city: string; carrier: string; payload: AnalyticsCall | AnalyticsLeg;
}
function legCounts(leg: AnalyticsLeg): AnalyticsCounts {
const connected = leg.first180At !== null || leg.first183At !== null || leg.acceptedAt !== null || leg.talkMs > 0;
const unknown = !connected && leg.unknown;
const answered = leg.talkMs > 0;
return { totalCalls: 1, connectedCalls: +connected, answeredCalls: +answered, failedCalls: +(!connected && !unknown && leg.endedAt !== null), pendingCalls: +(!connected && !unknown && leg.endedAt === null), unknownCalls: +unknown, activeCalls: +(leg.endedAt === null), talkMs: leg.talkMs,
durationUnknownCalls: +(connected && leg.endedAt !== null && !leg.durationComplete), provisionalAnsweredCalls: +(answered && !leg.durationFinal) };
}
export function projectAnalytics(call: AnalyticsCall): AnalyticsProjection[] {
const e = call.start; const key = analyticsHash(e.callUid);
const legs = Object.values(call.legs); const counts = legs.map(legCounts);
const connected = counts.some(c => c.connectedCalls > 0);
const legacyClosed = resultIsLegacy(call) && legs.length > 0 && counts.every(c => c.activeCalls === 0);
const active = resultIsLegacy(call) ? (!legs.length || counts.some(c => c.activeCalls > 0)) : call.closedAt == null;
const unknown = !connected && counts.some(c => c.unknownCalls > 0);
const base = { customerId: e.customerId, customerGatewayId: e.customerGatewayId, callee: e.callee, city: e.city, carrier: e.carrier, callKey: key };
return [{ ...base, id: analyticsHash([key, 'original']), view: 'original', startedAt: e.startedAt, caller: e.caller,
vendorId: '', vendorGatewayId: '', payload: call, totalCalls: 1, connectedCalls: +connected,
answeredCalls: +(counts.some(c => c.answeredCalls > 0)), failedCalls: +(!connected && !unknown && (legacyClosed || !active)),
pendingCalls: +(!connected && !unknown && active), unknownCalls: +unknown, activeCalls: +active,
talkMs: counts.reduce((sum, c) => sum + c.talkMs, 0),
durationUnknownCalls: +(connected && !counts.some(c => c.answeredCalls > 0) && counts.some(c => c.durationUnknownCalls > 0)),
provisionalAnsweredCalls: +(counts.some(c => c.provisionalAnsweredCalls > 0))
}, ...legs.filter(l => l.event.attemptId && l.event.vendorGatewayId).map(leg => ({
...base, ...legCounts(leg), id: analyticsHash([key, leg.event.attemptId]), view: 'landing' as const,
startedAt: leg.event.startedAt, caller: leg.event.landingCaller || leg.event.caller,
vendorId: leg.event.vendorId, vendorGatewayId: leg.event.vendorGatewayId, payload: leg
}))];
}
export function analyticsRates(counts: AnalyticsCounts) {
const ratio = (n: number, d: number) => d ? Number((n / d * 100).toFixed(4)) : null;
return { ...counts, notConnectedCalls: counts.failedCalls + counts.pendingCalls,
connectionRate: counts.unknownCalls ? null : ratio(counts.connectedCalls, counts.totalCalls),
overallAnswerRate: counts.durationUnknownCalls ? null : ratio(counts.answeredCalls, counts.totalCalls),
connectedAnswerRate: counts.unknownCalls || counts.durationUnknownCalls ? null : ratio(counts.answeredCalls, counts.connectedCalls) };
}
function resultIsLegacy(call: AnalyticsCall): boolean { return call.closeExpected !== true; }
@@ -0,0 +1,52 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { analyticsHash, foldAnalytics, projectAnalytics, COUNT_KEYS, type AnalyticsCall, type AnalyticsEvent, type AnalyticsProjection } from './caller-analytics-model.js';
type Tx = Prisma.TransactionClient;
const json = <T>(value: T | string): T => typeof value === 'string' ? JSON.parse(value) as T : value;
export class CallerAnalyticsStore {
constructor(private readonly db: PrismaClient) {}
async consume(event: AnalyticsEvent): Promise<'processed' | 'duplicate'> {
// The legacy router puts diagnostic markers in customer_id for unauthenticated traffic.
// Such traffic is outside the business population and must not create analysis calls.
if (!await this.db.customer.findUnique({ where: { id: event.customerId }, select: { id: true } })) return 'duplicate';
return this.db.$transaction(async tx => {
const callKey = analyticsHash(event.callUid);
await tx.$executeRaw`INSERT IGNORE INTO caller_analysis_calls (id, payload, updated_at) VALUES (${callKey}, NULL, NOW(3))`;
const rows = await tx.$queryRaw<Array<{ payload: AnalyticsCall | string | null }>>`SELECT payload FROM caller_analysis_calls WHERE id=${callKey} FOR UPDATE`;
const eventKey = analyticsHash(event.eventId);
const previous = await tx.$queryRaw<Array<{ id: string }>>`SELECT id FROM caller_analysis_events WHERE id=${eventKey}`;
if (previous.length) return 'duplicate';
const old = rows[0].payload ? json<AnalyticsCall>(rows[0].payload) : null;
const next = foldAnalytics(old, event);
const before = new Map((old ? projectAnalytics(old) : []).map(row => [row.id, row]));
for (const row of projectAnalytics(next)) {
const prior = before.get(row.id);
if (prior) await this.bucket(tx, prior, -1);
await this.bucket(tx, row, 1);
await this.state(tx, row);
}
await tx.$executeRaw`UPDATE caller_analysis_calls SET payload=${JSON.stringify(next)}, updated_at=NOW(3) WHERE id=${callKey}`;
await tx.$executeRaw`INSERT INTO caller_analysis_events (id, call_key, occurred_at, payload, created_at) VALUES (${eventKey}, ${callKey}, ${new Date(event.at)}, ${JSON.stringify(event)}, NOW(3))`;
return 'processed';
}, { timeout: 15000 });
}
private async state(tx: Tx, row: AnalyticsProjection) {
await tx.$executeRaw`INSERT INTO caller_analysis_states
(id, call_key, view, started_at, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, callee, city, carrier, counts, payload, updated_at)
VALUES (${row.id}, ${row.callKey}, ${row.view}, ${new Date(row.startedAt)}, ${row.customerId}, ${row.customerGatewayId}, ${row.vendorId}, ${row.vendorGatewayId}, ${row.caller}, ${row.callee}, ${row.city}, ${row.carrier}, ${JSON.stringify(Object.fromEntries(COUNT_KEYS.map(k => [k, row[k]])))}, ${JSON.stringify(row.payload)}, NOW(3))
ON DUPLICATE KEY UPDATE started_at=VALUES(started_at), customer_gateway_id=VALUES(customer_gateway_id), vendor_id=VALUES(vendor_id), vendor_gateway_id=VALUES(vendor_gateway_id), caller=VALUES(caller), callee=VALUES(callee), city=VALUES(city), carrier=VALUES(carrier), counts=VALUES(counts), payload=VALUES(payload), updated_at=NOW(3)`;
}
private async bucket(tx: Tx, row: AnalyticsProjection, sign: number) {
const minute = new Date(Math.floor(row.startedAt / 60000) * 60000);
const id = analyticsHash([minute.toISOString(), row.view, row.customerId, row.customerGatewayId, row.vendorId, row.vendorGatewayId, row.caller, row.city, row.carrier]);
const delta = JSON.stringify(Object.fromEntries(COUNT_KEYS.map(k => [k, sign * row[k]])));
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${k}`, Prisma.sql`COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED),0) + ${sign * row[k]}`]));
await tx.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_minute_buckets
(id, started_at, view, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, city, carrier, counts)
VALUES (${id}, ${minute}, ${row.view}, ${row.customerId}, ${row.customerGatewayId}, ${row.vendorId}, ${row.vendorGatewayId}, ${row.caller}, ${row.city}, ${row.carrier}, ${delta})
ON DUPLICATE KEY UPDATE counts=JSON_OBJECT(${additions})`);
}
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { analyticsRates, foldAnalytics, projectAnalytics, type AnalyticsEvent, type AnalyticsCall } from './caller-analytics-model.js';
const start = Date.UTC(2026, 7, 31, 0);
const event = (kind: AnalyticsEvent['kind'], extra: Partial<AnalyticsEvent> = {}): AnalyticsEvent => ({ eventId: kind, callUid: 'u1', callId: 'c1', attemptId: '1', kind, at: start + 1000, startedAt: start, customerId: 'c', customerGatewayId: 'cg', vendorId: 'v', vendorGatewayId: 'vg', caller: '123', landingCaller: '456', callee: '789', city: 'city', carrier: 'UNKNOWN', code: 0, talkMs: null, source: 'opensips', sequence: 0, ...extra });
function run(events: AnalyticsEvent[]) { return projectAnalytics(events.reduce<AnalyticsCall | null>((c, e) => foldAnalytics(c, e), null)!); }
describe('caller analytics business state', () => {
it.each([180,183])('counts %i as connected even if final call fails', code => {
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 487, source: 'verified-dialog-final' })]);
expect(analyticsRates(r)).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, failedCalls: 0, notConnectedCalls: 0, connectionRate: 100, overallAnswerRate: 0, connectedAnswerRate: 0, durationUnknownCalls:0 });
});
it.each([100,181,182])('does not treat %i as connected', code => {
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 486, source: 'verified-dialog-final' })]);
expect(analyticsRates(r)).toMatchObject({ connectedCalls: 0, failedCalls: 1, connectedAnswerRate: null });
});
it('counts positive sub-second duration, not just 200', () => {
const [zero] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 })]);
expect(zero.answeredCalls).toBe(0);
const [positive] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('TALK_START', { at:start+1100, source:'dialog-acked' }), event('END', { at: start + 1500, source: 'verified-dialog-final' })]);
expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 400 });
});
it('preserves zero actual duration', () => {
const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { talkMs: 0, source: 'verified-dialog-final' })]);
expect(r.answeredCalls).toBe(0);
});
it('is insensitive to duplicate progress and delayed attempt', () => {
const e = event('PROGRESS', { code: 183 });
const [r] = run([e, e, event('END', { source: 'verified-dialog-final', code: 487 }), event('ATTEMPT')]);
expect(r).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, activeCalls: 0 });
});
it('corrects provisional duration and rejects late provisional overwrite', () => {
const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('DURATION', { at: start + 2000, talkMs: 1000 }), event('RECONCILE', { at: start + 3000, talkMs: 0, source: 'verified-dialog-final' }), event('DURATION', { at: start + 4000, talkMs: 3000 })]);
expect(r).toMatchObject({ connectedCalls: 1, answeredCalls: 0, talkMs: 0 });
});
it('keeps original call separate from multiple landing attempts', () => {
const rows = run([event('ATTEMPT'), event('END', { code: 486 }), event('ATTEMPT', { attemptId: '2' }), event('PROGRESS', { attemptId: '2', code: 180 })]);
expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ totalCalls: 1, connectedCalls: 1 });
expect(rows.slice(1).reduce((n,r) => n+r.totalCalls,0)).toBe(2);
});
it('distinguishes pending and unknown', () => {
expect(run([event('ATTEMPT')])[0]).toMatchObject({ pendingCalls: 1, failedCalls: 0 });
expect(run([event('UNKNOWN')])[0]).toMatchObject({ unknownCalls: 1, failedCalls: 0, pendingCalls: 0 });
});
it('does not merge different customer identities', () => {
expect(() => run([event('ATTEMPT'), event('PROGRESS', { customerId: 'other', code: 180 })])).toThrow('Conflicting');
});
it('calculates the three different business rates', () => {
expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0, durationUnknownCalls:0, provisionalAnsweredCalls:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 });
});
it('does not erase missing progress evidence with an end event', () => {
expect(run([event('END',{code:487,source:'verified-dialog-final'})])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
for (const sequence of [[event('UNKNOWN'),event('END')],[event('END'),event('UNKNOWN')]]) {
expect(run([event('ATTEMPT'),...sequence,event('ATTEMPT')])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
}
});
it('gives verified final zero priority over a newer provisional observation and delayed END', () => {
const provisional=event('DURATION',{at:start+4000,talkMs:3000});
const final=event('RECONCILE',{at:start+3000,talkMs:0,source:'verified-dialog-final'});
for (const sequence of [[provisional,final],[final,provisional]]) {
expect(run([event('ATTEMPT'),event('ACCEPTED',{code:200}),...sequence,event('END',{at:start+5000,source:'verified-dialog-final'})])[0]).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0});
}
});
it('does not infer answer duration from 2xx without ACK evidence',()=>{
const[r]=run([event('ATTEMPT'),event('ACCEPTED',{code:200}),event('END',{at:start+5000,source:'verified-dialog-final'})]);
expect(analyticsRates(r)).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0,durationUnknownCalls:1,overallAnswerRate:null,connectedAnswerRate:null});
});
it('keeps a version 2 original call pending until explicit close',()=>{
const base={schemaVersion:2};
expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'})])[0]).toMatchObject({activeCalls:1,pendingCalls:1,failedCalls:0});
expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'}),event('CLOSE',{...base,source:'attempts-exhausted'})])[0]).toMatchObject({activeCalls:0,pendingCalls:0,failedCalls:1});
});
});
+3
View File
@@ -1,3 +1,6 @@
export { Prisma, PrismaClient } from '@prisma/client';
export const DATABASE_PROVIDER = 'mysql';
export * from './caller-analytics-model.js';
export * from './caller-analytics-store.js';
@@ -0,0 +1,43 @@
CREATE TABLE caller_analysis_calls (
id CHAR(64) NOT NULL PRIMARY KEY, payload JSON NULL, updated_at DATETIME(3) NOT NULL
);
CREATE TABLE caller_analysis_events (
id CHAR(64) NOT NULL PRIMARY KEY, call_key CHAR(64) NOT NULL, occurred_at DATETIME(3) NOT NULL,
payload JSON NOT NULL, created_at DATETIME(3) NOT NULL,
INDEX caller_events_call (call_key, occurred_at), INDEX caller_events_retention (created_at)
);
CREATE TABLE caller_analysis_states (
id CHAR(64) NOT NULL PRIMARY KEY, call_key CHAR(64) NOT NULL, view VARCHAR(16) NOT NULL,
started_at DATETIME(3) NOT NULL, customer_id VARCHAR(32) NOT NULL, customer_gateway_id VARCHAR(32) NOT NULL,
vendor_id VARCHAR(32) NOT NULL, vendor_gateway_id VARCHAR(32) NOT NULL, caller VARCHAR(64) NOT NULL,
callee VARCHAR(64) NOT NULL, city VARCHAR(12) NOT NULL, carrier VARCHAR(20) NOT NULL,
counts JSON NOT NULL, payload JSON NOT NULL, updated_at DATETIME(3) NOT NULL,
INDEX caller_states_window (view, started_at), INDEX caller_states_customer (customer_id, view, started_at),
INDEX caller_states_number (view, caller, started_at), INDEX caller_states_call (call_key)
);
CREATE TABLE caller_analysis_minute_buckets (
id CHAR(64) NOT NULL PRIMARY KEY, started_at DATETIME(3) NOT NULL, view VARCHAR(16) NOT NULL,
customer_id VARCHAR(32) NOT NULL, customer_gateway_id VARCHAR(32) NOT NULL,
vendor_id VARCHAR(32) NOT NULL, vendor_gateway_id VARCHAR(32) NOT NULL, caller VARCHAR(64) NOT NULL,
city VARCHAR(12) NOT NULL, carrier VARCHAR(20) NOT NULL, counts JSON NOT NULL,
INDEX caller_buckets_window (view, started_at), INDEX caller_buckets_customer (customer_id, view, started_at),
INDEX caller_buckets_number (view, caller, started_at)
);
CREATE TABLE caller_analysis_access (
user_id VARCHAR(32) NOT NULL, customer_id VARCHAR(32) NOT NULL, PRIMARY KEY(user_id, customer_id)
);
CREATE TABLE caller_analysis_health (
id VARCHAR(64) NOT NULL PRIMARY KEY, payload JSON NOT NULL, updated_at DATETIME(3) NOT NULL
);
CREATE TABLE caller_analysis_alerts (
id CHAR(64) NOT NULL PRIMARY KEY, customer_id VARCHAR(32) NOT NULL, view VARCHAR(16) NOT NULL,
caller VARCHAR(64) NOT NULL, status VARCHAR(20) NOT NULL, payload JSON NOT NULL,
updated_at DATETIME(3) NOT NULL, INDEX caller_alerts_customer (customer_id, status, updated_at)
);
INSERT INTO permissions (id, module, action, description, created_at, updated_at)
VALUES ('caller_analytics.view', 'caller_analytics', 'view', '查看授权客户的主叫分析', NOW(3), NOW(3)),
('caller_analytics.view_all', 'caller_analytics', 'view_all', '查看全部客户的主叫分析', NOW(3), NOW(3));
INSERT INTO role_permissions (role_id, permission_id, created_at)
SELECT id, 'caller_analytics.view', NOW(3) FROM roles WHERE id='ROLE_SUPER_ADMIN';
INSERT INTO role_permissions (role_id, permission_id, created_at)
SELECT id, 'caller_analytics.view_all', NOW(3) FROM roles WHERE id='ROLE_SUPER_ADMIN';
@@ -0,0 +1,11 @@
CREATE TABLE caller_analysis_gaps (
id CHAR(64) NOT NULL PRIMARY KEY, node_id VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL, started_at DATETIME(3) NOT NULL, ended_at DATETIME(3) NULL,
payload JSON NOT NULL, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
INDEX caller_gaps_status (status, started_at)
);
CREATE TABLE caller_analysis_dead_letters (
id CHAR(64) NOT NULL PRIMARY KEY, stream_id VARCHAR(64) NOT NULL, payload JSON NOT NULL,
error VARCHAR(500) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL, INDEX caller_dead_status (status, created_at)
);
+111
View File
@@ -960,6 +960,117 @@ model OutboxEvent {
@@map("outbox_events")
}
model CallerAnalysisCall {
id String @id @db.Char(64)
payload Json?
updatedAt DateTime @map("updated_at") @db.DateTime(3)
@@map("caller_analysis_calls")
}
model CallerAnalysisEvent {
id String @id @db.Char(64)
callKey String @map("call_key") @db.Char(64)
occurredAt DateTime @map("occurred_at") @db.DateTime(3)
payload Json
createdAt DateTime @map("created_at") @db.DateTime(3)
@@index([callKey, occurredAt], map: "caller_events_call")
@@index([createdAt], map: "caller_events_retention")
@@map("caller_analysis_events")
}
model CallerAnalysisState {
id String @id @db.Char(64)
callKey String @map("call_key") @db.Char(64)
view String @db.VarChar(16)
startedAt DateTime @map("started_at") @db.DateTime(3)
customerId String @map("customer_id") @db.VarChar(32)
customerGatewayId String @map("customer_gateway_id") @db.VarChar(32)
vendorId String @map("vendor_id") @db.VarChar(32)
vendorGatewayId String @map("vendor_gateway_id") @db.VarChar(32)
caller String @db.VarChar(64)
callee String @db.VarChar(64)
city String @db.VarChar(12)
carrier String @db.VarChar(20)
counts Json
payload Json
updatedAt DateTime @map("updated_at") @db.DateTime(3)
@@index([view, startedAt], map: "caller_states_window")
@@index([customerId, view, startedAt], map: "caller_states_customer")
@@index([view, caller, startedAt], map: "caller_states_number")
@@index([callKey], map: "caller_states_call")
@@map("caller_analysis_states")
}
model CallerAnalysisMinuteBucket {
id String @id @db.Char(64)
startedAt DateTime @map("started_at") @db.DateTime(3)
view String @db.VarChar(16)
customerId String @map("customer_id") @db.VarChar(32)
customerGatewayId String @map("customer_gateway_id") @db.VarChar(32)
vendorId String @map("vendor_id") @db.VarChar(32)
vendorGatewayId String @map("vendor_gateway_id") @db.VarChar(32)
caller String @db.VarChar(64)
city String @db.VarChar(12)
carrier String @db.VarChar(20)
counts Json
@@index([view, startedAt], map: "caller_buckets_window")
@@index([customerId, view, startedAt], map: "caller_buckets_customer")
@@index([view, caller, startedAt], map: "caller_buckets_number")
@@map("caller_analysis_minute_buckets")
}
model CallerAnalysisAccess {
userId String @map("user_id") @db.VarChar(32)
customerId String @map("customer_id") @db.VarChar(32)
@@id([userId, customerId])
@@map("caller_analysis_access")
}
model CallerAnalysisHealth {
id String @id @db.VarChar(64)
payload Json
updatedAt DateTime @map("updated_at") @db.DateTime(3)
@@map("caller_analysis_health")
}
model CallerAnalysisAlert {
id String @id @db.Char(64)
customerId String @map("customer_id") @db.VarChar(32)
view String @db.VarChar(16)
caller String @db.VarChar(64)
status String @db.VarChar(20)
payload Json
updatedAt DateTime @map("updated_at") @db.DateTime(3)
@@index([customerId, status, updatedAt], map: "caller_alerts_customer")
@@map("caller_analysis_alerts")
}
model CallerAnalysisGap {
id String @id @db.Char(64)
nodeId String @map("node_id") @db.VarChar(64)
kind String @db.VarChar(64)
status String @db.VarChar(20)
startedAt DateTime @map("started_at") @db.DateTime(3)
endedAt DateTime? @map("ended_at") @db.DateTime(3)
payload Json
createdAt DateTime @map("created_at") @db.DateTime(3)
updatedAt DateTime @map("updated_at") @db.DateTime(3)
@@index([status, startedAt], map: "caller_gaps_status")
@@map("caller_analysis_gaps")
}
model CallerAnalysisDeadLetter {
id String @id @db.Char(64)
streamId String @map("stream_id") @db.VarChar(64)
payload Json
error String @db.VarChar(500)
status String @db.VarChar(20)
createdAt DateTime @map("created_at") @db.DateTime(3)
updatedAt DateTime @map("updated_at") @db.DateTime(3)
@@index([status, createdAt], map: "caller_dead_status")
@@map("caller_analysis_dead_letters")
}
model IdempotencyKey {
id String @id @db.VarChar(40)
key String @unique @db.VarChar(128)
+2
View File
@@ -3,6 +3,8 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const permissions = [
['caller_analytics.view', 'caller_analytics', 'view', '查看授权客户的主叫分析'],
['caller_analytics.view_all', 'caller_analytics', 'view_all', '查看全部客户的主叫分析'],
['dashboard.view', 'dashboard', 'view', '查看 Dashboard 指标'],
['active_calls.view', 'active_calls', 'view', '查看当前通话'],
['active_calls.manage', 'active_calls', 'manage', '强制挂断当前通话'],
+4
View File
@@ -74,6 +74,10 @@ const copyEntries = [
['infra/server-b/s56', 'infra/server-b/s56', true],
['infra/server-b/s57', 'infra/server-b/s57', true],
['infra/server-b/s58', 'infra/server-b/s58', true],
['infra/server-b/caller-analytics', 'infra/server-b/caller-analytics', true],
['scripts/instrument-caller-analytics.mjs', 'scripts/instrument-caller-analytics.mjs', true],
['scripts/maintain-caller-analytics.mjs', 'scripts/maintain-caller-analytics.mjs', true],
['scripts/rebuild-caller-analytics.mjs', 'scripts/rebuild-caller-analytics.mjs', true],
['infra/ops/startup/lisglosips-start-b.sh', 'infra/ops/startup/lisglosips-start-b.sh', true],
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
+76
View File
@@ -0,0 +1,76 @@
// Add observational hooks to the verified S28 routing layout, without replacing routing or billing.
import { readFileSync, writeFileSync } from 'node:fs';
const [source, output] = process.argv.slice(2);
if (!source || !output) throw new Error('Usage: node instrument-caller-analytics.mjs source.cfg output.cfg');
let cfg = readFileSync(source, 'utf8').replaceAll('\r\n', '\n').replace(/^\uFEFF/, '');
if (cfg.includes('CRA_INSTRUMENTATION_VERSION=3')) throw new Error('Already instrumented at version 3');
function insert(marker, extra, after = false) {
if (cfg.split(marker).length !== 2) throw new Error(`Unsupported routing layout: ${marker}`);
cfg = cfg.replace(marker, after ? marker + extra : extra + marker);
}
const versionMarker = '# CRA_INSTRUMENTATION_VERSION=3\n';
const registerStats = () => insert('modparam("statistics", "variable", "s28_cdr_xadd_error_total/no_reset")', '\nmodparam("statistics", "variable", "cra_event_total/no_reset")', true);
const ackHook = ` if (is_method("ACK")) {\n $var(cra_kind)="TALK_START"; $var(cra_code)=200; $var(cra_source)="dialog-acked"; route(CRA_EMIT_DLG);\n }\n`;
const testPeerAckHook = ` if (loose_route()) {\n $var(cra_kind)="TALK_START"; $var(cra_code)=200; $var(cra_source)="dialog-acked"; route(CRA_EMIT_DLG);\n }\n`;
const placeAckHooks = () => {
insert(' if (loose_route()) {\n', ackHook, true);
insert(' if (is_method("ACK") && $si == "100.93.185.30") {\n', testPeerAckHook, true);
};
if (cfg.includes('CRA_INSTRUMENTATION_VERSION=2')) {
if (!cfg.includes(ackHook)) throw new Error('Version 2 ACK hook not found');
cfg = cfg.replace('# CRA_INSTRUMENTATION_VERSION=2\n', '').replace(ackHook, '');
placeAckHooks();
writeFileSync(output, versionMarker + cfg, 'utf8');
console.log('Analytics observation hooks upgraded to version 3 ACK matching; OpenSIPS config validation is required before activation.');
process.exit(0);
}
if (cfg.includes('CRA1|')) {
registerStats();
placeAckHooks();
cfg = cfg
.replace('$var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);', '$var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n $var(cra_kind)="CLOSE"; $var(cra_source)="platform-closed"; route(CRA_EMIT_AVP);')
.replace('$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-closed"; route(CRA_EMIT_DLG);')
.replace('$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-timeout"; route(CRA_EMIT_DLG);')
.replace('$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="attempts-exhausted"; route(CRA_EMIT_DLG);')
.replaceAll('xlog("L_NOTICE", "CRA1|', 'update_stat("cra_event_total", 1);\n xlog("L_NOTICE", "CRA1|');
writeFileSync(output, versionMarker + cfg, 'utf8');
console.log('Analytics observation hooks upgraded to version 3; OpenSIPS config validation is required before activation.');
process.exit(0);
}
registerStats();
placeAckHooks();
const metadata = {
uid: '$ci + "~" + $ft + "~" + $TS', callid: '$ci', customer: '$var(s28_customer_id)',
gateway: '$var(s28_gateway_id)', vendor: '$var(s28_vendor_id)', vendorGateway: '$var(s28_vendor_gateway_id)',
caller: '$fU', landing: '$var(s28_landing_caller)', callee: '$var(s28_real_callee)',
city: '$var(s28_callee_city_code)', carrier: '$var(s28_callee_operator)', startsec: '$Ts', startmicro: '$Tsm', attempt: '"none"'
};
const init = Object.entries(metadata).map(([k,v]) => ` $avp(cra_${k}) = ${v};`).join('\n');
insert(' if ($var(s28_decision) != "allow") {', `${init}\n $var(cra_kind)="START"; $var(cra_code)=0; $var(cra_source)="opensips"; route(CRA_EMIT_AVP);\n\n`);
const bind = Object.keys(metadata).map(k => ` $dlg_val(cra_${k}) = $avp(cra_${k});`).join('\n');
insert(' $dlg_val(s28_customer_id) = $var(s28_customer_id);', `${bind}\n dlg_on_hangup("CRA_HANGUP");\n dlg_on_timeout("CRA_TIMEOUT");\n`);
insert(' t_on_reply("S28_REPLY");', ' $dlg_val(cra_attempt)="1"; $avp(cra_attempt)="1";\n $var(cra_kind)="ATTEMPT"; $var(cra_code)=0; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n t_on_failure("CRA_FAILURE");\n');
insert('onreply_route[S28_REPLY] {', '\n if ($rs==180 || $rs==183 || $rs==100 || $rs==181 || $rs==182) {\n $var(cra_kind)="PROGRESS"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n if ($rs>=200 && $rs<300) {\n $var(cra_kind)="ACCEPTED"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n', true);
insert('route[S28_CDR_FAILURE] {', '\n $var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n $var(cra_kind)="CLOSE"; $var(cra_source)="platform-closed"; route(CRA_EMIT_AVP);\n', true);
const fields = ['uid', 'callid', 'customer', 'gateway', 'vendor', 'vendorGateway', 'caller', 'landing', 'callee', 'city', 'carrier', 'attempt'];
function emitter(type, prefix) {
const ref = k => `$${prefix}(cra_${k})`;
const encoded = fields.map(k => `$(${prefix}(cra_${k}){s.b64encode})`).join('|');
return `\nroute[CRA_EMIT_${type}] {\n if (${ref('customer')}==NULL || ${ref('customer')}=="none") return;\n update_stat("cra_event_total", 1);\n xlog("L_NOTICE", "CRA1|$var(cra_kind)|$Ts|$Tsm|${ref('startsec')}|${ref('startmicro')}|$var(cra_code)|$var(cra_source)|${encoded}\\n");\n}\n`;
}
cfg += emitter('AVP', 'avp') + emitter('DLG', 'dlg_val') + `
route[CRA_HANGUP] {
$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
$var(cra_kind)="CLOSE"; $var(cra_source)="dialog-closed"; route(CRA_EMIT_DLG);
}
route[CRA_TIMEOUT] {
$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
$var(cra_kind)="CLOSE"; $var(cra_source)="dialog-timeout"; route(CRA_EMIT_DLG);
}
failure_route[CRA_FAILURE] {
$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
$var(cra_kind)="CLOSE"; $var(cra_source)="attempts-exhausted"; route(CRA_EMIT_DLG);
}
`;
writeFileSync(output, versionMarker + cfg, 'utf8');
console.log('Analytics observation hooks generated at version 3; OpenSIPS config validation is required before activation.');
+14
View File
@@ -0,0 +1,14 @@
// Retention is intentionally shorter for raw evidence than aggregate trends.
import { PrismaClient } from '@prisma/client';
import assert from 'node:assert/strict';
const apply=process.argv.includes('--apply'); const db=new PrismaClient();
const days=(name,fallback)=>{const value=Number(process.env[name]??fallback);assert(Number.isInteger(value)&&value>=7&&value<=730,`Invalid ${name}`);return value;};
const eventBefore=new Date(Date.now()-days('ANALYTICS_EVENT_RETENTION_DAYS',30)*86400000);
const stateBefore=new Date(Date.now()-days('ANALYTICS_STATE_RETENTION_DAYS',90)*86400000);
const bucketBefore=new Date(Date.now()-days('ANALYTICS_BUCKET_RETENTION_DAYS',400)*86400000);
try{
const count=async rows=>Number(rows[0]?.n??0);
const counts={events:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_events WHERE created_at<${eventBefore}`),states:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_states WHERE started_at<${stateBefore}`),buckets:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_minute_buckets WHERE started_at<${bucketBefore}`),deadLetters:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_dead_letters WHERE status<>'OPEN' AND created_at<${stateBefore}`),gaps:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_gaps WHERE status<>'OPEN' AND started_at<${stateBefore}`)};
if(apply)await db.$transaction(async tx=>{await tx.$executeRaw`DELETE FROM caller_analysis_events WHERE created_at<${eventBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_minute_buckets WHERE started_at<${bucketBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_states WHERE started_at<${stateBefore}`;await tx.$executeRaw`DELETE c FROM caller_analysis_calls c LEFT JOIN caller_analysis_states s ON s.call_key=c.id WHERE s.id IS NULL AND c.updated_at<${stateBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_dead_letters WHERE status<>'OPEN' AND created_at<${stateBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_gaps WHERE status<>'OPEN' AND started_at<${stateBefore}`;},{timeout:60000});
console.log(JSON.stringify({apply,cutoffs:{eventBefore,stateBefore,bucketBefore},counts}));
}finally{await db.$disconnect();}
+25
View File
@@ -0,0 +1,25 @@
// Maintenance only: stop caller-analytics worker first; does not touch calls or billing.
import { PrismaClient } from '@prisma/client';
import { analyticsHash, COUNT_KEYS } from '../packages/database/dist/index.js';
import assert from 'node:assert/strict';
assert(process.argv.includes('--worker-stopped'), 'Stop the analytics worker before rebuilding derived buckets');
const db = new PrismaClient();
try {
await db.$transaction(async tx => {
const rows = await tx.$queryRaw`SELECT * FROM caller_analysis_states`;
assert(rows.length <= 100000, 'Use a batched maintenance job for larger data sets');
const buckets = new Map();
for (const row of rows) {
const minute = new Date(Math.floor(+row.started_at / 60000) * 60000);
const id = analyticsHash([minute.toISOString(), row.view, row.customer_id, row.customer_gateway_id, row.vendor_id, row.vendor_gateway_id, row.caller, row.city, row.carrier]);
const bucket = buckets.get(id) || {...row, id, started_at:minute, counts:Object.fromEntries(COUNT_KEYS.map(k=>[k,0]))};
for (const key of COUNT_KEYS) bucket.counts[key] += Number(row.counts[key]);
buckets.set(id,bucket);
}
await tx.$executeRaw`DELETE FROM caller_analysis_minute_buckets`;
for (const r of buckets.values()) await tx.$executeRaw`INSERT INTO caller_analysis_minute_buckets
(id,started_at,view,customer_id,customer_gateway_id,vendor_id,vendor_gateway_id,caller,city,carrier,counts)
VALUES (${r.id},${r.started_at},${r.view},${r.customer_id},${r.customer_gateway_id},${r.vendor_id},${r.vendor_gateway_id},${r.caller},${r.city},${r.carrier},${JSON.stringify(r.counts)})`;
console.log(JSON.stringify({states:rows.length,buckets:buckets.size,rebuilt:true}));
},{timeout:60000});
} finally {await db.$disconnect();}
+89
View File
@@ -0,0 +1,89 @@
// Run on test B from a staged release, with api.env loaded. No public telephone destinations.
import { PrismaClient } from '@prisma/client';
import { signAccessToken } from '../../packages/auth/dist/index.js';
import { spawnSync } from 'node:child_process';
import { writeFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { CallerAnalyticsStore, COUNT_KEYS } from '../../packages/database/dist/index.js';
const db = new PrismaClient();
const fixture = 'cra_20260831';
const user = await db.user.findFirst({ where:{ status:'ENABLED', userRoles:{some:{roleId:'ROLE_SUPER_ADMIN'}} } });
assert(user, 'An existing test administrator is required');
const token = signAccessToken({sub:user.id,username:user.username,roles:['ROLE_SUPER_ADMIN'],typ:'access'}, {secret:process.env.AUTH_ACCESS_TOKEN_SECRET,issuer:process.env.AUTH_TOKEN_ISSUER||'lisglosips-api',audience:process.env.AUTH_TOKEN_AUDIENCE||'lisglosips-web',ttlSeconds:900});
const get = async (path, authenticated = true, authToken = token) => {
const response = await fetch(`http://127.0.0.1:3000/api/v2${path}`, {headers:authenticated ? {Authorization:`Bearer ${authToken}`} : {}});
return {status:response.status,body:await response.json()};
};
try {
if (process.argv.includes('--setup')) {
assert.equal(await db.customerGateway.count({where:{sourceIp:'127.0.0.1',id:{not:fixture}}}),0,'Loopback gateway already in use');
await db.$transaction(async tx => {
await tx.customer.upsert({where:{id:fixture},create:{id:fixture,name:'主叫分析受控测试客户',balance:100,creditLimit:100},update:{status:'ENABLED'}});
await tx.vendor.upsert({where:{id:fixture},create:{id:fixture,name:'主叫分析本机模拟供应商'},update:{status:'ENABLED'}});
await tx.vendorGateway.upsert({where:{id:fixture},create:{id:fixture,vendorId:fixture,name:'主叫分析本机模拟落地',authMode:'IP',host:'127.0.0.1',port:50630,cycleRate:0},update:{status:'ENABLED'}});
await tx.landingLineGroup.upsert({where:{id:fixture},create:{id:fixture,name:'主叫分析本机测试线路组'},update:{status:'ENABLED'}});
await tx.landingLineGroupItem.upsert({where:{id:fixture},create:{id:fixture,lineGroupId:fixture,vendorGatewayId:fixture,priority:1},update:{status:'ENABLED'}});
await tx.customerGateway.upsert({where:{id:fixture},create:{id:fixture,customerId:fixture,name:'主叫分析本机测试入口',authMode:'IP',sourceIp:'127.0.0.1',lineGroupId:fixture,cycleRate:0},update:{status:'ENABLED'}});
await tx.outboxEvent.create({data:{id:`cra_${Date.now()}`,aggregateType:'customer_gateway_config',aggregateId:fixture,eventType:'CONFIG_CHANGED',payload:{reason:'isolated caller analytics test fixture'}}});
});
console.log('Loopback-only zero-rate fixture prepared; waiting for normal config publisher.');
} else if (process.argv.includes('--disable')) {
await db.customerGateway.update({where:{id:fixture},data:{status:'DISABLED'}});
await db.vendorGateway.update({where:{id:fixture},data:{status:'DISABLED'}});
await db.outboxEvent.create({data:{id:`cra_${Date.now()}`,aggregateType:'customer_gateway_config',aggregateId:fixture,eventType:'CONFIG_CHANGED',payload:{reason:'disable isolated test fixture'}}});
console.log('Isolated test ingress and egress disabled; evidence retained.');
} else {
const from = new Date().toISOString();
const sip = spawnSync('python3',['tests/api/caller-analytics-sip.py'],{encoding:'utf8',timeout:90000});
assert.equal(sip.status,0,sip.stderr || sip.stdout);
console.log(sip.stdout);
let result;
for (let i=0;i<12;i++) {
result = await get(`/caller-analytics/overview?customerId=${fixture}&view=landing&from=${encodeURIComponent(from)}`);
if (result.status===200 && result.body.summary.totalCalls===6 && result.body.summary.activeCalls===0) break;
await new Promise(r=>setTimeout(r,1000));
}
assert.equal(result.status,200,JSON.stringify(result.body));
assert.deepEqual(Object.fromEntries(['totalCalls','connectedCalls','answeredCalls','failedCalls'].map(k=>[k,result.body.summary[k]])),{totalCalls:6,connectedCalls:4,answeredCalls:2,failedCalls:2});
assert.equal(result.body.analysisRevision,'caller-analytics-r2');
assert.equal(result.body.coverageStatus,'CONTINUOUS',JSON.stringify(result.body.health));
assert.equal(result.body.summary.durationUnknownCalls,0);
assert.equal((await get('/caller-analytics/overview',false)).status,401);
assert.equal((await get('/caller-analytics/overview?view=invalid')).status,400);
assert.equal(result.body.summary.connectedAnswerRate,50);
const detail = await get(`/caller-analytics/calls?customerId=${fixture}&caller=99100001&view=landing&from=${encodeURIComponent(from)}`);
assert.equal(detail.status,200); assert.equal(detail.body.rows.length,6);
const short = detail.body.rows.find(r=>r.callee==='99105');
assert(short.counts.talkMs>0 && short.counts.talkMs<1000,'Subsecond duration must count as answer');
const auditFrom = new Date(Date.now()-86400000).toISOString();
const auditPath = `/caller-analytics/overview?customerId=${fixture}&view=landing&from=${encodeURIComponent(auditFrom)}`;
const beforeReplay = await get(auditPath); assert.equal(beforeReplay.status,200);
const stateRows = await db.$queryRaw`SELECT counts FROM caller_analysis_states WHERE customer_id=${fixture} AND view='landing' AND started_at>=${new Date(auditFrom)}`;
for (const key of COUNT_KEYS) assert.equal(beforeReplay.body.summary[key],stateRows.reduce((sum,row)=>sum+Number(row.counts[key]??0),0),`Minute bucket reconciliation: ${key}`);
const events = await db.$queryRaw`SELECT payload FROM caller_analysis_events WHERE call_key=${detail.body.rows[0].callKey}`;
const store = new CallerAnalyticsStore(db);
for (const event of events) assert.equal(await store.consume(event.payload),'duplicate');
assert.deepEqual((await get(auditPath)).body.summary,beforeReplay.body.summary,'Replay must not change counts');
const testUserId = `cra_rbac_${Date.now()}`;
try {
await db.role.create({data:{id:testUserId,name:testUserId,permissions:{create:{permissionId:'caller_analytics.view'}}}});
await db.user.create({data:{id:testUserId,username:testUserId,displayName:'临时主叫范围验收',userRoles:{create:{roleId:testUserId}}}});
const scopedToken=signAccessToken({sub:testUserId,username:testUserId,roles:[],typ:'access'}, {secret:process.env.AUTH_ACCESS_TOKEN_SECRET,issuer:process.env.AUTH_TOKEN_ISSUER||'lisglosips-api',audience:process.env.AUTH_TOKEN_AUDIENCE||'lisglosips-web',ttlSeconds:60});
assert.equal((await get(auditPath,true,scopedToken)).status,403,'No assigned customers must fail closed');
await db.$executeRaw`INSERT INTO caller_analysis_access(user_id,customer_id) VALUES (${testUserId},${fixture})`;
const scoped = await get('/caller-analytics/overview?minutes=1440',true,scopedToken);
assert.equal(scoped.status,200); assert(scoped.body.numbers.every(r=>r.customerId===fixture));
assert.equal((await get('/caller-analytics/overview?customerId=another_customer',true,scopedToken)).status,403);
const options=await get('/caller-analytics/options',true,scopedToken);
assert.equal(options.status,200); assert.deepEqual(options.body.customers.map(c=>c.id),[fixture]);
assert.deepEqual(options.body.vendors.map(v=>v.id),[fixture]);
} finally {
await db.$executeRaw`DELETE FROM caller_analysis_access WHERE user_id=${testUserId}`;
await db.user.deleteMany({where:{id:testUserId}}); await db.role.deleteMany({where:{id:testUserId}});
}
const sipEvents=JSON.parse(sip.stdout); assert.equal(sipEvents.filter(e=>e.direction==='UAC'&&e.code===200&&e.sdp).length,2,'Two successful calls must negotiate SDP through RTPEngine');
const evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:sipEvents,bucketAudit:beforeReplay.body.summary,replayed:events.length,tests:['real SIP six scenarios','two SDP offer/answer negotiations through RTPEngine','ACK-based positive duration including subsecond call','real API counts, completeness and three rates','real DB states and minute buckets','database replay idempotency','customer scope and 403 isolation','scoped customer/vendor filter options','unauthenticated 401','invalid query 400']};
await writeFile(process.env.CRA_REPORT || '/tmp/caller-analytics-real-evidence.json',JSON.stringify(evidence,null,2));
console.log(JSON.stringify({passed:true,summary:result.body.summary,shortMs:short.counts.talkMs,quality:result.body.qualityStatus}));
}
} finally { await db.$disconnect(); }
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Isolated UDP SIP UAC/UAS on test B. No PSTN routing or remote targets."""
import json
import re
import socket
import threading
import time
import uuid
events = []
stop = threading.Event()
def header(message, name):
match = re.search(r'^' + re.escape(name) + r':\s*(.+)$', message, re.I | re.M)
return match.group(1).strip() if match else ''
def sdp(port):
return '\r\n'.join(['v=0', 'o=cra 1 1 IN IP4 127.0.0.1', 's=caller-analytics-test', 'c=IN IP4 127.0.0.1', 't=0 0', f'm=audio {port} RTP/AVP 0', 'a=rtpmap:0 PCMU/8000', 'a=sendrecv', ''])
def reply(message, code, reason, body=''):
to = header(message, 'To')
if ';tag=' not in to:
to += ';tag=cra-uas'
vias = re.findall(r'^Via:\s*(.+)$', message, re.I | re.M)
routes = re.findall(r'^Record-Route:\s*(.+)$', message, re.I | re.M)
lines = [f'SIP/2.0 {code} {reason}'] + [f'Via: {v.strip()}' for v in vias]
lines += [f'From: {header(message,"From")}', f'To: {to}', f'Call-ID: {header(message,"Call-ID")}', f'CSeq: {header(message,"CSeq")}']
lines += [f'Record-Route: {r.strip()}' for r in routes]
lines += ['Contact: <sip:uas@127.0.0.1:50630>']
if body:
lines += ['Content-Type: application/sdp', f'Content-Length: {len(body.encode())}', '', body]
else:
lines += ['Content-Length: 0', '', '']
return '\r\n'.join(lines).encode()
def uas():
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(('127.0.0.1',50630)); sock.settimeout(.2)
while not stop.is_set():
try: data, addr = sock.recvfrom(65535)
except socket.timeout: continue
msg = data.decode(); method = msg.split()[0]
if method == 'INVITE':
number = re.search(r'INVITE sip:([^@]+)',msg).group(1)
cases = {'99101':[(180,'Ringing'),(486,'Busy Here')], '99102':[(183,'Session Progress'),(487,'Request Terminated')], '99103':[(486,'Busy Here')], '99104':[(200,'OK')], '99105':[(180,'Ringing'),(200,'OK')], '99106':[(100,'Trying'),(486,'Busy Here')]}
for code, reason in cases[number]:
answer = sdp(40000) if code == 200 else ''
sock.sendto(reply(msg,code,reason,answer),addr)
events.append({'callId':header(msg,'Call-ID'),'direction':'UAS','code':code,'sdp':bool(answer),'time':time.time()})
time.sleep(.15 if number!='99106' else 1.5)
elif method == 'BYE': sock.sendto(reply(msg,200,'OK'),addr)
thread = threading.Thread(target=uas,daemon=True); thread.start(); time.sleep(.3)
try:
for number in ['99101','99102','99103','99104','99105','99106']:
with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as sock:
sock.bind(('127.0.0.1',0)); sock.settimeout(8); port=sock.getsockname()[1]
callid=f'cra-{number}-{uuid.uuid4().hex}@loopback'; tag=uuid.uuid4().hex[:8]
branch='z9hG4bK'+uuid.uuid4().hex
base=[f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch={branch};rport', f'From: <sip:99100001@127.0.0.1>;tag={tag}', f'To: <sip:{number}@127.0.0.1>', f'Call-ID: {callid}', f'Contact: <sip:99100001@127.0.0.1:{port}>', 'Max-Forwards: 70']
offer = sdp(40002) if number in ('99104','99105') else ''
invite_lines=[f'INVITE sip:{number}@127.0.0.1:15060 SIP/2.0',*base,'CSeq: 1 INVITE']
if offer: invite_lines += ['Content-Type: application/sdp',f'Content-Length: {len(offer.encode())}','',''+offer]
else: invite_lines += ['Content-Length: 0','','']
invite='\r\n'.join(invite_lines)
sock.sendto(invite.encode(),('127.0.0.1',15060))
while True:
msg=sock.recv(65535).decode(); code=int(msg.split()[1]); has_sdp='application/sdp' in msg.lower(); events.append({'callId':callid,'direction':'UAC','code':code,'sdp':has_sdp,'time':time.time()})
if code<200: continue
if code>=300:
ack=invite.replace('INVITE sip:','ACK sip:',1).replace('CSeq: 1 INVITE','CSeq: 1 ACK').replace(base[2],f'To: {header(msg,"To")}')
sock.sendto(ack.encode(),('127.0.0.1',15060)); break
routes = list(reversed(re.findall(r'^Record-Route:\s*(.+)$',msg,re.I|re.M)))
assert not offer or has_sdp, 'Successful SDP offer must receive an SDP answer'
for method,cseq in [('ACK',1),('BYE',2)]:
if method=='BYE': time.sleep(.3 if number=='99105' else 1.5)
seq=[f'{method} sip:uas@127.0.0.1:50630 SIP/2.0',f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch=z9hG4bK{uuid.uuid4().hex};rport',base[1],f'To: {header(msg,"To")}',base[3],*['Route: '+r.strip() for r in routes],f'CSeq: {cseq} {method}','Max-Forwards: 70','Content-Length: 0','','']
sock.sendto('\r\n'.join(seq).encode(),('127.0.0.1',15060))
if method=='BYE':
final=sock.recv(65535).decode(); assert final.startswith('SIP/2.0 200'), final
break
time.sleep(.3)
finally:
stop.set(); thread.join(timeout=2)
print(json.dumps(events))
@@ -0,0 +1,599 @@
{
"date": "2026-08-31T09:54:19.504Z",
"from": "2026-08-31T09:54:12.271Z",
"api": {
"status": 200,
"body": {
"metricVersion": "caller-analytics-v1.1",
"view": "landing",
"from": "2026-08-31T09:54:12.271Z",
"to": "2026-08-31T09:54:19.395Z",
"asOf": "2026-08-31T09:54:19.399Z",
"qualityStatus": "LIVE",
"dataThrough": "2026-08-31T09:54:17.859Z",
"lagMs": null,
"durationPrecision": "milliseconds",
"health": {
"gap": false,
"lagMs": null,
"reason": "",
"degraded": false,
"updatedAt": "2026-08-31T09:54:18.752Z",
"dataThrough": "2026-08-31T09:54:17.859Z",
"spoolOffset": 40402,
"captureSince": "2026-08-31T09:24:19Z",
"coverageStatus": "OBSERVATIONS_ONLY"
},
"summary": {
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
},
"summaryScope": "时间及业务筛选;样本/比例/异常筛选仅影响号码列表",
"numbers": [
{
"customerId": "cra_20260831",
"caller": "99100001",
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50,
"alert": null
}
],
"total": 1,
"skip": 0,
"take": 25,
"trends": [
{
"time": 1788170040000,
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"caller": "",
"customerId": "",
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
}
],
"historyNotice": "仅统计新采集链路的数据;旧固定6秒话单不参与应答统计或校准。"
}
},
"detail": {
"rows": [
{
"id": "e1bad2678acf3d69b58d2992f2ef008bd0f54bd347d76f8011fc4e767d05c11c",
"callKey": "eef1447b3032d62a5b7f0dfadfe78ebad4794856540a36e18217379d49d76b93",
"startedAt": "2026-08-31T09:54:16.358Z",
"caller": "99100001",
"callee": "99106",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 1,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 0
},
"payload": {
"event": {
"at": 1788170056358,
"city": "UNKNOWN",
"code": 0,
"kind": "ATTEMPT",
"callId": "cra-99106-6d80829c329241ce817b249874bf2b66@loopback",
"callee": "99106",
"caller": "99100001",
"source": "opensips",
"talkMs": null,
"callUid": "a7d5c0d5eca6d45f9b67b3278e82d8d5eb350348dfccb4ca7c5f9f42750a88e8",
"carrier": "UNKNOWN",
"eventId": "2882824c4cbeb2e995755a42731897be4bd65ba375a0e490a250a38cedc63143",
"sequence": 0,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788170056358,
"customerId": "cra_20260831",
"landingCaller": "99100001",
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788170057859,
"unknown": false,
"finalCode": 486,
"acceptedAt": null,
"durationAt": 0,
"first180At": null,
"first183At": null,
"durationFinal": false
}
},
{
"id": "19e656a4a8d3dc370140d28d5e394987339d7f7b827f5b920d8b34fa9385f205",
"callKey": "c51a9184939429c82bd8528dd39293465139d02bd535b4b1f06aaa637b4c7170",
"startedAt": "2026-08-31T09:54:15.605Z",
"caller": "99100001",
"callee": "99105",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 301,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 1,
"connectedCalls": 1
},
"payload": {
"event": {
"at": 1788170055605,
"city": "UNKNOWN",
"code": 0,
"kind": "ATTEMPT",
"callId": "cra-99105-3d214eca9545467598ba188705ce4e7b@loopback",
"callee": "99105",
"caller": "99100001",
"source": "opensips",
"talkMs": null,
"callUid": "e13300239486e25f84c3bf8be16e3a6f128a6a10bde3591a061842ba83eb4f95",
"carrier": "UNKNOWN",
"eventId": "15e2c221e55d9627bcbe9f1752deeccd2b9aa802617769c1689665dba64a0547",
"sequence": 0,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788170055605,
"customerId": "cra_20260831",
"landingCaller": "99100001",
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 301,
"endedAt": 1788170056057,
"unknown": false,
"finalCode": 200,
"acceptedAt": 1788170055756,
"durationAt": 1788170056057,
"first180At": 1788170055606,
"first183At": null,
"durationFinal": true,
"durationAuthority": 1
}
},
{
"id": "3b2fb2b38b901cfdde5d25b91ade27f52cc7b4ef092b5dec813e491ad67d7dc9",
"callKey": "b50ad8424436383421d409c141a5297eaad91396105ce565d34a8f80fe1bf9b3",
"startedAt": "2026-08-31T09:54:13.802Z",
"caller": "99100001",
"callee": "99104",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 1500,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 1,
"connectedCalls": 1
},
"payload": {
"event": {
"at": 1788170053802,
"city": "UNKNOWN",
"code": 0,
"kind": "ATTEMPT",
"callId": "cra-99104-b8a5a3f0ceb74407bf522f5dc9a778b3@loopback",
"callee": "99104",
"caller": "99100001",
"source": "opensips",
"talkMs": null,
"callUid": "5114f1cb6f712cb77658f1a2cff32e36dafaaed944658f7b6b062797f621879f",
"carrier": "UNKNOWN",
"eventId": "d20bd87662801f01fd9223917bb2bdb2d4e0335791025d5f9ecb6d8a3e2b4ebf",
"sequence": 0,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788170053802,
"customerId": "cra_20260831",
"landingCaller": "99100001",
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 1500,
"endedAt": 1788170055303,
"unknown": false,
"finalCode": 200,
"acceptedAt": 1788170053803,
"durationAt": 1788170055303,
"first180At": null,
"first183At": null,
"durationFinal": true,
"durationAuthority": 1
}
},
{
"id": "9656e68727adc228abf7f4b688830c0e5f8eee429a836c1d6fd20fbbc99c6e86",
"callKey": "69f719276225346fef6ea390d75cb847eddfed12dd62e6286091d6bd443fbb64",
"startedAt": "2026-08-31T09:54:13.501Z",
"caller": "99100001",
"callee": "99103",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 1,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 0
},
"payload": {
"event": {
"at": 1788170053501,
"city": "UNKNOWN",
"code": 0,
"kind": "ATTEMPT",
"callId": "cra-99103-c26878322940440fbedea2256c8a662e@loopback",
"callee": "99103",
"caller": "99100001",
"source": "opensips",
"talkMs": null,
"callUid": "04831b2c1fde34f60dc9790dfd3b200f686c0e8441dffc6be01bbc627c695f94",
"carrier": "UNKNOWN",
"eventId": "bc4297c026cf60b3d7bde50b7a8c9036a2a36295f61e628511ecb1454ca5f5cb",
"sequence": 0,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788170053501,
"customerId": "cra_20260831",
"landingCaller": "99100001",
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788170053501,
"unknown": false,
"finalCode": 486,
"acceptedAt": null,
"durationAt": 0,
"first180At": null,
"first183At": null,
"durationFinal": false
}
},
{
"id": "d2875d12dd948b38884daacc2c8bcb26bf430ec7b428ee9db1b61529772034da",
"callKey": "b55748363d4f5c80be6c580c66ffa158f073d9b4e188ade7c36312cc089ca1ea",
"startedAt": "2026-08-31T09:54:13.049Z",
"caller": "99100001",
"callee": "99102",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 1
},
"payload": {
"event": {
"at": 1788170053049,
"city": "UNKNOWN",
"code": 0,
"kind": "ATTEMPT",
"callId": "cra-99102-b44ee7b04988463b8ca405748e9a0cea@loopback",
"callee": "99102",
"caller": "99100001",
"source": "opensips",
"talkMs": null,
"callUid": "2fa5c0a402cf014ebb163a1c27af3a8b181b240a3b4118a48bc9b2120cedce90",
"carrier": "UNKNOWN",
"eventId": "bbb9ff98dda66a10ac6492a6571f80a1d04b3f30f65d09fef7a58e4c8ef27080",
"sequence": 0,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788170053049,
"customerId": "cra_20260831",
"landingCaller": "99100001",
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788170053200,
"unknown": false,
"finalCode": 487,
"acceptedAt": null,
"durationAt": 0,
"first180At": null,
"first183At": 1788170053049,
"durationFinal": false
}
},
{
"id": "e90573a8144e12a7fba17a3db7ec91b712f57f545f9ee938c58686c22920cfad",
"callKey": "e63d5d684a3e1946df6db6cebc40ca71daa8fec59b98fddc81da79d5fbede5bc",
"startedAt": "2026-08-31T09:54:12.596Z",
"caller": "99100001",
"callee": "99101",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 1
},
"payload": {
"event": {
"at": 1788170052597,
"city": "UNKNOWN",
"code": 0,
"kind": "ATTEMPT",
"callId": "cra-99101-6241ef2089764314b2f5e93e89d172f2@loopback",
"callee": "99101",
"caller": "99100001",
"source": "opensips",
"talkMs": null,
"callUid": "79e429e65f1a6f43c12828690cff4b77bc71dc8fcd31fbc260e956c56c84b770",
"carrier": "UNKNOWN",
"eventId": "09f45529effc705c0c1c5fa7a606c4e31a5c628a3de6af48f9cb3742f3104952",
"sequence": 0,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788170052596,
"customerId": "cra_20260831",
"landingCaller": "99100001",
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788170052748,
"unknown": false,
"finalCode": 486,
"acceptedAt": null,
"durationAt": 0,
"first180At": 1788170052597,
"first183At": null,
"durationFinal": false
}
}
],
"breakdown": [
{
"vendorGatewayId": "cra_20260831",
"city": "UNKNOWN",
"carrier": "UNKNOWN",
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"caller": "",
"customerId": "",
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
}
],
"skip": 0,
"take": 25
},
"sip": [
{
"callId": "cra-99101-6241ef2089764314b2f5e93e89d172f2@loopback",
"direction": "UAC",
"code": 100,
"time": 1788170052.5973315
},
{
"callId": "cra-99101-6241ef2089764314b2f5e93e89d172f2@loopback",
"direction": "UAS",
"code": 180,
"time": 1788170052.597884
},
{
"callId": "cra-99101-6241ef2089764314b2f5e93e89d172f2@loopback",
"direction": "UAC",
"code": 180,
"time": 1788170052.5979683
},
{
"callId": "cra-99101-6241ef2089764314b2f5e93e89d172f2@loopback",
"direction": "UAS",
"code": 486,
"time": 1788170052.7480495
},
{
"callId": "cra-99101-6241ef2089764314b2f5e93e89d172f2@loopback",
"direction": "UAC",
"code": 486,
"time": 1788170052.748236
},
{
"callId": "cra-99102-b44ee7b04988463b8ca405748e9a0cea@loopback",
"direction": "UAC",
"code": 100,
"time": 1788170053.049644
},
{
"callId": "cra-99102-b44ee7b04988463b8ca405748e9a0cea@loopback",
"direction": "UAS",
"code": 183,
"time": 1788170053.049802
},
{
"callId": "cra-99102-b44ee7b04988463b8ca405748e9a0cea@loopback",
"direction": "UAC",
"code": 183,
"time": 1788170053.0499415
},
{
"callId": "cra-99102-b44ee7b04988463b8ca405748e9a0cea@loopback",
"direction": "UAS",
"code": 487,
"time": 1788170053.2000644
},
{
"callId": "cra-99102-b44ee7b04988463b8ca405748e9a0cea@loopback",
"direction": "UAC",
"code": 487,
"time": 1788170053.2002563
},
{
"callId": "cra-99103-c26878322940440fbedea2256c8a662e@loopback",
"direction": "UAC",
"code": 100,
"time": 1788170053.5014744
},
{
"callId": "cra-99103-c26878322940440fbedea2256c8a662e@loopback",
"direction": "UAS",
"code": 486,
"time": 1788170053.5018132
},
{
"callId": "cra-99103-c26878322940440fbedea2256c8a662e@loopback",
"direction": "UAC",
"code": 486,
"time": 1788170053.5018618
},
{
"callId": "cra-99104-b8a5a3f0ceb74407bf522f5dc9a778b3@loopback",
"direction": "UAC",
"code": 100,
"time": 1788170053.8030186
},
{
"callId": "cra-99104-b8a5a3f0ceb74407bf522f5dc9a778b3@loopback",
"direction": "UAS",
"code": 200,
"time": 1788170053.8031552
},
{
"callId": "cra-99104-b8a5a3f0ceb74407bf522f5dc9a778b3@loopback",
"direction": "UAC",
"code": 200,
"time": 1788170053.8033082
},
{
"callId": "cra-99105-3d214eca9545467598ba188705ce4e7b@loopback",
"direction": "UAC",
"code": 100,
"time": 1788170055.6059597
},
{
"callId": "cra-99105-3d214eca9545467598ba188705ce4e7b@loopback",
"direction": "UAS",
"code": 180,
"time": 1788170055.6061337
},
{
"callId": "cra-99105-3d214eca9545467598ba188705ce4e7b@loopback",
"direction": "UAC",
"code": 180,
"time": 1788170055.606258
},
{
"callId": "cra-99105-3d214eca9545467598ba188705ce4e7b@loopback",
"direction": "UAS",
"code": 200,
"time": 1788170055.7563334
},
{
"callId": "cra-99105-3d214eca9545467598ba188705ce4e7b@loopback",
"direction": "UAC",
"code": 200,
"time": 1788170055.7565722
},
{
"callId": "cra-99106-6d80829c329241ce817b249874bf2b66@loopback",
"direction": "UAC",
"code": 100,
"time": 1788170056.3588703
},
{
"callId": "cra-99106-6d80829c329241ce817b249874bf2b66@loopback",
"direction": "UAS",
"code": 100,
"time": 1788170056.35889
},
{
"callId": "cra-99106-6d80829c329241ce817b249874bf2b66@loopback",
"direction": "UAS",
"code": 486,
"time": 1788170057.859125
},
{
"callId": "cra-99106-6d80829c329241ce817b249874bf2b66@loopback",
"direction": "UAC",
"code": 486,
"time": 1788170057.8593173
}
],
"bucketAudit": {
"totalCalls": 24,
"connectedCalls": 16,
"answeredCalls": 8,
"failedCalls": 8,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 7204,
"notConnectedCalls": 8,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
},
"replayed": 4,
"tests": [
"real SIP six scenarios",
"real API counts and three rates",
"real DB states and minute buckets",
"database replay idempotency",
"customer scope and 403 isolation",
"scoped filter options",
"unauthenticated 401",
"invalid query 400",
"positive subsecond duration"
]
}
@@ -0,0 +1,752 @@
{
"date": "2026-09-01T02:44:55.304Z",
"from": "2026-09-01T02:44:48.013Z",
"api": {
"status": 200,
"body": {
"metricVersion": "caller-analytics-v1.1",
"analysisRevision": "caller-analytics-r2",
"snapshotId": "11b2aac60d66a436654584f578321207b4f3c0195942b10630ebd7bba8ed39f6",
"view": "landing",
"from": "2026-09-01T02:44:48.013Z",
"to": "2026-09-01T02:44:55.140Z",
"asOf": "2026-09-01T02:44:55.150Z",
"qualityStatus": "LIVE",
"coverageStatus": "CONTINUOUS",
"dataThrough": "2026-09-01T02:44:53.599Z",
"lagMs": 468,
"durationPrecision": "milliseconds",
"health": {
"gap": false,
"lagMs": 468,
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"reason": "",
"degraded": false,
"updatedAt": "2026-09-01T02:44:54.959Z",
"dataThrough": "2026-09-01T02:44:53.599Z",
"spoolOffset": 109742,
"captureSince": "2026-09-01T02:23:21Z",
"coverageStatus": "CONTINUOUS",
"spoolUnreadBytes": 0,
"producerEventCount": 32,
"collectedEventCount": 32
},
"gaps": [],
"summary": {
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0,
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
},
"summaryScope": "时间及业务筛选;样本/比例/异常筛选仅影响号码列表",
"numbers": [
{
"customerId": "cra_20260831",
"caller": "99100001",
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0,
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50,
"alert": null
}
],
"total": 1,
"skip": 0,
"take": 25,
"trends": [
{
"time": 1788230640000,
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0,
"caller": "",
"customerId": "",
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
}
],
"baseFilters": {
"view": "landing",
"from": "2026-09-01T02:44:48.013Z",
"to": "2026-09-01T02:44:55.140Z",
"customerId": "cra_20260831",
"customerGatewayId": "",
"vendorId": "",
"vendorGatewayId": "",
"caller": "",
"city": "",
"carrier": ""
},
"numberFilters": {
"minSamples": 0,
"onlyAlerts": false,
"connectionMin": 0,
"connectionMax": 100,
"overallMin": 0,
"overallMax": 100,
"connectedMin": 0,
"connectedMax": 100
},
"metricAvailability": {
"connectionRate": true,
"answerRates": true
},
"historyNotice": "当前窗口在连续采集覆盖内;旧固定6秒话单不参与校准。"
}
},
"detail": {
"rows": [
{
"id": "bd2f99d0f2f094dafa116dc400cfb9d7d8371005cc529737e327dfeb1d9b8345",
"callKey": "be549d085a7dee62f13ae303c379803081d7bc8e5436e80fed9a933707c22d0d",
"startedAt": "2026-09-01T02:44:52.099Z",
"caller": "99100001",
"callee": "99106",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 1,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 0,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0
},
"payload": {
"event": {
"at": 1788230692099,
"city": "UNKNOWN",
"code": 0,
"cseq": "",
"kind": "ATTEMPT",
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"branch": "",
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
"callee": "99106",
"caller": "99100001",
"method": "INVITE",
"nodeId": "YanZi",
"source": "opensips",
"talkMs": null,
"callUid": "e8c57831b2aac971c4ca3583cb67cad8582eed13b58e7ea39f5f0be2bc86c6ed",
"carrier": "UNKNOWN",
"eventId": "231e79d6950e2d7307bfe838c6baf2ca4d3ecc9c83bd929e6b3045bd2139021c",
"sequence": 1788230692099000,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788230692099,
"customerId": "cra_20260831",
"receivedAt": 1788230692439,
"landingCaller": "99100001",
"schemaVersion": 2,
"evidenceVersion": 2,
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788230693599,
"unknown": false,
"finalCode": 486,
"acceptedAt": null,
"durationAt": 1788230693599,
"first180At": null,
"first183At": null,
"durationFinal": true,
"talkStartedAt": null,
"durationComplete": true,
"durationAuthority": 1,
"terminationReason": "verified-dialog-final"
}
},
{
"id": "1ced517eabcf44a9c87bbb01859b0478486e3de0c57c9aacbc88373b26caa332",
"callKey": "87a0252e111310be93d0ac6200ca31519408e7eda0f6d0019e58129dbb394c8e",
"startedAt": "2026-09-01T02:44:51.345Z",
"caller": "99100001",
"callee": "99105",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 300,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 1,
"connectedCalls": 1,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0
},
"payload": {
"event": {
"at": 1788230691346,
"city": "UNKNOWN",
"code": 0,
"cseq": "",
"kind": "ATTEMPT",
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"branch": "",
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
"callee": "99105",
"caller": "99100001",
"method": "INVITE",
"nodeId": "YanZi",
"source": "opensips",
"talkMs": null,
"callUid": "158db5741881d9555396f88f8d71be8114b3de9b3fbb01b1977afc3216c191f3",
"carrier": "UNKNOWN",
"eventId": "a2573b627de297d67e9800ad9ce123999e4a56885c9fe4b60f8e046df0072622",
"sequence": 1788230691346000,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788230691345,
"customerId": "cra_20260831",
"receivedAt": 1788230691655,
"landingCaller": "99100001",
"schemaVersion": 2,
"evidenceVersion": 2,
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 300,
"endedAt": 1788230691797,
"unknown": false,
"finalCode": 200,
"acceptedAt": 1788230691496,
"durationAt": 1788230691797,
"first180At": 1788230691346,
"first183At": null,
"durationFinal": true,
"talkStartedAt": 1788230691497,
"durationComplete": true,
"durationAuthority": 1,
"terminationReason": "verified-dialog-final"
}
},
{
"id": "4c4589bdcd2ef482d577a76f47e7e3cd504bd05aee95b6228a86939e89530940",
"callKey": "52584c11a53a7388dd68ad49afc1e5dfc407b851aa12a813229eb81265975f71",
"startedAt": "2026-09-01T02:44:49.540Z",
"caller": "99100001",
"callee": "99104",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 1501,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 1,
"connectedCalls": 1,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0
},
"payload": {
"event": {
"at": 1788230689541,
"city": "UNKNOWN",
"code": 0,
"cseq": "",
"kind": "ATTEMPT",
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"branch": "",
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
"callee": "99104",
"caller": "99100001",
"method": "INVITE",
"nodeId": "YanZi",
"source": "opensips",
"talkMs": null,
"callUid": "5c68fe746adb05173b06bbc7f1fc3fb60b15a2c155500be4d5c860a34fcd408d",
"carrier": "UNKNOWN",
"eventId": "1b15c22588b24db034cff9e4a7bfa135d326ba3b38c8f592952fb72aaf13c041",
"sequence": 1788230689541000,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788230689540,
"customerId": "cra_20260831",
"receivedAt": 1788230689889,
"landingCaller": "99100001",
"schemaVersion": 2,
"evidenceVersion": 2,
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 1501,
"endedAt": 1788230691043,
"unknown": false,
"finalCode": 200,
"acceptedAt": 1788230689541,
"durationAt": 1788230691043,
"first180At": null,
"first183At": null,
"durationFinal": true,
"talkStartedAt": 1788230689542,
"durationComplete": true,
"durationAuthority": 1,
"terminationReason": "verified-dialog-final"
}
},
{
"id": "cdcbfca07604ebff4bbe4fe6734873f0eea61d1f798825b0ff2e698e33d0d6e6",
"callKey": "7a108c8adcfc4af723c08103a2be7bba190f9d42a6b8edc272cc2542a5fe6330",
"startedAt": "2026-09-01T02:44:49.239Z",
"caller": "99100001",
"callee": "99103",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 1,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 0,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0
},
"payload": {
"event": {
"at": 1788230689239,
"city": "UNKNOWN",
"code": 0,
"cseq": "",
"kind": "ATTEMPT",
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"branch": "",
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
"callee": "99103",
"caller": "99100001",
"method": "INVITE",
"nodeId": "YanZi",
"source": "opensips",
"talkMs": null,
"callUid": "cd37b5bfc20e65e02019970b922add6fab0e8980d6620fb4edadbde91428dc47",
"carrier": "UNKNOWN",
"eventId": "60c7fa2f2798a67e603bb6d4a39daa3bdc402ca3d3a924611e1067692372dba3",
"sequence": 1788230689239000,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788230689239,
"customerId": "cra_20260831",
"receivedAt": 1788230689889,
"landingCaller": "99100001",
"schemaVersion": 2,
"evidenceVersion": 2,
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788230689239,
"unknown": false,
"finalCode": 486,
"acceptedAt": null,
"durationAt": 1788230689239,
"first180At": null,
"first183At": null,
"durationFinal": true,
"talkStartedAt": null,
"durationComplete": true,
"durationAuthority": 1,
"terminationReason": "verified-dialog-final"
}
},
{
"id": "662cc9075f141345c45a274d2412d4ebffdf043dd75fa1b6e1a6ac9d85ce9460",
"callKey": "6c05dd76e643c25294847a1163d623262108b4176e37e33b51fc3685355f19f9",
"startedAt": "2026-09-01T02:44:48.787Z",
"caller": "99100001",
"callee": "99102",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 1,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0
},
"payload": {
"event": {
"at": 1788230688787,
"city": "UNKNOWN",
"code": 0,
"cseq": "",
"kind": "ATTEMPT",
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"branch": "",
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
"callee": "99102",
"caller": "99100001",
"method": "INVITE",
"nodeId": "YanZi",
"source": "opensips",
"talkMs": null,
"callUid": "7cda6788fb05dd8dcf36a3c2ffbb8fe124c9746a2c32bca294954ef72c91c748",
"carrier": "UNKNOWN",
"eventId": "17d7dc0a0bb67acb249cb5a1f82615e513853127cd327818224c492fa0dc16a3",
"sequence": 1788230688787000,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788230688787,
"customerId": "cra_20260831",
"receivedAt": 1788230689112,
"landingCaller": "99100001",
"schemaVersion": 2,
"evidenceVersion": 2,
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788230688938,
"unknown": false,
"finalCode": 487,
"acceptedAt": null,
"durationAt": 1788230688938,
"first180At": null,
"first183At": 1788230688787,
"durationFinal": true,
"talkStartedAt": null,
"durationComplete": true,
"durationAuthority": 1,
"terminationReason": "verified-dialog-final"
}
},
{
"id": "f5a3e11aa1b23e91559582bf2ea0dbc32032c0b5bb55a04d28f1ab02bbb5a64b",
"callKey": "fe25de2674a793555634a0b1732790425667f16cc81f89a3e28aaa46e35c3acc",
"startedAt": "2026-09-01T02:44:48.335Z",
"caller": "99100001",
"callee": "99101",
"vendorGatewayId": "cra_20260831",
"counts": {
"talkMs": 0,
"totalCalls": 1,
"activeCalls": 0,
"failedCalls": 0,
"pendingCalls": 0,
"unknownCalls": 0,
"answeredCalls": 0,
"connectedCalls": 1,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0
},
"payload": {
"event": {
"at": 1788230688335,
"city": "UNKNOWN",
"code": 0,
"cseq": "",
"kind": "ATTEMPT",
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
"branch": "",
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
"callee": "99101",
"caller": "99100001",
"method": "INVITE",
"nodeId": "YanZi",
"source": "opensips",
"talkMs": null,
"callUid": "69136797dbe483cbd977dcf5576464b07eda7fe03ca8d7810144b9de44b99423",
"carrier": "UNKNOWN",
"eventId": "d4b0b273a78d6aad97e695882c34a44c59fc8a9c1130b98c5865b4b00f8cadb1",
"sequence": 1788230688335000,
"vendorId": "cra_20260831",
"attemptId": "1",
"startedAt": 1788230688335,
"customerId": "cra_20260831",
"receivedAt": 1788230688370,
"landingCaller": "99100001",
"schemaVersion": 2,
"evidenceVersion": 2,
"vendorGatewayId": "cra_20260831",
"customerGatewayId": "cra_20260831"
},
"talkMs": 0,
"endedAt": 1788230688486,
"unknown": false,
"finalCode": 486,
"acceptedAt": null,
"durationAt": 1788230688486,
"first180At": 1788230688336,
"first183At": null,
"durationFinal": true,
"talkStartedAt": null,
"durationComplete": true,
"durationAuthority": 1,
"terminationReason": "verified-dialog-final"
}
}
],
"breakdown": [
{
"vendorGatewayId": "cra_20260831",
"city": "UNKNOWN",
"carrier": "UNKNOWN",
"totalCalls": 6,
"connectedCalls": 4,
"answeredCalls": 2,
"failedCalls": 2,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 1801,
"durationUnknownCalls": 0,
"provisionalAnsweredCalls": 0,
"caller": "",
"customerId": "",
"notConnectedCalls": 2,
"connectionRate": 66.6667,
"overallAnswerRate": 33.3333,
"connectedAnswerRate": 50
}
],
"breakdownTruncated": false,
"skip": 0,
"take": 25
},
"sip": [
{
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
"direction": "UAC",
"code": 100,
"sdp": false,
"time": 1788230688.3355267
},
{
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
"direction": "UAS",
"code": 180,
"sdp": false,
"time": 1788230688.3360817
},
{
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
"direction": "UAC",
"code": 180,
"sdp": false,
"time": 1788230688.3362823
},
{
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
"direction": "UAS",
"code": 486,
"sdp": false,
"time": 1788230688.4863043
},
{
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
"direction": "UAC",
"code": 486,
"sdp": false,
"time": 1788230688.4864848
},
{
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
"direction": "UAC",
"code": 100,
"sdp": false,
"time": 1788230688.7877743
},
{
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
"direction": "UAS",
"code": 183,
"sdp": false,
"time": 1788230688.7879457
},
{
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
"direction": "UAC",
"code": 183,
"sdp": false,
"time": 1788230688.7880127
},
{
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
"direction": "UAS",
"code": 487,
"sdp": false,
"time": 1788230688.9381335
},
{
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
"direction": "UAC",
"code": 487,
"sdp": false,
"time": 1788230688.9384828
},
{
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
"direction": "UAC",
"code": 100,
"sdp": false,
"time": 1788230689.239587
},
{
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
"direction": "UAS",
"code": 486,
"sdp": false,
"time": 1788230689.2397466
},
{
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
"direction": "UAC",
"code": 486,
"sdp": false,
"time": 1788230689.2398334
},
{
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
"direction": "UAC",
"code": 100,
"sdp": false,
"time": 1788230689.541705
},
{
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
"direction": "UAS",
"code": 200,
"sdp": true,
"time": 1788230689.5418484
},
{
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
"direction": "UAC",
"code": 200,
"sdp": true,
"time": 1788230689.54246
},
{
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
"direction": "UAC",
"code": 100,
"sdp": false,
"time": 1788230691.3461745
},
{
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
"direction": "UAS",
"code": 180,
"sdp": false,
"time": 1788230691.3463302
},
{
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
"direction": "UAC",
"code": 180,
"sdp": false,
"time": 1788230691.3463998
},
{
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
"direction": "UAS",
"code": 200,
"sdp": true,
"time": 1788230691.4965453
},
{
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
"direction": "UAC",
"code": 200,
"sdp": true,
"time": 1788230691.4974086
},
{
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
"direction": "UAC",
"code": 100,
"sdp": false,
"time": 1788230692.099361
},
{
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
"direction": "UAS",
"code": 100,
"sdp": false,
"time": 1788230692.0994966
},
{
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
"direction": "UAS",
"code": 486,
"sdp": false,
"time": 1788230693.5997336
},
{
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
"direction": "UAC",
"code": 486,
"sdp": false,
"time": 1788230693.5999396
}
],
"bucketAudit": {
"totalCalls": 54,
"connectedCalls": 36,
"answeredCalls": 16,
"failedCalls": 18,
"pendingCalls": 0,
"unknownCalls": 0,
"activeCalls": 0,
"talkMs": 14405,
"durationUnknownCalls": 2,
"provisionalAnsweredCalls": 0,
"notConnectedCalls": 18,
"connectionRate": null,
"overallAnswerRate": null,
"connectedAnswerRate": null
},
"replayed": 5,
"tests": [
"real SIP six scenarios",
"two SDP offer/answer negotiations through RTPEngine",
"ACK-based positive duration including subsecond call",
"real API counts, completeness and three rates",
"real DB states and minute buckets",
"database replay idempotency",
"customer scope and 403 isolation",
"scoped customer/vendor filter options",
"unauthenticated 401",
"invalid query 400"
]
}