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