Compare commits
27
Commits
20260630release
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d370b52d | ||
|
|
367211ee47 | ||
|
|
9fa1964dec | ||
|
|
6f1cf41043 | ||
|
|
f048312fc2 | ||
|
|
e8c09d7b64 | ||
|
|
e5d32e6b4c | ||
|
|
29dbeb6981 | ||
|
|
7f8825cf3a | ||
|
|
a116926867 | ||
|
|
2a3dcb0c68 | ||
|
|
d78616561e | ||
|
|
116e316eca | ||
|
|
5284d41f9a | ||
|
|
8627228bbc | ||
|
|
406b3744aa | ||
|
|
6da5782ac7 | ||
|
|
c36709f366 | ||
|
|
564234b769 | ||
|
|
138297d191 | ||
|
|
309bffc7ff | ||
|
|
f67b33de33 | ||
|
|
f52e3c5fbc | ||
|
|
be53848dee | ||
|
|
32bba1bd84 | ||
|
|
ecb567c0da | ||
|
|
7ff019e829 |
@@ -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=success;B `mysql`、`redis-server`、`nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`lisglosips@config-publisher`、`heplify-server`、`lisglosips-prometheus`、`grafana-server` active,preflight ok,API ready ok;T `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/enabled,API ready为ok。
|
||||||
|
- 真实验收:6通受控SIP得到T=6/C=4/A=2/N=2,两个成功场景完成SDP协商并经过RTPEngine,300ms短通话计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=success;B `mysql`、`redis-server`、`nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`lisglosips@config-publisher`、`heplify-server`、`lisglosips-prometheus`、`grafana-server` active,preflight ok,API 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
|
||||||
|
|
||||||
- 状态:已完成
|
- 状态:已完成
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 }) }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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: '被叫' },
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||||
|
import { PageTitle, Panel, Toolbar, SimpleTable, Drawer, Pagination } from '../components/layout.jsx';
|
||||||
|
import { api, explainApiError } from '../api.js';
|
||||||
|
import { formatDateTime } from '../utils/formatters.js';
|
||||||
|
|
||||||
|
const percent = v => v === null || v === undefined ? '—' : `${Number(v).toFixed(2)}%`;
|
||||||
|
const rateColumns = [
|
||||||
|
{ key: 'connectionRate', label: '实时接通率', render: r => percent(r.connectionRate) },
|
||||||
|
{ key: 'overallAnswerRate', label: '实时总体应答率', render: r => percent(r.overallAnswerRate) },
|
||||||
|
{ key: 'connectedAnswerRate', label: '实时已接通应答率', render: r => percent(r.connectedAnswerRate) }
|
||||||
|
];
|
||||||
|
const countColumns = [{ key: 'totalCalls', label: '总呼叫数' }, { key: 'connectedCalls', label: '接通数' }, { key: 'notConnectedCalls', label: '未接通数' }, { key: 'answeredCalls', label: '应答数' }];
|
||||||
|
const initial = { view: 'landing', minutes: '15', caller: '', customerId: '', customerGatewayId: '', vendorId: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
|
||||||
|
export function CallerAnalyticsPage() {
|
||||||
|
const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial);
|
||||||
|
const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
|
||||||
|
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendors: [], vendorGateways: [] });
|
||||||
|
const [auto, setAuto] = useState(true); const [tick, setTick] = useState(0);
|
||||||
|
const [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
|
||||||
|
const [detailSkip, setDetailSkip] = useState(0); const sequence = useRef(0);
|
||||||
|
useEffect(() => { const c = new AbortController(); api.callerAnalyticsOptions({ signal: c.signal }).then(setOptions).catch(e => { if (!c.signal.aborted) setError(explainApiError(e)); }); return () => c.abort(); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController(); const id = ++sequence.current;
|
||||||
|
let timer;
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const q = { ...filters, onlyAlerts: String(filters.onlyAlerts) };
|
||||||
|
if (q.minutes !== 'custom') { delete q.from; delete q.to; }
|
||||||
|
if (q.minutes === 'today') { const now = new Date(); q.from = new Date(now.toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' }) + 'T00:00:00+08:00').toISOString(); delete q.minutes; }
|
||||||
|
if (q.minutes === 'custom') { q.from = new Date(q.from).toISOString(); q.to = new Date(q.to).toISOString(); delete q.minutes; }
|
||||||
|
const result = await api.callerAnalytics(q, { signal: controller.signal });
|
||||||
|
if (id === sequence.current) { setData(result); setError(''); }
|
||||||
|
} catch (e) { if (!controller.signal.aborted && id === sequence.current) setError(explainApiError(e)); }
|
||||||
|
finally { if (!controller.signal.aborted && id === sequence.current) { setLoading(false); if (auto) timer = setTimeout(refresh, 4000); } }
|
||||||
|
};
|
||||||
|
void refresh(); return () => { controller.abort(); clearTimeout(timer); };
|
||||||
|
}, [filters, auto, tick]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!target || !data) return undefined;
|
||||||
|
const c = new AbortController();
|
||||||
|
api.callerAnalyticsCalls({ ...filters, from: data.from, to: data.to, caller: target.caller, customerId: target.customerId, skip: detailSkip, take: 25 }, { signal: c.signal })
|
||||||
|
.then(r => { setDetail(r); setDetailError(''); }).catch(e => { if (!c.signal.aborted) setDetailError(explainApiError(e)); });
|
||||||
|
return () => c.abort();
|
||||||
|
}, [target, data, filters, detailSkip]);
|
||||||
|
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorId:'', vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
|
||||||
|
const names = Object.fromEntries(options.customers.map(c => [c.id, c.name]));
|
||||||
|
const gatewayNames = Object.fromEntries(options.vendorGateways.map(c => [c.id, c.name]));
|
||||||
|
const select = (label, key, choices) => <Field label={label}><Select value={draft[key]} onChange={e => change(key, e.target.value)}><option value="">全部</option>{choices.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></Field>;
|
||||||
|
const openDetail = row => { setTarget(row); setDetail(null); setDetailSkip(0); setDetailError(''); };
|
||||||
|
const metrics = data?.summary;
|
||||||
|
const timeline = data?.trends ?? [];
|
||||||
|
const max = Math.max(1, ...timeline.map(r => r.totalCalls));
|
||||||
|
return <>
|
||||||
|
<PageTitle title="主叫号码分析" desc="接通:180 / 183 或后续阶段;应答:实际通话时长大于 0。" actions={<><Button variant="outline" onClick={() => setAuto(v => !v)}>{auto ? '自动刷新中' : '已暂停刷新'}</Button><Button disabled={loading} onClick={() => setTick(v => v + 1)}>{loading ? '更新中…' : '刷新'}</Button></>} />
|
||||||
|
{error ? <Alert tone="danger" title="更新失败,保留上次数据">{error}</Alert> : null}
|
||||||
|
<Panel title="筛选条件"><form onSubmit={e => { e.preventDefault(); setFilters({ ...draft, skip: 0 }); setTarget(null); }}>
|
||||||
|
<Toolbar>
|
||||||
|
<Field label="号码视角"><Select value={draft.view} onChange={e => change('view', e.target.value)}><option value="landing">落地主叫</option><option value="original">原始主叫</option></Select></Field>
|
||||||
|
<Field label="时间窗口"><Select value={draft.minutes} onChange={e => change('minutes', e.target.value)}>{[5,15,30,60].map(n => <option key={n} value={n}>最近 {n} 分钟</option>)}<option value="today">今日</option><option value="custom">自定义</option></Select></Field>
|
||||||
|
{draft.minutes === 'custom' ? <><Field label="开始时间"><Input type="datetime-local" required value={draft.from || ''} onChange={e => change('from', e.target.value)} /></Field><Field label="结束时间"><Input type="datetime-local" required value={draft.to || ''} onChange={e => change('to', e.target.value)} /></Field></> : null}
|
||||||
|
<Field label="主叫号码"><Input value={draft.caller} onChange={e => change('caller', e.target.value)} placeholder="精确匹配号码" /></Field>
|
||||||
|
{select('客户', 'customerId', options.customers)}
|
||||||
|
{select('客户网关', 'customerGatewayId', options.customerGateways.filter(g => !draft.customerId || g.customerId === draft.customerId))}
|
||||||
|
{draft.view === 'landing' ? <>{select('供应商', 'vendorId', options.vendors)}{select('落地网关', 'vendorGatewayId', options.vendorGateways)}</> : null}
|
||||||
|
{select('被叫运营商', 'carrier', ['MOBILE','UNICOM','TELECOM','BROADCAST','MVNO','UNKNOWN'].map((id,i) => ({id,name:['移动','联通','电信','广电','虚拟运营商','未知'][i]})))}
|
||||||
|
<Field label="被叫城市代码"><Input value={draft.city} onChange={e => change('city', e.target.value)} /></Field>
|
||||||
|
<Field label="最小呼叫数"><Input type="number" min="0" value={draft.minSamples} onChange={e => change('minSamples', e.target.value)} /></Field>
|
||||||
|
<Field label="排序"><Select value={draft.sort} onChange={e => change('sort', e.target.value)}>{[...countColumns, ...rateColumns].map(c => <option key={c.key} value={c.key}>{c.label}</option>)}</Select></Field>
|
||||||
|
<Field label="顺序"><Select value={draft.order} onChange={e => change('order', e.target.value)}><option value="desc">从高到低</option><option value="asc">从低到高</option></Select></Field>
|
||||||
|
<Field label="异常筛选"><Select value={String(draft.onlyAlerts)} onChange={e => change('onlyAlerts', e.target.value === 'true')}><option value="false">全部号码</option><option value="true">仅看告警</option></Select></Field>
|
||||||
|
</Toolbar>
|
||||||
|
<details className="cra-rate-filters"><summary>接通率 / 应答率区间</summary><Toolbar>{[['connection','接通率'],['overall','总体应答率'],['connected','已接通应答率']].map(([key,label]) => <Field key={key} label={`${label}(%)`}><span className="table-actions"><Input aria-label={`${label}下限`} type="number" min="0" max="100" value={draft[`${key}Min`] ?? ''} onChange={e => change(`${key}Min`, e.target.value)} placeholder="0" /><span>至</span><Input aria-label={`${label}上限`} type="number" min="0" max="100" value={draft[`${key}Max`] ?? ''} onChange={e => change(`${key}Max`, e.target.value)} placeholder="100" /></span></Field>)}</Toolbar></details>
|
||||||
|
<Button type="submit">查询</Button>
|
||||||
|
</form></Panel>
|
||||||
|
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
|
||||||
|
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.coverageStatus === 'CONTINUOUS' ? '连续采集覆盖' : '数据覆盖不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 接通未知 ${metrics.unknownCalls} · 时长未知 ${metrics.durationUnknownCalls} · 暂定应答 ${metrics.provisionalAnsweredCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
||||||
|
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通。口径 {data.metricVersion},算法 {data.analysisRevision},采集截至 {data.dataThrough ? formatDateTime(data.dataThrough) : '未知'},延迟 {data.lagMs == null ? '未知' : `${data.lagMs}ms`}。</Alert> : null}
|
||||||
|
<Panel title="分钟趋势" aside={<span className="muted-text">灰:呼叫 / 蓝:接通 / 绿:应答</span>}>
|
||||||
|
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
|
||||||
|
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
|
||||||
|
</Panel>
|
||||||
|
<Panel title="主叫号码列表" aside={<Badge>{data?.total ?? 0} 个号码</Badge>} className="wide-panel">
|
||||||
|
<SimpleTable rows={(data?.numbers ?? []).map(r => ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[
|
||||||
|
{key:'caller',label:'主叫号码',render:r => <Button size="sm" variant="ghost" onClick={() => openDetail(r)}>{r.caller}</Button>}, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns,
|
||||||
|
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'接通未知'}, {key:'durationUnknownCalls',label:'时长未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'} title={r.alert?.scope}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls || r.durationUnknownCalls ? '证据不足' : '观察'}</Badge>}
|
||||||
|
]} />
|
||||||
|
<Pagination total={data?.total ?? 0} take={filters.take} skip={filters.skip} count={data?.numbers.length ?? 0} loading={loading} onPageChange={skip => setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} />
|
||||||
|
</Panel>
|
||||||
|
{target ? <Drawer title={`号码详情 · ${target.caller}`} onClose={() => setTarget(null)}>
|
||||||
|
{detailError ? <Alert tone="danger" title="详情更新失败">{detailError}</Alert> : null}
|
||||||
|
<p>{names[target.customerId] || target.customerId} · {filters.view === 'landing' ? '落地主叫' : '原始主叫'}</p>
|
||||||
|
<Panel title="线路 / 地区 / 运营商分布"><SimpleTable rows={(detail?.breakdown ?? []).map((r,i) => ({...r,id:i,gateway:gatewayNames[r.vendorGatewayId] || r.vendorGatewayId || '业务呼叫汇总'}))} columns={[{key:'gateway',label:'落地网关'},{key:'city',label:'城市'},{key:'carrier',label:'运营商'},...countColumns,...rateColumns]} /></Panel>
|
||||||
|
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
|
||||||
|
const p=r.payload; const legs=p.legs ? Object.values(p.legs) : [p];
|
||||||
|
return {...r, callId:p.start?.callId || p.event?.callId, original:p.start?.caller || p.event?.caller,
|
||||||
|
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : l.talkMs > 0 ? '可信正时长补计' : '未达接通').join(' / '),
|
||||||
|
final:legs.map(l => l.finalCode ? `初始 INVITE ${l.finalCode}` : '初始 INVITE 进行中').join(' / '), termination:legs.map(l => l.terminationReason || '未结束').join(' / '), duration:r.counts.durationUnknownCalls ? '证据不足' : `${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.provisionalAnsweredCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
|
||||||
|
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'初始 INVITE 结果'},{key:'termination',label:'结束原因'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
|
||||||
|
<div className="table-actions"><Button variant="outline" disabled={!detailSkip} onClick={() => setDetailSkip(v => Math.max(0,v-25))}>上一页</Button><Button variant="outline" disabled={(detail?.rows.length ?? 0)<25} onClick={() => setDetailSkip(v => v+25)}>下一页</Button></div>
|
||||||
|
<p className="muted-text">按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。</p>
|
||||||
|
</Drawer> : null}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,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: '操作',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -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: '功能模块' },
|
||||||
|
|||||||
@@ -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}%` },
|
||||||
|
|||||||
@@ -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
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '角色说明' },
|
||||||
|
|||||||
@@ -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: '手机号' },
|
||||||
|
|||||||
@@ -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: '认证方式' },
|
||||||
|
|||||||
@@ -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: '使用客户网关数' },
|
||||||
|
|||||||
@@ -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,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'],
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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')`;
|
||||||
|
}
|
||||||
@@ -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();});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')]]));
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 均为 active,API 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 Publisher,CPU、内存和磁盘空间满足当前负载。现有单机化缺口只有录音 Worker 仍配置为 SSH 访问 A;A 不可达会导致该 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 units:0。
|
||||||
|
- 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=2;C/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,直接200,ACK后约1.5秒BYE | 1 | 1 |
|
||||||
|
| 180、200,ACK后约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. 整改结果
|
||||||
|
|
||||||
|
| 整改组 | 落地结果 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R01~R03 时长、未知与乱序 | TALK_START在对话路由匹配后产生,ACK后开始计时;时长分未知/暂定/最终;最终零时长具有权威优先级;状态归约支持重复和乱序 | 已完成 |
|
||||||
|
| R04~R05 完整性、留存与隔离 | 生产/采集计数、水位、日志未读字节、持久gap/DLQ、pending安全裁剪;独立Redis启用AOF、noeviction及256MB上限;维护和logrotate已安装 | 已完成 |
|
||||||
|
| R06~R07 生命周期与契约 | 增加显式CLOSE、生产节点/boot/sequence、事务阶段、证据来源与算法版本;单attempt保持当前真实路由语义 | 已完成;真实重试启用前补验 |
|
||||||
|
| R08~R10 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`、上限256MB;Stream pending为0。
|
||||||
|
|
||||||
|
末轮6通真实本机回环SIP全部通过原有平台路由,两个成功场景包含SDP offer/answer并通过RTPEngine:
|
||||||
|
|
||||||
|
| 场景 | 统计结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 180后486 | C=1,A=0 |
|
||||||
|
| 183后487 | C=1,A=0 |
|
||||||
|
| 直接486 | C=0,A=0 |
|
||||||
|
| 直接200,ACK后约1.5秒BYE | C=1,A=1 |
|
||||||
|
| 180→200,ACK后约300ms BYE | C=1,A=1 |
|
||||||
|
| 100后486 | C=0,A=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机服务。
|
||||||
@@ -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+P;U 单列 |
|
||||||
|
| 应答数 | answeredCalls | A |
|
||||||
|
| 实时接通率 | connectionRate | C / T × 100% |
|
||||||
|
| 实时总体应答率 | overallAnswerRate | A / T × 100% |
|
||||||
|
| 实时已接通应答率 | connectedAnswerRate | A / C × 100% |
|
||||||
|
|
||||||
|
分母为 0 时接口返回 null、页面显示“—”。比率由整数计数计算,不平均各时间桶的百分比。数据不完整时,计数与比率应标注“已观测/暂定”,未知或采集缺口不可展示为完整结果。
|
||||||
|
|
||||||
|
旧方案的“已判定接通率”不作为主指标,本版以以上三个率为准;不使用未经解释的 ASR 简称混淆业务口径。
|
||||||
|
|
||||||
|
示例:T=100,C=60(其中 A=30),F=25,P=15,U=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猜测匹配。
|
||||||
|
|
||||||
|
建议API:GET /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=1,N=0,A=1;三个率均100% |
|
||||||
|
| CRA-002 | INVITE→100→183→487,无正时长 | C=1,N=0,A=0;接通率100%,两个应答率0% |
|
||||||
|
| CRA-003 | INVITE→100→486,从未180/183/2xx | C=0,N=F=1,A=0;总体应答率0%,已接通应答率为null |
|
||||||
|
| CRA-004 | 未见100,直接180,随后取消 | C=1,A=0,不因缺100漏计 |
|
||||||
|
| CRA-005 | 直接初始INVITE 200,正时长 | C=A=1;DIRECT_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=0,F=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.1–21.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:79–84` 在没有显式talkMs时,用结束时间减acceptedAt生成最终时长。
|
||||||
|
- `apps/worker-cdr/src/analytics-main.ts:60–71` 只按Call-ID匹配MI,状态3或4均可生成暂定正时长。
|
||||||
|
- `scripts/instrument-caller-analytics.mjs:31–38` 将挂断、超时和事务失败统一标为 `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:68–78` 初始可把缺过程的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:86–89` 同时以“最终/暂定”和event.at决定更新。MI先取得在线快照、结束事件晚到时,可能已产生晚于真实结束时刻的暂定观察。最终零时长事件若event.at较早,会被忽略。
|
||||||
|
|
||||||
|
同一组事实的本轮输出:
|
||||||
|
|
||||||
|
| 输入到达顺序(t为统一起点) | 当前结果 | 应整改为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ACCEPTED(t+1s) → DURATION(t+4s,3000ms) → RECONCILE(t+3s,0ms) | A=1,talkMs=3000 | A=0,talkMs=0 |
|
||||||
|
| ACCEPTED(t+1s) → RECONCILE(t+3s,0ms) → DURATION(t+4s,3000ms) | A=0,talkMs=0 | 与上一行相同 |
|
||||||
|
| ATTEMPT → UNKNOWN → END(487) | F=1,U=0 | 缺口尚未修复时U=1 |
|
||||||
|
| ATTEMPT → END(487) → UNKNOWN | F=0,U=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:89–95` 只看消费组pending/lag,正常时直接返回 `lagMs: 0`;未计入未读日志、rsyslog队列或生产端停采。
|
||||||
|
- `caller-analytics.service.ts:88–90` 以Worker心跳新鲜且未degraded标LIVE。即使OpenSIPS日志钩子失效,只要日志可读、MI可用且队列空,仍可能显示LIVE。
|
||||||
|
|
||||||
|
**整改:**增加生产端序列/心跳、日志未读字节、Redis最旧未处理年龄、消费速率、DB提交水位及持久化gap表。按节点维护“连续处理到哪个生产序列”,再合成查询窗口覆盖状态。没有可靠延迟证据时lagMs返回null;空闲与停采必须可区分。恢复进程只改变运行状态,不能自动修复历史缺口。
|
||||||
|
|
||||||
|
**关闭标准:**停采但不停Worker、积压日志但Stream暂空、轮转后重启、迟到旧事件四类场景均不误标完整;质量提示可按时间窗口查询,缺口修复有审计记录。
|
||||||
|
|
||||||
|
### R05|P1|留存、持久化与资源隔离没有形成闭环
|
||||||
|
|
||||||
|
**对应原文:**第6、9节。**证据:源码确认;线上参数待核验。**
|
||||||
|
|
||||||
|
- `analytics-main.ts:45–49` 在XADD返回后推进本机游标,但这不等于Redis数据已经落盘。本地Redis基线 `infra/server-b/s04/redis/redis-lisglosips.conf:8–18` 为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:52–55,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:307–315`)。若本地转发失败且未实际发包,仍可能计入“实际发出的线路尝试”。
|
||||||
|
- `projectAnalytics():109–115` 把“当前所有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:26–31` 每个“客户+号码+视角”只有一个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 基线冻结 | P1,R13 | V1.2文档、需求/用例矩阵、实际部署只读核对表 | 确定生效配置、版本、授权测试范围,不实施业务变更 |
|
||||||
|
| W1 证据与状态 | P1,R01/R02/R03/R06;带R07字段 | 采集契约、确定性归约、真实会话时长验证 | 无法核验时长来源时,A不能宣称完整;单元边界先通过 |
|
||||||
|
| W2 完整性与可靠性 | P1,R04/R05 | gap、水位、补送/死信/留存、资源隔离 | 必须完成真实Redis/MySQL恢复与计费隔离验证 |
|
||||||
|
| W3 API/页面/告警 | P1/P2,R08/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-001~009、011~014、018、021~023 | 真实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/403;overview/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:32(Asia/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) | 18–98行,日志/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.
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
代码回滚:
|
代码回滚:
|
||||||
|
|||||||
@@ -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
@@ -35,8 +35,8 @@
|
|||||||
| 环境 | 用途 | 说明 |
|
| 环境 | 用途 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 本地开发 | 单元、服务、API e2e、前端构建 | Windows 工作区,Node.js 22+,pnpm 10.33.0,Vitest |
|
| 本地开发 | 单元、服务、API e2e、前端构建 | Windows 工作区,Node.js 22+,pnpm 10.33.0,Vitest |
|
||||||
| 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`。
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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', '强制挂断当前通话'],
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
@@ -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();}
|
||||||
@@ -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();}
|
||||||
@@ -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(); }
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user