Author SHA1 Message Date
hectorzhao a116926867 docs: record caller analytics test deployment and acceptance limits 2026-08-31 17:57:19 +08:00
hectorzhao 2a3dcb0c68 fix: reconcile caller aggregates and strengthen evidence handling 2026-08-31 17:52:11 +08:00
hectorzhao d78616561e fix: close caller analytics SIP integration gaps 2026-08-31 17:42:19 +08:00
hectorzhao 116e316eca fix: validate caller collector encoding and release engine permissions 2026-08-31 17:26:29 +08:00
hectorzhao 5284d41f9a feat: add caller connection and answer rate analytics 2026-08-31 17:21:20 +08:00
hectorzhao 8627228bbc docs: record Server B autostart deployment 2026-08-30 21:14:56 +08:00
hectorzhao 406b3744aa feat: add Server B systemd startup target 2026-08-30 21:11:22 +08:00
hectorzhao 6da5782ac7 docs: record S57 production verification 2026-08-27 20:58:30 +08:00
hectorzhao c36709f366 fix: verify API readiness during S57 deploy 2026-08-27 20:43:36 +08:00
hectorzhao 564234b769 feat: optimize UI queries and seed number library 2026-08-27 20:24:18 +08:00
hectorzhao 138297d191 docs(release): record server B consolidation evidence 2026-08-27 18:18:21 +08:00
hectorzhao 309bffc7ff fix(recording): use portable fuser invocation 2026-08-27 17:38:34 +08:00
hectorzhao f67b33de33 chore(release): add server B recording deployment script 2026-08-27 17:29:39 +08:00
hectorzhao f52e3c5fbc fix(recording): expose host ready directory to worker 2026-08-27 17:23:48 +08:00
hectorzhao be53848dee feat(recording): consolidate recording pipeline on server B 2026-08-27 16:57:45 +08:00
hectorzhao 32bba1bd84 record s55 ui performance deployment 2026-08-27 15:55:32 +08:00
hectorzhao ecb567c0da optimize ui loading and dashboard performance 2026-08-27 15:17:47 +08:00
hectorzhao 7ff019e829 record 20260630release baseline 2026-06-30 14:19:38 +08:00
90 changed files with 4897 additions and 259 deletions
+45 -7
View File
@@ -4,11 +4,28 @@
## 当前状态 ## 当前状态
- 当前任务:菜单展开 Logo 增加 SIP 文字并发布到 B ### 2026-08-31 主叫号码实时分析实施(测试发布验证中)
- 本次变更摘要:已核对 `tests/reports/` 下 8.2-8.10 远程测试结果;修复客户充值接口负数扣款、active call hangup 超长/无分隔 ID 参数校验、Fastify 路由参数长度限制,以及既有环境号码库内置角色权限补齐迁移。追加修复业务前缀管理页面 `normalizeBusinessPrefix is not defined`、号码库页面 `emptyNumberLibraryRows is not defined`、客户网关编辑触发 React #137 的前端运行时错误。最新 s53 在菜单展开 Logo 后增加 `SIP` 文字,收起菜单仍展示 `logo2`,并发布 Web-only release。新增测试门禁:`pnpm lint` 覆盖 `apps/web/src/**``pnpm test:remote-web-ui` 使用真实浏览器登录并逐个点击核心菜单及每页一个安全主操作,客户网关编辑动作会验证业务前缀 checkbox 可 false->true->false 往返切换。B 当前 release 为 `s53-brand-logo-sip-text-20260630105500`;准备提交 Git。
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s53-brand-logo-sip-text-20260630105500`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 okT `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-s28-uas``apache2``mariadb/mysql` active;三机 `systemctl --failed` 均为 0。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_20260630T031146Z.md` 通过;完整按钮级远程浏览器巡检 `REMOTE_WEB_UI_SMOKE_20260629T104040Z.md` 通过,覆盖 15 个核心菜单及安全主操作,客户网关管理编辑报告包含 `business-prefix-checkbox-toggle-ok: false->true->false`;B 本机号码库四个列表接口均返回 200,当前 total 均为 0,符合真实号段尚未导入状态;本地代码验证 `lint``typecheck`、全量 `test``build``node --check tests/web/remote-web-ui-smoke.mjs` 均通过;Redis `cfg:active_version=s28-v1`A 当前 EVALSHA 脚本在 Redis 中存在;A 已加载二期 Lua 热路径 SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练 - 新增独立分析进程、MySQL事件/状态/分钟汇总/权限范围/告警表、真实API和主叫分析页面,计费Worker不变。
- 最后更新:2026-06-30 11:12 +08:00 - 新采集使用OpenSIPS进展及结束观察钩子、本机rsyslog持久队列/日志、Redis Stream,再由独立进程事务去重及聚合。
- 当前阻塞:本轮 8.2/8.7/8.8、业务前缀页面、号码库页面、客户网关编辑运行时错误、测试门禁修复、B SSH/HTTPS 来源限制移除无阻塞。遗留问题:未提交 Git;未改 A OpenSIPS/Lua/RTPEngine 或 T 配置;8.9 轻量 API burst p95 超过 10 秒需结合服务端日志和测试方式继续定位;SIP CPS 探针中 BYE 被 `403 Rate Limited` 属 A 侧限流策略问题,修复前需说明影响、备份 A 配置并安排维护窗口;8.10 备份、隔离恢复、真实回滚、OpenSIPS 配置恢复受权限或维护窗口阻塞;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析 - 本地校验: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 重新演练
- 最后更新:2026-08-27 18:16 +08:00
- 当前阻塞:无发布阻塞。尚未发起新的真实收费/外呼来生成生产录音,因此真实 `incoming -> ready -> /data/recordings -> recordings READY -> 鉴权播放` 仍需在下一次受控呼叫时补验;本次未修改 TLS、数据库结构或 A/T 配置。
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云 - 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显 - 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显
@@ -16,8 +33,8 @@
| 代号 | 地址 | 用户 | 用途 | | 代号 | 地址 | 用户 | 用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| A | `100.90.90.90` | `hector` | OpenSIPS、RTPEngine、录音内存盘、HEP、Exporter | | A | `100.90.90.90` | `hector` | 历史节点/回滚参考;S56 起不参与当前生产服务或录音传输 |
| B | `100.90.90.91` | `hector` | API、Worker、Redis、MySQL、录音、HOMER、Prometheus/Grafana | | B | `100.90.90.91` | `hector` | OpenSIPS、RTPEngine、录音、Nginx、API、Worker、Redis、MySQL、HOMER、Prometheus/Grafana |
| T | `100.93.185.30` | `hector` | 已安装 OpenSIPS,模拟客户 SIP 注册与呼叫 | | T | `100.93.185.30` | `hector` | 已安装 OpenSIPS,模拟客户 SIP 注册与呼叫 |
## 任务状态表 ## 任务状态表
@@ -90,6 +107,27 @@
## 交接记录 ## 交接记录
### 2026-08-27 15:50 - UI 性能整改提交、推送并发布 B s55
- 状态:已完成,存在一项与本次代码无关的环境告警
- 操作服务器:B、本地;未修改 A/T、TLS、数据库 schema、Redis 配置或 SIP 路由
- 完成内容:提交并推送 `ecb567c0dacd749607808fbed09648267757be6f`B 从精确 Git bundle 在 Linux 上构建并生成固定 artifact,发布为 `/opt/lisglosips/releases/s55-ui-performance-20260827153500`,由 s54 原子切换到 s55。Dashboard 使用 overview 聚合接口,话单默认 25 条并行查询,页面统一 2 秒加载弹窗和 10 秒请求超时。
- 发布证据:artifact SHA256 `acc49bb21506448527732e965c345a5e71e83090781be76df750a93cffccaece`19,681 个文件;MySQL/Redis 备份均为 `/data/backups/*/20260827T073857Z` 且 SHA 校验通过;B preflight、API ready、Nginx 配置与外部 smoke 通过,新首页引用 `index-DcpB-OOG.js``index-u-EPfKoT.css`
- 性能回读:真实管理员认证后,`/dashboard/overview` 外部耗时 1,843ms、24 个趋势桶;`/cdrs?take=25` 外部耗时 2,357ms,返回 25/97 条,较整改前约 5.5 秒下降,但仍会按设计在 2 秒后显示加载弹窗。
- 验证结果:本地 ESLint、TypeScript、Prisma validate、95 条 Vitest、Web build 通过;B Linux workspace build、release artifact、服务 preflight、匿名 smoke、真实认证 Dashboard/CDR 只读检查通过。完整 Playwright UI 巡检因本机缺少 Chromium 二进制未执行,未临时安装软件。
- 回滚方式:`sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/s54-cmpp-ui-style-20260702103305`;发布脚本自身带失败自动回切,本次未触发。
- 环境告警:B 到 A `100.90.90.90` 的 Tailscale ping 和录音专用 SSH 均超时,导致 recording-worker 约每 135 秒由 systemd 自动重启;本次未改 worker 源码,B 其他服务均 active。需 A 恢复在线后复核录音 Worker,不应通过回滚 s55 掩盖 A 不可达问题。
### 2026-06-30 14:20 - `20260630release` 稳定版封版
- 状态:已完成
- 操作服务器:本地、A、B、T;未改业务代码,未发布新 release,未改数据库 schema
- 完成内容:将稳定代码提交 `9920575bbae7fbef4929843632f94d687ffbe289` 打 tag `20260630release`,创建分支 `stable/20260630release`;触发 B `lisglosips-backup.service` 生成封版 MySQL/Redis 备份;采集 A/B/T 服务状态、OpenSIPS 配置校验和关键 SHA;生成封版报告 `docs/20260630_RELEASE_BASELINE_REPORT.md`
- 修改文件:`docs/20260630_RELEASE_BASELINE_REPORT.md``IMPLEMENTATION_STATUS.md`、新增 smoke 报告 `tests/reports/REMOTE_SMOKE_20260630T061636Z.md`
- 验证结果:B current 为 `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500`B backup result `success`MySQL 备份 `/data/backups/mysql/20260630T061723Z`Redis 备份 `/data/backups/redis/20260630T061723Z`B preflight ok;远程 smoke `REMOTE_SMOKE_20260630T061636Z.md` 通过;A OpenSIPS 配置 SHA256 `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5`,热路径 Lua SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`,热路径加载 `success`T OpenSIPS 配置 SHA256 `5255dd540d33c3a7bf3db278fe6fe39540b176eb79e7fe12a7a26e5217c263da`A/T `fwupd-refresh.service` failed 为既有无关遗留。
- 回滚方式:代码回滚可执行 `git checkout 20260630release``git switch stable/20260630release`B 应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500` 并重启 API/Worker、reload Nginx;数据恢复使用封版备份前必须先在隔离环境验证。
- 未解决问题:真实 80 万手机号段尚未导入;8.9/8.10 既有遗留问题未处理;A/T `fwupd-refresh.service` failed 未处理。
### 2026-06-30 11:12 - Web-only s53 菜单展开 Logo 增加 SIP ### 2026-06-30 11:12 - Web-only s53 菜单展开 Logo 增加 SIP
- 状态:已完成 - 状态:已完成
+4
View File
@@ -12,6 +12,10 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
本文作为下一阶段执行基线,后续数据库 DDL、OpenAPI、OpenSIPS 脚本和部署脚本应从本文拆分并版本化管理。 本文作为下一阶段执行基线,后续数据库 DDL、OpenAPI、OpenSIPS 脚本和部署脚本应从本文拆分并版本化管理。
### 1.1 2026-08-31 新增设计:主叫号码实时分析
详见 [主叫号码接通率与应答率实时分析设计方案](docs/CALLER_REALTIME_ANALYTICS_DESIGN.md)。该功能处于设计完成、实现待开始阶段。新功能“接通数”按180 Ringing/183 Session Progress或后续阶段统计;“应答数”按实际通话时长大于0统计;实时总体应答率=应答数/总呼叫数,实时已接通应答率=应答数/接通数。直接成功、缺少100、未接通/待接通/未知数据及历史回算边界以专项文档V1.1为准。既有首页历史指标与计费规则不在本次文档任务中变更。
## 2. V2 范围 ## 2. V2 范围
### 2.1 本期实施页面 ### 2.1 本期实施页面
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConfigService } from '@nestjs/config';
import type { RuntimeConfig } from '../../shared/config.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
function client() {
const config = {
get: () => ({
mode: 'http',
httpUrl: 'http://127.0.0.1:8888/mi',
sshHost: 'unused',
remoteCommand: 'unused',
timeoutMs: 3000
})
} as unknown as ConfigService<RuntimeConfig, true>;
return new OpenSipsMiClient(config);
}
describe('OpenSipsMiClient HTTP MI', () => {
afterEach(() => vi.unstubAllGlobals());
it('lists dialogs through the local JSON-RPC endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
});
it('preserves OpenSIPS MI errors for the API layer', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
});
});
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
private async call(method: string, params: string[] = []): Promise<unknown> { private async call(method: string, params: string[] = []): Promise<unknown> {
const config = this.configService.get('activeCalls', { infer: true }); const config = this.configService.get('activeCalls', { infer: true });
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' '); let response: MiResponse;
const args = [];
if (config.sshConfig) {
args.push('-F', config.sshConfig);
}
args.push(config.sshHost, remoteCommand);
let output: string;
try { try {
output = await this.executor.run(args, config.timeoutMs); response = config.mode === 'http'
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
: await this.callSsh(config, method, params);
} catch (error) { } catch (error) {
if (error instanceof BadGatewayException) throw error;
throw new BadGatewayException({ throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_UNAVAILABLE', code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
message: 'OpenSIPS control plane is unavailable.', message: 'OpenSIPS control plane is unavailable.',
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
}); });
} }
let response: MiResponse;
try {
response = JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
if (response.error) { if (response.error) {
throw new BadGatewayException({ throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_ERROR', code: 'ACTIVE_CALLS_MI_ERROR',
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
return response.result; return response.result;
} }
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(timeoutMs)
});
if (!response.ok) {
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
}
try {
return await response.json() as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
const args: string[] = [];
if (config.sshConfig) args.push('-F', config.sshConfig);
args.push(config.sshHost, remoteCommand);
const output = await this.executor.run(args, config.timeoutMs);
try {
return JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
} }
function shellQuote(value: string): string { function shellQuote(value: string): string {
+2
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CallerAnalyticsModule } from './caller-analytics/caller-analytics.module.js';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
@@ -60,6 +61,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
AuditModule, AuditModule,
AuthModule, AuthModule,
ActiveCallsModule, ActiveCallsModule,
CallerAnalyticsModule,
BusinessPrefixesModule, BusinessPrefixesModule,
CdrsModule, CdrsModule,
DashboardModule, DashboardModule,
@@ -1,4 +1,5 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js'; import { PrismaService } from '../database/prisma.service.js';
export interface AuditLogQuery { export interface AuditLogQuery {
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
objectType?: string; objectType?: string;
objectId?: string; objectId?: string;
result?: 'SUCCESS' | 'FAILURE'; result?: 'SUCCESS' | 'FAILURE';
createdFrom?: Date;
createdTo?: Date;
take: number; take: number;
skip: number; skip: number;
} }
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> { async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
const where = { const where: Prisma.AuditLogWhereInput = {
module: query.module, module: query.module,
action: query.action, action: query.action,
userId: query.userId, userId: query.userId,
objectType: query.objectType, objectType: query.objectType,
objectId: query.objectId, objectId: query.objectId,
result: query.result result: query.result,
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
}; };
const [items, total] = await this.prisma.$transaction([ const [items, total] = await this.prisma.$transaction([
@@ -13,6 +13,8 @@ export class AuditLogsService {
objectType: this.optionalString(rawQuery.objectType), objectType: this.optionalString(rawQuery.objectType),
objectId: this.optionalString(rawQuery.objectId), objectId: this.optionalString(rawQuery.objectId),
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result), result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
take: this.positiveInt(rawQuery.take, 50, 100), take: this.positiveInt(rawQuery.take, 50, 100),
skip: this.positiveInt(rawQuery.skip, 0, 10_000) skip: this.positiveInt(rawQuery.skip, 0, 10_000)
}; };
@@ -56,4 +58,16 @@ export class AuditLogsService {
return parsed; return parsed;
} }
private optionalDate(value: unknown, field: string): Date | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
} }
@@ -0,0 +1,18 @@
import { Controller, Get, Inject, Module, Query } from '@nestjs/common';
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
import { CallerAnalyticsService } from './caller-analytics.service.js';
@Controller('caller-analytics')
@RequirePermissions('caller_analytics.view')
class CallerAnalyticsController {
constructor(@Inject(CallerAnalyticsService) private readonly service: CallerAnalyticsService) {}
@Get('overview') overview(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('summary') summary(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('numbers') numbers(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('trends') trends(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.overview(q, user); }
@Get('calls') calls(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.detail(q, user); }
@Get('breakdown') breakdown(@Query() q: Record<string, unknown>, @CurrentUserParam() user: CurrentUser) { return this.service.detail(q, user); }
@Get('options') options(@CurrentUserParam() user: CurrentUser) { return this.service.options(user); }
}
@Module({ controllers: [CallerAnalyticsController], providers: [CallerAnalyticsService] })
export class CallerAnalyticsModule {}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { parseAnalyticsQuery } from './caller-analytics.service.js';
describe('analytics query boundary', () => {
it('rejects inverted, excessive and invalid date windows', () => {
for (const q of [{from:'bad'}, {from:'2026-01-01',to:'2026-02-01'}, {from:'2026-08-02',to:'2026-08-01'}]) expect(() => parseAnalyticsQuery(q)).toThrow();
});
it('rejects sort injection and mixed-grain vendor filters', () => {
expect(() => parseAnalyticsQuery({sort:'caller; DROP TABLE x'})).toThrow();
expect(() => parseAnalyticsQuery({view:'original',vendorGatewayId:'g'})).toThrow();
});
it('rejects invalid percentage and pagination values', () => {
expect(() => parseAnalyticsQuery({connectionMin:'101'})).toThrow();
expect(() => parseAnalyticsQuery({take:'1.5'})).toThrow();
});
});
@@ -0,0 +1,113 @@
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ANALYTICS_VERSION, COUNT_KEYS, Prisma, analyticsRates, emptyCounts, type AnalyticsCounts } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
import type { CurrentUser } from '../security/security.metadata.js';
type Query = Record<string, unknown>;
export interface AnalyticsQuery {
view: 'original' | 'landing'; from: Date; to: Date; customerId: string; caller: string;
customerGatewayId: string; vendorId: string; vendorGatewayId: string; city: string; carrier: string;
minSamples: number; skip: number; take: number; sort: string; order: string; onlyAlerts: boolean;
connectionMin: number; connectionMax: number; overallMin: number; overallMax: number; connectedMin: number; connectedMax: number;
}
export function parseAnalyticsQuery(raw: Query, now = new Date()): AnalyticsQuery {
const str = (key: string, max: number) => {
const v = raw[key] ?? ''; if (typeof v !== 'string' || v.length > max) throw new BadRequestException(`Invalid ${key}`); return v.trim();
};
const number = (key: string, fallback: number, min: number, max: number) => {
const v = raw[key] === undefined || raw[key] === '' ? fallback : Number(raw[key]);
if (!Number.isFinite(v) || v < min || v > max) throw new BadRequestException(`Invalid ${key}`); return v;
};
const view = str('view', 16) || 'landing'; if (!['original', 'landing'].includes(view)) throw new BadRequestException('Invalid view');
const to = raw.to ? new Date(str('to', 40)) : now;
const from = raw.from ? new Date(str('from', 40)) : new Date(to.getTime() - number('minutes', 15, 1, 10080) * 60000);
if (!Number.isFinite(+from) || !Number.isFinite(+to) || from >= to || +to - +from > 7 * 86400000 || +to > +now + 5000) throw new BadRequestException('Time range must be within 7 days and not in future');
const sort = str('sort', 32) || 'totalCalls';
if (!['caller', ...COUNT_KEYS, '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] = await this.db.$transaction([
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT customer_id AS customerId, caller, ${countSql} FROM (${source}) a GROUP BY customer_id, caller HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 10001`),
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT FLOOR(UNIX_TIMESTAMP(started_at)/60)*60000 AS time, ${countSql} FROM (${source}) a GROUP BY time ORDER BY time`),
this.db.$queryRaw<Array<{ payload: Record<string, unknown>; updated_at: Date }>>`SELECT payload, updated_at FROM caller_analysis_health WHERE id='worker'`,
this.db.$queryRaw<Array<{ customer_id: string; caller: string; payload: unknown; status: string }>>(Prisma.sql`SELECT customer_id, caller, payload, status FROM caller_analysis_alerts WHERE view=${q.view} ${scope ? Prisma.sql`AND customer_id IN (${Prisma.join(scope)})` : Prisma.empty} AND status='ACTIVE'`)
]);
if (numbers.length > 10000) throw new BadRequestException('号码超过10000个,请缩短时间或筛选客户');
const annotated = numbers.map(r => ({ ...normalize(r), alert: alerts.find(a => a.customer_id === r.customerId && a.caller === r.caller)?.payload ?? null }));
const fits = (v: number | null, min: number, max: number) => v === null ? min === 0 && max === 100 : v >= min && v <= max;
const filtered = annotated.filter(r => r.totalCalls >= q.minSamples && (!q.onlyAlerts || r.alert) && fits(r.connectionRate, q.connectionMin, q.connectionMax) && fits(r.overallAnswerRate, q.overallMin, q.overallMax) && fits(r.connectedAnswerRate, q.connectedMin, q.connectedMax));
filtered.sort((a, b) => {
const av = a[q.sort as keyof typeof a]; const bv = b[q.sort as keyof typeof b];
if (av === null) return bv === null ? 0 : 1; if (bv === null) return -1;
const cmp = typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : Number(av) - Number(bv);
return (q.order === 'asc' ? cmp : -cmp) || String(a.caller).localeCompare(String(b.caller));
});
const sum = annotated.reduce((a, r) => { for (const k of COUNT_KEYS) a[k] += r[k]; return a; }, emptyCounts());
const h = health[0]; const quality = !h || Date.now() - +h.updated_at > 10000 || h.payload.degraded ? 'DEGRADED' : 'LIVE';
return { metricVersion: ANALYTICS_VERSION, view: q.view, from: q.from, to: q.to, asOf: new Date(), qualityStatus: quality,
dataThrough: h?.payload.dataThrough ?? null, lagMs: h?.payload.lagMs ?? null, durationPrecision: 'milliseconds',
health: h?.payload ?? null, summary: analyticsRates(sum), summaryScope: '时间及业务筛选;样本/比例/异常筛选仅影响号码列表',
numbers: filtered.slice(q.skip, q.skip + q.take), total: filtered.length, skip: q.skip, take: q.take,
trends: trends.map(r => ({ ...normalize(r), time: Number(r.time) })), historyNotice: '仅统计新采集链路的数据;旧固定6秒话单不参与应答统计或校准。' };
}
async detail(raw: Query, user: CurrentUser) {
const q = parseAnalyticsQuery(raw); if (!q.caller || !q.customerId) throw new BadRequestException('请选择客户和主叫号码');
const scope = await this.scope(user, q.customerId); const where = this.where(q, scope);
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>(Prisma.sql`SELECT id, call_key AS callKey, started_at AS startedAt, caller, callee, vendor_gateway_id AS vendorGatewayId, counts, payload FROM caller_analysis_states WHERE ${where} AND started_at>=${q.from} AND started_at<${q.to} ORDER BY started_at DESC, id DESC LIMIT ${q.take} OFFSET ${q.skip}`);
const source = this.source(q, scope);
const breakdown = await this.db.$queryRaw<Row[]>(Prisma.sql`SELECT vendor_gateway_id AS vendorGatewayId, city, carrier, ${countSql} FROM (${source}) a GROUP BY vendor_gateway_id, city, carrier HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 500`);
return { rows, breakdown: breakdown.map(normalize), skip: q.skip, take: q.take };
}
async options(user: CurrentUser) {
const scope = await this.scope(user, '');
const [customers, customerGateways, vendorGateways] = await Promise.all([
this.db.customer.findMany({ where: { ...(scope ? { id: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true }, take: 2000 }),
this.db.customerGateway.findMany({ where: { ...(scope ? { customerId: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true, customerId: true }, take: 2000 }),
// Limited users only receive gateway IDs they actually have traffic on, not the global vendor configuration.
scope ? this.db.$queryRaw<Array<{ id: string; name: string }>>(Prisma.sql`SELECT DISTINCT vendor_gateway_id AS id, vendor_gateway_id AS name FROM caller_analysis_states WHERE customer_id IN (${Prisma.join(scope)}) AND vendor_gateway_id<>'' LIMIT 2000`) : this.db.vendorGateway.findMany({ where: { deletedAt: null }, select: { id: true, name: true }, take: 2000 })
]);
return { customers, customerGateways, vendorGateways };
}
}
+2 -2
View File
@@ -202,10 +202,10 @@ export class PrismaCdrsRepository implements CdrsRepository {
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> { async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
const where = this.where(query); const where = this.where(query);
const [items, total] = await this.prisma.$transaction([ const [items, total] = await Promise.all([
this.prisma.rawCdr.findMany({ this.prisma.rawCdr.findMany({
where, where,
orderBy: [{ startedAt: 'desc' }], orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
take: query.take, take: query.take,
skip: query.skip, skip: query.skip,
include: this.includeCdr() include: this.includeCdr()
@@ -75,6 +75,15 @@ class MemoryCdrsRepository implements CdrsRepository {
} }
describe('CDR service', () => { describe('CDR service', () => {
it('uses a 25-row default page to keep the first list response small', async () => {
const repository = new MemoryCdrsRepository();
const service = new CdrsService(repository);
await service.list({});
expect(repository.lastQuery).toMatchObject({ take: 25, skip: 0 });
});
it('normalizes filters for city, carrier and time range queries', async () => { it('normalizes filters for city, carrier and time range queries', async () => {
const repository = new MemoryCdrsRepository(); const repository = new MemoryCdrsRepository();
const service = new CdrsService(repository); const service = new CdrsService(repository);
+1 -1
View File
@@ -15,7 +15,7 @@ export class CdrsService {
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier), carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from), startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to), startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
take: this.int(rawQuery.take, 100, 1, 500), take: this.int(rawQuery.take, 25, 1, 500),
skip: this.int(rawQuery.skip, 0, 0, 1_000_000) skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
}; };
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) { if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
@@ -14,6 +14,12 @@ export class DashboardController {
return this.dashboardService.summary(); return this.dashboardService.summary();
} }
@Get('overview')
@RequirePermissions('dashboard.view')
overview(@Query() query: { hours?: string; bucketMinutes?: string }) {
return this.dashboardService.overview(query);
}
@Get('trends') @Get('trends')
@RequirePermissions('dashboard.view') @RequirePermissions('dashboard.view')
trends(@Query() query: { hours?: string; bucketMinutes?: string }) { trends(@Query() query: { hours?: string; bucketMinutes?: string }) {
@@ -4,6 +4,22 @@ import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc
import type { DashboardRepository } from './dashboard.repository.js'; import type { DashboardRepository } from './dashboard.repository.js';
describe('dashboard service', () => { describe('dashboard service', () => {
it('loads summary and trends together for the overview response', async () => {
const repository = {
summaryWindow: async () => ({
cdrs: [], ratedCdrs: [], failureCodes: [], abnormalGateways: [],
activeCustomers: 0, activeCustomerGateways: 0, activeVendorGateways: 0, pendingQuality: 0
}),
trendWindow: async () => ({ cdrs: [], ratedCdrs: [] })
} as unknown as DashboardRepository;
const service = new DashboardService(repository);
const result = await service.overview({ hours: 1, bucketMinutes: 60 });
expect(result.summary).toHaveProperty('calls.totalCalls', 0);
expect(result.trends.buckets).toHaveLength(1);
});
it('uses Asia/Shanghai day boundary for today summary', async () => { it('uses Asia/Shanghai day boundary for today summary', async () => {
const calls: Array<{ start: Date; end: Date }> = []; const calls: Array<{ start: Date; end: Date }> = [];
const repository = { const repository = {
@@ -36,6 +36,14 @@ export interface DashboardTrendBucket {
export class DashboardService { export class DashboardService {
constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {} constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {}
async overview(query: { hours?: unknown; bucketMinutes?: unknown } = {}) {
const [summary, trends] = await Promise.all([
this.summary(),
this.trends(query)
]);
return { summary, trends };
}
async summary(now = new Date()) { async summary(now = new Date()) {
const start = startOfShanghaiDayUtc(now); const start = startOfShanghaiDayUtc(now);
const snapshot = await this.dashboard.summaryWindow(start, now); const snapshot = await this.dashboard.summaryWindow(start, now);
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY'); export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
export interface RecordingListQuery {
status?: string;
reviewStatus?: 'PENDING' | 'REVIEWED';
limit?: number;
startedFrom?: Date;
startedTo?: Date;
}
export interface RecordingsRepository { export interface RecordingsRepository {
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>; list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
getDetail(id: string): Promise<RecordingDetail>; getDetail(id: string): Promise<RecordingDetail>;
getReadyForPlayback(id: string): Promise<RecordingPlayback>; getReadyForPlayback(id: string): Promise<RecordingPlayback>;
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>; saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
export class PrismaRecordingsRepository implements RecordingsRepository { export class PrismaRecordingsRepository implements RecordingsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> { async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
const take = Math.min(query.limit ?? 100, 500); const take = Math.min(query.limit ?? 100, 500);
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
const recordings = await this.prisma.recording.findMany({ const recordings = await this.prisma.recording.findMany({
where: { where: {
status: query.status ? (query.status as never) : 'READY', status: query.status ? (query.status as never) : 'READY',
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
OR: startedAt ? [
{ rawCdr: { is: { startedAt } } },
{ rawCdrId: null, createdAt: startedAt }
] : undefined
}, },
orderBy: [{ createdAt: 'desc' }], orderBy: [{ createdAt: 'desc' }],
take, take,
@@ -26,11 +26,13 @@ export class RecordingsService {
@Inject(QualityService) private readonly qualityService: QualityService @Inject(QualityService) private readonly qualityService: QualityService
) {} ) {}
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) { async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
const recordings = await this.recordings.list({ const recordings = await this.recordings.list({
status: query.status === undefined ? undefined : this.recordingStatus(query.status), status: query.status === undefined ? undefined : this.recordingStatus(query.status),
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus), reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500) limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
}); });
return Promise.all(recordings.map((recording) => this.withSampling(recording))); return Promise.all(recordings.map((recording) => this.withSampling(recording)));
} }
@@ -123,6 +125,17 @@ export class RecordingsService {
} }
return parsed; return parsed;
} }
private date(value: unknown, field: string): Date {
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
} }
export function internalRecordingPath(storageKey: string): string { export function internalRecordingPath(storageKey: string): string {
+6
View File
@@ -19,6 +19,8 @@ export interface RuntimeConfig {
url: string; url: string;
}; };
activeCalls: { activeCalls: {
mode: 'http' | 'ssh';
httpUrl: string;
sshHost: string; sshHost: string;
sshConfig?: string; sshConfig?: string;
remoteCommand: string; remoteCommand: string;
@@ -47,6 +49,8 @@ export const validationSchema = Joi.object({
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'), LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(), DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(), REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
ACTIVE_CALLS_MODE: Joi.string().valid('http', 'ssh').default('http'),
ACTIVE_CALLS_HTTP_URL: Joi.string().uri({ scheme: ['http', 'https'] }).default('http://127.0.0.1:8888/mi'),
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'), ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(), ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'), ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
@@ -89,6 +93,8 @@ export function appConfig(): RuntimeConfig {
url: process.env.REDIS_URL ?? '' url: process.env.REDIS_URL ?? ''
}, },
activeCalls: { activeCalls: {
mode: process.env.ACTIVE_CALLS_MODE === 'ssh' ? 'ssh' : 'http',
httpUrl: process.env.ACTIVE_CALLS_HTTP_URL ?? 'http://127.0.0.1:8888/mi',
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a', sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined, sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control', remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
+103 -41
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx'; import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
import { Icon } from './components/layout.jsx'; import { Icon, PageLoadingDialog } from './components/layout.jsx';
import { api, explainApiError } from './api.js'; import { api, explainApiError } from './api.js';
import { dateRangeParams, lastSevenDays } from './utils/dateRange.js';
import { import {
formatCurrency, formatCurrency,
normalizeAuditLog, normalizeAuditLog,
@@ -15,6 +16,7 @@ import {
normalizeVendorGateway, normalizeVendorGateway,
} from './utils/formatters.js'; } from './utils/formatters.js';
import { DashboardPage } from './pages/DashboardPage.jsx'; import { DashboardPage } from './pages/DashboardPage.jsx';
import { CallerAnalyticsPage } from './pages/CallerAnalyticsPage.jsx';
import { ActiveCallsPage } from './pages/ActiveCallsPage.jsx'; import { ActiveCallsPage } from './pages/ActiveCallsPage.jsx';
import { CustomersPage } from './pages/CustomersPage.jsx'; import { CustomersPage } from './pages/CustomersPage.jsx';
import { RechargeRecordsPage } from './pages/RechargeRecordsPage.jsx'; import { RechargeRecordsPage } from './pages/RechargeRecordsPage.jsx';
@@ -57,6 +59,7 @@ const navGroups = [
items: [ items: [
{ key: 'activeCalls', label: '当前通话', permissions: PAGE_PERMISSIONS.activeCalls }, { key: 'activeCalls', label: '当前通话', permissions: PAGE_PERMISSIONS.activeCalls },
{ key: 'cdr', label: '话单中心', permissions: PAGE_PERMISSIONS.cdr }, { key: 'cdr', label: '话单中心', permissions: PAGE_PERMISSIONS.cdr },
{ key: 'callerAnalytics', label: '主叫号码分析', permissions: PAGE_PERMISSIONS.callerAnalytics },
{ key: 'quality', label: '质检中心', permissions: PAGE_PERMISSIONS.quality }, { key: 'quality', label: '质检中心', permissions: PAGE_PERMISSIONS.quality },
{ key: 'sipops', label: 'SIP 运维', pending: true }, { key: 'sipops', label: 'SIP 运维', pending: true },
{ key: 'monitoring', label: '监控告警', pending: true }, { key: 'monitoring', label: '监控告警', pending: true },
@@ -75,6 +78,7 @@ const navGroups = [
const pages = { const pages = {
callerAnalytics: CallerAnalyticsPage,
dashboard: DashboardPage, dashboard: DashboardPage,
activeCalls: ActiveCallsPage, activeCalls: ActiveCallsPage,
customers: CustomersPage, customers: CustomersPage,
@@ -238,9 +242,13 @@ export default function App() {
const [dashboardTrends, setDashboardTrends] = useState(null); const [dashboardTrends, setDashboardTrends] = useState(null);
const [activeCalls, setActiveCalls] = useState([]); const [activeCalls, setActiveCalls] = useState([]);
const [activeCallsLoading, setActiveCallsLoading] = useState(false); const [activeCallsLoading, setActiveCallsLoading] = useState(false);
const [activeCallsBlockingLoading, setActiveCallsBlockingLoading] = useState(false);
const [activeCallsError, setActiveCallsError] = useState(''); const [activeCallsError, setActiveCallsError] = useState('');
const [activeCallsUpdatedAt, setActiveCallsUpdatedAt] = useState(null);
const [apiLoading, setApiLoading] = useState(true); const [apiLoading, setApiLoading] = useState(true);
const [apiError, setApiError] = useState(''); const [apiError, setApiError] = useState('');
const loadedPagesRef = useRef(new Set());
const pageRequestRef = useRef({ id: 0, controller: null });
const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]); const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]);
const canPermission = (permission) => can(currentPermissions, permission); const canPermission = (permission) => can(currentPermissions, permission);
const allowedNavGroups = useMemo(() => navGroups const allowedNavGroups = useMemo(() => navGroups
@@ -249,55 +257,99 @@ export default function App() {
const ActivePage = pages[active]; const ActivePage = pages[active];
const activeLabel = useMemo(() => navGroups.flatMap((group) => group.items).find((item) => item.key === active)?.label, [active]); const activeLabel = useMemo(() => navGroups.flatMap((group) => group.items).find((item) => item.key === active)?.label, [active]);
const canAccessPage = (key, permissions = currentPermissions) => canAll(permissions, PAGE_PERMISSIONS[key] || []); const canAccessPage = (key, permissions = currentPermissions) => canAll(permissions, PAGE_PERMISSIONS[key] || []);
const refreshApi = async (user = authUser) => { const loadPageData = async (page = active, user = authUser, { force = false } = {}) => {
const sourceUser = user && Array.isArray(user.permissions) ? user : authUser; const sourceUser = user && Array.isArray(user.permissions) ? user : authUser;
if (!sourceUser) return;
if (!force && loadedPagesRef.current.has(page)) {
setApiLoading(false);
setApiError('');
return;
}
const granted = permissionSet(sourceUser); const granted = permissionSet(sourceUser);
const skipped = Symbol('skipped'); pageRequestRef.current.controller?.abort('PAGE_CHANGED');
const loadIfAllowed = (permission, loader) => (can(granted, permission) ? loader() : Promise.resolve(skipped)); const controller = new AbortController();
const requestId = pageRequestRef.current.id + 1;
pageRequestRef.current = { id: requestId, controller };
setApiLoading(true); setApiLoading(true);
setApiError(''); setApiError('');
const [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList] = await Promise.allSettled([ const signalOptions = { signal: controller.signal };
loadIfAllowed('dashboard.view', api.dashboardSummary), const definitions = [];
loadIfAllowed('dashboard.view', api.dashboardTrends), const add = (permission, load, apply) => {
loadIfAllowed('customers.view', api.customers), if (can(granted, permission)) definitions.push({ load, apply });
loadIfAllowed('vendors.view', api.vendors), };
loadIfAllowed('customer_gateways.view', api.customerGateways), if (page === 'dashboard') {
loadIfAllowed('vendor_gateways.view', api.vendorGateways), add('dashboard.view', () => api.dashboardOverview(undefined, signalOptions), (value) => {
loadIfAllowed('line_groups.view', api.landingLineGroups), setDashboardSummary(value.summary);
loadIfAllowed('recharges.view', api.recharges), setDashboardTrends(value.trends);
loadIfAllowed('users.view', api.users), });
loadIfAllowed('roles.view', api.roles), } else if (page === 'customers') {
loadIfAllowed('audit.view', api.auditLogs), add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
]); } else if (page === 'vendors') {
const failures = [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList].filter((result) => result.status === 'rejected'); add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
if (summary.status === 'fulfilled' && summary.value !== skipped) setDashboardSummary(summary.value); } else if (page === 'customerGateways') {
if (trends.status === 'fulfilled' && trends.value !== skipped) setDashboardTrends(trends.value); add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
if (customerList.status === 'fulfilled' && customerList.value !== skipped) setCustomerRows(customerList.value.map(normalizeCustomer)); add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
if (vendorList.status === 'fulfilled' && vendorList.value !== skipped) setVendorRows(vendorList.value.map(normalizeVendor)); add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
if (customerGatewayList.status === 'fulfilled' && customerGatewayList.value !== skipped) setCustomerGatewayRows(customerGatewayList.value.map(normalizeCustomerGateway)); } else if (page === 'vendorGateways') {
if (vendorGatewayList.status === 'fulfilled' && vendorGatewayList.value !== skipped) setVendorGatewayRows(vendorGatewayList.value.map(normalizeVendorGateway)); add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
if (lineGroupList.status === 'fulfilled' && lineGroupList.value !== skipped) setLandingLineGroupRows(lineGroupList.value.map(normalizeLandingLineGroup)); add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
if (rechargeList.status === 'fulfilled' && rechargeList.value !== skipped) setRechargeRows(rechargeList.value.items.map(normalizeRecharge)); } else if (page === 'vendorLineGroups') {
if (userList.status === 'fulfilled' && userList.value !== skipped) setUserRows(userList.value.map(normalizeUser)); add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
if (roleList.status === 'fulfilled' && roleList.value !== skipped) setRoleRows(roleList.value.map(normalizeRole)); add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
if (auditLogList.status === 'fulfilled' && auditLogList.value !== skipped) setLogRows(auditLogList.value.items.map(normalizeAuditLog)); } else if (page === 'rechargeRecords') {
if (failures.length) { add('recharges.view', () => api.recharges(signalOptions), (value) => setRechargeRows(value.items.map(normalizeRecharge)));
setApiError(explainApiError(failures[0].reason)); } else if (page === 'users' || page === 'roles') {
add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser)));
add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole)));
} else if (page === 'operationLogs') {
const range = lastSevenDays();
add('audit.view', () => api.auditLogs(dateRangeParams(range.startDate, range.endDate, 'createdFrom', 'createdTo'), signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
} else if (page === 'cdr') {
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
} else if (page === 'qualityRuleDependencies') {
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
} }
const results = await Promise.allSettled(definitions.map((definition) => definition.load()));
if (pageRequestRef.current.id !== requestId) return;
const failures = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') definitions[index].apply(result.value);
else if (!controller.signal.aborted) failures.push(result.reason);
});
if (failures.length) setApiError(explainApiError(failures[0]));
else loadedPagesRef.current.add(page);
setApiLoading(false); setApiLoading(false);
}; };
const refreshActiveCalls = async () => { const refreshApi = () => loadPageData(active, authUser, { force: true });
const refreshAuditLogs = async (params = {}) => {
setApiLoading(true);
setApiError('');
try {
const value = await api.auditLogs(params);
setLogRows((value.items || []).map(normalizeAuditLog));
} catch (error) {
setApiError(explainApiError(error));
} finally {
setApiLoading(false);
}
};
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
setActiveCallsLoading(true); setActiveCallsLoading(true);
if (!silent) setActiveCallsBlockingLoading(true);
setActiveCallsError(''); setActiveCallsError('');
try { try {
const response = await api.activeCalls(); const response = await api.activeCalls();
setActiveCalls(response.items || []); setActiveCalls(response.items || []);
setActiveCallsUpdatedAt(new Date());
} catch (error) { } catch (error) {
setActiveCallsError(explainApiError(error)); setActiveCallsError(explainApiError(error));
} finally { } finally {
setActiveCallsLoading(false); setActiveCallsLoading(false);
if (!silent) setActiveCallsBlockingLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -305,7 +357,7 @@ export default function App() {
.then((response) => { .then((response) => {
if (!mounted) return; if (!mounted) return;
setAuthUser(response.user); setAuthUser(response.user);
void refreshApi(response.user); void loadPageData('dashboard', response.user, { force: true });
}) })
.catch(() => { .catch(() => {
if (!mounted) return; if (!mounted) return;
@@ -343,7 +395,7 @@ export default function App() {
if (firstAllowed) { if (firstAllowed) {
setActive(firstAllowed.key); setActive(firstAllowed.key);
} }
await refreshApi(response.user); await loadPageData(firstAllowed?.key || 'dashboard', response.user, { force: true });
}; };
const handleLogout = async () => { const handleLogout = async () => {
await api.logout(); await api.logout();
@@ -363,10 +415,12 @@ export default function App() {
setDashboardTrends(null); setDashboardTrends(null);
setActiveCalls([]); setActiveCalls([]);
setActiveCallsError(''); setActiveCallsError('');
loadedPagesRef.current.clear();
pageRequestRef.current.controller?.abort('LOGOUT');
}; };
const reloadAfterMutation = async (operation) => { const reloadAfterMutation = async (operation) => {
await operation(); await operation();
await refreshApi(); await loadPageData(active, authUser, { force: true });
}; };
const applyRechargeResult = (response) => { const applyRechargeResult = (response) => {
const normalized = normalizeRecharge(response); const normalized = normalizeRecharge(response);
@@ -458,7 +512,7 @@ export default function App() {
: active === 'rechargeRecords' : active === 'rechargeRecords'
? { rechargeRows, apiLoading, apiError, refreshApi } ? { rechargeRows, apiLoading, apiError, refreshApi }
: active === 'dashboard' : active === 'dashboard'
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows } ? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }
: active === 'businessPrefixes' : active === 'businessPrefixes'
? { can: canPermission } ? { can: canPermission }
: active === 'numberLibrary' : active === 'numberLibrary'
@@ -467,7 +521,9 @@ export default function App() {
? { ? {
activeCalls, activeCalls,
activeCallsLoading, activeCallsLoading,
activeCallsBlockingLoading,
activeCallsError, activeCallsError,
activeCallsUpdatedAt,
refreshActiveCalls, refreshActiveCalls,
can: canPermission, can: canPermission,
onHangupActiveCall: async (id) => { onHangupActiveCall: async (id) => {
@@ -478,13 +534,18 @@ export default function App() {
: active === 'cdr' : active === 'cdr'
? { customerGatewayRows, vendorGatewayRows, can: canPermission } ? { customerGatewayRows, vendorGatewayRows, can: canPermission }
: active === 'quality' : active === 'quality'
? { customerRows, lineGroupRows: landingLineGroupRows, can: canPermission } ? {
customerRows,
lineGroupRows: landingLineGroupRows,
can: canPermission,
loadRuleDependencies: () => loadPageData('qualityRuleDependencies', authUser),
}
: active === 'users' : active === 'users'
? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) } ? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) }
: active === 'roles' : active === 'roles'
? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) } ? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) }
: active === 'operationLogs' : active === 'operationLogs'
? { logRows, apiLoading, apiError, refreshApi } ? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs }
: {}; : {};
if (!authUser) { if (!authUser) {
@@ -527,7 +588,7 @@ export default function App() {
<div className="nav-group" key={group.title}> <div className="nav-group" key={group.title}>
<p>{group.title}</p> <p>{group.title}</p>
{group.items.filter((item) => !item.pending).map((item) => ( {group.items.filter((item) => !item.pending).map((item) => (
<button key={item.key} className={active === item.key ? 'active' : ''} onClick={() => setActive(item.key)} title={`${item.label}${item.pending ? '(待设计)' : ''}`}> <button key={item.key} className={active === item.key ? 'active' : ''} onClick={() => { setActive(item.key); void loadPageData(item.key); }} title={`${item.label}${item.pending ? '(待设计)' : ''}`}>
<span className="nav-short" aria-hidden="true">{item.label.slice(0, 1)}</span> <span className="nav-short" aria-hidden="true">{item.label.slice(0, 1)}</span>
<span className="nav-label">{item.label}</span> <span className="nav-label">{item.label}</span>
{item.pending ? <span className="nav-status">待设计</span> : null} {item.pending ? <span className="nav-status">待设计</span> : null}
@@ -553,6 +614,7 @@ export default function App() {
<main className="page-content"> <main className="page-content">
{canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />} {canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />}
</main> </main>
<PageLoadingDialog loading={apiLoading && active !== 'activeCalls'} />
</div> </div>
</div> </div>
); );
+54 -20
View File
@@ -22,6 +22,36 @@ export class ApiError extends Error {
} }
} }
const DEFAULT_TIMEOUT_MS = 10_000;
async function fetchWithTimeout(path, options = {}) {
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
const abortFromExternal = () => controller.abort(externalSignal?.reason);
if (externalSignal) {
if (externalSignal.aborted) {
abortFromExternal();
} else {
externalSignal.addEventListener('abort', abortFromExternal, { once: true });
}
}
const timeout = window.setTimeout(() => controller.abort('REQUEST_TIMEOUT'), timeoutMs);
try {
return await fetch(`${API_BASE}${path}`, {
...fetchOptions,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted && !externalSignal?.aborted) {
throw new ApiError('请求超过 10 秒未响应,请稍后重试。', { status: 408, code: 'REQUEST_TIMEOUT' });
}
throw error;
} finally {
window.clearTimeout(timeout);
externalSignal?.removeEventListener('abort', abortFromExternal);
}
}
async function request(path, options = {}) { async function request(path, options = {}) {
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY); const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
@@ -33,7 +63,7 @@ async function request(path, options = {}) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
const response = await fetch(`${API_BASE}${path}`, { const response = await fetchWithTimeout(path, {
...options, ...options,
headers, headers,
credentials: 'include', credentials: 'include',
@@ -58,7 +88,7 @@ async function requestBlob(path, options = {}) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
const response = await fetch(`${API_BASE}${path}`, { const response = await fetchWithTimeout(path, {
...options, ...options,
headers, headers,
credentials: 'include', credentials: 'include',
@@ -98,6 +128,9 @@ function queryString(params = {}) {
} }
export const api = { 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'), captcha: () => request('/auth/captcha'),
login: async (body) => { login: async (body) => {
const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) }); const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) });
@@ -116,13 +149,14 @@ export const api = {
setAccessToken(''); setAccessToken('');
} }
}, },
dashboardSummary: () => request('/dashboard/summary'), dashboardSummary: (options) => request('/dashboard/summary', options),
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`), dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/trends?${new URLSearchParams(params)}`, options),
activeCalls: () => request('/active-calls'), dashboardOverview: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/overview?${new URLSearchParams(params)}`, options),
activeCalls: (options) => request('/active-calls', options),
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }), hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`), cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`),
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`), cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`), recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 50, ...params })}`),
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`), recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`), recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`),
saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }), saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }),
@@ -132,7 +166,7 @@ export const api = {
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }), enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }), disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
customers: () => request('/customers'), customers: (options) => request('/customers', options),
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }), createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }), enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
@@ -143,7 +177,7 @@ export const api = {
method: 'POST', method: 'POST',
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }), body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }),
}), }),
vendors: () => request('/vendors'), vendors: (options) => request('/vendors', options),
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }), createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
@@ -152,39 +186,39 @@ export const api = {
method: 'POST', method: 'POST',
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }), body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
}), }),
customerGateways: () => request('/customer-gateways'), customerGateways: (options) => request('/customer-gateways', options),
createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }), createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }),
updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }), enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }), disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
vendorGateways: () => request('/vendor-gateways'), vendorGateways: (options) => request('/vendor-gateways', options),
createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }), createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }),
updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }), enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }), disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
landingLineGroups: () => request('/landing-line-groups'), landingLineGroups: (options) => request('/landing-line-groups', options),
deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }),
recharges: () => request('/recharges?take=100'), recharges: (options) => request('/recharges?take=100', options),
users: () => request('/users'), users: (options) => request('/users', options),
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
roles: () => request('/roles'), roles: (options) => request('/roles', options),
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
auditLogs: () => request('/audit-logs?take=100'), auditLogs: (params = {}, options) => request(`/audit-logs${queryString({ take: 100, ...params })}`, options),
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`), businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }), createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }), enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }), disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`), numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 25, ...params })}`),
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`), numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 25, ...params })}`),
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`), numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 25, ...params })}`),
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 100, ...params })}`), numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 25, ...params })}`),
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }), importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
}; };
+67 -6
View File
@@ -1,4 +1,5 @@
import { Alert, Badge, Button } from './ui.jsx'; import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Field, Input } from './ui.jsx';
const selectedBlue = '#2563EB'; const selectedBlue = '#2563EB';
@@ -41,6 +42,36 @@ export function Toolbar({ children }) {
return <div className="toolbar">{children}</div>; return <div className="toolbar">{children}</div>;
} }
export function DateRangeFields({ startDate, endDate, onStartChange, onEndChange }) {
return (
<>
<Field label="开始日期"><Input type="date" value={startDate} max={endDate || undefined} onChange={(event) => onStartChange(event.target.value)} /></Field>
<Field label="结束日期"><Input type="date" value={endDate} min={startDate || undefined} onChange={(event) => onEndChange(event.target.value)} /></Field>
</>
);
}
export function Pagination({ total, take, skip, count, loading, onPageChange, onPageSizeChange }) {
const start = total ? skip + 1 : 0;
const end = Math.min(total, skip + count);
return (
<div className="pagination-bar" aria-label="分页">
<span>显示 {start}-{end} {total} </span>
<div className="pagination-actions">
<label>每页
<select value={take} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<Button size="sm" variant="outline" disabled={loading || skip === 0} onClick={() => onPageChange(Math.max(0, skip - take))}>上一页</Button>
<Button size="sm" variant="outline" disabled={loading || skip + count >= total} onClick={() => onPageChange(skip + take)}>下一页</Button>
</div>
</div>
);
}
export function Panel({ title, aside, children, className = '' }) { export function Panel({ title, aside, children, className = '' }) {
return ( return (
<section className={`prototype-panel ${className}`}> <section className={`prototype-panel ${className}`}>
@@ -55,7 +86,7 @@ export function Panel({ title, aside, children, className = '' }) {
export function ApiNotice({ loading, error, onRetry }) { export function ApiNotice({ loading, error, onRetry }) {
if (loading) { if (loading) {
return <Alert title="正在读取真实 API">正在从 LisgloSIPS API 拉取页面数据</Alert>; return null;
} }
if (error) { if (error) {
return ( return (
@@ -68,6 +99,32 @@ export function ApiNotice({ loading, error, onRetry }) {
return null; return null;
} }
export function PageLoadingDialog({ loading, message = '页面加载中,请稍候…', delay = 2000 }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!loading) {
setVisible(false);
return undefined;
}
const timer = window.setTimeout(() => setVisible(true), delay);
return () => window.clearTimeout(timer);
}, [delay, loading]);
if (!visible) return null;
return (
<div className="page-loading-backdrop" role="status" aria-live="polite" aria-label={message}>
<div className="page-loading-dialog">
<span className="page-loading-spinner" aria-hidden="true" />
<div>
<strong>{message}</strong>
<span>正在读取真实业务数据请不要重复刷新</span>
</div>
</div>
</div>
);
}
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) { export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
return ( return (
<div className="empty-state"> <div className="empty-state">
@@ -139,10 +196,10 @@ export function StatusBadge({ children }) {
return <Badge tone={toneForStatus(children)}>{children}</Badge>; return <Badge tone={toneForStatus(children)}>{children}</Badge>;
} }
export function SimpleTable({ columns, rows, onRowClick, selectedKey }) { export function SimpleTable({ columns, rows, onRowClick, selectedKey, loading = false, emptyTitle = '暂无数据', emptyDescription }) {
return ( return (
<div className="table-shell"> <div className="table-shell">
<table className="proto-table"> <table className="proto-table" aria-busy={loading}>
{columns.some((column) => column.width) ? ( {columns.some((column) => column.width) ? (
<colgroup> <colgroup>
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)} {columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
@@ -154,10 +211,14 @@ export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.length === 0 ? ( {loading && rows.length === 0 ? Array.from({ length: 4 }, (_, rowIndex) => (
<tr className="table-skeleton-row" key={`skeleton-${rowIndex}`} aria-hidden="true">
{columns.map((column) => <td key={column.key}><span className="table-skeleton-cell" /></td>)}
</tr>
)) : rows.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length}> <td colSpan={columns.length}>
<EmptyState /> <EmptyState title={emptyTitle}>{emptyDescription}</EmptyState>
</td> </td>
</tr> </tr>
) : rows.map((row, index) => ( ) : rows.map((row, index) => (
+13 -9
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Badge, Button } from '../components/ui.jsx'; import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx'; import { Icon, PageTitle, Panel, ApiNotice, PageLoadingDialog, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { formatDateTime, formatDurationText } from '../utils/formatters.js'; import { formatDateTime, formatDurationText } from '../utils/formatters.js';
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) { export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsBlockingLoading, activeCallsError, activeCallsUpdatedAt, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
const [busyId, setBusyId] = useState(''); const [busyId, setBusyId] = useState('');
const [hangupTarget, setHangupTarget] = useState(null); const [hangupTarget, setHangupTarget] = useState(null);
const [autoRefresh, setAutoRefresh] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true);
@@ -36,32 +36,36 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
} }
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
if (!activeCallsLoading) { if (!activeCallsLoading) {
void refreshActiveCalls(); void refreshActiveCalls({ silent: true });
} }
}, 5000); }, 5000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]); }, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
useEffect(() => {
if (activeCallsError) setAutoRefresh(false);
}, [activeCallsError]);
return ( return (
<> <>
<PageTitle <PageTitle
title="当前通话" title="当前通话"
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。" desc="查看交换机当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
actions={( actions={(
<div className="table-actions"> <div className="table-actions">
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}> <Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
{autoRefresh ? '自动刷新中' : '开启自动刷新'} {autoRefresh ? '自动刷新中' : '开启自动刷新'}
</Button> </Button>
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button> <Button icon={<Icon type="reload" />} onClick={() => refreshActiveCalls()} disabled={activeCallsLoading}>刷新通话</Button>
</div> </div>
)} )}
/> />
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={refreshActiveCalls} /> <ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={() => refreshActiveCalls()} />
<PageLoadingDialog loading={activeCallsBlockingLoading} />
<section className="metric-grid active-call-metrics"> <section className="metric-grid active-call-metrics">
<div className="metric-card"> <div className="metric-card">
<span>当前通话数</span> <span>当前通话数</span>
<strong>{rows.length}</strong> <strong>{rows.length}</strong>
<em className="metric-neutral">OpenSIPS MI</em> <em className="metric-neutral">实时交换机数据</em>
</div> </div>
<div className="metric-card"> <div className="metric-card">
<span>最长通话</span> <span>最长通话</span>
@@ -71,11 +75,11 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
<div className="metric-card"> <div className="metric-card">
<span>控制面</span> <span>控制面</span>
<strong>{activeCallsError ? '异常' : '就绪'}</strong> <strong>{activeCallsError ? '异常' : '就绪'}</strong>
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>MI 受控访问</em> <em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>{activeCallsUpdatedAt ? `更新于 ${activeCallsUpdatedAt.toLocaleTimeString('zh-CN', { hour12: false })}` : '受控接口访问'}</em>
</div> </div>
</section> </section>
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} </Badge>} className="wide-panel"> <Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} </Badge>} className="wide-panel">
<SimpleTable rows={rows} columns={[ <SimpleTable loading={activeCallsBlockingLoading} rows={rows} columns={[
{ key: 'callId', label: 'Call-ID' }, { key: 'callId', label: 'Call-ID' },
{ key: 'callerText', label: '主叫' }, { key: 'callerText', label: '主叫' },
{ key: 'calleeText', label: '被叫' }, { key: 'calleeText', label: '被叫' },
+3 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx'; import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { enStatus, formatDate, zhStatus } from '../utils/formatters.js'; import { enStatus, formatDate, zhStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
@@ -145,6 +145,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null} actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
/> />
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} /> <ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
<PageLoadingDialog loading={loading} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null} {message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Panel <Panel
title="业务前缀" title="业务前缀"
@@ -164,7 +165,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
</Field> </Field>
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button> <Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
</Toolbar> </Toolbar>
<SimpleTable rows={rows} columns={[ <SimpleTable loading={loading} rows={rows} columns={[
{ key: 'prefix', label: '业务前缀', width: '120px' }, { key: 'prefix', label: '业务前缀', width: '120px' },
{ key: 'name', label: '名称', width: '160px' }, { key: 'name', label: '名称', width: '160px' },
{ key: 'priority', label: '优先级', width: '90px' }, { key: 'priority', label: '优先级', width: '90px' },
+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: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
export function CallerAnalyticsPage() {
const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial);
const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendorGateways: [] });
const [auto, setAuto] = useState(true); const [tick, setTick] = useState(0);
const [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
const [detailSkip, setDetailSkip] = useState(0); const sequence = useRef(0);
useEffect(() => { const c = new AbortController(); api.callerAnalyticsOptions({ signal: c.signal }).then(setOptions).catch(e => { if (!c.signal.aborted) setError(explainApiError(e)); }); return () => c.abort(); }, []);
useEffect(() => {
const controller = new AbortController(); const id = ++sequence.current;
let timer;
const refresh = async () => {
setLoading(true);
try {
const q = { ...filters, onlyAlerts: String(filters.onlyAlerts) };
if (q.minutes !== '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' ? { 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('落地网关', 'vendorGatewayId', options.vendorGateways) : null}
{select('被叫运营商', 'carrier', ['MOBILE','UNICOM','TELECOM','BROADCAST','MVNO','UNKNOWN'].map((id,i) => ({id,name:['移动','联通','电信','广电','虚拟运营商','未知'][i]})))}
<Field label="被叫城市代码"><Input value={draft.city} onChange={e => change('city', e.target.value)} /></Field>
<Field label="最小呼叫数"><Input type="number" min="0" value={draft.minSamples} onChange={e => change('minSamples', e.target.value)} /></Field>
<Field label="排序"><Select value={draft.sort} onChange={e => change('sort', e.target.value)}>{[...countColumns, ...rateColumns].map(c => <option key={c.key} value={c.key}>{c.label}</option>)}</Select></Field>
<Field label="顺序"><Select value={draft.order} onChange={e => change('order', e.target.value)}><option value="desc">从高到低</option><option value="asc">从低到高</option></Select></Field>
<Field label="异常筛选"><Select value={String(draft.onlyAlerts)} onChange={e => change('onlyAlerts', e.target.value === 'true')}><option value="false">全部号码</option><option value="true">仅看告警</option></Select></Field>
</Toolbar>
<details className="cra-rate-filters"><summary>接通率 / 应答率区间</summary><Toolbar>{[['connection','接通率'],['overall','总体应答率'],['connected','已接通应答率']].map(([key,label]) => <Field key={key} label={`${label}%`}><span className="table-actions"><Input aria-label={`${label}下限`} type="number" min="0" max="100" value={draft[`${key}Min`] ?? ''} onChange={e => change(`${key}Min`, e.target.value)} placeholder="0" /><span></span><Input aria-label={`${label}上限`} type="number" min="0" max="100" value={draft[`${key}Max`] ?? ''} onChange={e => change(`${key}Max`, e.target.value)} placeholder="100" /></span></Field>)}</Toolbar></details>
<Button type="submit">查询</Button>
</form></Panel>
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)}${formatDateTime(data.to)} · ${data.qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 未知 ${metrics.unknownCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}183 不代表实际振铃未接通数含待接通进行中指标为暂定值采集运行状态不代表窗口数据完整性已核验端到端延迟尚未测定</Alert> : null}
<Panel title="分钟趋势" aside={<span className="muted-text">呼叫 / 接通 / 绿应答</span>}>
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
</Panel>
<Panel title="主叫号码列表" aside={<Badge>{data?.total ?? 0} 个号码</Badge>} className="wide-panel">
<SimpleTable rows={(data?.numbers ?? []).map(r => ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[
{key:'caller',label:'主叫号码',render:r => <Button size="sm" variant="ghost" onClick={() => openDetail(r)}>{r.caller}</Button>}, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns,
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls ? '证据不足' : '观察'}</Badge>}
]} />
<Pagination total={data?.total ?? 0} take={filters.take} skip={filters.skip} count={data?.numbers.length ?? 0} loading={loading} onPageChange={skip => setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} />
</Panel>
{target ? <Drawer title={`号码详情 · ${target.caller}`} onClose={() => setTarget(null)}>
{detailError ? <Alert tone="danger" title="详情更新失败">{detailError}</Alert> : null}
<p>{names[target.customerId] || target.customerId} · {filters.view === 'landing' ? '落地主叫' : '原始主叫'}</p>
<Panel title="线路 / 地区 / 运营商分布"><SimpleTable rows={(detail?.breakdown ?? []).map((r,i) => ({...r,id:i,gateway:gatewayNames[r.vendorGatewayId] || r.vendorGatewayId || '业务呼叫汇总'}))} columns={[{key:'gateway',label:'落地网关'},{key:'city',label:'城市'},{key:'carrier',label:'运营商'},...countColumns,...rateColumns]} /></Panel>
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
const p=r.payload; const legs=p.legs ? Object.values(p.legs) : [p];
return {...r, callId:p.start?.callId || p.event?.callId, original:p.start?.caller || p.event?.caller,
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : '未达接通').join(' / '),
final:legs.map(l => l.finalCode || '进行中').join(' / '), duration:`${(r.counts.talkMs/1000).toFixed(3)}${r.counts.activeCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'最终结果'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
<div className="table-actions"><Button variant="outline" disabled={!detailSkip} onClick={() => setDetailSkip(v => Math.max(0,v-25))}>上一页</Button><Button variant="outline" disabled={(detail?.rows.length ?? 0)<25} onClick={() => setDetailSkip(v => v+25)}>下一页</Button></div>
<p className="muted-text"> Call-ID 对应原始话单或信令旧固定时长话单仅作追溯不参与本统计校准</p>
</Drawer> : null}
</>;
}
+18 -21
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx'; import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js'; import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const defaultRange = lastSevenDays();
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) { export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
const [detailCdr, setDetailCdr] = useState(null); const [detailCdr, setDetailCdr] = useState(null);
const [cdrRows, setCdrRows] = useState([]); const [cdrRows, setCdrRows] = useState([]);
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false }); const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 25, skip: 0, hasMore: false });
const [cdrLoading, setCdrLoading] = useState(false); const [cdrLoading, setCdrLoading] = useState(false);
const [cdrError, setCdrError] = useState(''); const [cdrError, setCdrError] = useState('');
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
@@ -24,9 +27,9 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
vendorGatewayId: 'all', vendorGatewayId: 'all',
cityCode: '', cityCode: '',
carrier: 'all', carrier: 'all',
startedFrom: '', startedFrom: defaultRange.startDate,
startedTo: '', startedTo: defaultRange.endDate,
take: '50', take: '25',
skip: 0, skip: 0,
}); });
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6)); const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
@@ -100,8 +103,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
vendorGatewayId: nextFilters.vendorGatewayId, vendorGatewayId: nextFilters.vendorGatewayId,
cityCode: nextFilters.cityCode.trim(), cityCode: nextFilters.cityCode.trim(),
carrier: nextFilters.carrier, carrier: nextFilters.carrier,
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '', ...dateRangeParams(nextFilters.startedFrom, nextFilters.startedTo),
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
take: nextFilters.take, take: nextFilters.take,
skip: String(nextFilters.skip), skip: String(nextFilters.skip),
}); });
@@ -133,12 +135,12 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
void loadCdrs(nextFilters); void loadCdrs(nextFilters);
}; };
const resetFilters = () => { const resetFilters = () => {
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 }; const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: defaultRange.startDate, startedTo: defaultRange.endDate, take: filters.take, skip: 0 };
setFilters(nextFilters); setFilters(nextFilters);
void loadCdrs(nextFilters); void loadCdrs(nextFilters);
}; };
const changePage = (direction) => { const changePage = (direction) => {
const take = Number(filters.take) || 50; const take = Number(filters.take) || 25;
const nextSkip = Math.max(0, filters.skip + direction * take); const nextSkip = Math.max(0, filters.skip + direction * take);
const nextFilters = { ...filters, skip: nextSkip }; const nextFilters = { ...filters, skip: nextSkip };
setFilters(nextFilters); setFilters(nextFilters);
@@ -202,6 +204,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>} actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
/> />
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} /> <ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
<PageLoadingDialog loading={cdrLoading} />
<Toolbar> <Toolbar>
<Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field> <Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field>
<Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field> <Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field>
@@ -229,8 +232,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
<option value="UNKNOWN">未知</option> <option value="UNKNOWN">未知</option>
</Select> </Select>
</Field> </Field>
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field> <DateRangeFields startDate={filters.startedFrom} endDate={filters.startedTo} onStartChange={(value) => updateFilter('startedFrom', value)} onEndChange={(value) => updateFilter('startedTo', value)} />
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
<Field label="每页"> <Field label="每页">
<Select value={filters.take} onChange={(event) => { <Select value={filters.take} onChange={(event) => {
const nextFilters = { ...filters, take: event.target.value, skip: 0 }; const nextFilters = { ...filters, take: event.target.value, skip: 0 };
@@ -247,19 +249,14 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button> <Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
</Toolbar> </Toolbar>
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} </Badge>}> <Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} </Badge>}>
<SimpleTable rows={cdrRows} columns={[ <SimpleTable loading={cdrLoading} rows={cdrRows} columns={[
{ key: 'caller', label: '主叫号码' }, { key: 'caller', label: '主叫号码' },
{ key: 'callee', label: '被叫号码' }, { key: 'callee', label: '被叫号码' },
{ key: 'location', label: '地级市' }, { key: 'callIp', label: '呼叫 IP' },
{ key: 'operatorText', label: '运营商' }, { key: 'lineIp', label: '线路 IP' },
{ key: 'customerGatewayName', label: '客户网关名称' }, { key: 'customerFee', label: '客户费用' },
{ key: 'callIp', label: '呼叫IP地址' },
{ key: 'vendorGatewayName', label: '落地网关名称' },
{ key: 'lineIp', label: '线路IP地址' },
{ key: 'callTime', label: '呼叫时间' },
{ key: 'durationText', label: '通话时长' },
{ key: 'ratingStatusText', label: '计费状态', status: true },
{ key: 'recordingText', label: '录音', status: true }, { key: 'recordingText', label: '录音', status: true },
{ key: 'callTime', label: '呼叫时间' },
{ {
key: 'actions', key: 'actions',
label: '操作', label: '操作',
+1 -1
View File
@@ -296,7 +296,7 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null} {localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
<section className="content-grid"> <section className="content-grid">
<Panel title="客户网关列表" className="wide-panel"> <Panel title="客户网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[ <SimpleTable loading={apiLoading} rows={gatewayRows} columns={[
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' }, { key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' }, { key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' }, { key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' },
+1
View File
@@ -157,6 +157,7 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
<section className="master-detail"> <section className="master-detail">
<Panel title="客户列表" className="main-list wide-panel"> <Panel title="客户列表" className="main-list wide-panel">
<SimpleTable <SimpleTable
loading={apiLoading}
rows={customerRows} rows={customerRows}
columns={[ columns={[
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' }, { key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
+14 -24
View File
@@ -1,20 +1,26 @@
import { Badge, Button } from '../components/ui.jsx'; import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx'; import { Icon, PageTitle, Panel, ApiNotice, MiniBarChart, LineChart } from '../components/layout.jsx';
import { formatCurrency } from '../utils/formatters.js'; import { formatCurrency } from '../utils/formatters.js';
import { metrics, callTrend, answerTrend } from '../fixtures/devFixtures.js';
const pendingMetrics = [
'今日通话数', '当前在线通话', '今日接通率', '客户消费', '供应商成本', '今日毛利',
'在线注册用户', '活跃客户', '活跃落地网关', '异常网关', '质检待处理',
].map((label) => ({ label, value: '--', delta: '等待真实数据', tone: 'neutral' }));
const realtimeSourceLabel = (source) => source === 'not_configured' ? '实时数据暂未接入' : source || '实时数据';
function dashboardMetrics(summary) { function dashboardMetrics(summary) {
if (!summary) { if (!summary) {
return metrics; return pendingMetrics;
} }
return [ return [
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' }, { label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' }, { label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' },
{ label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' }, { label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' },
{ label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' }, { label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' },
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' }, { label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
{ label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' }, { label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' },
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' }, { label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' },
{ label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' }, { label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' },
{ label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' }, { label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' },
{ label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' }, { label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' },
@@ -22,11 +28,10 @@ function dashboardMetrics(summary) {
]; ];
} }
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) { export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }) {
const trendBuckets = dashboardTrends?.buckets || []; const trendBuckets = dashboardTrends?.buckets || [];
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend; const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0];
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend; const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
return ( return (
<> <>
<PageTitle <PageTitle
@@ -51,22 +56,7 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}> <Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} /> <LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
</Panel> </Panel>
<Panel title="客户消费 TOP 10">
<div className="rank-list">
{customerRows.length ? customerRows.slice(0, 3).map((item, index) => (
<div key={item.id}><span>{index + 1}</span><strong>{item.name}</strong><em>{item.balance}</em></div>
)) : <EmptyState title="暂无客户数据">客户 API 返回空列表</EmptyState>}
</div>
</Panel>
<Panel title="失败响应码分布">
<div className="code-grid">
{failureCodes.length ? failureCodes.map((code) => (
<div key={code.sipCode}><strong>{code.count}</strong><span>{code.sipCode}</span></div>
)) : <EmptyState title="暂无失败码">今日没有失败 CDR API 尚未返回数据</EmptyState>}
</div>
</Panel>
</section> </section>
</> </>
); );
} }
+41 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx'; import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable, Pagination } from '../components/layout.jsx';
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js'; import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
@@ -53,10 +53,10 @@ export function NumberLibraryPage({ can = () => true }) {
const [rows, setRows] = useState(emptyNumberLibraryRows); const [rows, setRows] = useState(emptyNumberLibraryRows);
const [totals, setTotals] = useState(emptyNumberLibraryTotals); const [totals, setTotals] = useState(emptyNumberLibraryTotals);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
cities: { keyword: '' }, cities: { keyword: '', take: 25, skip: 0 },
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' }, phoneSegments: { segment7: '', cityCode: '', carrier: 'all', take: 25, skip: 0 },
areaCodes: { areaCode: '', cityCode: '' }, areaCodes: { areaCode: '', cityCode: '', take: 25, skip: 0 },
carrierPrefixRules: { prefix: '', carrier: 'all' }, carrierPrefixRules: { prefix: '', carrier: 'all', take: 25, skip: 0 },
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -66,8 +66,7 @@ export function NumberLibraryPage({ can = () => true }) {
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const canManage = can('number_library.manage'); const canManage = can('number_library.manage');
const readTab = async (tab) => { const readTab = async (tab, params = filters[tab] || {}) => {
const params = filters[tab] || {};
if (tab === 'cities') { if (tab === 'cities') {
const payload = await api.numberLibraryCities(params); const payload = await api.numberLibraryCities(params);
return normalizeNumberLibraryList(payload, (item) => ({ return normalizeNumberLibraryList(payload, (item) => ({
@@ -120,11 +119,11 @@ export function NumberLibraryPage({ can = () => true }) {
})); }));
}; };
const loadTab = async (tab = activeTab) => { const loadTab = async (tab = activeTab, params = filters[tab]) => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const result = await readTab(tab); const result = await readTab(tab, params);
setRows((current) => ({ ...current, [tab]: result.rows })); setRows((current) => ({ ...current, [tab]: result.rows }));
setTotals((current) => ({ ...current, [tab]: result.total })); setTotals((current) => ({ ...current, [tab]: result.total }));
} catch (loadError) { } catch (loadError) {
@@ -136,18 +135,27 @@ export function NumberLibraryPage({ can = () => true }) {
useEffect(() => { useEffect(() => {
void loadTab('cities'); void loadTab('cities');
void loadTab('phoneSegments');
void loadTab('areaCodes');
void loadTab('carrierPrefixRules');
}, []); }, []);
const updateFilter = (key, value) => { const updateFilter = (key, value) => {
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
[activeTab]: { ...current[activeTab], [key]: value }, [activeTab]: { ...current[activeTab], [key]: value, skip: 0 },
})); }));
}; };
const searchActiveTab = () => {
const next = { ...filters[activeTab], skip: 0 };
setFilters((current) => ({ ...current, [activeTab]: next }));
void loadTab(activeTab, next);
};
const changePage = (skip, take = filters[activeTab].take) => {
const next = { ...filters[activeTab], skip, take };
setFilters((current) => ({ ...current, [activeTab]: next }));
void loadTab(activeTab, next);
};
const openImport = (tab) => { const openImport = (tab) => {
setImportTarget(tab); setImportTarget(tab);
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2)); setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
@@ -190,7 +198,7 @@ export function NumberLibraryPage({ can = () => true }) {
return ( return (
<> <>
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field> <Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
} }
@@ -210,7 +218,7 @@ export function NumberLibraryPage({ can = () => true }) {
<option value="UNKNOWN">未知</option> <option value="UNKNOWN">未知</option>
</Select> </Select>
</Field> </Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
} }
@@ -219,7 +227,7 @@ export function NumberLibraryPage({ can = () => true }) {
<> <>
<Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field> <Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field>
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field> <Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
} }
@@ -237,7 +245,7 @@ export function NumberLibraryPage({ can = () => true }) {
<option value="UNKNOWN">未知</option> <option value="UNKNOWN">未知</option>
</Select> </Select>
</Field> </Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button> <Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</> </>
); );
}; };
@@ -245,7 +253,7 @@ export function NumberLibraryPage({ can = () => true }) {
const renderTable = (tab) => { const renderTable = (tab) => {
if (tab === 'cities') { if (tab === 'cities') {
return ( return (
<SimpleTable rows={rows.cities} columns={[ <SimpleTable loading={loading} rows={rows.cities} columns={[
{ key: 'code', label: '地级市代码', width: '110px' }, { key: 'code', label: '地级市代码', width: '110px' },
{ key: 'provinceName', label: '省份', width: '120px' }, { key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityName', label: '地级市', width: '140px' }, { key: 'cityName', label: '地级市', width: '140px' },
@@ -258,7 +266,7 @@ export function NumberLibraryPage({ can = () => true }) {
} }
if (tab === 'phoneSegments') { if (tab === 'phoneSegments') {
return ( return (
<SimpleTable rows={rows.phoneSegments} columns={[ <SimpleTable loading={loading} rows={rows.phoneSegments} columns={[
{ key: 'segment7', label: '前 7 位', width: '110px' }, { key: 'segment7', label: '前 7 位', width: '110px' },
{ key: 'provinceName', label: '省份', width: '120px' }, { key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityCode', label: '地级市代码', width: '120px' }, { key: 'cityCode', label: '地级市代码', width: '120px' },
@@ -272,7 +280,7 @@ export function NumberLibraryPage({ can = () => true }) {
} }
if (tab === 'areaCodes') { if (tab === 'areaCodes') {
return ( return (
<SimpleTable rows={rows.areaCodes} columns={[ <SimpleTable loading={loading} rows={rows.areaCodes} columns={[
{ key: 'areaCode', label: '区号', width: '100px' }, { key: 'areaCode', label: '区号', width: '100px' },
{ key: 'provinceName', label: '省份', width: '120px' }, { key: 'provinceName', label: '省份', width: '120px' },
{ key: 'cityCode', label: '地级市代码', width: '120px' }, { key: 'cityCode', label: '地级市代码', width: '120px' },
@@ -283,7 +291,7 @@ export function NumberLibraryPage({ can = () => true }) {
); );
} }
return ( return (
<SimpleTable rows={rows.carrierPrefixRules} columns={[ <SimpleTable loading={loading} rows={rows.carrierPrefixRules} columns={[
{ key: 'prefix', label: '前缀', width: '100px' }, { key: 'prefix', label: '前缀', width: '100px' },
{ key: 'carrier', label: '运营商', width: '110px' }, { key: 'carrier', label: '运营商', width: '110px' },
{ key: 'priority', label: '优先级', width: '90px' }, { key: 'priority', label: '优先级', width: '90px' },
@@ -305,6 +313,7 @@ export function NumberLibraryPage({ can = () => true }) {
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null} actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
/> />
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} /> <ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
<PageLoadingDialog loading={loading} />
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null} {message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -329,6 +338,17 @@ export function NumberLibraryPage({ can = () => true }) {
</Toolbar> </Toolbar>
) : null} ) : null}
{renderTable(tab.value)} {renderTable(tab.value)}
{tab.value === activeTab ? (
<Pagination
total={totals[tab.value]}
take={filters[tab.value].take}
skip={filters[tab.value].skip}
count={rows[tab.value].length}
loading={loading}
onPageChange={(skip) => changePage(skip)}
onPageSizeChange={(take) => changePage(0, take)}
/>
) : null}
</Panel> </Panel>
), ),
}))} }))}
+27 -8
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx'; import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js'; import { operationLogRows } from '../fixtures/devFixtures.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const defaultRange = lastSevenDays();
function toneForStatus(status) { function toneForStatus(status) {
return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral'; return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
@@ -11,8 +14,8 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all'); const [moduleFilter, setModuleFilter] = useState('all');
const [resultFilter, setResultFilter] = useState('all'); const [resultFilter, setResultFilter] = useState('all');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState(defaultRange.endDate);
const [detailLog, setDetailLog] = useState(null); const [detailLog, setDetailLog] = useState(null);
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]); const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
const visibleLogs = useMemo(() => { const visibleLogs = useMemo(() => {
@@ -23,22 +26,38 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate); return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
}); });
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]); }, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); }; const queryLogs = (overrides = {}) => {
const next = { moduleFilter, resultFilter, startDate, endDate, ...overrides };
return refreshApi({
module: next.moduleFilter,
result: next.resultFilter === '成功' ? 'SUCCESS' : next.resultFilter === '失败' ? 'FAILURE' : 'all',
...dateRangeParams(next.startDate, next.endDate, 'createdFrom', 'createdTo'),
});
};
const resetFilters = () => {
setKeyword('');
setModuleFilter('all');
setResultFilter('all');
setStartDate(defaultRange.startDate);
setEndDate(defaultRange.endDate);
void queryLogs({ moduleFilter: 'all', resultFilter: 'all', ...defaultRange });
};
return ( return (
<> <>
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} /> <PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} /> <ApiNotice loading={apiLoading} error={apiError} onRetry={() => void queryLogs()} />
<PageLoadingDialog loading={apiLoading} />
<Toolbar> <Toolbar>
<Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field> <Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field>
<Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field> <Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field>
<Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field> <Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field>
<Field label="开始日期"><Input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></Field> <DateRangeFields startDate={startDate} endDate={endDate} onStartChange={setStartDate} onEndChange={setEndDate} />
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field> <Button icon={<Icon type="search" />} disabled={apiLoading} onClick={() => void queryLogs()}>查询</Button>
<Button variant="outline" onClick={resetFilters}>重置</Button> <Button variant="outline" onClick={resetFilters}>重置</Button>
</Toolbar> </Toolbar>
<Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel"> <Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel">
<SimpleTable rows={visibleLogs} columns={[ <SimpleTable loading={apiLoading} rows={visibleLogs} columns={[
{ key: 'time', label: '操作时间' }, { key: 'time', label: '操作时间' },
{ key: 'user', label: '操作用户' }, { key: 'user', label: '操作用户' },
{ key: 'module', label: '功能模块' }, { key: 'module', label: '功能模块' },
+40 -16
View File
@@ -1,22 +1,25 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx'; import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js'; import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' }; const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
const defaultRange = lastSevenDays();
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true }) { export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) {
const [ruleRows, setRuleRows] = useState([]); const [ruleRows, setRuleRows] = useState([]);
const [recordingRows, setRecordingRows] = useState([]); const [recordingRows, setRecordingRows] = useState([]);
const [qualityLoading, setQualityLoading] = useState(false); const [qualityLoading, setQualityLoading] = useState(false);
const [qualityError, setQualityError] = useState(''); const [qualityError, setQualityError] = useState('');
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '100' }); const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50', ...defaultRange });
const [showRules, setShowRules] = useState(false); const [showRules, setShowRules] = useState(false);
const [editingRule, setEditingRule] = useState(undefined); const [editingRule, setEditingRule] = useState(undefined);
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm); const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null); const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
const [ruleBusy, setRuleBusy] = useState(false); const [ruleBusy, setRuleBusy] = useState(false);
const [rulesLoading, setRulesLoading] = useState(false);
const [ruleError, setRuleError] = useState(''); const [ruleError, setRuleError] = useState('');
const [detailRecording, setDetailRecording] = useState(null); const [detailRecording, setDetailRecording] = useState(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
@@ -50,19 +53,33 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
const params = { const params = {
limit: nextFilter.limit, limit: nextFilter.limit,
reviewStatus: nextFilter.reviewStatus, reviewStatus: nextFilter.reviewStatus,
...dateRangeParams(nextFilter.startDate, nextFilter.endDate),
}; };
const [recordingList, ruleList] = await Promise.all([ const recordingList = await api.recordings(params);
api.recordings(params),
api.qualityRules(),
]);
setRecordingRows((recordingList || []).map(normalizeRecording)); setRecordingRows((recordingList || []).map(normalizeRecording));
setRuleRows((ruleList || []).map(normalizeQualityRule));
} catch (error) { } catch (error) {
setQualityError(explainApiError(error)); setQualityError(explainApiError(error));
} finally { } finally {
setQualityLoading(false); setQualityLoading(false);
} }
}; };
const refreshRules = async () => {
setRulesLoading(true);
setRuleError('');
try {
const ruleList = await api.qualityRules();
setRuleRows((ruleList || []).map(normalizeQualityRule));
} catch (error) {
setRuleError(explainApiError(error));
} finally {
setRulesLoading(false);
}
};
const openRulesDrawer = () => {
setShowRules(true);
if (!ruleRows.length) void refreshRules();
if (loadRuleDependencies) void loadRuleDependencies();
};
useEffect(() => { useEffect(() => {
void refreshQuality(); void refreshQuality();
}, []); }, []);
@@ -134,7 +151,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
} else { } else {
await api.createQualityRule(ruleBody()); await api.createQualityRule(ruleBody());
} }
await refreshQuality(); await refreshRules();
closeRuleModal(); closeRuleModal();
} catch (error) { } catch (error) {
setRuleError(explainApiError(error)); setRuleError(explainApiError(error));
@@ -151,7 +168,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
} else { } else {
await api.enableQualityRule(rule.id); await api.enableQualityRule(rule.id);
} }
await refreshQuality(); await refreshRules();
} catch (error) { } catch (error) {
setRuleError(explainApiError(error)); setRuleError(explainApiError(error));
} finally { } finally {
@@ -164,7 +181,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
setRuleError(''); setRuleError('');
try { try {
await api.deleteQualityRule(deleteRuleTarget.id); await api.deleteQualityRule(deleteRuleTarget.id);
await refreshQuality(); await refreshRules();
setDeleteRuleTarget(null); setDeleteRuleTarget(null);
} catch (error) { } catch (error) {
setRuleError(explainApiError(error)); setRuleError(explainApiError(error));
@@ -279,6 +296,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
const changeRecordingFilter = (key, value) => { const changeRecordingFilter = (key, value) => {
const nextFilter = { ...recordingFilter, [key]: value }; const nextFilter = { ...recordingFilter, [key]: value };
setRecordingFilter(nextFilter); setRecordingFilter(nextFilter);
};
const resetRecordingFilter = () => {
const nextFilter = { reviewStatus: 'all', limit: recordingFilter.limit, ...defaultRange };
setRecordingFilter(nextFilter);
void refreshQuality(nextFilter); void refreshQuality(nextFilter);
}; };
@@ -287,9 +308,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<PageTitle <PageTitle
title="质检中心" title="质检中心"
desc="集中查看录音、完成试听、问题标注和人工质检评分。" desc="集中查看录音、完成试听、问题标注和人工质检评分。"
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={() => setShowRules(true)}>抽检规则</Button></div>} actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={openRulesDrawer}>抽检规则</Button></div>}
/> />
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} /> <ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
<PageLoadingDialog loading={qualityLoading} />
<Toolbar> <Toolbar>
<Field label="质检状态"> <Field label="质检状态">
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}> <Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
@@ -298,17 +320,19 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<option value="REVIEWED">已完成</option> <option value="REVIEWED">已完成</option>
</Select> </Select>
</Field> </Field>
<DateRangeFields startDate={recordingFilter.startDate} endDate={recordingFilter.endDate} onStartChange={(value) => changeRecordingFilter('startDate', value)} onEndChange={(value) => changeRecordingFilter('endDate', value)} />
<Field label="读取条数"> <Field label="读取条数">
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}> <Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
<option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</Select> </Select>
</Field> </Field>
<Button icon={<Icon type="search" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>查询</Button>
<Button variant="outline" disabled={qualityLoading} onClick={resetRecordingFilter}>重置</Button>
</Toolbar> </Toolbar>
<Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel"> <Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel">
<SimpleTable rows={recordingRows} columns={[ <SimpleTable loading={qualityLoading} rows={recordingRows} columns={[
{ key: 'callId', label: 'Call-ID' }, { key: 'callId', label: 'Call-ID' },
{ key: 'customer', label: '客户' }, { key: 'customer', label: '客户' },
{ key: 'caller', label: '主叫' }, { key: 'caller', label: '主叫' },
@@ -329,7 +353,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例</span></div> <div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例</span></div>
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null} {canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
</div> </div>
<SimpleTable rows={ruleRows} columns={[ <SimpleTable loading={rulesLoading} rows={ruleRows} columns={[
{ key: 'name', label: '规则名称' }, { key: 'name', label: '规则名称' },
{ key: 'customer', label: '客户' }, { key: 'customer', label: '客户' },
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` }, { key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
+1 -2
View File
@@ -9,7 +9,7 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
const recordTable = ( const recordTable = (
<section className="content-grid"> <section className="content-grid">
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel"> <Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
<SimpleTable rows={visibleRows} columns={[ <SimpleTable loading={apiLoading} rows={visibleRows} columns={[
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' }, { key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' }, { key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
{ key: 'amount', label: '充值金额' }, { key: 'amount', label: '充值金额' },
@@ -53,4 +53,3 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
</> </>
); );
} }
+1 -1
View File
@@ -65,7 +65,7 @@ export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiErro
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} /> <ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null} {actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
<Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel"> <Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel">
<SimpleTable rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[ <SimpleTable loading={apiLoading} rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
{ key: 'name', label: '角色名称' }, { key: 'name', label: '角色名称' },
{ key: 'type', label: '类型' }, { key: 'type', label: '类型' },
{ key: 'description', label: '角色说明' }, { key: 'description', label: '角色说明' },
+1 -1
View File
@@ -81,7 +81,7 @@ export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiErro
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button> <Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
</Toolbar> </Toolbar>
<Panel title="用户列表" aside={<Badge tone="neutral"> {visibleUsers.length} 个用户</Badge>} className="wide-panel"> <Panel title="用户列表" aside={<Badge tone="neutral"> {visibleUsers.length} 个用户</Badge>} className="wide-panel">
<SimpleTable rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[ <SimpleTable loading={apiLoading} rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
{ key: 'username', label: '用户名' }, { key: 'username', label: '用户名' },
{ key: 'name', label: '姓名' }, { key: 'name', label: '姓名' },
{ key: 'phone', label: '手机号' }, { key: 'phone', label: '手机号' },
+1 -1
View File
@@ -235,7 +235,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
</Toolbar> </Toolbar>
<section className="content-grid"> <section className="content-grid">
<Panel title="落地网关列表" className="wide-panel"> <Panel title="落地网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[ <SimpleTable loading={apiLoading} rows={gatewayRows} columns={[
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' }, { key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' }, { key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' }, { key: 'authMode', label: '认证方式' },
+1 -1
View File
@@ -86,7 +86,7 @@ export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineG
</Toolbar> </Toolbar>
<section className="content-grid"> <section className="content-grid">
<Panel title="落地线路组列表" className="wide-panel"> <Panel title="落地线路组列表" className="wide-panel">
<SimpleTable rows={lineGroupRows} columns={[ <SimpleTable loading={apiLoading} rows={lineGroupRows} columns={[
{ key: 'name', label: '名称' }, { key: 'name', label: '名称' },
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length }, { key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
{ key: 'customerGatewayCount', label: '使用客户网关数' }, { key: 'customerGatewayCount', label: '使用客户网关数' },
+1 -1
View File
@@ -114,7 +114,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
</Toolbar> </Toolbar>
<section className="master-detail"> <section className="master-detail">
<Panel title="供应商列表" className="wide-panel"> <Panel title="供应商列表" className="wide-panel">
<SimpleTable rows={vendorRows} columns={[ <SimpleTable loading={apiLoading} rows={vendorRows} columns={[
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' }, { key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' }, { key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'balance', label: '余额' }, { key: 'balance', label: '余额' },
+1
View File
@@ -1,4 +1,5 @@
export const PAGE_PERMISSIONS = { export const PAGE_PERMISSIONS = {
callerAnalytics: ['caller_analytics.view'],
dashboard: ['dashboard.view'], dashboard: ['dashboard.view'],
activeCalls: ['active_calls.view'], activeCalls: ['active_calls.view'],
customers: ['customers.view'], customers: ['customers.view'],
+983
View File
@@ -1919,3 +1919,986 @@ button:disabled {
font-size: 22px; font-size: 22px;
} }
} }
/* CMPP platform design alignment */
:root {
color-scheme: light;
--brand: #12121a;
--brand-2: #1c1c27;
--accent: #d9c3a0;
--accent-soft: #f6efe4;
--selected: #2563eb;
--selected-hover: #1d4ed8;
--selected-soft: #eff6ff;
--danger: #dc2626;
--danger-soft: #fef2f2;
--warning: #d97706;
--warning-soft: #fffbeb;
--success: #16a34a;
--success-soft: #ecfdf3;
--surface: #ffffff;
--surface-muted: #f3f4f6;
--page: #f6f7f9;
--line: #e5e7eb;
--line-strong: #d1d5db;
--text: #1f2937;
--ink: #111827;
--muted: #6b7280;
--subtle: #9ca3af;
--shadow: 0 1px 2px rgba(18, 18, 26, 0.08);
--shadow-hover: 0 8px 24px rgba(18, 18, 26, 0.08);
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.18);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--sidebar-width: 248px;
--sidebar-collapsed-width: 84px;
--topbar-height: 64px;
--content-max-width: 1440px;
--control-height-sm: 32px;
--control-height-md: 38px;
font-family: Inter, "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
}
html {
text-size-adjust: 100%;
}
body {
min-height: 100vh;
background: var(--page);
color: var(--text);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
:focus {
outline: none;
}
:focus-visible {
box-shadow: var(--focus-ring);
}
.prototype-app {
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
height: 100vh;
min-height: 0;
overflow: hidden;
}
.prototype-app.sidebar-collapsed {
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
}
.sidebar {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
height: 100vh;
padding: 24px 16px;
overflow: hidden;
background: var(--surface);
border-right: 1px solid var(--line);
box-shadow: none;
}
.brand-block {
display: flex;
grid-template-columns: none;
align-items: center;
gap: 12px;
padding: 0 8px 8px;
border-bottom: 0;
}
.brand-expanded {
flex: 1 1 auto;
gap: 8px;
}
.brand-logo-expanded {
width: min(100%, 132px);
height: 34px;
}
.brand-sip-text {
color: var(--ink);
font-size: 18px;
font-weight: 700;
}
.brand-mark,
.brand-logo-collapsed {
border-radius: var(--radius-lg);
}
.sidebar-toggle {
flex: 0 0 auto;
width: 38px;
height: 38px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.sidebar-toggle:hover {
color: var(--selected);
background: var(--selected-soft);
border-color: rgba(37, 99, 235, 0.22);
}
.sidebar nav {
display: grid;
align-content: start;
flex: 1 1 auto;
gap: 20px;
min-height: 0;
margin-right: -16px;
padding-right: 14px;
overflow-y: auto;
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.sidebar nav::-webkit-scrollbar {
width: 6px;
}
.sidebar nav::-webkit-scrollbar-track,
.sidebar nav::-webkit-scrollbar-thumb {
background: transparent;
}
.sidebar nav::-webkit-scrollbar-thumb {
border-radius: 999px;
}
.sidebar:hover nav {
scrollbar-color: rgba(37, 99, 235, 0.24) transparent;
}
.sidebar:hover nav::-webkit-scrollbar-thumb {
background: rgba(37, 99, 235, 0.18);
}
.sidebar nav:hover::-webkit-scrollbar-thumb {
background: rgba(37, 99, 235, 0.22);
}
.nav-group {
gap: 4px;
padding: 0;
}
.nav-group p {
margin: 0 0 8px;
padding: 0 12px;
color: var(--subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
}
.nav-group button {
min-height: 42px;
padding: 10px 12px;
color: var(--text);
background: transparent;
border: 0;
border-radius: var(--radius-md);
font-weight: 500;
transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.nav-group button:hover,
.nav-group button.active {
color: var(--selected);
background: var(--selected-soft);
}
.nav-group button.active {
box-shadow: inset 3px 0 0 var(--selected);
font-weight: 600;
}
.nav-short {
background: var(--selected-soft);
color: var(--selected);
}
.sidebar-collapsed .brand-block {
padding: 0 0 8px;
}
.sidebar-collapsed .sidebar {
align-items: center;
padding-right: 12px;
padding-left: 12px;
}
.sidebar-collapsed .sidebar nav {
width: 100%;
margin-right: 0;
padding-right: 0;
}
.sidebar-collapsed .nav-group {
justify-items: center;
}
.sidebar-collapsed .nav-group button {
width: 42px;
height: 42px;
justify-content: center;
padding: 0;
}
.sidebar-collapsed .nav-group button.active {
box-shadow: inset 0 -3px 0 var(--selected);
}
.sidebar-collapsed .nav-group button.active .nav-short {
color: #ffffff;
background: var(--selected);
}
.main-area {
display: grid;
grid-template-rows: var(--topbar-height) minmax(0, 1fr);
height: 100vh;
overflow: hidden;
}
.topbar {
min-height: var(--topbar-height);
padding: 0 28px;
background: var(--surface);
border-bottom: 1px solid var(--line);
backdrop-filter: none;
}
.topbar strong {
color: var(--ink);
font-weight: 600;
}
.page-content {
width: 100%;
max-width: var(--content-max-width);
gap: 20px;
align-content: start;
grid-auto-rows: max-content;
min-height: 0;
padding: 28px;
overflow-y: auto;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 16px;
color: var(--muted);
font-size: 13px;
}
.pagination-actions,
.pagination-actions label {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-actions select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
padding: 0 28px 0 10px;
}
.login-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)),
var(--page);
}
.login-panel,
.page-title,
.toolbar,
.prototype-panel,
.metric-card,
.modal-dialog,
.drawer-panel,
.match-card,
.config-card,
.prefix-picker,
.permission-group,
.log-summary {
border-color: var(--line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.login-panel,
.page-title,
.toolbar,
.prototype-panel,
.metric-card {
background: var(--surface);
}
.page-title {
align-items: flex-end;
padding: 24px;
}
.page-title h1 {
color: var(--ink);
font-size: 24px;
font-weight: 700;
line-height: 1.25;
}
.page-title p {
color: var(--muted);
font-size: 14px;
}
.toolbar,
.sub-toolbar {
gap: 12px;
padding: 16px;
}
.sub-toolbar,
.match-card,
.config-card,
.rank-list div,
.code-grid div,
.kv,
.ops-row,
.scope-list span,
.role-grid div,
.drawer-toolbar,
.log-summary {
background: var(--surface-muted);
border-color: var(--line);
}
.prototype-panel {
gap: 16px;
padding: 20px;
}
.panel-head h2 {
color: var(--ink);
font-size: 16px;
font-weight: 600;
}
.metric-grid {
gap: 16px;
}
.metric-card {
gap: 8px;
padding: 20px;
}
.metric-card span {
color: var(--muted);
font-size: 13px;
font-weight: 500;
}
.metric-card strong {
color: var(--ink);
font-size: 28px;
font-weight: 700;
}
.content-grid,
.master-detail {
gap: 20px;
}
.table-shell,
.ui-table-wrap {
border-color: var(--line);
border-radius: var(--radius-lg);
background: var(--surface);
}
.proto-table,
.ui-table {
background: var(--surface);
}
.proto-table th,
.ui-table th {
height: 48px;
color: var(--muted);
background: #fbfbfc;
font-size: 13px;
font-weight: 600;
}
.proto-table td,
.ui-table td {
height: 64px;
color: var(--text);
border-bottom-color: var(--line);
font-size: 14px;
vertical-align: middle;
}
.proto-table tbody tr:hover,
.proto-table tbody tr.is-selected,
.ui-table tbody tr:hover {
background: #fbfbfc;
}
.table-actions {
align-items: center;
}
.ui-button {
height: var(--control-height-md);
max-width: 100%;
padding: 0 16px;
border-radius: var(--radius-md);
font-weight: 600;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.ui-button-primary {
color: #ffffff;
background: var(--brand);
border-color: var(--brand);
}
.ui-button-primary:hover {
background: var(--brand-2);
}
.ui-button-secondary {
color: #ffffff;
background: var(--selected);
border-color: var(--selected);
}
.ui-button-secondary:hover {
background: var(--selected-hover);
}
.ui-button-outline,
.ui-button-ghost {
color: var(--text);
background: var(--surface);
border-color: var(--line);
}
.ui-button-outline:hover,
.ui-button-ghost:hover {
color: var(--selected);
background: var(--selected-soft);
border-color: rgba(37, 99, 235, 0.22);
}
.ui-button-danger {
background: var(--danger);
border-color: var(--danger);
}
.ui-button-sm {
height: var(--control-height-sm);
padding: 0 10px;
font-size: 13px;
}
.ui-button-icon-only {
width: var(--control-height-md);
padding: 0;
}
.ui-field {
gap: 8px;
}
.ui-field-label {
color: var(--ink);
font-size: 13px;
font-weight: 600;
}
.ui-field-hint,
.ui-field-error {
font-size: 12px;
}
.ui-input {
min-height: var(--control-height-md);
padding: 8px 12px;
color: var(--text);
background: var(--surface);
border-color: var(--line);
border-radius: var(--radius-md);
outline: 0;
}
.ui-input::placeholder {
color: var(--subtle);
}
.ui-input:focus {
border-color: var(--selected);
box-shadow: var(--focus-ring);
}
.ui-textarea {
min-height: 116px;
}
.ui-check,
.ui-switch {
color: var(--text);
font-weight: 500;
}
.ui-check-box,
.ui-radio-dot {
border-color: var(--line-strong);
}
.ui-switch-track {
background: var(--line-strong);
}
.ui-tabs {
gap: 16px;
}
.ui-tab-list {
gap: 8px;
border-bottom-color: var(--line);
}
.ui-tab-list button {
min-height: 38px;
color: var(--muted);
font-weight: 600;
}
.ui-tab-list button.is-active {
color: var(--selected);
}
.ui-badge {
min-height: 24px;
padding: 2px 10px;
font-size: 12px;
font-weight: 600;
}
.ui-badge-neutral {
color: var(--muted);
background: var(--surface-muted);
}
.ui-badge-brand {
color: #b99763;
background: var(--accent-soft);
}
.ui-badge-info {
color: var(--selected);
background: var(--selected-soft);
}
.ui-badge-success {
color: var(--success);
background: var(--success-soft);
}
.ui-badge-warning {
color: var(--warning);
background: var(--warning-soft);
}
.ui-badge-danger {
color: var(--danger);
background: var(--danger-soft);
}
.ui-alert {
color: #1e40af;
background: var(--selected-soft);
border-color: #bfdbfe;
border-radius: var(--radius-lg);
}
.ui-alert-warning {
color: var(--warning);
background: var(--warning-soft);
border-color: #fed7aa;
}
.ui-alert-success {
color: var(--success);
background: var(--success-soft);
border-color: #bbf7d0;
}
.ui-alert-danger {
color: var(--danger);
background: var(--danger-soft);
border-color: #fecaca;
}
.modal-dialog {
grid-template-rows: auto minmax(0, 1fr);
max-height: min(760px, calc(100vh - 48px));
overflow: hidden;
box-shadow: 0 18px 48px rgba(18, 18, 26, 0.12);
}
.modal-head,
.drawer-head {
padding: 20px 24px;
border-bottom-color: var(--line);
}
.modal-head h2,
.drawer-head h2 {
color: var(--ink);
font-size: 20px;
font-weight: 600;
}
.modal-body,
.drawer-body {
padding: 24px;
}
.modal-actions,
.drawer-actions {
gap: 12px;
}
.empty-state {
color: var(--muted);
}
.empty-state strong {
color: var(--ink);
font-weight: 600;
}
.mini-chart,
.line-chart {
background: linear-gradient(to top, #eef1f5 1px, transparent 1px);
background-size: 100% 44px;
}
.ui-segmented {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
}
.ui-segmented button {
min-height: 30px;
padding: 0 12px;
color: var(--muted);
background: transparent;
border: 0;
border-radius: var(--radius-md);
font-weight: 600;
}
.ui-segmented button.is-active {
color: var(--selected);
background: var(--surface);
box-shadow: var(--shadow);
}
.ui-slider {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto;
align-items: center;
gap: 12px;
color: var(--text);
font-weight: 500;
}
.ui-slider input {
accent-color: var(--selected);
}
.ui-slider output {
color: var(--selected);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 760px) {
.prototype-app,
.prototype-app.sidebar-collapsed {
grid-template-columns: minmax(0, 1fr);
height: auto;
min-height: 100vh;
overflow: visible;
}
.sidebar,
.sidebar-collapsed .sidebar {
align-items: stretch;
height: auto;
padding: 16px;
}
.sidebar nav {
display: grid;
gap: 12px;
max-height: 260px;
margin-right: -16px;
padding-right: 16px;
}
.nav-group {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 0 0 4px;
}
.nav-group p {
flex: 0 0 auto;
align-self: center;
margin: 0;
}
.nav-group button,
.sidebar-collapsed .nav-group button {
flex: 0 0 auto;
width: auto;
height: auto;
min-height: 38px;
justify-content: flex-start;
padding: 0 12px;
}
.sidebar-collapsed .brand-block {
justify-items: stretch;
padding: 0 8px 8px;
}
.sidebar-collapsed .brand-expanded {
display: inline-flex;
}
.sidebar-collapsed .brand-logo-expanded {
display: block;
}
.sidebar-collapsed .brand-logo-collapsed,
.sidebar-collapsed .nav-short {
display: none;
}
.sidebar-collapsed .nav-label {
display: inline;
}
.main-area {
height: auto;
overflow: visible;
}
.topbar {
align-items: stretch;
flex-direction: column;
gap: 12px;
height: auto;
min-height: 0;
overflow: visible;
padding: 12px 20px;
}
.topbar > div:first-child {
flex-wrap: wrap;
justify-content: flex-start;
}
.topbar-right {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px 12px;
align-items: center;
justify-content: stretch;
}
.topbar-right > span:not(.ui-badge),
.topbar-right > strong {
min-width: 0;
white-space: normal;
}
.topbar-right .ui-button {
justify-self: start;
}
.page-content {
overflow-y: visible;
padding: 20px;
}
.page-title {
padding: 20px;
}
.modal-head,
.modal-body,
.drawer-head,
.drawer-body {
padding: 16px;
}
.ui-slider {
grid-template-columns: minmax(0, 1fr);
}
}
/* UI performance remediation: stable async states and readable wide tables. */
.page-loading-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px;
background: rgb(15 23 42 / 38%);
backdrop-filter: blur(2px);
}
.page-loading-dialog {
display: flex;
align-items: center;
gap: 16px;
width: min(420px, 100%);
padding: 22px 24px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 24px 60px rgb(15 23 42 / 24%);
}
.page-loading-dialog > div {
display: grid;
gap: 5px;
}
.page-loading-dialog strong {
font-size: 16px;
}
.page-loading-dialog span:not(.page-loading-spinner) {
color: var(--muted);
font-size: 13px;
}
.page-loading-spinner {
flex: 0 0 auto;
width: 30px;
height: 30px;
border: 3px solid color-mix(in srgb, var(--selected) 22%, transparent);
border-top-color: var(--selected);
border-radius: 50%;
animation: page-loading-spin 0.8s linear infinite;
}
@keyframes page-loading-spin {
to { transform: rotate(360deg); }
}
.table-skeleton-cell {
display: block;
min-width: 72px;
height: 13px;
border-radius: 999px;
background: linear-gradient(90deg, var(--surface-muted) 20%, var(--border) 45%, var(--surface-muted) 70%);
background-size: 220% 100%;
animation: table-skeleton-shimmer 1.2s ease-in-out infinite;
}
@keyframes table-skeleton-shimmer {
to { background-position-x: -220%; }
}
.table-shell {
max-width: 100%;
overflow: auto;
overscroll-behavior-inline: contain;
}
.wide-panel .proto-table {
min-width: 1040px;
}
.wide-panel .proto-table th:last-child,
.wide-panel .proto-table td:last-child {
position: sticky;
right: 0;
z-index: 1;
background: var(--surface);
box-shadow: -10px 0 14px -14px rgb(15 23 42 / 55%);
}
.wide-panel .proto-table th:last-child {
z-index: 2;
background: var(--surface-muted);
}
.proto-table td,
.key-value dd,
.log-summary p {
overflow-wrap: anywhere;
}
.proto-table tbody tr {
content-visibility: auto;
contain-intrinsic-size: 52px;
}
@media (prefers-reduced-motion: reduce) {
.page-loading-spinner,
.table-skeleton-cell {
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; }
+19
View File
@@ -0,0 +1,19 @@
const pad = (value) => String(value).padStart(2, '0');
export function formatDateInput(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function lastSevenDays(reference = new Date()) {
const end = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const start = new Date(end);
start.setDate(start.getDate() - 6);
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
}
export function dateRangeParams(startDate, endDate, fromKey = 'startedFrom', toKey = 'startedTo') {
const params = {};
if (startDate) params[fromKey] = new Date(`${startDate}T00:00:00`).toISOString();
if (endDate) params[toKey] = new Date(`${endDate}T23:59:59.999`).toISOString();
return params;
}
+36
View File
@@ -0,0 +1,36 @@
import { PrismaClient, Prisma, analyticsHash, analyticsRates, COUNT_KEYS, type AnalyticsCounts } from '@lisglosips/database';
export async function evaluateCallerAlerts(db: PrismaClient) {
const end = new Date(Math.floor(Date.now() / 60000) * 60000); const start = new Date(+end - 15 * 60000);
const aggregates = Prisma.join(COUNT_KEYS.map(k => Prisma.sql`SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,${`$.${k}`})) AS SIGNED)) AS ${Prisma.raw(k)}`));
const rows = await db.$queryRaw<Array<AnalyticsCounts & { customer_id: string; caller: string; view: string }>>(Prisma.sql`SELECT customer_id, caller, view, ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${start} AND started_at<${end} GROUP BY customer_id, caller, view HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>=50`);
for (const row of rows) {
const counts = Object.fromEntries(COUNT_KEYS.map(k => [k, Number(row[k])])) as unknown as AnalyticsCounts;
const rates = analyticsRates(counts);
if (rates.unknownCalls || rates.activeCalls / rates.totalCalls > 0.2) continue;
const reasons = [
rates.connectionRate !== null && rates.connectionRate < Number(process.env.ANALYTICS_LOW_CONNECTION_RATE ?? 20) ? '低接通率' : '',
rates.overallAnswerRate !== null && rates.overallAnswerRate < Number(process.env.ANALYTICS_LOW_OVERALL_RATE ?? 10) ? '低总体应答率' : '',
rates.connectedCalls >= 30 && rates.connectedAnswerRate !== null && rates.connectedAnswerRate < Number(process.env.ANALYTICS_LOW_CONNECTED_RATE ?? 20) ? '低已接通应答率' : ''
].filter(Boolean);
const previous = await db.$queryRaw<Array<AnalyticsCounts>>(Prisma.sql`SELECT ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${new Date(+start - 15 * 60000)} AND started_at<${start} AND customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view}`);
const p = analyticsRates(Object.fromEntries(COUNT_KEYS.map(k => [k, Number(previous[0]?.[k] ?? 0)])) as unknown as AnalyticsCounts);
if (p.totalCalls >= 50 && !p.unknownCalls && p.activeCalls / p.totalCalls <= 0.2) {
for (const [key, label] of [['connectionRate','接通率骤降'],['overallAnswerRate','总体应答率骤降'],['connectedAnswerRate','已接通应答率骤降']] as const) {
if (key === 'connectedAnswerRate' && (p.connectedCalls < 30 || rates.connectedCalls < 30)) continue;
if (p[key] !== null && rates[key] !== null && p[key]! - rates[key]! > Number(process.env.ANALYTICS_RATE_DROP_POINTS ?? 15)) reasons.push(label);
}
}
const tail = await db.$queryRaw<Array<{ counts: AnalyticsCounts }>>`SELECT counts FROM caller_analysis_states WHERE customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view} AND started_at>=${start} AND started_at<${end} ORDER BY started_at DESC, id DESC LIMIT 20`;
if (tail.length === 20 && tail.every(t => t.counts.failedCalls === 1)) reasons.push('连续20次已结束未接通');
const id = analyticsHash([row.customer_id, row.caller, row.view]);
const existing = await db.$queryRaw<Array<{ status: string; payload: { streak?: number; recovered?: number; since?: string } }>>`SELECT status,payload FROM caller_analysis_alerts WHERE id=${id}`;
const streak = reasons.length ? (existing[0]?.payload.streak ?? 0) + 1 : 0;
const recovered = reasons.length ? 0 : (existing[0]?.payload.recovered ?? 0) + 1;
const status = streak >= 3 ? 'ACTIVE' : existing[0]?.status === 'ACTIVE' && recovered < 3 ? 'ACTIVE' : 'OBSERVING';
const payload = JSON.stringify({ reasons, rates, streak, recovered, windowStart: start, windowEnd: end, since: existing[0]?.payload.since ?? new Date().toISOString() });
await db.$executeRaw`INSERT INTO caller_analysis_alerts(id,customer_id,view,caller,status,payload,updated_at) VALUES (${id},${row.customer_id},${row.view},${row.caller},${status},${payload},NOW(3)) ON DUPLICATE KEY UPDATE status=VALUES(status),payload=VALUES(payload),updated_at=VALUES(updated_at)`;
}
// Old low-sample windows are no longer active evidence, not permanent alarms.
await db.$executeRaw`UPDATE caller_analysis_alerts SET status='EXPIRED' WHERE updated_at<${new Date(Date.now() - 120000)} AND status='ACTIVE'`;
}
+102
View File
@@ -0,0 +1,102 @@
import { open, readFile, writeFile, rename, stat, appendFile } from 'node:fs/promises';
import { hostname } from 'node:os';
import { PrismaClient, Prisma, CallerAnalyticsStore, ANALYTICS_GROUP, ANALYTICS_STREAM, analyticsHash, parseAnalyticsEvent, type AnalyticsCall, type AnalyticsEvent } from '@lisglosips/database';
import { createRedisClient } from '@lisglosips/redis';
import { createLogger } from '@lisglosips/observability';
import { evaluateCallerAlerts } from './analytics-alerts.js';
const logger = createLogger('caller-analytics');
const db = new PrismaClient(); const redis = createRedisClient(process.env.REDIS_URL ?? '');
const store = new CallerAnalyticsStore(db);
const spool = process.env.ANALYTICS_SPOOL ?? '/var/log/lisglosips/caller-analytics.log';
const checkpoint = process.env.ANALYTICS_CHECKPOINT ?? '/var/lib/lisglosips/caller-analytics-offset.json';
let cursor = { offset: 0, ino: 0 }; let stopping = false; let lastAlerts = 0;
let dataThrough: string | null = null; let gap = false;
const consumer = `${hostname()}-${process.pid}`;
process.on('SIGTERM', () => { stopping = true; }); process.on('SIGINT', () => { stopping = true; });
function decodeLine(line: string): AnalyticsEvent | null {
const marker = line.indexOf('CRA1|'); if (marker < 0) return null;
const parts = line.slice(marker).trim().split('|');
if (parts.length !== 20) throw new Error('Malformed producer record');
const decoded = parts.slice(8).map(s => Buffer.from(s, 'base64').toString('utf8'));
const [uid, callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId] = decoded;
const at = Number(parts[2]) * 1000 + Math.floor(Number(parts[3]) / 1000);
const startedAt = Number(parts[4]) * 1000 + Math.floor(Number(parts[5]) / 1000);
const fields = { version: '1', eventId: analyticsHash(parts), callUid: analyticsHash(uid), callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId,
kind: parts[1], at: String(at), startedAt: String(startedAt), code: parts[6], source: parts[7], talkMs: '-1', sequence: '0' };
return parseAnalyticsEvent(Object.entries(fields).flat());
}
async function collect() {
const info = await stat(spool);
if (cursor.ino && (cursor.ino !== info.ino || info.size < cursor.offset)) { gap = true; cursor.offset = 0; }
cursor.ino = info.ino;
const file = await open(spool, 'r');
try {
const buffer = Buffer.alloc(Math.min(1024 * 1024, Math.max(0, info.size - cursor.offset)));
const { bytesRead } = await file.read(buffer, 0, buffer.length, cursor.offset);
const last = buffer.subarray(0, bytesRead).lastIndexOf(10); if (last < 0) return;
for (const line of buffer.subarray(0, last + 1).toString('utf8').split('\n').filter(Boolean)) {
let event: AnalyticsEvent | null;
try { event = decodeLine(line); } catch {
await appendFile(`${checkpoint}.deadletter`, `${JSON.stringify({ at:new Date(), line })}\n`, { mode:0o600 });
gap = true; continue;
}
if (event) await redis.xadd(ANALYTICS_STREAM, '*', 'event', JSON.stringify(event));
}
// Persist only after durable queue writes. Crash before checkpoint replays harmlessly.
cursor.offset += last + 1;
await writeFile(`${checkpoint}.tmp`, JSON.stringify({ ...cursor, gap }), { mode: 0o600 }); await rename(`${checkpoint}.tmp`, checkpoint);
} finally { await file.close(); }
}
async function entries(entries: Array<[string, string[]]>) {
for (const [id, fields] of entries) {
const event = JSON.parse(fields[1]) as AnalyticsEvent;
await store.consume(event);
await redis.xack(ANALYTICS_STREAM, ANALYTICS_GROUP, id);
if (!dataThrough || event.at > Date.parse(dataThrough)) dataThrough = new Date(event.at).toISOString();
}
}
async function observeActive() {
const response = await fetch(process.env.ANALYTICS_MI_URL ?? 'http://127.0.0.1:8888/mi', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] }), signal: AbortSignal.timeout(2000) });
const body = await response.json() as { result?: { Dialogs?: Array<{ callid: string; state: number }> } };
if (!response.ok || !body.result?.Dialogs) throw new Error('Dialog observation unavailable');
const calls = await db.$queryRaw<Array<{ payload: AnalyticsCall }>>`SELECT c.payload FROM caller_analysis_calls c WHERE EXISTS (SELECT 1 FROM caller_analysis_states s WHERE s.call_key=c.id AND s.view='original' AND CAST(JSON_UNQUOTE(JSON_EXTRACT(s.counts,'$.activeCalls')) AS SIGNED)>0) LIMIT 2000`;
const live = new Map(body.result.Dialogs.map(d => [d.callid, Number(d.state)]));
const now = Date.now();
for (const { payload } of calls) for (const leg of Object.values(payload.legs)) {
if (leg.endedAt !== null || leg.acceptedAt === null || leg.talkMs > 0) continue;
// Only a confirmed live dialog supplies provisional positive duration. Never a browser timer.
if ((live.get(leg.event.callId) ?? 0) >= 3 && (live.get(leg.event.callId) ?? 0) <= 4 && now > leg.acceptedAt) {
await store.consume({ ...leg.event, kind: 'DURATION', at: now, talkMs: now - leg.acceptedAt, source: 'dialog-observation', eventId: analyticsHash([leg.event.callUid, leg.event.attemptId, 'positive-duration']) });
}
}
}
async function main() {
try { const saved = JSON.parse(await readFile(checkpoint, 'utf8')); cursor = saved; gap = saved.gap === true; } catch { /* First start reads the retained spool. */ }
await redis.connect(); await db.$connect();
try { await redis.xgroup('CREATE', ANALYTICS_STREAM, ANALYTICS_GROUP, '0', 'MKSTREAM'); } catch (e) { if (!(e instanceof Error) || !e.message.includes('BUSYGROUP')) throw e; }
let pendingCursor = '0-0';
while (!stopping) {
let degraded = gap; let reason = '';
try {
await collect();
const claimed = await redis.xautoclaim(ANALYTICS_STREAM, ANALYTICS_GROUP, consumer, 5000, pendingCursor, 'COUNT', 100) as [string, Array<[string, string[]]>];
pendingCursor = claimed[0]; await entries(claimed[1]);
const fresh = await redis.xreadgroup('GROUP', ANALYTICS_GROUP, consumer, 'COUNT', 200, 'BLOCK', 100, 'STREAMS', ANALYTICS_STREAM, '>') as Array<[string, Array<[string, string[]]>]> | null;
if (fresh) for (const [, batch] of fresh) await entries(batch);
await observeActive();
const groups = await redis.xinfo('GROUPS', ANALYTICS_STREAM) as Array<Array<string | number>>;
const group = groups.map(g => Object.fromEntries(Array.from({ length: g.length / 2 }, (_, i) => [g[i * 2], g[i * 2 + 1]]))).find(g => g.name === ANALYTICS_GROUP);
if (Number(group?.pending ?? 0) || Number(group?.lag ?? 0)) { degraded = true; reason = 'QUEUE_BACKLOG'; }
if (Date.now() - lastAlerts > 15000 && !degraded) { await evaluateCallerAlerts(db); lastAlerts = Date.now(); }
} catch (e) { degraded = true; reason = e instanceof Error ? e.message.slice(0, 200) : 'Worker error'; logger.error({ reason }, 'Analytics cycle failed'); }
try {
const payload = JSON.stringify({ degraded, reason, dataThrough, lagMs: null, coverageStatus: 'OBSERVATIONS_ONLY', captureSince: process.env.ANALYTICS_CAPTURE_SINCE ?? null, spoolOffset: cursor.offset, gap, updatedAt: new Date().toISOString() });
await db.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_health(id,payload,updated_at) VALUES ('worker',${payload},NOW(3)) ON DUPLICATE KEY UPDATE payload=VALUES(payload),updated_at=VALUES(updated_at)`);
} catch { logger.error('Analytics heartbeat write failed'); }
await new Promise(resolve => setTimeout(resolve, 700));
}
redis.disconnect(); await db.$disconnect();
}
void main().catch(e => { logger.error({ message: e instanceof Error ? e.message : 'Startup failed' }, 'Analytics failed'); process.exitCode = 1; redis.disconnect(); void db.$disconnect(); });
+14 -5
View File
@@ -1,13 +1,15 @@
import { createLogger } from '@lisglosips/observability'; import { createLogger } from '@lisglosips/observability';
import { PrismaClient } from '@lisglosips/database'; import { PrismaClient } from '@lisglosips/database';
import { PrismaRecordingStore, RecordingTransferService, SshRecordingClient } from './transfer.js'; import { LocalRecordingClient, PrismaRecordingStore, RecordingTransferService, SshRecordingClient } from './transfer.js';
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-recording'; const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-recording';
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info'); const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
const sourceMode = process.env.RECORDING_SOURCE_MODE ?? 'ssh';
const remoteHost = process.env.RECORDING_REMOTE_HOST ?? 'lisglosips-a'; const remoteHost = process.env.RECORDING_REMOTE_HOST ?? 'lisglosips-a';
const sshConfig = process.env.RECORDING_SSH_CONFIG; const sshConfig = process.env.RECORDING_SSH_CONFIG;
const remoteReadyDir = process.env.RECORDING_REMOTE_READY_DIR ?? '/dev/shm/voip_rec/ready'; const remoteReadyDir = process.env.RECORDING_REMOTE_READY_DIR ?? '/dev/shm/voip_rec/ready';
const localReadyDir = process.env.RECORDING_LOCAL_READY_DIR ?? '/dev/shm/voip_rec/ready';
const localRoot = process.env.RECORDING_LOCAL_ROOT ?? '/data/recordings'; const localRoot = process.env.RECORDING_LOCAL_ROOT ?? '/data/recordings';
const scanIntervalMs = Number.parseInt(process.env.RECORDING_SCAN_INTERVAL_MS ?? '10000', 10); const scanIntervalMs = Number.parseInt(process.env.RECORDING_SCAN_INTERVAL_MS ?? '10000', 10);
const maxFilesPerScan = Number.parseInt(process.env.RECORDING_MAX_FILES_PER_SCAN ?? '50', 10); const maxFilesPerScan = Number.parseInt(process.env.RECORDING_MAX_FILES_PER_SCAN ?? '50', 10);
@@ -17,8 +19,14 @@ let shuttingDown = false;
async function main(): Promise<void> { async function main(): Promise<void> {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const remote = new SshRecordingClient({ host: remoteHost, sshConfig, remoteReadyDir }); if (sourceMode !== 'ssh' && sourceMode !== 'local') {
const service = new RecordingTransferService(remote, new PrismaRecordingStore(prisma), { throw new Error(`Unsupported RECORDING_SOURCE_MODE: ${sourceMode}`);
}
const source =
sourceMode === 'local'
? new LocalRecordingClient({ readyDir: localReadyDir })
: new SshRecordingClient({ host: remoteHost, sshConfig, remoteReadyDir });
const service = new RecordingTransferService(source, new PrismaRecordingStore(prisma), {
localRoot, localRoot,
deleteSourceAfterCopy, deleteSourceAfterCopy,
maxFilesPerScan maxFilesPerScan
@@ -27,8 +35,9 @@ async function main(): Promise<void> {
await prisma.$connect(); await prisma.$connect();
logger.info( logger.info(
{ {
remoteHost, sourceMode,
remoteReadyDir, sourceDir: sourceMode === 'local' ? localReadyDir : remoteReadyDir,
...(sourceMode === 'ssh' ? { remoteHost } : {}),
localRoot, localRoot,
scanIntervalMs, scanIntervalMs,
maxFilesPerScan, maxFilesPerScan,
+31 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
import { import {
callIdFromStorageKey, callIdFromStorageKey,
LocalRecordingClient,
normalizeRemoteRelativePath, normalizeRemoteRelativePath,
type PulledRecordingFile, type PulledRecordingFile,
type RecordingStore, type RecordingStore,
@@ -90,6 +91,35 @@ describe('recording transfer', () => {
} }
}); });
it('lists, copies, and deletes ready files from a local source directory', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-local-rec-'));
const readyDir = path.join(root, 'ready');
const outputDir = path.join(root, 'recordings');
const sourcePath = path.join(readyDir, '2026', '08', '27', 'call.wav.ready');
const client = new LocalRecordingClient({ readyDir });
const store = new MemoryStore();
const service = new RecordingTransferService(client, store, {
localRoot: outputDir,
deleteSourceAfterCopy: true,
maxFilesPerScan: 10
});
try {
await mkdir(path.dirname(sourcePath), { recursive: true });
await writeFile(sourcePath, 'local-audio');
await writeFile(path.join(readyDir, 'ignored.part'), 'incomplete');
await expect(client.listReadyFiles()).resolves.toEqual([
{ relativePath: '2026/08/27/call.wav.ready', bytes: 11 }
]);
await expect(service.scanOnce()).resolves.toEqual({ moved: 1, skipped: 0, failed: 0 });
await expect(readFile(path.join(outputDir, '2026', '08', '27', 'call.wav'), 'utf8')).resolves.toBe('local-audio');
await expect(stat(sourcePath)).rejects.toThrow();
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('keeps source file when local size verification fails', async () => { it('keeps source file when local size verification fails', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-')); const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]])); const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]]));
+53 -1
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs'; import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rename, rm, stat } from 'node:fs/promises'; import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
@@ -149,6 +149,31 @@ export interface SshRecordingClientOptions {
remoteReadyDir: string; remoteReadyDir: string;
} }
export interface LocalRecordingClientOptions {
readyDir: string;
}
/** Reads finalized recordings from a directory on the same host as the worker. */
export class LocalRecordingClient implements RemoteRecordingClient {
constructor(private readonly options: LocalRecordingClientOptions) {}
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
const files: RemoteRecordingFile[] = [];
await walkReadyFiles(this.options.readyDir, '', files);
return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
}
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
const sourcePath = safeJoin(this.options.readyDir, normalizeRemoteRelativePath(file.relativePath));
await pipeline(createReadStream(sourcePath), createWriteStream(localPath, { mode: 0o640 }));
}
async deleteFile(file: RemoteRecordingFile): Promise<void> {
const sourcePath = safeJoin(this.options.readyDir, normalizeRemoteRelativePath(file.relativePath));
await rm(sourcePath);
}
}
export class SshRecordingClient implements RemoteRecordingClient { export class SshRecordingClient implements RemoteRecordingClient {
constructor(private readonly options: SshRecordingClientOptions) {} constructor(private readonly options: SshRecordingClientOptions) {}
@@ -225,6 +250,33 @@ function safeJoin(root: string, relativePath: string): string {
return resolvedPath; return resolvedPath;
} }
async function walkReadyFiles(root: string, relativeDir: string, files: RemoteRecordingFile[]): Promise<void> {
const directory = relativeDir ? safeJoin(root, relativeDir) : path.resolve(root);
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
for (const entry of entries) {
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await walkReadyFiles(root, relativePath, files);
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.ready')) {
continue;
}
const normalized = normalizeRemoteRelativePath(relativePath);
const fileStat = await stat(safeJoin(root, normalized));
files.push({ relativePath: normalized, bytes: fileStat.size });
}
}
function remotePathFor(baseDir: string, relativePath: string): string { function remotePathFor(baseDir: string, relativePath: string): string {
return `${baseDir.replace(/\/+$/, '')}/${normalizeRemoteRelativePath(relativePath)}`; return `${baseDir.replace(/\/+$/, '')}/${normalizeRemoteRelativePath(relativePath)}`;
} }
+141
View File
@@ -0,0 +1,141 @@
# LisgloSIPS V2 20260630release 稳定版封版报告
## 封版信息
| 项目 | 值 |
| --- | --- |
| 稳定版名称 | `20260630release` |
| 封版时间 | 2026-06-30 14:18 +08:00 |
| Git commit | `9920575bbae7fbef4929843632f94d687ffbe289` |
| B current release | `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500` |
| Web 验收地址 | `https://100.90.90.91/` |
## Git 基线
封版 tag 与稳定分支:
- Tag`20260630release`
- Branch`stable/20260630release`
后续开发应从该 tag 或分支新建功能分支;需要恢复代码时,可 checkout tag 或 stable 分支。
## 服务器基线
### A / TonyHouse
| 项目 | 值 |
| --- | --- |
| Tailscale IP | `100.90.90.90` |
| OpenSIPS 配置 SHA256 | `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5` |
| 热路径 Lua SHA1 | `aff9995dbda2a2425e95a3e25b5d7ce194348ce7` |
| 热路径加载结果 | `success` |
| OpenSIPS 监听 | `100.90.90.90:15060/udp``100.90.90.90:9061/hep_udp` |
服务状态:
- `opensips`: active
- `rtpengine-daemon`: active
- `rtpengine-recording-daemon`: active
- `lisglosips-redis-auth-proxy`: active
- `lisglosips-node-exporter`: active
- `lisglosips-recording-finalize.timer`: active
已知无关 failed unit
- `fwupd-refresh.service`
### B / YanZi
| 项目 | 值 |
| --- | --- |
| Tailscale IP | `100.90.90.91` |
| Current release | `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500` |
| MySQL 封版备份 | `/data/backups/mysql/20260630T061723Z` |
| Redis 封版备份 | `/data/backups/redis/20260630T061723Z` |
| Backup service result | `success` |
| Preflight | `preflight ok` |
服务状态:
- `mysql`: active
- `redis-server`: active
- `nginx`: active
- `lisglosips@api`: active
- `lisglosips@cdr-worker`: active
- `lisglosips@recording-worker`: active
- `lisglosips@config-publisher`: active
- `heplify-server`: active
- `lisglosips-prometheus`: active
- `grafana-server`: active
API ready
```json
{"status":"ok","service":"api","checks":{"config":"ok","database":"ok","redis":"ok"}}
```
### T / SteveTable
| 项目 | 值 |
| --- | --- |
| Tailscale IP | `100.93.185.30` |
| OpenSIPS 配置 SHA256 | `5255dd540d33c3a7bf3db278fe6fe39540b176eb79e7fe12a7a26e5217c263da` |
| OpenSIPS 监听 | `100.93.185.30:5060/udp` |
服务状态:
- `opensips`: active
- `rtpengine-daemon`: active
- `rtpengine-recording-daemon`: active
- `lisglosips-s28-uas`: active
- `apache2`: active
- `mariadb`: active
已知无关 failed unit
- `fwupd-refresh.service`
## 验证结果
- B release preflight:通过
- 远程 Web/API smoke:通过,报告 `tests/reports/REMOTE_SMOKE_20260630T061636Z.md`
- 最近完整按钮级浏览器巡检:通过,报告 `tests/reports/REMOTE_WEB_UI_SMOKE_20260629T104040Z.md`
- A OpenSIPS 配置检查:通过
- T OpenSIPS 配置检查:通过
## 回滚方式
### 代码回滚
```bash
git checkout 20260630release
# 或
git switch stable/20260630release
```
### B 应用回滚到封版 release
```bash
sudo ln -sfn /opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500 /opt/lisglosips/current
sudo systemctl restart lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
sudo nginx -t
sudo systemctl reload nginx
sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
```
### 数据恢复点
封版备份:
- MySQL`/data/backups/mysql/20260630T061723Z`
- Redis`/data/backups/redis/20260630T061723Z`
生产式数据库恢复前必须先在隔离环境验证备份可恢复性,再安排维护窗口执行。
## 后续开发约束
- 新功能或修复从 `stable/20260630release``20260630release` 新建分支。
- B 服务器发布必须新增 release 目录,不覆盖 `s53-brand-logo-sip-text-20260630105500`
- A/B/T 服务器配置修改前必须先备份配置并记录回滚命令。
- 数据库 schema、服务重启、服务器写操作、SSH、防火墙、数据删除、生产式迁移前,必须先说明影响、风险和回滚点。
@@ -0,0 +1,59 @@
# S57 UI、查询与号码库发布报告(2026-08-27)
## 发布结论
- 代码提交:`c36709f366422b9dc5d37156d42d6e2f78bea051`
- B 发布目录:`/opt/lisglosips/releases/s57-ui-number-library-20260827T124730Z`
- 配置/回滚备份:`/var/backups/lisglosips-s57/20260827T124730Z`
- 数据备份:`/data/backups/mysql/20260827T124730Z``/data/backups/redis/20260827T124730Z`
- 发布前检查:MySQL、Redis、Nginx、API、CDR Worker、录音 Worker、HEP、Prometheus、Grafana 均为 activeAPI ready 与 Nginx 配置检查通过。
- 第一次切换因发布脚本误检查不存在的 `/health` 路径得到 404,自动回滚成功;修正为 `/health/ready` 后重新发布并通过,没有遗留半切换状态。
## 生产数据结果
| 数据表 | 生产数量 |
| --- | ---: |
| 行政城市 | 371 |
| 手机前 7 位号段 | 517,258 |
| 城市区号 | 321 |
| 运营商前三位规则 | 70 |
- 数据文件上传前后 SHA256 一致。
- 生产干跑:`phone.dat` 2502 版本,有效号段 517,258,行政区未匹配 0。
- 导入器拒绝向非空号码库重复写入,并在导入前由发布流程完成 MySQL/Redis 备份。
- Prisma 并行计数和首屏 25 条直查共 85 ms。
## 生产接口证据
以下耗时来自 B 的 API 完成日志:
| 接口 | 状态 | 服务端耗时 |
| --- | ---: | ---: |
| `/api/v2/active-calls` | 200 | 首次 46 ms,后续 4-6 ms |
| `/api/v2/number-library/cities?take=25&skip=0` | 200 | 9 ms |
| `/api/v2/number-library/phone-segments?take=25&skip=0` | 200 | 74 ms |
| `/api/v2/cdrs` 最近一周 | 200 | 7 ms |
| `/api/v2/recordings` 最近一周 | 200 | 5 ms |
| `/api/v2/audit-logs` 最近一周 | 200 | 5 ms |
当前通话页面已显示“控制面:就绪”,空闲时返回 0 路真实 dialog,不再出现 OpenSIPS control plane unavailable。
## 浏览器验收
- 登录态下 Dashboard 只保留两个趋势模块,“客户消费 TOP 10”和“失败响应码分布”已删除。
- 客户网关页标题和列表按内容高度从顶部连续排列,截图所示大面积空白消失。
- 号码库首屏 25 条,显示 1-25 / 371;手机号码库显示 1-25 / 517,258,上一页/下一页状态正确。
- 话单、质检、操作日志日期均默认 `2026-08-21``2026-08-27`;请求转换为上海时区自然日对应的 UTC 起止值。
- 页面 DOM 均有真实业务内容,没有框架错误覆盖层;关键请求均在 API 日志中返回 200。
## 本地质量门禁
- ESLint:通过。
- TypeScript project build/typecheck:通过。
- Web production build:通过。
- Vitest:27 个测试文件、98 个测试全部通过。
- 号码库导入器本地与生产干跑:均为未匹配 0。
## 回滚
如需回滚代码和 API 配置:恢复 `/var/backups/lisglosips-s57/20260827T124730Z/api.env`,将 `/opt/lisglosips/current` 指回该目录内 `previous-release` 记录的版本,并重启 `lisglosips@api`。号码库数据回滚应使用本次发布前 MySQL 备份,不应直接无条件清空四张表。
@@ -0,0 +1,28 @@
# Server B 单机服务收敛实施方案
日期:2026-08-27
范围:停止 B 到 A 的录音 SSH 依赖,当前生产服务全部留在 B。
## 结论
B 已运行 OpenSIPS、RTPEngine、RTPEngine Recording Daemon、Nginx、API、MySQL、Redis、CDR Worker、Recording Worker 与 Config PublisherCPU、内存和磁盘空间满足当前负载。现有单机化缺口只有录音 Worker 仍配置为 SSH 访问 AA 不可达会导致该 Worker 周期性失败重启。
## 改动
1. Recording Worker 新增 `local` 源模式,递归扫描 B 的 `.ready` 文件,保留大小校验、SHA-256、原子落盘、数据库 upsert 与成功后删除源文件语义。
2. B 启用录音 finalizer timer,将 Recording Daemon 生成的稳定 WAV/MP3 从 `incoming` 原子推进到 `ready`
3. Worker systemd 增加 `rtpengine` 附加组,只开放 ready 目录和持久化目录权限。
4. B 生产环境改为 `RECORDING_SOURCE_MODE=local`;旧 SSH 文件不立即删除,只作短期回滚材料。
5. 不修改 TLS、数据库结构、SIP/RTP 监听地址和防火墙,不迁移历史录音,不停止不可达的 A。
## 验收
- 定向单元测试和 TypeScript 构建通过。
- finalizer timer、Recording Worker、OpenSIPS、RTPEngine、API、Nginx、MySQL、Redis 均 active。
- Recording Worker 启动日志显示 `sourceMode=local`,稳定运行超过原失败周期,且无 SSH 子进程。
- API readiness、首页和发布 preflight 通过。
- 真实新通话录音应在下一次受控呼叫时补做:`incoming -> ready -> /data/recordings -> recordings READY -> 鉴权播放`
## 回滚
发布前备份 Worker env、systemd drop-in、finalizer 脚本和 unit。异常时停用 finalizer timer,恢复备份配置并将 release symlink 切回上一版本;不删除 B 已落盘录音。
@@ -0,0 +1,52 @@
# Server B 单机服务收敛发布报告
发布日期:2026-08-27
生产提交:`309bffc7ffa70a760bce21ae59cb6c457c9c57fe`
生产 release`/opt/lisglosips/releases/s56-b-consolidation-20260827T095812Z`
## 发布结果
当前服务已全部留在 Server B。Recording Worker 使用 `RECORDING_SOURCE_MODE=local` 读取
`/dev/shm/voip_rec/ready`,不再连接 A,也不再产生录音 SSH 子进程。A 未执行任何配置修改或停机操作;B 上旧 SSH 文件暂留作短期回滚材料,但不被当前 env 和进程引用。
## 代码与配置
- 新增 `LocalRecordingClient`,保留大小校验、SHA-256、临时文件、原子改名、数据库 upsert 和成功后删除源文件的原语义。
- 新增 B finalizer service/timer,每 5 秒将稳定 WAV/MP3 从 `incoming` 推进到 `ready`
- Recording Worker 使用 `SupplementaryGroups=rtpengine``BindPaths=/dev/shm/voip_rec/ready`;持久化目录仍为 `/data/recordings`
- finalizer 的 `fuser` 调用已按 B 的 psmisc 实现改为兼容语法。
- 未修改 TLS、数据库 schema、SIP/RTP 监听地址、防火墙、A 或 T。
## 备份与回滚点
- 最终 MySQL 备份:`/data/backups/mysql/20260827T095812Z`
- 最终 Redis 备份:`/data/backups/redis/20260827T095813Z`
- 最终配置备份:`/var/backups/lisglosips-s56/20260827T095812Z`
- 上一可用 release`/opt/lisglosips/releases/s56-b-consolidation-20260827T093227Z`
发布脚本会在失败时恢复 previous release、Recording Worker env 与 systemd drop-in,并重启旧 Worker。回滚不删除已持久化录音。
## 验证证据
| 检查 | 结果 |
| --- | --- |
| 定向 Vitest | `apps/worker-recording/src/transfer.spec.ts` 5/5 通过 |
| TypeScript / ESLint | Recording Worker 构建及三个变更文件 lint 通过 |
| 发布 preflight | MySQL、Redis、Nginx、API、全部 Worker、HOMER、Prometheus、Grafana 通过 |
| 核心服务 | OpenSIPS、RTPEngine、Recording Daemon、Web/API/DB/Worker 全部 active |
| API 与首页 | readiness HTTP 200;首页 HTTP 200 |
| Worker 模式 | 启动日志为 `sourceMode=local``sourceDir=/dev/shm/voip_rec/ready` |
| 稳定性 | 连续运行 1100 秒,`NRestarts=0`,超过旧 SSH 约 135 秒失败周期 |
| SSH 依赖 | Worker 子进程为空;只存在系统管理用 sshd/运维会话 |
| 目录可见性 | 主机与 Worker 命名空间 ready 目录设备号/inode 同为 `27:7` |
| finalizer 隔离测试 | 19 字节测试文件成功形成 `.ready`,权限 `0640 rtpengine:rtpengine`,测试目录已清理 |
| 生效文件一致性 | systemd drop-in 与 finalizer 的仓库/服务器 SHA-256 分别一致 |
## 剩余验收
本次未主动发起真实外呼,避免生成计费、CDR 和业务录音。下一次受控真实呼叫需要核对:
1. Recording Daemon 在 B 生成 `incoming` 文件。
2. finalizer 生成 `.ready`
3. Worker 写入 `/data/recordings` 并将数据库状态更新为 `READY`
4. 有 `recordings.play` 权限的账号可播放,直接访问 Nginx internal 路径仍返回 404。
@@ -0,0 +1,45 @@
# LisgloSIPS UI 性能整改 B 发布报告
## 发布信息
| 项目 | 值 |
| --- | --- |
| 发布时间 | 2026-08-27 15:39 +08:00 |
| 代码提交 | `ecb567c0dacd749607808fbed09648267757be6f` |
| Git 分支 | `main`,已推送 `origin/main` |
| 新 release | `/opt/lisglosips/releases/s55-ui-performance-20260827153500` |
| 回滚 release | `/opt/lisglosips/releases/s54-cmpp-ui-style-20260702103305` |
| Artifact SHA256 | `acc49bb21506448527732e965c345a5e71e83090781be76df750a93cffccaece` |
| Web 资产 | `index-DcpB-OOG.js``index-u-EPfKoT.css` |
## 发布门禁
- 本地 ESLint、TypeScript、Prisma schema validate 通过。
- Vitest 26 个测试文件、95 项测试通过。
- 本地 Web production build 和发布结构检查通过。
- B 使用 Linux `node_modules` 从精确 Git bundle 构建,workspace build 通过。
- Linux release artifact 生成成功,共 19,681 个文件。
- 发布前 MySQL、Redis 备份 SHA256 校验通过:`/data/backups/mysql/20260827T073857Z``/data/backups/redis/20260827T073857Z`
## 发布后验证
- `current` 已切换到 s55`.deployed-commit` 写入业务提交。
- Nginx 配置测试通过,首页返回 200 并引用新 Web 资产。
- B release preflight 通过;MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Heplify、Prometheus、Grafana 在 preflight 时均为 active。
- API live/ready、验证码接口和外部匿名 smoke 全部通过,报告见 `tests/reports/REMOTE_SMOKE_20260827T073955Z.md`
- 真实管理员只读回读:Dashboard overview 1,843ms、24 个趋势桶;话单接口 2,357ms、默认 `take=25`、返回 25/97 条。
## 已知环境问题
B 到 A `100.90.90.90` 的 Tailscale ping 和录音专用 SSH 当前超时。Recording Worker 启动后在扫描 A 录音目录时约 135 秒失败,systemd 会自动拉起。该模块在本次提交中没有代码变化,回滚 s55不能恢复 A 网络;待 A 上线后应复核 Worker 连续稳定和录音积压。
完整 Playwright UI 巡检未完成,原因是本机没有对应 Chromium 二进制;未为本次发布临时下载安装浏览器。真实认证 API、页面静态资产和匿名 Web smoke 已通过。
## 回滚命令
```bash
sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh \
/opt/lisglosips/releases/s54-cmpp-ui-style-20260702103305
```
本次没有数据库迁移、TLS、Redis 热路径或 A/T 配置回滚项。
@@ -0,0 +1,29 @@
# Server B 开机自启发布报告(2026-08-30)
## 结论
- 代码提交:`406b374`
- systemd 项目编组:`lisglosips.target`,状态 `active / enabled`
- 开机验收:`lisglosips-boot-check.service`,状态 `active (exited) / enabled`,最近执行结果 `0/SUCCESS`
- 配置备份:`/var/backups/lisglosips-s58/20260830T131241Z`
- 本次未重启服务器,未中断现有通话服务;真正的冷启动验收应安排维护窗口。
## 本次补齐
- `lisglosips@recording-worker.service`:由 `inactive / disabled` 修正为 `active / enabled`
- `lisglosips@config-publisher.service`:由 `inactive / disabled` 修正为 `active / enabled`
- API、CDR Worker 两个实例继续保持 `active / enabled`
- 备份 timer 和录音整理 timer 继续保持 `active / enabled`
## 现场验证
- API `/api/v2/health/ready``status=ok`,数据库和 Redis 检查正常。
- Nginx 配置、OpenSIPS 配置和发布 preflight:通过。
- systemd failed units0。
- Recording Worker:本机录音模式启动成功,当前内存约 22 MB,重启次数 0。
- Config Publisher:启动成功,当前内存约 29 MB、峰值约 39 MB,重启次数 0。
- Config Publisher 的 Redis 用户仍无 `INFO` 权限,因此 ioredis 会输出“跳过 ready check”的提示;实际 Redis 连接和 Worker 启动成功,此提示不阻断服务,但后续可单独收敛日志或关闭 ready check。
## 架构说明
业务进程仍由独立 systemd unit 托管,项目 target 仅负责统一编组和开机拉起,启动脚本负责就绪等待与验收。这比 `rc.local``nohup` 或单个常驻 shell 脚本更符合生产环境常见做法。
@@ -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变更未混入本次提交。
+221
View File
@@ -0,0 +1,221 @@
# 主叫号码接通率与应答率实时分析设计方案
日期:2026-08-31
版本:V1.1(按用户修订后的业务口径)
状态:设计文档已完成;代码、数据库迁移、真实联调与上线均待实施。
实施更新(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不构成功能验收。单元测试用于公式/状态转换,真实联调用于端到端与故障恢复。正常约定负载下事件或可信正时长可用至页面可见的P95目标≤5秒;这是待测目标,不是当前容量承诺。需分别记录采集、消费、API和页面延迟及原有呼叫/计费回归结果。
## 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。
- 告警通过独立进程环境变量配置三个低率阈值和前窗下降百分点;采用样本/未结束占比门槛、连续三次评估及三次恢复,站内展示,不执行停号换路。后续应按实际业务校准默认阈值。
Binary file not shown.
+41 -4
View File
@@ -2,6 +2,21 @@
> 任务:S24 - 录音搬运与播放 > 任务:S24 - 录音搬运与播放
> 完成时间:2026-06-21 20:20 +08:00 > 完成时间:2026-06-21 20:20 +08:00
> 当前模式:S56 - Server B 本机录音闭环(2026-08-27
## 0. 当前生产架构(S56
录音不再经过 B 到 A 的专用 SSH。Server B 上的 RTPEngine Recording Daemon 先写入
`/dev/shm/voip_rec/incoming`,本机 finalizer 将稳定文件原子改名为
`/dev/shm/voip_rec/ready/**/*.ready`Recording Worker 再复制、校验并持久化到
`/data/recordings`,最后写入数据库并删除本机 `.ready` 源文件。
```text
B RTPEngine -> B incoming -> B finalizer -> B ready
-> B Recording Worker -> B /data/recordings + MySQL
```
旧的 B -> A SSH 配置仅作为短期回滚材料保留,不参与当前服务。S24 以下章节保留为历史实施记录。
## 1. 目标 ## 1. 目标
@@ -26,8 +41,23 @@ S24 完成录音闭环的最小能力:
## 3. Worker 环境变量 ## 3. Worker 环境变量
S56 当前生产配置:
```text ```text
DATABASE_URL DATABASE_URL
RECORDING_SOURCE_MODE=local
RECORDING_LOCAL_READY_DIR=/dev/shm/voip_rec/ready
RECORDING_LOCAL_ROOT=/data/recordings
RECORDING_SCAN_INTERVAL_MS=10000
RECORDING_MAX_FILES_PER_SCAN=50
RECORDING_DELETE_SOURCE_AFTER_COPY=true
```
`RECORDING_SOURCE_MODE=ssh` 仍受代码支持,以下变量只用于回滚到旧架构:
```text
DATABASE_URL
RECORDING_SOURCE_MODE=ssh
RECORDING_REMOTE_HOST=lisglosips-a RECORDING_REMOTE_HOST=lisglosips-a
RECORDING_SSH_CONFIG=/etc/lisglosips/recording/ssh_config RECORDING_SSH_CONFIG=/etc/lisglosips/recording/ssh_config
RECORDING_REMOTE_READY_DIR=/dev/shm/voip_rec/ready RECORDING_REMOTE_READY_DIR=/dev/shm/voip_rec/ready
@@ -37,12 +67,16 @@ RECORDING_MAX_FILES_PER_SCAN=50
RECORDING_DELETE_SOURCE_AFTER_COPY=true RECORDING_DELETE_SOURCE_AFTER_COPY=true
``` ```
本地开发可使用 `.codex-private/ssh/config`;生产或 B 端 systemd 应使用 `/etc/lisglosips/recording/` 下的受控 key 和 known_hosts。 SSH 模式本地开发可使用 `.codex-private/ssh/config`;生产回滚时才使用 `/etc/lisglosips/recording/` 下的受控 key 和 known_hosts。
## 4. 服务器变更 ## 4. 服务器变更
### Server B ### Server B
- S56 新增 `lisglosips-recording-finalize.timer`,每 5 秒检查本机稳定录音文件。
- Recording Worker 以 `lisglo-recorder` 身份运行,通过 `SupplementaryGroups=rtpengine` 访问 ready 目录。
- systemd 只开放 `/dev/shm/voip_rec/ready``/data/recordings` 所需写权限。
- Worker 使用 `RECORDING_SOURCE_MODE=local`,运行期不再启动 `ssh` 子进程。
- 新增 `/etc/lisglosips/recording/a_pull_ed25519`,权限 `0640 root:lisglosips` - 新增 `/etc/lisglosips/recording/a_pull_ed25519`,权限 `0640 root:lisglosips`
- 新增 `/etc/lisglosips/recording/known_hosts` - 新增 `/etc/lisglosips/recording/known_hosts`
- 录音持久化目录仍为 `/data/recordings`,由 `lisglo-recorder:lisglosips` 管理。 - 录音持久化目录仍为 `/data/recordings`,由 `lisglo-recorder:lisglosips` 管理。
@@ -109,7 +143,7 @@ location /_recordings/ {
## 7. 当前限制 ## 7. 当前限制
B 当前 `/opt/lisglosips/current` 仍是 S05 placeholder release,未切换到完整 monorepo API/Worker release。因此 S24 本次完成代码、构建、服务器私网拉取链路和 Nginx 内部播放保护验证;正式 `lisglosips@recording-worker.service` 随完整应用 release 发布时启用 S24 记载的 placeholder 限制已经失效;B 已运行完整 monorepo API/Worker release。S56 不迁移 A 的历史录音,也不自动删除旧 SSH key;旧凭据待本地闭环稳定运行并完成真实录音验收后再单独清理
## 8. 回滚 ## 8. 回滚
@@ -125,8 +159,11 @@ sudo chmod 0750 /dev/shm/voip_rec/ready
Server B Server B
```bash ```bash
sudo rm -rf /etc/lisglosips/recording sudo systemctl disable --now lisglosips-recording-finalize.timer
sudo rm -rf /data/recordings/s24-smoke sudo cp /var/backups/lisglosips-s56/<timestamp>/recording-worker.env /etc/lisglosips/recording-worker.env
sudo cp /var/backups/lisglosips-s56/<timestamp>/10-s28-recording.conf /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf
sudo systemctl daemon-reload
sudo systemctl restart lisglosips@recording-worker
``` ```
代码回滚: 代码回滚:
+38
View File
@@ -0,0 +1,38 @@
# Server B 单机服务开机自启 Runbook
## 方案
生产进程继续由各自的 systemd unit 管理,使用 `lisglosips.target` 编组整套单 B 服务。`lisglosips-start-b` 是人工启动和开机后验收脚本,不直接常驻托管业务进程。
这种方式比 `rc.local`、桌面启动项或后台 shell 脚本更适合生产环境:systemd 能分别记录日志、限制权限、处理依赖、实施失败重启,并准确显示单个服务状态。
## 自启范围
- 基础设施:Tailscale、MySQL、Redis、PostgreSQL、Nginx。
- 通信:OpenSIPS、RTPEngine、RTPEngine Recording、Redis AUTH Proxy、Redis 热路径加载。
- 应用:API、CDR Worker、Recording Worker、Config Publisher。
- 信令与监控:HEP、HOMER、Prometheus、Grafana、Node/MySQL/PostgreSQL/Redis Exporter。
- 定时任务:每日数据备份、每 5 秒录音整理。
## 安装
```bash
sudo /opt/lisglosips/current/infra/server-b/s58/deploy-autostart.sh
```
安装流程会备份已有文件、安装 target 和开机检查服务、启用四个应用实例、启动整套 target,并运行 API ready、Nginx、OpenSIPS 和 preflight 检查。失败时自动撤销本次新建的 enable 链接并恢复旧文件。
## 日常操作
```bash
sudo systemctl start lisglosips.target
sudo /usr/local/sbin/lisglosips-start-b
systemctl status lisglosips.target lisglosips-boot-check.service
systemctl --failed
```
不建议使用 `kill``nohup` 或重复启动脚本托管进程。单个服务变更仍应使用 `systemctl restart <unit>`,避免无关服务中断。
## 验收
安装后检查 `lisglosips.target``lisglosips-boot-check.service`、API、CDR Worker、Recording Worker、Config Publisher 是否 enabled。首次真正重启验收应安排维护窗口,记录重启时间、所有服务状态、API ready、当前通话 MI、录音入库和配置发布结果。
+86 -12
View File
@@ -35,8 +35,8 @@
| 环境 | 用途 | 说明 | | 环境 | 用途 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 本地开发 | 单元、服务、API e2e、前端构建 | Windows 工作区,Node.js 22+pnpm 10.33.0Vitest | | 本地开发 | 单元、服务、API e2e、前端构建 | Windows 工作区,Node.js 22+pnpm 10.33.0Vitest |
| Server A | SIP/RTP/录音/HEP/Exporter | OpenSIPS、RTPEngine、录音 tmpfs、Redis 热路径 | | Server A | 历史节点/回滚参考 | S56 起不承载当前生产服务,不参与录音传输 |
| Server B | Web/API/Worker/DB/监控 | Nginx、API、Redis、MySQL、Worker、HOMER、Prometheus/Grafana | | Server B | SIP/RTP/录音/Web/API/Worker/DB/监控 | OpenSIPS、RTPEngine、录音 tmpfs、Nginx、API、Redis、MySQL、Worker、HOMER、Prometheus/Grafana |
| Server T | 客户与落地模拟 | SIP 注册、呼叫、失败场景、并发呼叫脚本 | | Server T | 客户与落地模拟 | SIP 注册、呼叫、失败场景、并发呼叫脚本 |
| 阿里云迁移环境 | 上线前复验 | 生产网络、安全组、正式 TLS、数据盘、备份恢复和灰度呼叫必须重新执行 | | 阿里云迁移环境 | 上线前复验 | 生产网络、安全组、正式 TLS、数据盘、备份恢复和灰度呼叫必须重新执行 |
@@ -1331,18 +1331,18 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
- 只读质检账号 `quality_viewer_001`:包含 `quality.view`,不包含 `quality.manage``recordings.play` - 只读质检账号 `quality_viewer_001`:包含 `quality.view`,不包含 `quality.manage``recordings.play`
- 至少 1 条成功通话 CDR,关联 READY 录音 `rec_ready_seed_001`storageKey 指向 B 本地录音数据盘。 - 至少 1 条成功通话 CDR,关联 READY 录音 `rec_ready_seed_001`storageKey 指向 B 本地录音数据盘。
- 至少 1 条 PENDING 录音 `rec_pending_seed_001`,用于播放和质检不可用状态。 - 至少 1 条 PENDING 录音 `rec_pending_seed_001`,用于播放和质检不可用状态。
- A`/dev/shm/voip_rec` 或测试目录可生成 ready 文件,B 侧录音存储目录可写 - B`/dev/shm/voip_rec` 可生成 ready 文件,`/data/recordings` 可写;Worker 配置为本地源模式
#### REC-001 录音搬运成功 #### REC-001 录音搬运成功
| 字段 | 内容 | | 字段 | 内容 |
| --- | --- | | --- | --- |
| 优先级 | P0 | | 优先级 | P0 |
| 目的 | 验证 Recording Worker 从 A 拉取 ready 文件,校验大小/hash 后写入 B,并安全删除源文件。 | | 目的 | 验证 Recording Worker 从 B 本机读取 ready 文件,校验大小/hash 后持久化,并安全删除 tmpfs 源文件。 |
| 前置条件 | A 侧存在测试录音文件和 ready 标记;B 侧 Recording Worker 可通过私网访问 A;关联 CDR/callId 可匹配。 | | 前置条件 | B 侧存在测试录音文件和 ready 标记;本机 finalizer 与 Recording Worker active;关联 CDR/callId 可匹配。 |
| 测试数据 | 文件名包含 Call-ID;ready 内容包含相对路径和 size;文件大小固定,例如 55758 bytes。 | | 测试数据 | 文件名包含 Call-ID;ready 内容包含相对路径和 size;文件大小固定,例如 55758 bytes。 |
| 步骤 | 1. 在 A 侧生成测试录音 ready 文件。2. 启动或触发 Recording Worker。3. 查询 B 侧录音文件、数据库 recordings 表和 Worker 日志。4. 检查 A 源文件和 ready 文件。 | | 步骤 | 1. 在 B 侧生成测试录音,由 finalizer 形成 ready 文件。2. 启动或触发 Recording Worker。3. 查询 B 持久化文件、数据库 recordings 表和 Worker 日志。4. 检查 B ready 文件。 |
| 预期结果 | B 文件存在,大小/hash 与 A 源文件一致;recording 状态为 `READY`storageKey 为安全相对路径;A 源文件在校验成功后删除;日志记录成功搬运。 | | 预期结果 | B 持久化文件存在,大小/hash 与 ready 源文件一致;recording 状态为 `READY`storageKey 为安全相对路径;B ready 源文件在校验成功后删除;日志记录成功搬运,进程树中无录音 SSH。 |
| 数据检查 | recordings 记录包含 callId、rawCdrId 或关联键、fileSize、checksum、storageKey、readyAt;状态从 PENDING 变为 READY。 | | 数据检查 | recordings 记录包含 callId、rawCdrId 或关联键、fileSize、checksum、storageKey、readyAt;状态从 PENDING 变为 READY。 |
| 安全检查 | Worker 只处理配置目录下文件;不会跟随任意绝对路径或 `../` 路径。 | | 安全检查 | Worker 只处理配置目录下文件;不会跟随任意绝对路径或 `../` 路径。 |
@@ -1351,13 +1351,13 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 字段 | 内容 | | 字段 | 内容 |
| --- | --- | | --- | --- |
| 优先级 | P0 | | 优先级 | P0 |
| 目的 | 验证复制后大小/hash 不一致时不删除 A 源文件,避免录音丢失。 | | 目的 | 验证复制后大小/hash 不一致时不删除 B 本机 ready 源文件,避免录音丢失。 |
| 前置条件 | 可构造错误 ready size、截断文件或模拟 B 写入失败。 | | 前置条件 | 可构造错误 ready size、截断文件或模拟 B 写入失败。 |
| 测试数据 | ready size 与实际文件大小不一致。 | | 测试数据 | ready size 与实际文件大小不一致。 |
| 步骤 | 1. 生成 size 不一致的 ready 文件。2. 执行 Recording Worker。3. 查询 A/B 文件和 recordings 状态。 | | 步骤 | 1. 生成 size 不一致的 ready 文件。2. 执行 Recording Worker。3. 查询 B ready/持久化文件和 recordings 状态。 |
| 预期结果 | Worker 标记失败或保持待重试;A 源文件保留;B 不产生 READY 记录;错误日志可定位。 | | 预期结果 | Worker 标记失败或保持待重试;B ready 源文件保留;持久化目录不产生有效成品且数据库不误标 READY;错误日志可定位。 |
| 数据检查 | recordings 不应被误标记 READY;如生成 FAILED 状态,应记录失败原因和重试信息。 | | 数据检查 | recordings 不应被误标记 READY;如生成 FAILED 状态,应记录失败原因和重试信息。 |
| 安全检查 | 失败日志不泄露 SSH 私钥、完整内部路径或连接凭据。 | | 安全检查 | 失败日志不泄露数据库连接凭据;Worker 不访问配置目录之外的文件。 |
#### REC-003 播放接口鉴权与权限 #### REC-003 播放接口鉴权与权限
@@ -1655,6 +1655,42 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 数据检查 | release 目录和 public 目录一致。 | | 数据检查 | release 目录和 public 目录一致。 |
| 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 | | 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 |
#### WEB-007 慢页面统一加载反馈
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证页面请求超过 2 秒时才显示加载弹窗,完成或失败后立即隐藏。 |
| 前置条件 | 管理员已登录;浏览器可将目标接口延迟到 1.5 秒、2.5 秒和失败三种状态。 |
| 步骤 | 1. 分别以三种延迟进入 Dashboard、话单、质检、号码库。2. 观察弹窗和列表状态。3. 在请求过程中切换菜单。 |
| 预期结果 | 2 秒内无弹窗;超过 2 秒出现“页面加载中”;请求完成、失败或被页面切换取消后弹窗立即消失;加载阶段只显示骨架,不出现“暂无数据”。 |
| 数据检查 | 页面切换会取消上一页面未完成请求,不产生旧响应覆盖新页面。 |
| 安全检查 | 弹窗和错误信息不显示 token、SQL 或内部堆栈。 |
#### WEB-008 Dashboard 按页面取数
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证登录首页只请求首页概览数据,不再预加载全站接口。 |
| 前置条件 | 管理员登录,浏览器 Network 保留日志。 |
| 步骤 | 1. 清空 Network。2. 登录或刷新首页。3. 检查 `/api/v2/dashboard/overview` 及其他业务接口。4. 切换到客户、供应商后复查。 |
| 预期结果 | 首页使用一个 overview 请求并行取得 summary/trends;未进入的客户、供应商、网关、用户、角色、日志等接口不请求;进入对应页面后才按需请求。 |
| 数据检查 | 首页指标全部来自真实 overview 响应,不使用 fixture 回退。 |
| 安全检查 | 只请求当前账号具备权限的数据。 |
#### WEB-009 话单首屏性能与分页稳定性
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证话单首屏缩小到 25 条、列表和总数并行查询、同时间记录分页顺序稳定。 |
| 前置条件 | 话单表存在超过 50 条记录,其中多条 `startedAt` 相同。 |
| 步骤 | 1. 首次进入话单中心。2. 检查请求 take。3. 连续翻页并返回。4. 记录接口耗时并观察重复/遗漏。 |
| 预期结果 | 默认 `take=25`;按 `startedAt desc, id desc` 稳定排序;列表与 count 并行;主列表仅展示核心字段,完整字段在详情抽屉。 |
| 数据检查 | 前后页无重复或遗漏,total/hasMore 正确。 |
| 安全检查 | 话单筛选和详情仍遵守 `cdrs.view` 权限。 |
### 8.9 性能、故障与安全 ### 8.9 性能、故障与安全
本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。 本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。
@@ -1905,6 +1941,44 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 | | 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 |
| 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 | | 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 |
### 8.10 S57 页面布局、日期范围与号码库
#### WEB-010 页面内容顶部对齐
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 步骤 | 登录后依次打开客户网关、用户、号码库以及数据量较少的配置页,检查标题、筛选区和列表面板。 |
| 预期结果 | 所有区块按内容高度从顶部连续排列,不因视口剩余高度产生大面积空白或组件纵向拉伸。 |
#### ACT-004 B 本机 OpenSIPS MI
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 步骤 | 1. 在 B 调用 OpenSIPS HTTP MI `dlg_list`。2. 打开当前通话。3. 发起测试呼叫后刷新。 |
| 预期结果 | 无 A 服务器 SSH 依赖;空闲时快速返回空列表,呼叫中显示真实 dialog;MI 不可用时 3 秒内失败并显示受控提示。 |
#### DATE-001 三中心默认最近一周
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 步骤 | 依次打开话单、质检、操作日志,检查日期输入和请求参数;修改范围查询,再点击重置。 |
| 预期结果 | 首次和重置均为今天至前 6 天;开始日按 00:00:00、结束日按 23:59:59.999 查询;后端只返回范围内数据。 |
#### NUM-004 号码库全量与分页性能
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 步骤 | 1. 导入器先干跑并确认未匹配为 0。2. 备份后导入。3. 核对四表数量。4. 四个页签执行首屏、翻页、页大小和筛选。 |
| 预期结果 | 371 个行政城市、517,258 个有效七位号段、321 个区号、70 个前缀规则;首屏只读取 25 条;上一页/下一页与总数正确;页面加载不扫描或返回全表。 |
## 8A. 主叫号码实时分析验收设计索引(2026-08-31)
[主叫号码实时分析V1.1](CALLER_REALTIME_ANALYTICS_DESIGN.md)第10节定义CRA-001至CRA-024,覆盖180/183业务接通、正时长应答、总体应答率A/T、已接通应答率A/C、直接200/缺少100/零时长/亚秒时长、待接通与未知、线路重试、重复乱序、历史缺口、权限隔离和故障恢复。该组用例目前全部待执行,不计入既有通过数量;最终验收必须有真实SIP、API、数据库及CDR对账证据。
## 9. 缺陷分级 ## 9. 缺陷分级
| 级别 | 定义 | 示例 | | 级别 | 定义 | 示例 |
@@ -1929,7 +2003,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
## 11. 当前已知风险与补充建议 ## 11. 当前已知风险与补充建议
- Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。 - Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。
- 当前真实 80 万手机号段尚未导入。号码库性能、导入耗时、批量校验和 CDR 归属地准确性需单独做数据量级测试 - S57 号码库采用 517,258 条有效七位号段;归属和运营商字段是公开号段库口径,携号转网后的当前运营商不能仅凭该字段断言,仍需运营商实时数据源校验
- 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。 - 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。
- 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。 - 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。
- 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。 - 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。
@@ -0,0 +1,48 @@
# UI 与页面性能整改实施记录(2026-08-27)
## 实施范围
本次按《LisgloSIPS UI 性能整改方案》实施前端加载体验、按页面取数、Dashboard 请求合并、话单首屏性能、质检与号码库懒加载、宽表可读性和中文状态文案。TLS 证书按要求不在本次代码整改范围内,未修改证书或生产环境配置。
## 已完成
- 全局请求增加 10 秒超时和主动取消;切换菜单时取消上一页面请求,避免旧响应覆盖当前页面。
- 页面请求超过 2 秒才显示加载弹窗,完成、失败或取消后立即隐藏;表格加载中显示骨架,不再与空状态同时出现。
- 登录首页由全站 11 组接口预加载改为按当前路由加载;Dashboard 新增 `/api/v2/dashboard/overview`,服务端并行取得 summary 与 trends,前端只发一个首页概览请求。
- 话单默认页大小由 100/50 降为 25;列表与总数改为并行查询,并增加 `startedAt desc, id desc` 稳定排序;主表精简为核心字段,完整信息保留在详情抽屉。
- 质检录音默认限制为 50;抽检规则及客户/线路依赖只在打开规则抽屉时加载。
- 号码库由首次同时加载四个页签改为只加载当前页签,其他页签首次打开时再读取。
- 当前通话自动刷新改为静默更新;失败后停止自动轮询,避免 5 秒间隔持续叠加失败请求;手动首次加载仍使用延迟弹窗。
- 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。
- Dashboard 未接入指标明确显示“等待真实数据”,不再使用开发 fixture 伪装实时数据;操作向文案已做中文化。
## S57 补充整改
- 全局 `.page-content` 固定为顶部对齐和内容高度网格行,消除数据较少时标题、筛选区和列表面板被拉伸形成的大面积空白;分页和日期范围控件收敛为全局组件和样式。
- 当前通话不再依赖 B 到 A 的 SSH 包装命令,API 改为请求 B 本机 `http://127.0.0.1:8888/mi` 的 OpenSIPS JSON-RPC;保留 SSH 模式作为显式回滚选项。
- 话单中心、质检中心、操作日志统一默认查询最近 7 个自然日(含今天),结束日期覆盖至当天 23:59:59.999;重置后仍恢复最近一周,不再回到无限时间范围。
- 质检录音和操作日志补齐后端日期过滤,操作日志查询由“只对首批 100 条做浏览器过滤”改为日期、模块、结果的服务端过滤。
- Dashboard 删除“客户消费 TOP 10”和“失败响应码分布”两个模块。
- 号码库默认每页 25 条,四个页签分别维护分页游标、页大小和总数,只请求当前页;增加可重复执行的数据导入脚本,导入时保存来源和批次并拒绝部分匹配。
## 号码库数据口径
- 行政区划:国家统计局 2023-06-30 区划代码的 2024 整理版本;包含地级行政区和省/自治区直辖县级市,共 371 条。
- 手机号段:`phone.dat` 2502 版本,共 517,258 条有效七位号段;运营商信息受携号转网影响,只表示号段原始分配归属,不作为当前在网运营商的强校验。
- 城市区号:由号段库归属记录按城市多数匹配生成 321 条;当前表结构一个区号只能关联一个城市,共享区号按数据中出现最多的城市保存。
- 运营商前缀:由七位号段按前三位多数归属生成 70 条,业务判定仍应优先使用七位号段。
- 导入器要求所有有效号段均能映射到行政区代码;本批次干跑结果为未匹配 0 条。生产导入前必须先运行 MySQL/Redis 备份。
## S56 时暂未直接实施(现状更新)
- 未新增数据库索引。整改方案要求先以生产库 `EXPLAIN ANALYZE` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。
- 未修改 TLS 证书、Nginx 或生产部署配置。
- S57 发布结果、生产数量和浏览器证据记录在对应发布报告中。
## 本地验证
- ESLint:通过。
- TypeScript project build/typecheck:通过。
- Vitest:27 个测试文件、98 个测试全部通过。
- Web production build:通过,生成 `dist/index.html`、CSS 和 JS hash 产物。
- 浏览器验收:已在 B 的真实登录态逐页检查 Dashboard、客户网关、当前通话、号码库、话单、质检和操作日志;生产结果和接口耗时见 `20260827_S57_UI_NUMBER_LIBRARY_RELEASE_REPORT.md`
+43 -23
View File
@@ -8,37 +8,58 @@ fi
echo "== LisgloSIPS B startup ==" echo "== LisgloSIPS B startup =="
hostname hostname
systemctl start \ systemctl start lisglosips.target
mysql \
redis-server \
nginx \
heplify-server \
lisglosips-prometheus \
grafana-server
systemctl start \ required_services=(
lisglosips@api \ tailscaled
lisglosips@cdr-worker \ mysql
lisglosips@recording-worker \ redis-server
postgresql
nginx
opensips
rtpengine-daemon
rtpengine-recording-daemon
lisglosips-redis-auth-proxy
lisglosips@api
lisglosips@cdr-worker
lisglosips@recording-worker
lisglosips@config-publisher lisglosips@config-publisher
heplify-server
homer-app
lisglosips-prometheus
grafana-server
lisglosips-node-exporter
lisglosips-mysqld-exporter
lisglosips-postgres-exporter
lisglosips-redis-exporter
)
for attempt in $(seq 1 30); do
missing=()
for unit in "${required_services[@]}"; do
systemctl --quiet is-active "$unit" || missing+=("$unit")
done
[ "${#missing[@]}" -eq 0 ] && break
[ "$attempt" -eq 30 ] && {
printf 'startup timeout; inactive services: %s\n' "${missing[*]}" >&2
exit 1
}
sleep 2
done
nginx -t nginx -t
opensips -C -f /etc/opensips/opensips.cfg >/dev/null
echo "== release ==" echo "== release =="
readlink -f /opt/lisglosips/current readlink -f /opt/lisglosips/current
echo "== services ==" echo "== services =="
systemctl is-active \ systemctl is-active "${required_services[@]}"
mysql \
redis-server \ echo "== timers =="
nginx \ systemctl is-active lisglosips-backup.timer lisglosips-recording-finalize.timer
lisglosips@api \ systemctl is-enabled lisglosips.target lisglosips-backup.timer lisglosips-recording-finalize.timer \
lisglosips@cdr-worker \ lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
lisglosips@recording-worker \
lisglosips@config-publisher \
heplify-server \
lisglosips-prometheus \
grafana-server
echo "== api ready ==" echo "== api ready =="
curl -fsS http://127.0.0.1:3000/api/v2/health/ready curl -fsS http://127.0.0.1:3000/api/v2/health/ready
@@ -49,4 +70,3 @@ echo "== preflight =="
echo "== failed units ==" echo "== failed units =="
systemctl --failed --no-pager systemctl --failed --no-pager
+95
View File
@@ -0,0 +1,95 @@
#!/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"
cp -a /etc/opensips/opensips.cfg "$backup/opensips.cfg"
printf '%s\n' "$old" > "$backup/previous-release"
for file in /etc/lisglosips/caller-analytics.env /etc/rsyslog.d/35-caller-analytics.conf; 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)
rollback() {
rc=$?
if test "$changed" = 1; then
systemctl stop lisglosips@caller-analytics || true
cp -a "$backup/opensips.cfg" /etc/opensips/opensips.cfg
for file in /etc/lisglosips/caller-analytics.env /etc/rsyslog.d/35-caller-analytics.conf; 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 restart opensips lisglosips@api || true
if test "$was_running" = active; then systemctl start lisglosips@caller-analytics || true; 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 'CRA1|' "$source_cfg"; then
# A code-only redeploy keeps previously reviewed instrumentation unchanged.
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
if ! test -f /etc/lisglosips/caller-analytics.env; then
grep -E '^(DATABASE_URL|REDIS_URL)=' /etc/lisglosips/cdr-worker.env > /etc/lisglosips/caller-analytics.env
printf '%s\n' 'LISGLOSIPS_ENTRYPOINT=apps/worker-cdr/dist/analytics-main.js' 'LISGLOSIPS_SERVICE_NAME=caller-analytics' "ANALYTICS_CAPTURE_SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /etc/lisglosips/caller-analytics.env
chown root:lisglosips /etc/lisglosips/caller-analytics.env
chmod 0640 /etc/lisglosips/caller-analytics.env
fi
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"
changed=1
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 restart opensips lisglosips@api
systemctl enable --now lisglosips@caller-analytics
systemctl restart lisglosips@caller-analytics
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
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,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])
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -Eeuo pipefail
commit=${1:?commit is required}
archive=${2:?archive path is required}
expected_sha=${3:?archive sha256 is required}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup=/var/backups/lisglosips-s56/$stamp
old=$(readlink -f /opt/lisglosips/current)
new=/opt/lisglosips/releases/s56-b-consolidation-$stamp
echo "phase=verify-upload"
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_sha"
test -d "$old"
test ! -e "$new"
echo "phase=data-backup"
systemctl start lisglosips-backup.service
if systemctl --quiet is-failed lisglosips-backup.service; then
systemctl status --no-pager lisglosips-backup.service
exit 1
fi
find /data/backups/mysql -mindepth 1 -maxdepth 1 -type d | sort | tail -1
find /data/backups/redis -mindepth 1 -maxdepth 1 -type d | sort | tail -1
echo "phase=config-backup"
install -d -m 0700 "$backup"
cp -a /etc/lisglosips/recording-worker.env "$backup/recording-worker.env"
cp -a /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf "$backup/10-s28-recording.conf"
printf '%s\n' "$old" > "$backup/previous-release"
[ ! -e /usr/local/sbin/lisglosips-recording-finalize ] || cp -a /usr/local/sbin/lisglosips-recording-finalize "$backup/finalizer.previous"
[ ! -e /etc/systemd/system/lisglosips-recording-finalize.service ] || cp -a /etc/systemd/system/lisglosips-recording-finalize.service "$backup/finalizer-service.previous"
[ ! -e /etc/systemd/system/lisglosips-recording-finalize.timer ] || cp -a /etc/systemd/system/lisglosips-recording-finalize.timer "$backup/finalizer-timer.previous"
changed=0
rollback() {
rc=$?
if [ "$changed" -eq 1 ]; then
echo "phase=rollback rc=$rc"
systemctl disable --now lisglosips-recording-finalize.timer 2>/dev/null || true
cp -a "$backup/recording-worker.env" /etc/lisglosips/recording-worker.env
cp -a "$backup/10-s28-recording.conf" /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf
ln -sfn "$old" /opt/lisglosips/current
systemctl daemon-reload
systemctl restart lisglosips@recording-worker || true
fi
exit "$rc"
}
trap rollback ERR
echo "phase=stage-release"
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"
chown -R root:lisglosips "$new/apps/worker-recording/dist" "$new/infra/server-b/s56"
chmod -R u=rwX,g=rX,o= "$new/apps/worker-recording/dist" "$new/infra/server-b/s56"
chmod 0755 "$new/infra/server-b/s56/scripts/lisglosips-recording-finalize"
echo "phase=install-local-recording"
install -o root -g root -m 0755 "$new/infra/server-b/s56/scripts/lisglosips-recording-finalize" /usr/local/sbin/lisglosips-recording-finalize
install -o root -g root -m 0644 "$new/infra/server-b/s56/systemd/lisglosips-recording-finalize.service" /etc/systemd/system/lisglosips-recording-finalize.service
install -o root -g root -m 0644 "$new/infra/server-b/s56/systemd/lisglosips-recording-finalize.timer" /etc/systemd/system/lisglosips-recording-finalize.timer
install -o root -g root -m 0644 "$new/infra/server-b/s56/systemd/10-local-recording.conf" /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf
tmp_env=$(mktemp)
grep -Ev '^(RECORDING_SOURCE_MODE|RECORDING_LOCAL_READY_DIR|RECORDING_REMOTE_HOST|RECORDING_SSH_CONFIG|RECORDING_REMOTE_READY_DIR)=' /etc/lisglosips/recording-worker.env > "$tmp_env"
printf '%s\n' 'RECORDING_SOURCE_MODE=local' 'RECORDING_LOCAL_READY_DIR=/dev/shm/voip_rec/ready' >> "$tmp_env"
install -o root -g lisglosips -m 0640 "$tmp_env" /etc/lisglosips/recording-worker.env
rm -f "$tmp_env"
install -d -o rtpengine -g rtpengine -m 0750 /dev/shm/voip_rec /dev/shm/voip_rec/incoming /dev/shm/voip_rec/ready /dev/shm/voip_rec/failed
changed=1
systemctl daemon-reload
systemctl enable --now lisglosips-recording-finalize.timer
systemctl start lisglosips-recording-finalize.service
ln -sfn "$new" /opt/lisglosips/current
systemctl restart lisglosips@recording-worker
sleep 12
echo "phase=verify"
test "$(readlink -f /opt/lisglosips/current)" = "$new"
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
test "$(systemctl is-active lisglosips-recording-finalize.timer)" = active
test "$(systemctl is-active rtpengine-recording-daemon)" = active
test "$(systemctl is-active lisglosips@recording-worker)" = active
/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
journalctl -u lisglosips@recording-worker --since "@$(( $(date +%s) - 60 ))" --no-pager | tail -30
echo "ENV_MODE"
grep -E '^(RECORDING_SOURCE_MODE|RECORDING_LOCAL_READY_DIR|RECORDING_LOCAL_ROOT|RECORDING_DELETE_SOURCE_AFTER_COPY)=' /etc/lisglosips/recording-worker.env
echo "PROCESS_CHECK"
worker_pid=$(systemctl show -p MainPID --value lisglosips@recording-worker)
ps -o pid,ppid,comm,args --forest -g "$(ps -o sid= -p "$worker_pid" | tr -d ' ')" || true
pgrep -a ssh || true
printf 'BACKUP=%s\nRELEASE=%s\nCOMMIT=%s\n' "$backup" "$new" "$commit"
changed=0
trap - ERR
@@ -0,0 +1,10 @@
LISGLOSIPS_ENTRYPOINT=apps/worker-recording/dist/main.js
LISGLOSIPS_SERVICE_NAME=worker-recording
LISGLOSIPS_LOG_LEVEL=info
DATABASE_URL=mysql://user:password@127.0.0.1:3306/lisglosips
RECORDING_SOURCE_MODE=local
RECORDING_LOCAL_READY_DIR=/dev/shm/voip_rec/ready
RECORDING_LOCAL_ROOT=/data/recordings
RECORDING_SCAN_INTERVAL_MS=10000
RECORDING_MAX_FILES_PER_SCAN=50
RECORDING_DELETE_SOURCE_AFTER_COPY=true
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR=${1:-/dev/shm/voip_rec}
INCOMING_DIR="$BASE_DIR/incoming"
READY_DIR="$BASE_DIR/ready"
FAILED_DIR="$BASE_DIR/failed"
MIN_AGE_SECONDS=${MIN_AGE_SECONDS:-15}
mkdir -p "$INCOMING_DIR" "$READY_DIR" "$FAILED_DIR"
chmod 0750 "$BASE_DIR" "$INCOMING_DIR" "$READY_DIR" "$FAILED_DIR"
find "$INCOMING_DIR" -type f \( -name '*.wav' -o -name '*.mp3' \) -print0 |
while IFS= read -r -d '' source; do
if [ ! -s "$source" ]; then
continue
fi
now=$(date +%s)
mtime=$(stat -c %Y "$source")
if [ $((now - mtime)) -lt "$MIN_AGE_SECONDS" ]; then
continue
fi
if command -v fuser >/dev/null 2>&1 && fuser -s "$source"; then
continue
fi
rel=${source#"$INCOMING_DIR"/}
part="$READY_DIR/$rel.part"
ready="$READY_DIR/$rel.ready"
failed="$FAILED_DIR/$rel"
mkdir -p "$(dirname "$part")" "$(dirname "$failed")"
chmod 0750 "$(dirname "$part")" "$(dirname "$failed")"
if [ -e "$ready" ] || [ -e "$part" ]; then
mv -- "$source" "$failed.$(date -u +%Y%m%dT%H%M%SZ).duplicate"
continue
fi
mv -- "$source" "$part"
chmod 0640 "$part"
mv -- "$part" "$ready"
done
@@ -0,0 +1,6 @@
[Service]
User=lisglo-recorder
Group=lisglosips
SupplementaryGroups=rtpengine
BindPaths=/dev/shm/voip_rec/ready
ReadWritePaths=/data/recordings /dev/shm/voip_rec/ready /run/lisglosips /run/lisglo-recorder
@@ -0,0 +1,15 @@
[Unit]
Description=Finalize local RTPEngine recordings for LisgloSIPS
After=rtpengine-recording-daemon.service
Requires=rtpengine-recording-daemon.service
[Service]
Type=oneshot
User=rtpengine
Group=rtpengine
ExecStart=/usr/local/sbin/lisglosips-recording-finalize /dev/shm/voip_rec
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/dev/shm/voip_rec
@@ -0,0 +1,12 @@
[Unit]
Description=Finalize local RTPEngine recordings every five seconds
[Timer]
OnBootSec=10s
OnUnitActiveSec=5s
AccuracySec=1s
Unit=lisglosips-recording-finalize.service
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -Eeuo pipefail
commit=${1:?commit is required}
archive=${2:?archive path is required}
expected_sha=${3:?archive sha256 is required}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup=/var/backups/lisglosips-s57/$stamp
old=$(readlink -f /opt/lisglosips/current)
new=/opt/lisglosips/releases/s57-ui-number-library-$stamp
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_sha"
test -d "$old"
test ! -e "$new"
echo "phase=data-backup"
systemctl start lisglosips-backup.service
if systemctl --quiet is-failed lisglosips-backup.service; then
systemctl status --no-pager lisglosips-backup.service
exit 1
fi
echo "phase=config-backup"
install -d -m 0700 "$backup"
cp -a /etc/lisglosips/api.env "$backup/api.env"
printf '%s\n' "$old" > "$backup/previous-release"
changed=0
rollback() {
rc=$?
if [ "$changed" -eq 1 ]; then
echo "phase=rollback rc=$rc"
cp -a "$backup/api.env" /etc/lisglosips/api.env
ln -sfn "$old" /opt/lisglosips/current
systemctl restart lisglosips@api || true
fi
exit "$rc"
}
trap rollback ERR
echo "phase=stage-release"
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"
chown -R root:lisglosips "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
chmod 0755 "$new/infra/server-b/s57/deploy-ui-number-library.sh"
tmp_env=$(mktemp)
grep -Ev '^(ACTIVE_CALLS_MODE|ACTIVE_CALLS_HTTP_URL|ACTIVE_CALLS_TIMEOUT_MS)=' /etc/lisglosips/api.env > "$tmp_env"
printf '%s\n' 'ACTIVE_CALLS_MODE=http' 'ACTIVE_CALLS_HTTP_URL=http://127.0.0.1:8888/mi' 'ACTIVE_CALLS_TIMEOUT_MS=3000' >> "$tmp_env"
install -o root -g lisglosips -m 0640 "$tmp_env" /etc/lisglosips/api.env
rm -f "$tmp_env"
changed=1
ln -sfn "$new" /opt/lisglosips/current
systemctl restart lisglosips@api
sleep 4
echo "phase=verify"
test "$(readlink -f /opt/lisglosips/current)" = "$new"
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
test "$(systemctl is-active lisglosips@api)" = active
curl --fail --silent --show-error http://127.0.0.1:3000/api/v2/health/ready >/dev/null
curl --fail --silent --show-error -H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' \
http://127.0.0.1:8888/mi >/dev/null
/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
printf 'BACKUP=%s\nRELEASE=%s\nCOMMIT=%s\n' "$backup" "$new" "$commit"
changed=0
trap - ERR
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -Eeuo pipefail
release_root=${1:-/opt/lisglosips/current}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup=/var/backups/lisglosips-s58/$stamp
test -x "$release_root/infra/ops/startup/lisglosips-start-b.sh"
test -f "$release_root/infra/server-b/s58/systemd/lisglosips.target"
test -f "$release_root/infra/server-b/s58/systemd/lisglosips-boot-check.service"
install -d -m 0700 "$backup"
for path in \
/usr/local/sbin/lisglosips-start-b \
/etc/systemd/system/lisglosips.target \
/etc/systemd/system/lisglosips-boot-check.service; do
[ ! -e "$path" ] || cp -a "$path" "$backup/$(basename "$path").previous"
done
changed=0
rollback() {
rc=$?
if [ "$changed" -eq 1 ]; then
echo "phase=rollback rc=$rc"
systemctl disable lisglosips-boot-check.service lisglosips.target \
lisglosips@recording-worker.service lisglosips@config-publisher.service 2>/dev/null || true
for name in lisglosips-start-b lisglosips.target lisglosips-boot-check.service; do
previous="$backup/$name.previous"
case "$name" in
lisglosips-start-b) target=/usr/local/sbin/lisglosips-start-b ;;
*) target=/etc/systemd/system/$name ;;
esac
if [ -e "$previous" ]; then cp -a "$previous" "$target"; else rm -f "$target"; fi
done
systemctl daemon-reload
fi
exit "$rc"
}
trap rollback ERR
echo "phase=install"
install -o root -g root -m 0755 "$release_root/infra/ops/startup/lisglosips-start-b.sh" /usr/local/sbin/lisglosips-start-b
install -o root -g root -m 0644 "$release_root/infra/server-b/s58/systemd/lisglosips.target" /etc/systemd/system/lisglosips.target
install -o root -g root -m 0644 "$release_root/infra/server-b/s58/systemd/lisglosips-boot-check.service" /etc/systemd/system/lisglosips-boot-check.service
changed=1
systemctl daemon-reload
systemctl enable lisglosips.target lisglosips-boot-check.service \
lisglosips@api.service lisglosips@cdr-worker.service \
lisglosips@recording-worker.service lisglosips@config-publisher.service
echo "phase=start-and-verify"
systemctl start lisglosips.target
systemctl restart lisglosips-boot-check.service
test "$(systemctl is-enabled lisglosips.target)" = enabled
test "$(systemctl is-enabled lisglosips@recording-worker.service)" = enabled
test "$(systemctl is-enabled lisglosips@config-publisher.service)" = enabled
test "$(systemctl is-active lisglosips@recording-worker.service)" = active
test "$(systemctl is-active lisglosips@config-publisher.service)" = active
printf 'BACKUP=%s\nTARGET=%s\n' "$backup" "$(systemctl is-enabled lisglosips.target)"
changed=0
trap - ERR
@@ -0,0 +1,13 @@
[Unit]
Description=Validate LisgloSIPS after Server B boot
Requires=lisglosips.target
After=lisglosips.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/lisglosips-start-b
RemainAfterExit=yes
TimeoutStartSec=90
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=LisgloSIPS single-server stack on Server B
Wants=network-online.target tailscaled.service
Wants=mysql.service redis-server.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
After=network-online.target tailscaled.service
After=mysql.service redis-server.service postgresql.service
[Install]
WantedBy=multi-user.target
+2
View File
@@ -4,6 +4,8 @@ export const ACCESS_TOKEN_TYPE = 'Bearer';
export const PASSWORD_ALGO_ARGON2ID = 'argon2id'; export const PASSWORD_ALGO_ARGON2ID = 'argon2id';
export type PermissionKey = export type PermissionKey =
| 'caller_analytics.view'
| 'caller_analytics.view_all'
| 'dashboard.view' | 'dashboard.view'
| 'active_calls.view' | 'active_calls.view'
| 'active_calls.manage' | 'active_calls.manage'
@@ -0,0 +1,133 @@
import { createHash } from 'node:crypto';
export const ANALYTICS_VERSION = 'caller-analytics-v1.1';
export const ANALYTICS_STREAM = 'stream:caller_analytics';
export const ANALYTICS_GROUP = 'caller-analytics-workers';
export type AnalyticsKind = 'START' | 'ATTEMPT' | 'PROGRESS' | 'ACCEPTED' | 'DURATION' | 'END' | '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;
}
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;
}
export interface AnalyticsCall { start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>; }
export interface AnalyticsCounts {
totalCalls: number; connectedCalls: number; answeredCalls: number;
failedCalls: number; pendingCalls: number; unknownCalls: number; activeCalls: number; talkMs: number;
}
export const COUNT_KEYS = ['totalCalls', 'connectedCalls', 'answeredCalls', 'failedCalls', 'pendingCalls', 'unknownCalls', 'activeCalls', 'talkMs'] as const;
export const emptyCounts = (): AnalyticsCounts => ({ totalCalls: 0, connectedCalls: 0, answeredCalls: 0, failedCalls: 0, pendingCalls: 0, unknownCalls: 0, activeCalls: 0, talkMs: 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 (f.version !== '1' || !['START', 'ATTEMPT', 'PROGRESS', 'ACCEPTED', 'DURATION', 'END', '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')
};
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;
}
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.kind === 'START') 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'
};
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 === '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;
if (event.source === 'verified-dialog-final' && leg.acceptedAt !== null && event.talkMs === null && !leg.durationFinal) {
leg.talkMs = Math.max(0, event.at - leg.acceptedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1;
}
}
if (event.kind === 'ACCEPTED' && leg.endedAt !== null && !leg.durationFinal) {
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1;
}
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;
}
}
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;
return { totalCalls: 1, connectedCalls: +connected, answeredCalls: +(leg.talkMs > 0), failedCalls: +(!connected && !unknown && leg.endedAt !== null), pendingCalls: +(!connected && !unknown && leg.endedAt === null), unknownCalls: +unknown, activeCalls: +(leg.endedAt === null), talkMs: leg.talkMs };
}
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 active = !legs.length || counts.some(c => c.activeCalls > 0);
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 && !active),
pendingCalls: +(!connected && !unknown && active), unknownCalls: +unknown, activeCalls: +active,
talkMs: counts.reduce((sum, c) => sum + c.talkMs, 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: ratio(counts.answeredCalls, counts.totalCalls),
connectedAnswerRate: counts.unknownCalls ? null : ratio(counts.answeredCalls, counts.connectedCalls) };
}
@@ -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`CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED) + ${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,62 @@
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 });
});
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('END', { at: start + 1500, source: 'verified-dialog-final' })]);
expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 500 });
});
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 })).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});
}
});
});
+3
View File
@@ -1,3 +1,6 @@
export { Prisma, PrismaClient } from '@prisma/client'; export { Prisma, PrismaClient } from '@prisma/client';
export const DATABASE_PROVIDER = 'mysql'; 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';
+85
View File
@@ -960,6 +960,91 @@ model OutboxEvent {
@@map("outbox_events") @@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 IdempotencyKey { model IdempotencyKey {
id String @id @db.VarChar(40) id String @id @db.VarChar(40)
key String @unique @db.VarChar(128) key String @unique @db.VarChar(128)
+2
View File
@@ -3,6 +3,8 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const permissions = [ const permissions = [
['caller_analytics.view', 'caller_analytics', 'view', '查看授权客户的主叫分析'],
['caller_analytics.view_all', 'caller_analytics', 'view_all', '查看全部客户的主叫分析'],
['dashboard.view', 'dashboard', 'view', '查看 Dashboard 指标'], ['dashboard.view', 'dashboard', 'view', '查看 Dashboard 指标'],
['active_calls.view', 'active_calls', 'view', '查看当前通话'], ['active_calls.view', 'active_calls', 'view', '查看当前通话'],
['active_calls.manage', 'active_calls', 'manage', '强制挂断当前通话'], ['active_calls.manage', 'active_calls', 'manage', '强制挂断当前通话'],
+8 -1
View File
@@ -71,8 +71,15 @@ const copyEntries = [
['packages/redis/dist', 'packages/redis/dist', true], ['packages/redis/dist', 'packages/redis/dist', true],
['packages/redis/node_modules', 'packages/redis/node_modules', true], ['packages/redis/node_modules', 'packages/redis/node_modules', true],
['infra/server-b/s30', 'infra/server-b/s30', true], ['infra/server-b/s30', 'infra/server-b/s30', true],
['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],
['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], ['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] ['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
['scripts/import-number-library.mjs', 'scripts/import-number-library.mjs', true]
]; ];
function run(command, commandArgs, options = {}) { function run(command, commandArgs, options = {}) {
+184
View File
@@ -0,0 +1,184 @@
import fs from 'node:fs';
import zlib from 'node:zlib';
import crypto from 'node:crypto';
import { PrismaClient } from '../packages/database/dist/index.js';
const args = Object.fromEntries(process.argv.slice(2).map((entry) => {
const [key, ...value] = entry.replace(/^--/, '').split('=');
return [key, value.length ? value.join('=') : true];
}));
if (!args.admin || !args.phone) {
throw new Error('Usage: node scripts/import-number-library.mjs --admin=/path/area.json.gz --phone=/path/phone.dat [--dry-run]');
}
const batchId = String(args.batch || 'number-library-20260827');
const adminSource = '国家统计局区划代码整理库 2024 (2023-06-30)';
const phoneSource = 'pangongzi/phone phone.dat 2025-02';
const adminTree = JSON.parse(zlib.gunzipSync(fs.readFileSync(String(args.admin))).toString('utf8'));
const sixDigitCode = (code) => String(code).slice(0, 6);
const normalize = (value) => String(value || '')
.replace(/\s+/g, '')
.replace(/特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|土家族苗族自治州|苗族侗族自治州|藏族自治州|蒙古族藏族自治州|柯尔克孜自治州|哈萨克自治州|自治州|省|市|地区|盟|县$/g, '');
const cities = [];
for (const province of adminTree) {
for (const city of province.children || []) {
const base = {
code: sixDigitCode(city.code),
provinceCode: sixDigitCode(province.code),
provinceName: province.name,
cityCode: sixDigitCode(city.code),
cityName: city.name === '市辖区' ? province.name : city.name,
cityLevel: 'PREFECTURE',
status: 'ENABLED'
};
cities.push(base);
if (/直辖县级行政区划/.test(city.name)) {
cities.pop();
for (const countyCity of city.children || []) {
cities.push({ ...base, code: sixDigitCode(countyCity.code), cityCode: sixDigitCode(countyCity.code), cityName: countyCity.name, cityLevel: 'COUNTY_DIRECT' });
}
}
}
}
const phoneBuffer = fs.readFileSync(String(args.phone));
const indexOffset = phoneBuffer.readUInt32LE(4);
const phoneRecords = [];
for (let offset = indexOffset; offset + 9 <= phoneBuffer.length; offset += 9) {
const segment7 = String(phoneBuffer.readUInt32LE(offset)).padStart(7, '0');
const dataOffset = phoneBuffer.readUInt32LE(offset + 4);
const end = phoneBuffer.indexOf(0, dataOffset);
const [provinceName, cityName, zipCode, areaCode] = phoneBuffer.subarray(dataOffset, end).toString('utf8').split('|');
phoneRecords.push({ segment7, provinceName, cityName, zipCode, areaCode, type: phoneBuffer[offset + 8] });
}
const byProvince = new Map();
for (const city of cities) {
const key = normalize(city.provinceName);
const values = byProvince.get(key) || [];
values.push(city);
byProvince.set(key, values);
}
function findCity(record) {
const provinceCities = byProvince.get(normalize(record.provinceName)) || [];
const aliasKey = `${normalize(record.provinceName)}|${normalize(record.cityName)}`;
const cityAliases = {
'山东|莱芜': '济南',
'新疆|巴州': '巴音郭楞',
'新疆|博州': '博尔塔拉',
'新疆|克州': '克孜勒苏',
'新疆|奎屯': '伊犁',
'青海|格尔木': '海西'
};
const cityName = cityAliases[aliasKey] || normalize(record.cityName || record.provinceName);
const exact = provinceCities.find((city) => normalize(city.cityName) === cityName);
if (exact) return exact;
const partial = provinceCities.filter((city) => normalize(city.cityName).startsWith(cityName) || cityName.startsWith(normalize(city.cityName)));
return partial.length === 1 ? partial[0] : provinceCities.length === 1 ? provinceCities[0] : null;
}
const directMatches = phoneRecords.map((record) => ({ record, city: findCity(record) }));
const areaVotes = new Map();
for (const { record, city } of directMatches) {
if (!city || !record.areaCode) continue;
const key = `${record.areaCode}|${city.cityCode}`;
areaVotes.set(key, (areaVotes.get(key) || 0) + 1);
}
const areaCity = new Map();
for (const [key, count] of areaVotes) {
const [areaCode, cityCode] = key.split('|');
const current = areaCity.get(areaCode);
if (!current || count > current.count) areaCity.set(areaCode, { cityCode, count });
}
const cityByCode = new Map(cities.map((city) => [city.cityCode, city]));
const unmatched = [];
const carrierMap = { 1: 'MOBILE', 2: 'UNICOM', 3: 'TELECOM', 4: 'MVNO', 5: 'MVNO', 6: 'MVNO', 7: 'BROADCAST', 8: 'MVNO' };
const segments = [];
for (const match of directMatches) {
if (!/^1\d{6}$/.test(match.record.segment7)) continue;
const city = match.city || cityByCode.get(areaCity.get(match.record.areaCode)?.cityCode);
if (!city) {
unmatched.push(match.record);
continue;
}
segments.push({
segment7: match.record.segment7,
cityCode: city.cityCode,
provinceName: city.provinceName,
cityName: city.cityName,
carrier: carrierMap[match.record.type] || 'UNKNOWN',
source: phoneSource,
batchId
});
}
const areaCodes = [...areaCity.entries()].map(([areaCode, vote]) => {
const city = cityByCode.get(vote.cityCode);
return { areaCode, cityCode: city.cityCode, provinceName: city.provinceName, cityName: city.cityName, source: phoneSource, batchId };
}).filter((item) => /^0\d{2,3}$/.test(item.areaCode));
const prefixVotes = new Map();
for (const segment of segments) {
const prefix = segment.segment7.slice(0, 3);
const key = `${prefix}|${segment.carrier}`;
prefixVotes.set(key, (prefixVotes.get(key) || 0) + 1);
}
const prefixWinners = new Map();
for (const [key, count] of prefixVotes) {
const [prefix, carrier] = key.split('|');
const current = prefixWinners.get(prefix);
if (!current || count > current.count) prefixWinners.set(prefix, { carrier, count });
}
const prefixes = [...prefixWinners.entries()].map(([prefix, value]) => ({ prefix, carrier: value.carrier, priority: 100, source: phoneSource, batchId }));
const report = {
batchId,
adminSource,
phoneSource,
phoneVersion: phoneBuffer.subarray(0, 4).toString('utf8'),
cities: cities.length,
sourcePhoneRecords: phoneRecords.length,
segments: segments.length,
unmatched: unmatched.length,
areaCodes: areaCodes.length,
prefixes: prefixes.length,
unmatchedLocations: [...new Map(unmatched.map(({ provinceName, cityName, areaCode }) => [`${provinceName}|${cityName}|${areaCode}`, { provinceName, cityName, areaCode }])).values()]
};
console.log(JSON.stringify(report, null, 2));
if (args['dry-run']) process.exit(0);
if (unmatched.length) throw new Error(`Refusing partial import: ${unmatched.length} phone segments could not be mapped to an administrative city.`);
const prisma = new PrismaClient();
try {
const current = {
cities: await prisma.geoCity.count(),
segments: await prisma.phoneNumberSegment.count(),
areaCodes: await prisma.phoneAreaCode.count(),
prefixes: await prisma.carrierPrefixRule.count()
};
if (Object.values(current).some(Boolean)) throw new Error(`Number library is not empty: ${JSON.stringify(current)}`);
await prisma.geoCity.createMany({ data: cities, skipDuplicates: true });
for (let offset = 0; offset < segments.length; offset += 1000) {
await prisma.phoneNumberSegment.createMany({ data: segments.slice(offset, offset + 1000), skipDuplicates: true });
}
await prisma.phoneAreaCode.createMany({ data: areaCodes, skipDuplicates: true });
await prisma.carrierPrefixRule.createMany({ data: prefixes, skipDuplicates: true });
await prisma.outboxEvent.create({
data: {
id: `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`,
aggregateType: 'number_library_config',
aggregateId: 'number-library',
eventType: 'number_library.seeded',
payload: report
}
});
console.log(JSON.stringify({ imported: report }, null, 2));
} finally {
await prisma.$disconnect();
}
+42
View File
@@ -0,0 +1,42 @@
// 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('CRA1|')) throw new Error('Already instrumented; use the recorded pre-analytics backup');
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 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', 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 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);
}
route[CRA_TIMEOUT] {
$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; 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);
}
`;
writeFileSync(output, cfg, 'utf8');
console.log('Analytics observation hooks generated; OpenSIPS config validation is required before activation.');
+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();}
+84
View File
@@ -0,0 +1,84 @@
// 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((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+row.counts[key],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]);
} 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 evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:JSON.parse(sip.stdout),bucketAudit:beforeReplay.body.summary,replayed:events.length,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']};
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(); }
+72
View File
@@ -0,0 +1,72 @@
#!/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 reply(message, code, reason):
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>', '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]:
sock.sendto(reply(msg,code,reason),addr)
events.append({'callId':header(msg,'Call-ID'),'direction':'UAS','code':code,'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']
invite='\r\n'.join([f'INVITE sip:{number}@127.0.0.1:15060 SIP/2.0',*base,'CSeq: 1 INVITE','Content-Length: 0','',''])
sock.sendto(invite.encode(),('127.0.0.1',15060))
while True:
msg=sock.recv(65535).decode(); code=int(msg.split()[1]); events.append({'callId':callid,'direction':'UAC','code':code,'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)))
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,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T06:16:36.256Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=5724ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=596ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T06:16:35.041Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=158ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T06:16:35.202Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1793ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=df88f31f-3a39-4fc9-b60d-2ecda12f8446, expiresAt=2026-06-30T06:21:36.991Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=5724ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=596ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=158ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1793ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-08-27T07:39:55.055Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1373ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=376ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-08-27T07:39:54.555Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=377ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-08-27T07:39:54.933Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=389ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=4a8cafd9-270b-4fe7-bd53-08e0d0e587ec, expiresAt=2026-08-27T07:44:55.308Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1373ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=376ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=377ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=389ms, attempt=1, contentType=application/json; charset=utf-8
@@ -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"
]
}